Tags: praetorian-inc/brutus
Tags
v1.10.0 - fix(enum/github): send Accept header so the signup page returns the CSRF page instead of 403 (#276) - fix(enum/github): make --rotating-proxy actually rotate — per-email 403 retry + DisableKeepAlives, gated on an effective proxy (#277) - feat(postgresql): support TLS via --verify / TLS mode (#265)
Merge dev into main: LinkedIn Sales Navigator connector (#211) + rele… …ase sync (#227) * perf(logon): admission-control concurrency + indeterminate≠clean (fix silent backdoor misses) (#170) * feat(logon): indeterminate outcome distinct from clean A non-stabilized RDP render or a failed connection no longer reads as a benign clean result. pumpSession now reports frame stabilization; a clean verdict on a non-stabilized render becomes 'indeterminate'. Surfaced as a loud [WARN] banner, a JSON indeterminate field, and a new exit code 2. Positive verdicts are never downgraded (false-negative guard). Implements fix #2 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): admission control bounds concurrent WASM decode sessions A process-wide weighted semaphore (~1.5x GOMAXPROCS) gates DetectBackdoors so only ~N-core sessions decode at once, regardless of --threads. The per-host timeout budget is no longer consumed while queued: a host blocks in Acquire (no socket, no clock) until it holds a slot. A cancelled-while- queued host returns indeterminate, never clean. --threads still drives network fan-out; a scan-path note warns when it greatly exceeds core count. Implements fix #1 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): wire --retries into detection path; pump honors timeout DetectBackdoors now retries the per-host detection while the result is indeterminate (up to --retries+1 attempts, holding the decode slot across retries, capped backoff). It never retries a found backdoor or a stabilized clean. The frame-pump budget in runSession/runUtilmanSession now honors the per-host timeout instead of hardcoded 5s/3s constants, so the timeout lever actually controls render completeness. Implements fix #4 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(logon): run sticky/utilman sequentially under one decode slot Replaces the two-goroutine-per-host detection with sequential execution (sticky then utilman) under the single held decode slot. The shared- connector design was structurally impossible (the IronRDP WASM FFI moves the connection result out on the first session_new), so each check keeps its own fresh connection — which also eliminates inter-check screen-state contamination. This makes the admission-control bound accurate (one concurrent decoder per slot, not two). Both checks always run and are reported (full attribution); no early-exit. Adds detectSticky/detectUtilman seam vars for deterministic sequencing tests. Implements fix #3 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(logon): address review — gofmt, indeterminate on Test() path, cancelled hosts - gofmt struct alignment in brutus.go (B1, blocker) - formatStickyKeysBanner/formatUtilmanBanner now surface indeterminate as a loud [WARN] instead of dropping it (S1) — closes the starvation-masking bug on the rdp Plugin.Test() sibling path - runScanTargetsConcurrent emits logon.CancelledResults for rate-limiter/ ctx-cancelled hosts instead of nil, so a cancelled host is visible and indeterminate, never silently absent (S2/I6) - remove dead trailing return in DetectBackdoors Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(logon): cover cancelled-host, stabilization guard, runner cancel paths - TestDetectBackdoors_CancelledWhileQueued + TestCancelledResults: a queued- then-cancelled host yields indeterminate (not clean/silent) and never runs detection (GAP 1, invariants I5/I6). - Extract stabilizedVerdict helper (removes 2x duplication) + TestStabilizedVerdict: clean+unstable downgrades to indeterminate; positives never downgraded (GAP 2, I2). - runScanTargetsConcurrentCtx seam + TestRunScanTargetsConcurrent_CancelledIndexIndeterminate: runner cancel branches emit CancelledResults, no host silently vanishes (GAP 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): stickykeys/utilman subcommands + contamination-aware downgrade (#171) * feat(logon): stickykeys/utilman subcommands + contamination-aware downgrade Promote stickykeys and utilman from logon aliases to real single-check subcommands (each runs one check on a clean screen = reliable per-binary attribution). 'brutus logon' stays the combined both-check presence scan. A Check selector threads through DetectBackdoors/runDetection. In both mode, contamination can only occur after sticky pops, so if sticky is positive and utilman comes back clean, utilman is downgraded to indeterminate ('could not independently confirm — rerun: brutus utilman'). Verdict-based (not fragile image analysis); never downgrades a positive, never upgrades, never in single mode. Presence is never lost — a sticky positive flags the host regardless. --exec/--web on 'utilman' are rejected (interactive modes use the sticky- keys backdoor) to avoid silently driving the wrong vector. Aliases remapped: sethc/sticky-keys -> stickykeys, accessibility/ease-of-access -> utilman, winlogon stays on logon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): drop all aliases from the logon command logon is now alias-free (removed winlogon); brutus logon is the only spelling of the combined scan. Single-check aliases remain on their own subcommands (stickykeys: sticky-keys/sethc; utilman: accessibility/ease-of-access). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): remove all subcommand aliases The logon family is now exactly three commands with no aliases: logon, stickykeys, utilman. Dropped sticky-keys/sethc (stickykeys) and accessibility/ease-of-access (utilman). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): exit 0 on a completed scan regardless of findings A logon/stickykeys/utilman scan that completes is now success (exit 0) whether or not a backdoor was found — 'no backdoor' is no longer treated as a failure. Exit 2 still means at least one host was indeterminate (rerun), and now takes precedence over a found result so the operator knows to rerun the undetermined hosts. errNoSuccess is no longer used on this path (still used by other scan paths). scanExitError drops its now-irrelevant hasSuccess parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): corporate Teams user enumeration + posture audit (and device-code auth fixes) (#173) * fix(enum/teams): repair & harden device-code auth `brutus enum teams auth` was unusable. Fixes, in order of discovery: - Drop the `-t` shorthand on `--tenant`: it collided with the global persistent `--threads/-t` flag, which cobra merges into the subcommand at execute time, panicking before the command ran. - Default the tenant to `organizations` instead of `common`: the default Teams first-party client is not enabled for consumer (personal MSA) accounts, so `/common` and `/consumers` returned 401 AADSTS70002 invalid_client. `/organizations` is also the correct audience for Teams enumeration. Verified live: organizations -> 200, common/consumers -> 401. - Auto-open the verification URL in the default browser (validated to https Microsoft hosts only); add `--no-browser` for headless/SSH use. - Floor the OAuth HTTP timeout at 30s so interactive auth is not governed by the aggressive 10s per-target scan `--timeout`. - Route device-code/token requests through the SOCKS5 `--proxy` (reusing brutus.NewHTTPClientWithProxy, which still follows OAuth redirects); previously `--proxy` was silently ignored for teams auth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): corporate Teams user enumeration (TeamsEnum-style) Turn `enum teams` from a token-dumper into a real corporate user enumerator modeled on lucidra-security/TeamsEnum, and fix the auth so it produces a usable token. Auth fixes: - Scope: request the Skype/Teams resource the Teams first-party client is actually preauthorized for ("offline_access https://api.spaces.skype.com/.default") instead of Microsoft Graph, which the client cannot get a token for (AADSTS65002). offline_access is required so Azure AD returns a refresh token (needed for renewal and the enum 401-retry). - Add Client.RefreshAccessToken (refresh_token grant; single request). Enumeration (corporate only): - pkg/enum/teams/enum.go: Enumerator with tri-state EnumResult (yes/no/blocked/unknown) via teams.microsoft.com externalsearchv3 (existence, displayName, MRI) and optional presence (presence.teams.microsoft.com). Bounded concurrency (errgroup + SetLimit, honors --threads/--rate-limit/--jitter), one-shot serialized 401-refresh-retry, proxy support via brutus.NewHTTPClientWithProxy. Access token read through a mutex-guarded getter so concurrent workers never race the refresh write. - New `enum teams users` subcommand: targets via -e/-E, token reuse via --access-token/--refresh-token/--token-file, --presence, --no-browser. Credential store: - `enum teams auth` auto-saves the full token set to ~/.brutus/teams.json (dir 0700, file 0600, O_NOFOLLOW); `enum teams users` auto-loads it when no token flag is given. Terminal output stays truncated; full values only on disk or via --json/-o. Token values never logged or put in errors. Tests: externalsearchv3 status mapping, presence, 401-refresh, a concurrency+refresh regression test run under -race, RefreshAccessToken, credstore perms/round-trip/P0-1, and CLI flag/registration coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/generate): use ranked statistically-likely name list, kill N×N runaway `enum generate --format first.last` cross-multiplied 8,477 first × 8,175 last names = 69,299,475 combos, building the whole slice + dedup map in memory before printing — it appeared to hang and could exhaust RAM/disk (observed >10M lines, 300MB+). The same blowup hit `enum saas`/`enum custom` via GenerateUsernames/GenerateEmails. Replace the cross-product with a prebuilt, frequency-ranked list of statistically-likely first.last pairs (pkg/enum/wordlists/likely-names.txt.gz, 248,231 entries). All 8 username formats are derived from these ranked pairs by splitting on the first "." and reformatting, so output is bounded (≤248k), ranked by likelihood, and internally consistent. `first.last` now completes in ~2s instead of never. - Add `--limit N` to `enum generate` (default 0 = all) to cap output; since the list is ranked, --limit yields the N most-likely names. - Remove the now-orphaned firstnames.txt.gz / lastnames.txt.gz. - Attribute the name list (derived from insidetrust/statistically-likely-usernames) in pkg/enum/wordlists/SOURCES.md. Tests: format derivation (john.smith → jsmith/j.smith/smith.john/…), ranked head-of-list, dedup, multi-part surnames, bounded/non-empty for all formats, GenerateEmails, and capResults/--limit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/generate): add first_last (underscore) username format Add `first_last` format (paul_davis) alongside the existing first.last, flast, etc. Derived from the ranked name pairs like the others; last-name dots are stripped (juan.dela.cruz -> juan_delacruz). Wired into ListFormats, formatUsername, and the --format help on generate/saas/custom. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): report tenant config posture (cross-tenant chat, leakage) Surface Microsoft Teams misconfiguration/leakage from the externalsearchv3 + presence responses already fetched (no extra requests for the core signals): - External / cross-tenant chat: 403 => blocked, 200 + type:Federated => open. - Account metadata leak: tenantId, userPrincipalName, objectId, accountEnabled, featureSettings.coExistenceMode captured per user. - Presence + OOO leakage (under --presence): sourceNetwork (Federated/Unknown), outOfOfficeNote.message (parsed from presence or calendarData), availability, deviceType. - TenantPosture aggregation + always-on summary: external_chat_allowed (open|blocked|unknown), federated_observed, presence_visible, ooo_exposed, coexistence_mode — emitted as a posture block (human) and a teams_posture JSON line. EnumResult gains the new fields; outputs sanitize server-controlled strings (OOO notes, UPN, coexistence mode) and never include tokens. Refs SSE research (medium.com/sse-blog) on externalsearchv3 federation/leakage indicators. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): add `audit` subcommand — graded posture findings `brutus enum teams audit --email known.user@target.com` turns one seed user's enumeration result + derived tenant posture into graded security findings about external Teams exposure: - teams-external-access (Medium) — cross-tenant/federated chat enabled - teams-user-enumeration (Low) — externalsearchv3 oracle distinguishes users - teams-presence-disclosure (Low) — presence exposed externally (--presence) - teams-oof-disclosure (Low) — out-of-office note exposed externally (--presence) - teams-metadata-disclosure (Info)— UPN/objectId/tenantId returned to external party Reuses the `users` token-resolution / refresh / enumerator machinery (a shared teamsTokenSource struct now feeds both subcommands). Graded human report (severity-colored, sanitized evidence, remediation) plus a teams_finding JSONL line per finding. Tokens never appear in findings or output. Tests: Audit ruleset (presence/absence/severity/ordering, presence-gating, metadata), JSONL/human output (P0-1 token-safety, sanitization, truncation), and command registration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): stream results live, hits-only output, label account type Running `enum teams users` over a large list printed nothing until the process ended (results were buffered and dumped at the end), and printed a NOT FOUND row for every miss. Also, EXISTS hits with 8:live: MRIs (consumer/personal accounts) were indistinguishable from 8:orgid: (corporate) ones. - Stream results live: new Enumerator.EnumerateWith(..., onResult) invokes a callback per completed result (serialized under the results mutex; race-clean). Enumerate stays as a nil-callback wrapper. The CLI prints each hit as it lands. - Hits-only by default: print only EXISTS and BLOCKED (both positive signals); NOT FOUND / UNKNOWN are suppressed unless --verbose. A periodic "processed N/total — F found, B blocked" progress line goes to stderr. Final summary + posture print once at the end (rows no longer re-printed). - Account-type labeling: AccountType(mri) classifies corporate (8:orgid:) vs consumer (8:live:); shown on EXISTS rows and as account_type in JSONL — so personal-account noise is distinguishable from real corporate users. Tests: AccountType, EnumerateWith callback (per-result, nil, and concurrent 401-refresh under -race), result-line account-type + sanitization, JSONL account_type + token-safety. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): ignore consumer (8:live) accounts by default externalsearchv3 returns both corporate (8:orgid:) and consumer/personal (8:live:, "Teams For Life") accounts. A consumer account merely using a corporate email is usually noise (e.g. a personal Teams account on a work address), so treat those as misses by default. - EnumerateOne now prefers the corporate (8:orgid:) entry from the results and takes all metadata from it. If only a consumer (8:live:) match exists, it's reported as not found unless --include-consumer is set. Corporate users are returned by externalsearchv3 regardless of the consumer setting, so no real corporate hit is lost. - New --include-consumer flag (default false) on `users` and `audit` re-enables consumer matches (labeled "consumer") for cases where a real employee only surfaces via a personal account. (Trade-off: an employee whose corporate Teams is external-search-blocked and who only has a personal account won't show by default; --include-consumer recovers them.) Tests: consumer-only ignored by default / included with flag, corporate preferred over consumer in a mixed result (metadata from the corporate entry), corporate always found, and the --include-consumer flag on both subcommands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/teams): demote user-enumeration finding to Info Teams user enumeration via externalsearchv3 is inherent to Microsoft Teams and cannot be disabled by the tenant (it works even when external access is blocked — 403 differs from an empty 200). Reporting it as Low overstated it, since its own remediation says it can't be fixed. Reclassify it as Info (informational observation) and reword the description/remediation to match. The remediable findings (external-access Medium, presence/OOO Low) keep their severities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): present 403 as EXISTS (details restricted) A 403 from externalsearchv3 reliably means the account exists but the tenant withholds its details, so for enumeration it's a confirmed hit — calling it "BLOCKED" undersold it. Present it as a hit instead (presentation/counting only; the internal Existence enum and tenant posture logic are unchanged, since posture still needs the 200-vs-403 distinction). - Human: a 403 renders as "[+] EXISTS (details restricted)" (no name/MRI). - Summary: Exists headline = with-details + details-restricted, e.g. "Exists: 1043 (28 with details, 1015 details-restricted)". - JSONL: 403 -> exists:"yes", details_restricted:true (no metadata fields). - Tenant posture still reports External chat: BLOCKED for the tenant config — separate from per-account existence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): generate candidate emails on the fly via --domain `enum teams users` can now generate the candidate wordlist internally instead of requiring a pipe from `enum generate`. Passing --domain produces the ranked statistically-likely emails for that domain (reusing enum.GenerateEmails), optionally capped with --limit and shaped with --format (default first.last). Generated candidates combine with any -e/-E inputs and are deduped, then flow through the same enumeration path. Invalid --format is rejected with the valid list. brutus enum teams users --domain target.com --format first.last --limit 5000 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): gather presence/OOO by default, add --no-presence Presence (availability/device) and out-of-office data are useful, so collect them by default rather than behind an opt-in flag. Replaces `--presence` (default off) with `--no-presence` (default off => presence on) on both `enum teams users` and `enum teams audit`. Presence is a second request that only fires for users that resolve with an MRI, so on locked-down (all-403) tenants this adds no calls; --no-presence skips it for speed/stealth. For audit, the presence/out-of-office findings are now evaluated by default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(enum/teams): fix stale --presence comment and empty field comment Update the Audit doc comment to reference --no-presence (presence is default-on now), and fill in the dangling empty comment on Finding.Remediation. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/saas): identify Microsoft Teams as an oracle for M365 orgs (#174) * feat(enum/saas): identify Microsoft Teams as an oracle for M365 orgs When DNS TXT recon detects a Microsoft 365 tenant (MS=ms / spf.protection.outlook.com), surface `teams` as an available enumeration oracle for the org (display/inference only — it is not run as an unauthenticated oracle; the enum loop still filters to registered plugins). Output points the user to `brutus enum teams users` / `audit`, and the dns_recon JSON gains `teams_available:true`. `enum saas discover` additionally CONFIRMS the Teams oracle opportunistically: when a token is cached at ~/.brutus/teams.json, it runs the Teams enumerator against the --known-valid seed and reports working / exists-restricted / not-found / unconfirmed (reusing the teams enumerator + credstore; no new flags, no token in output). Without a cached token it reports "available (unconfirmed) — run `brutus enum teams auth`". pkg/enum/dns.go stays a pure DNS parser; the Teams inference/confirmation lives in the saas command layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/saas): always validate oracles (discover) and require --known-valid `enum saas` now requires --known-valid and always validates oracles against it before enumerating — enumeration runs only against oracles that confirmed the known-valid user, so broken/rate-limited oracles don't produce misleading results. The validation step now also confirms the Teams oracle (via the cached token, mirroring `discover`). The standalone `discover` subcommand is unchanged. Note: --known-valid is now required for all `enum saas` invocations (including DNS-recon-only runs), per the always-validate model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(enum): rename `enum saas` -> `enum oracles`, reframe output on oracles The command's purpose is identifying which account-existence ORACLES work for an org, not cataloguing SaaS services in use — so rename it (hard rename, no `saas` alias) and refocus the output: - `enum saas` -> `enum oracles` (file, symbols, flags, help, README all renamed). - Output now leads with an "Oracle Check" report: each oracle checked against --known-valid shown as [+] working / [-] not working (microsoft365, google, and the Teams oracle). DNS recon is demoted to a one-line "discovered candidate oracles" note (full TXT detail under --verbose / in JSON). - `discover` remains the validate-only subcommand under `enum oracles`. Behavior (required --known-valid, always-validate, enumerate-only-working, Teams confirmation, --domain generation) is unchanged. Tests renamed/updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/google): add `enum google` — Workspace account enum + SSO/IdP New `brutus enum google` identifies Google Workspace / Gmail accounts (parallel to `enum teams users`, but unauthenticated — no token needed): - Targets via -e/-E or on-the-fly generation (--domain/--format/--limit, reusing the ranked statistically-likely list); streamed hits-only output + summary; JSON (type "google_account"). - Per account surfaces the detection method and federation detail: AccountChooser SSO redirect -> "workspace-sso (-> <IdP host>)"; Gmail GXLU cookie -> "gmail". Detection logic moved into a new reusable pkg/enum/google package (rich Result: exists + method + IdP, with errgroup/streaming EnumerateWith mirroring teams). The existing unauthenticated `google` oracle (internal/enumplugins/google) now delegates to it — one implementation shared by `enum oracles -s google` and `enum google`. Tests: CheckAccount (SAML-header / non-google-redirect -> workspace-sso+IdP, GXLU -> gmail, not-found, transport error), EnumerateWith under -race, command flags/registration, and output (JSON shape + IdP sanitization). All offline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(examples): add enum-custom demo — vulnerable app + oracle specs (#175) Add examples/enum-custom-demo/: a self-contained, intentionally account-enumeration-vulnerable HTTP server plus matching Brutus `enum custom` oracle specs, to teach/demo the declarative oracle spec format. - main.go: zero-dependency net/http target. /api/forgot-password leaks account existence (valid email -> 200 "reset link sent", invalid -> 404 "No account found"); /api/login leaks via distinct messages (401 vs 404). 4 hardcoded demo accounts; loud "intentionally vulnerable" banner; --addr flag. - forgot-password-oracle.yaml / login-oracle.yaml: schema-v1 custom specs whose match rules map those responses to exists/absent verdicts. - README.md: walkthrough (run target -> build brutus -> `enum custom`), the expected output, and a "how the spec works" mapping of the YAML to the app's behavior + a safety note. Verified end-to-end: `brutus enum custom -f forgot-password-oracle.yaml -e alice@demo.local,nobody@demo.local` -> alice EXISTS, nobody NOT FOUND (same for the login oracle). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): behavioral nonce-confirmation + region classifier + ambiguity→indeterminate (fix no-Vision specificity) (#172) * feat(rdp): color/position pre-filter + ambiguity->indeterminate (Levers 2+3) detectChangedRectangle now returns the changed-pixel bounding box. New pure classifyRegion (dark+large+top-left=console vs light+small+centered=dialog) and decideVerdict total function route an ambiguous backdoor_likely box to indeterminate instead of asserting a backdoor — killing the no-Vision false positives on non-NLA hosts. Cardinal rule: no backdoor_likely->clean path; clean only from a clean heuristic. No new deps. Implements Levers 2+3 of the logon-behavioral-detection feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): behavioral nonce-confirmation in detection (Lever 1) After a trigger produces a candidate window, type a benign read-only nonce (echo BRUTUS-<rand>) into it, re-capture, and verify a shell-like text render (verifyEcho pixel-diff scoped to the box). A real shell echoes; a legit dialog does not. runSession/runUtilmanSession thread the nonceResult into decideVerdict: confirmed echo -> backdoor_confirmed; box-but-unconfirmed -> indeterminate (never clean). Any type/capture error -> nonceUnconfirmed (fail-safe). crypto/rand only; no new deps. Implements Lever 1 of the logon-behavioral-detection feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): map sticky Vision STICKY_KEYS_DIALOG to vulnerable runStickyKeysAnalysis was missing the Vision vulnerable branch that runUtilmanAnalysis has, so Vision-mode sticky over-flagged the legit accessibility dialog as backdoor_likely. Add the symmetric branch (after the backdoor_confirmed escalation, so it can't suppress a confirmed backdoor). Implements Phase C of the logon-behavioral-detection feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): wire classifyRegion into confidence/banner + broaden nonce gate Review fixes (both cardinal-safe, verdict logic unchanged): - SF-2: gate behavioral nonce-confirmation on the heuristic verdict (backdoor_likely) instead of just rectangularity, so non-rectangular real backdoors also get confirmed instead of dropping to indeterminate. - SF-1: classifyRegion was dead (decideVerdict discarded it). It now feeds result Confidence + a diagnostic banner note (console-confirmed -> 0.95; geometry notes on indeterminate reruns) — never the verdict. Adds regionConfidenceAndNote (+ test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): dark-pixel-delta discriminator, remove behavioral confirmation Live matrix testing proved the behavioral pixel-diff (verifyEcho) false- confirms on real non-NLA hosts: the legit accessibility dialog reacts to typed keystrokes, so the coarse diff confirmed every host (incl. clean). Replace it with a Sticky-Keys-Slayer-style passive dark-pixel-delta: count the increase in dark pixels (brightness<60, covering black cmd + blue PowerShell) baseline->response. A dark console-sized region (~4-65% of frame) -> backdoor_likely; near-zero new dark (light dialog/nothing) -> clean; ambiguous (small below band / full-screen above) -> indeterminate (never clean). Thresholds scaled to 1024x768, to be calibrated on the matrix. Removes typeAndConfirm/verifyEcho/nonce plumbing; keeps the indeterminate safety net, region classifier (diagnostic), and Vision sticky fix. No deps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): require a settled, complete frame before analyzing Detection was capturing half-painted RDP frames: the settle check declared 'stable' after just 3 identical frame-available steps (no min pump time, no wall-clock quiet window), so a brief mid-paint pause short-circuited it; and the baseline pump ignored stabilization, so we captured/triggered during session init ('Please wait for the Local Session Manager'). Now pumpSession settles on a wall-clock quiet window (framebuffer unchanged >=1500ms) AND a minimum pump time (>=2s) before declaring stable; the baseline is settled before the trigger; stabilized = baselineStable && responseStable. A still-initializing or never-settling screen yields stabilized=false -> indeterminate (never a false clean). Adds settled() pure helper + TestSettled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): optional debug screenshot dump (BRUTUS_DEBUG_SCREENSHOT_DIR) When the env var is set, dump each baseline+response frame to PNG during logon detection (per host/check/phase). No behavior change when unset. Used to diagnose what's actually on screen; also useful as operator evidence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): confirm sticky-keys prompt + noise-tolerant settle Sticky path: after Shift x5, send Enter to confirm the legit 'turn on Sticky Keys?' dialog (verified present on Server 2016/2019/2022) so the hijacked sethc.exe actually runs -> cmd. utilman (Win+U) needs no prompt, unchanged. Settle: replace exact crc32-equality with an inter-frame changed-pixel threshold (settleNoisePixels=2000) so a blinking console cursor / spinner no longer prevents settling (the main cause of the indeterminate flood). A real window repaint is far above the threshold, so detection is unaffected. Adds framesQuiet pure helper + TestFramesQuiet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(rdp): drop spurious sticky-keys confirm keystroke Commit 9ec5d0a added a post-Shift x5 confirm keystroke (later switched Enter -> 'Y') in runSession to dismiss the "Sticky Keys?" prompt. Live testing showed a properly-hijacked sethc (binary replacement) yields a direct cmd with no prompt, so the confirm keystroke is unnecessary and spurious. Restore runSession's sticky trigger to the pre-experiment flow: 5x Shift -> post-trigger sleep (1500ms) -> pumpSession settle -> captureFrame, structurally matching runUtilmanSession. Remove the extra ~1s sleep and the now-unused yKeyScancode const. Unchanged: dark-delta verdict, quiet-window/min-pump settled(), noise-tolerant framesQuiet, BRUTUS_DEBUG_SCREENSHOT_DIR dump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): internet-scale logon scanning (re-land #176 onto dev) (#179) * feat(logon): NLA negotiation pre-filter + --timeout settle-floor warning Add a pure-Go RDP negotiation probe that runs before the expensive WASM decode and classifies NLA-required hosts as a terminal, non-retryable 'nla_required' verdict, skipping the session on hosts whose pre-auth logon screen is unreachable without credentials. The probe requests SSL-only and returns nla_required strictly on NEG_FAILURE HYBRID_REQUIRED_BY_SERVER; every other outcome fails open to the full WASM scan, so it can never skip a scannable host. It runs network-bound before decode-slot acquisition, so unscannable hosts cost ~1 RTT and never touch the core-bounded decoder pool. Disable with --no-nla-probe. Also warn when --timeout is below the settle floor (minPump + quietWindow = 3.5s, exported as rdp.MinViableTimeout): too short a timeout forces every host to INDETERMINATE and wastes the full retry budget for zero signal. Validated live on a Server 2016/2019/2022 x {both,sticky,util,clean,NLA} matrix: 10/10 armed backdoors detected [HIGH], 0 false positives, NLA host correctly classified nla_required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): --connect-timeout + terminal "unreachable" verdict for dead hosts Make a dead/filtered RDP host the cheapest case, not the most expensive. Adds --connect-timeout (default 3s), separate from the per-host settle --timeout, for all scan TCP dials. A failed TCP connect is now a terminal, NON-retryable 'unreachable' verdict (Success=false, Indeterminate=false) instead of a retryable indeterminate: the NLA probe short-circuits unreachable hosts BEFORE acquiring a decode slot (new rdp.NegoUnreachable), and the WASM-path dial failure maps to unreachable via a new Unreachable flag set only at the dial site — WASM-connector/init/session failures stay indeterminate. Validated live on GCP: a filtered host (SYN black-holed) drops from ~100s (full timeout + retry, holding a decode slot) to ~3.5s — no slot, no retry. --connect-timeout directly controls the dial wait (3s→3.5s, 15s→15.5s); both probe and WASM paths yield terminal unreachable; closed/RST hosts resolve in ~0.5s. backend-reviewer + backend-security both APPROVED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(logon): rename --timeout to --scan-timeout for logon/stickykeys/utilman The logon family now uses --scan-timeout for the post-connect settle/scan deadline, distinct from --connect-timeout (the TCP dial). Passing --timeout to these commands is a hard error pointing to the new flags. --timeout is unchanged for all other commands (creds/enum/etc.), where it remains the generic per-target timeout. Resolves the confusion of two timeouts where --timeout no longer described what it does in a scan. BREAKING (logon family only): brutus {logon,stickykeys,utilman} --timeout <d> must become --scan-timeout <d>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): --fast survey mode (never-clean) + console-region precision gate Add opt-in --fast triage for logon/stickykeys/utilman: a short SettleBudget for internet-scale sweeps, made safe by a never-clean invariant — fast reports only HIGH/CRITICAL or indeterminate, NEVER clean (a slow-rendering backdoor that hasn't painted is flagged indeterminate, never mis-reported clean). ~5x faster on clean hosts. Also harden the HIGH verdict with a console-region gate: a backdoor_likely from the dark-pixel-COUNT delta alone now requires real console evidence (confidence floor OR a dark+large+genuinely-dark-filled changed region via darkBoxFraction); otherwise it downgrades to indeterminate. Kills a false positive where a login-wallpaper shift put the dark-pixel count in the console band with no actual console (0%-confidence [HIGH]), and hardens precision in BOTH fast and careful modes. Validated live (cmd + PowerShell binary-replacement + real-frame fixtures): never-clean holds (0 clean verdicts on fast); no false positives (clean -> indeterminate, not HIGH); recall: cmd + windowed-PowerShell -> HIGH, careful recall fully restored, slow sticky-path renders -> indeterminate (recoverable via careful re-sweep), never clean. Real captured frames committed as permanent regression fixtures (testdata/realframes): wallpaper FP -> not HIGH, real consoles -> HIGH. Dark-delta band thresholds unchanged (additive gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/dehashed): add DeHashed domain breach-data collection (no credentials) (#183) Add `brutus enum dehashed --domain <d>`: collect breach-exposed identity data (emails, usernames, names, db name, obtained date) for a domain via the DeHashed v2 search API, paginated, 1 credit per page. Adapts the verified client from praetorian-inc/frumentarii into the brutus enum client+subcommand pattern (mirrors Hunter.io). Scope: credentials are intentionally NOT collected. The `password` and `hashed_password` API fields are absent from the unmarshal target, the public Record type, and all output — they are dropped at decode and can never surface (TestSearch_DropsCredentials locks this invariant). `--domain` only for v1. Security: API key (Dehashed-Api-Key header) held in an unexported field, never logged or in the URL; classifyDehashedError reports status codes only (errors.As, never %w-wrapping vendor Details); bounded 1MB reads; terminal-injection sanitization on all breach-sourced output (hostile-controlled); --limit bounds credit spend with a stderr cost notice; 10k API result cap; no silent 429 retry. 87.7% test coverage; backend-security review APPROVED. NOTE: DeHashed v2 status codes/field names are sourced from the frumentarii reference + v2 docs and isolated in unexported structs — validate against a live key before relying on live calls (httptest suite passes regardless). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(build): make teams credstore O_NOFOLLOW portable (fix Windows build) (#184) cmd/brutus/teams_credstore.go used syscall.O_NOFOLLOW directly in the os.OpenFile call that persists the Teams token. syscall.O_NOFOLLOW is Unix-only and undefined on Windows, so GOOS=windows go build ./... failed with "undefined: syscall.O_NOFOLLOW". Split the constant behind a build tag: - teams_credstore_nofollow_unix.go (//go:build !windows) defines oNoFollow = syscall.O_NOFOLLOW, preserving the symlink-redirect protection on Unix. - teams_credstore_nofollow_windows.go (//go:build windows) defines oNoFollow = 0; Windows has no O_NOFOLLOW and Go's os layer does not follow file symlinks there, so 0 is the safe portable fallback. teams_credstore.go now references oNoFollow and no longer imports syscall (its only syscall use). Behavior on Unix is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum): add Apollo.io + Lusha HUMINT enum subcommands (#182) * feat(enum/apollo): add Apollo.io people discovery + opt-in email enrichment Add `brutus enum apollo`: resolve an org via the Apollo People Search API (/api/v1/mixed_people/api_search — free, no PII), list people (name, title, seniority, department), and optionally unlock emails via /people/match with `--reveal` (consumes credits, bounded by --limit, default 100). Mirrors the verified Hunter.io client+subcommand pattern. Emails only: Apollo phone reveal requires an async webhook_url (impractical for a CLI; avoids an inbound callback/SSRF surface). Security: API key (X-Api-Key) held in an unexported field, never logged or placed in the URL; classifyApolloError reports status codes only (no vendor Details echo); bounded 1MB response reads; terminal-injection sanitization on all human output; no silent 429 retry. 88.7% test coverage (httptest-driven). NOTE: Apollo endpoint paths/field names are docs-derived and isolated in unexported consts/structs — validate against a live key before relying on live calls (httptest suite passes regardless). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/lusha): add Lusha v3 single-contact enrichment; register apollo+lusha Add `brutus enum lusha`: resolve one person identity (name+company/domain, email, or LinkedIn URL — mutually exclusive) to an enriched contact via the Lusha v3 /v3/contacts/search-and-enrich API, returning emails (type + confidence) and phone numbers (type + Do-Not-Call flag). Targets v3, not the sunsetting v2 (v2 retires 2026-11-18). Every call consumes credits, so the command prints an unconditional cost notice. Also wires both new subcommands into `brutus enum` (cmd_enum.go) and the help text. Mirrors the verified Hunter.io client+subcommand pattern. Security: api_key held in an unexported field, never logged or in the URL; classifyLushaError reports status codes only (errors.As, never %w-wrapping the vendor Details that could echo the key); bounded 1MB reads; terminal-injection sanitization; Do-Not-Call flag surfaced (never hidden) in human + JSONL output; no silent 429 retry. 93.6% test coverage (httptest-driven). NOTE: Lusha v3 request/response field names are docs-derived and isolated in unexported consts/structs — validate against a live key before relying on live calls (httptest suite passes regardless). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum): address PR #182 review (Lusha v3 schema, Apollo limit/partials, error hygiene) Apply automated-review findings (Codex/Gemini/Claude/CodeRabbit) on PR #182: - Lusha v3 batch schema: request now sends a contacts[] array + reveal[] and decodes the results[].contactMethods response (flat single-object shape was wrong and would silently bind nothing against the real API). Single contact in/out; address key + reveal tokens still flagged UNVERIFIED pending a live key. - Apollo --limit guard: reject negative --limit, and reject --reveal with --limit 0 (the documented credit bound is now enforced, not just advisory). - Preserve paid/discovered data on error: SearchPeople/RevealEmails return partial results with the error; Revealed is set on first success; runEnumApollo emits partials before returning a nonzero error. - APIError.Error() no longer embeds vendor Details (prevents echoed key/PII leak via the exported client error); classify*Error %w-wraps non-APIError (network) errors for debuggability while keeping APIError status-code-only. - Lint: staticcheck S1016 struct conversions + gofmt alignment. Tests updated to the v3 schema and new behaviors; coverage apollo 90.6%, lusha 95.0%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum): correct Apollo/Lusha response schemas (verified against live APIs) (#185) Live-key testing against fox.com surfaced two response-binding bugs in the passive enrichment commands (both shipped in #182): - Apollo: `total_entries` is a TOP-LEVEL field on the /mixed_people/api_search response, not nested under `pagination` (which is null). Result.Total always showed 0; now reports the real count (e.g. 2376 for fox.com). Removed the unused apolloPagination struct. (last_name/name are null from this credit-free endpoint by design — Apollo masks them pre-reveal; left as-is.) - Lusha v3: each result's `emails`/`phones` are TOP-LEVEL, not under a `contactMethods` wrapper; the email key is `email` (not `address`); and `confidence` is a string grade ("A+"), not an int. Enrichment previously reported "no contact data" despite a successful, credit-charged reveal; now returns the email + phone correctly. `reveal:["emails","phones"]` confirmed correct (the singular form is rejected by the API). Schemas are now verified against the live Apollo & Lusha APIs (2026-06-26), not docs-derived. Tests updated to the real shapes; apollo 90.6% / lusha 95.0%. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/dehashed): filter noise (corporate-only, dedup, drop combolists) + show phones (#186) DeHashed domain results are noisy (personal webmail that leaks into the domain match, recycled combolists, duplicate rows per breach). Add a default-on refinement pipeline plus phone output: - Corporate-only: keep records whose email is @<searched domain>; drop leaked-in personal/webmail. Opt out with --all-emails. - Dedup-by-email: merge a contact's records across breaches into one row — union names/usernames/phones, list distinct source DBs + a count. Opt out with --no-dedup. - Drop combolists/aggregators: curated, case-insensitive denylist (Naz.API, ALIEN TXTBASE, Collection*, Combolist, AntiPublic, BreachCompilation, Exploit.in, Cit0day, Pemiblanc) of recycled dumps; real breaches kept. Opt back in with --include-combolists. - Phones surfaced: a new Phone column / JSONL field (already collected, just not shown). Empty Date column dropped. Refine(records, RefineOptions) is a pure, unit-tested function (Entry output type). Credentials remain omitted. Verified live against fox.com: 100 records → 67 unique @fox.com contacts with phones, combolists/Gmail stripped. Coverage 93.5%. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum): capture full target data model (linkedin, dept, seniority, employment) across Apollo/Lusha/Hunter (#188) Make the HUMINT sources collect every field the phishing-targeting model uses, where the API provides it (verified against live Apollo/Lusha/Hunter APIs): - Apollo: reveal is now DEFAULT-ON (--no-reveal for free discovery; --limit still bounds credit spend). Reveal (/people/match) now captures the FULL record — last_name (un-obfuscated), linkedin_url, seniority, departments[], verified email, city/state/country, and raw employment_history (organization/title/ start_date/end_date/current) for tenure. twitter_url too. - Lusha: now maps socialLinks.linkedin, jobTitle.departments[]+seniority, company.domain, location.country, and previousEmployment → Employment[] (current + prior). Was dropping all of these. - Hunter: now maps the linkedin + twitter fields the domain-search API already returns (previously discarded). - DeHashed: breach data has no title/dept/linkedin/tenure — unchanged (already collects email/name/phone/username). Tenure is passed as raw employment history (no tenure-years computed). New EmploymentEntry type per package. JSONL carries the full richness; human output adds a LinkedIn column. Coverage: hunter 91.7% / apollo 92.0% / lusha 95.2%. Stacked on #185 (corrected Apollo/Lusha live schemas). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(cmd): gofmt enum/logon test files to unblock CI lint (#189) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/hunter): handle plan result-cap as soft pagination stop (preserve partials) (#190) On capped Hunter plans (e.g. Free = 10 results), Search paginated past the cap; Hunter returns HTTP 400 "results are limited to N … on your current plan", which fetchPage treated as fatal — discarding the page-1 emails already collected. A raw single-page call succeeds, but our client always issued the follow-up offset page, so every --limit value failed and the command returned nothing. Fix: add sentinel ErrPlanLimited (APIError.Unwrap maps a 400 whose details contain the plan-limit marker; other 400s stay generic/fatal) and a DomainResult.Truncated flag. Search now treats ErrPlanLimited mid-pagination as a soft stop — break and return the results collected so far with nil error. The command prints a stderr notice when truncated ("plan result cap reached — returning first N of M") unless --quiet/--json. 401/429/451 stay fatal. Verified live (Free key): `enum hunter --domain fox.com` now returns the first 10 of 2138 with a cap notice instead of erroring out. 92.3% coverage. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum): group passive API-key sources under `enum passive` (#191) Hunter, Apollo, Lusha and DeHashed are all API-key OSINT/HUMINT sources, so group them under a new `brutus enum passive` parent (distinct from the active enumerators oracles/kerberos/teams and the local generate/custom utilities): brutus enum passive hunter|apollo|lusha|dehashed Each command is now built by a `newEnumXCmd()` builder so it can be mounted both under `passive` (canonical) and as a hidden, deprecated back-compat alias directly under `enum` — so existing `brutus enum hunter` (etc.) invocations keep working and just print a deprecation notice pointing at the passive path. No behavior change to the commands themselves; flags and RunE unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/dehashed): collect plaintext breach passwords, associate per user (#193) Reverses the earlier credential-omission (PR #183) for authorized red-team use: DeHashed now collects plaintext `password[]` and associates it with each contact (unioned across that email's breach records on dedup). `hashed_password` is still omitted (plaintext only, per scope). - Collected & shown BY DEFAULT; `--no-credentials` suppresses passwords from output (library always returns them — the SaaS consumer decides display). - Combolists are now INCLUDED by default (breach passwords live almost entirely in combolist/aggregator dumps); `--exclude-combolists` opts into clean identity-only enumeration. Corporate-only + dedup stay on, so non-corporate webmail noise is still filtered. - Passwords are attacker-controlled strings → sanitizeTerminal+truncate (P0-4). The DeHashed API key is still never logged/in-URL (separate from breach creds). Library (pkg/enum/dehashed) prints nothing; Record/Entry expose Passwords. Verified live (fox.com): plaintext passwords now associate to contacts; gone under --no-credentials. 93.5% coverage. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/lusha): add domain-roster enumeration (search + enrich whole company) (#192) `enum passive lusha --domain <d>` (with no name/email/linkedin) now enumerates an entire company: Lusha prospecting search (filters.companies.include.domains) → prospecting enrich each contactId → a full roster of enriched people (email, phone, LinkedIn, dept, seniority, location). Mirrors the Apollo domain flow. `--domain` WITH a name still does the single-contact lookup (unchanged). Library-clean for SaaS import: pkg/enum/lusha prints nothing; SearchDomain returns *DomainResult{Domain,Contacts,Total,CreditsCharged} so the caller can track spend. limit<=0 collects all (bounded by a page ceiling); CLI default 0 (collect all) per "maximize collection", with a stderr cost notice for operators (suppressed by --quiet/--json). The prospecting API uses different response field names than the single-identity endpoint, so it has its own mapping structs. Verified live (fox.com): roster of contacts with emails/phones/LinkedIn + a credits-charged total. 88.3% coverage, race-clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/apollo): discover→enrich split (stop auto-revealing the whole org) (#195) * feat(enum/apollo): discover→enrich split (stop auto-revealing the whole org) Apollo no longer reveals every discovered person by default (that cost ~1 credit/person × the whole org). New model: cheap discovery by default, enrichment is selective — driven by the SaaS UI. - Library: SearchPeople → Discover (free api_search; surfaces availability via Person.HasEmail/HasPhone from has_email / has_direct_phone). New EnrichByIDs(ids) does selective /people/match — the UI's per-selection call. RevealEmails now delegates to EnrichByIDs over a result's ids (the --enrich full-pull). DomainResult.CreditsCharged reports spend. Library prints nothing. - CLI: default is now DISCOVER ONLY (no credits); --no-reveal removed, --enrich added (reveal all discovered, with the cost notice). --limit still bounds. - Output branches on Revealed: discovery shows Email?/Phone? availability (no values); --enrich shows full records + credits charged. Note: has_direct_phone is a STRING ("Yes"/"Maybe: …") not a bool — HasPhone is true only when == "Yes". 92.9% coverage, race-clean; discovery asserted to make zero /people/match calls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/apollo): address AI review — bound --enrich spend, dedupe, retain phone availability Addresses the AI reviewer comments on PR #195: - Reject `--enrich` without a positive `--limit` (guard runs before key resolution, so it fails fast with no network call). Closes the unbounded whole-org reveal that Claude/Codex/Gemini all flagged — the exact cost blowout this discover→enrich split exists to prevent. - Emit the credit-spend notice on stderr regardless of --json (stderr does not corrupt stdout JSON); still suppressed by --quiet. Previously `--enrich --json` spent credits with no notice. - RevealEmails now dedupes (and drops empty) ids before EnrichByIDs, so a duplicate id from pagination no longer burns a second credit; CreditsCharged counts unique ids. - Retain free has_email/has_phone availability on enrich: add them to the enriched JSON and a Phone? column to the enriched table (phone is never revealed, so this was a data loss). The SearchPeople→Discover rename and --no-reveal removal are intentional: in-repo only this CLI used them, and the SaaS UI uses the new Discover/EnrichByIDs selective-enrichment API. Tests: +TestRunEnumApollo_EnrichWithZeroLimitRejected, +TestRevealEmails_DeduplicatesIDs, enriched-output availability assertions. gofmt/vet clean; cmd/brutus + pkg/enum/apollo suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/lusha): correct domain-roster pagination + review findings (#192 follow-up) (#194) * fix(enum/lusha): correct domain-roster pagination + review findings (#192 follow-up) Address the Codex/Gemini review on #192 (merged): - CRITICAL: stop mutating prospecting search pages.size per remaining-limit while incrementing pages.page — that corrupted the offset (page*size), causing duplicate/skipped contacts and wasted enrich credits for --limit > page size. Search page size is now constant; only the enrich side is bounded by remaining, and the loop stops at limit. (Regression-tested: --limit 75 over 2 pages → 75 unique contacts, constant size, correct offsets.) - Reject negative --limit (only 0 = collect-all). - Reject --phone/--email-only in domain-roster mode (were silently ignored). - Emit a trailing JSONL summary envelope {type:"lusha_summary",domain,total, returned,credits_charged} so pipeline/SaaS consumers can track spend + detect truncation. - Widen the human phone column to 26 so the [DNC] marker isn't truncated on long international numbers. Library still prints nothing. 87.9% coverage, race-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/lusha): preserve DNC marker on long phones + stop broken-pipe flood Addresses Codex + Gemini review on #194: - Human roster phone column: truncate the number to 20 runes before appending " [DNC]" so the Do-Not-Call compliance marker (P0-DNC) is always fully visible in the 26-wide column instead of being cut to " [D…". (Codex P2) - JSONL roster output: break the per-contact encode loop on the first enc.Encode error so an early-closed consumer (e.g. `| head`) gets a single stderr line, not one per remaining contact. (Gemini) Regression tests cover both: a >20-rune international number with DoNotCall asserts the full " [DNC]" survives, and a failing writer asserts the JSONL loop stops early. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum): group active sources under `enum active` + add `enum active github` (#200) Mirror the `enum passive` grouping for active enumeration: move oracles, google, kerberos, teams, and custom under a new `brutus enum active` parent. This is a hard move — the old `brutus enum <name>` paths are removed (no aliases). `enum generate` stays top-level (local wordlist utility) and the `enum passive` sources are untouched. Help text, examples, and README updated. Add `brutus enum active github`, a port of GhEmailBrute: - Existence (unauthenticated): scrape the CSRF token from the GitHub signup page (following /join -> /signup) and POST /email_validity_checks; HTTP 422 means the email is in use, 200 means available. Bounded, ctx-aware 429 backoff. Reuses the shared --domain/--format/--limit wordlist generator. - Username reveal (authenticated): when a PAT is supplied via GITHUB_TOKEN or --token, create a temporary private repo, push one commit per existing email (author/committer = the email), list commits to resolve email -> author.login even under commit-email privacy, then always delete the repo. On delete failure the owner/repo is surfaced so the operator can clean up manually. The token is never logged; token-bearing API requests do not follow redirects (PAT-leak protection), while the token-less existence flow does. The github enumerator mirrors the google package's concurrency model (errgroup + rate limiter + jitter + serialized result callback) and is split across github.go / existence.go / reveal.go. Output (human + JSONL) sanitizes server-controlled email/username before printing to a TTY. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum): live progress bar with rate + ETA for `enum active` scans (#201) Replace the per-500 `processed N/M` counter (triplicated across the github, google, and teams scans) with a reusable progressReporter that renders a live, in-place bar on a TTY: [*] [=====> ] 42% · 4200/10000 · 38/s · elapsed 1m50s · ETA 2m32s · 8 found A background ticker redraws on a timer, so elapsed/ETA keep advancing even while the scan is stalled on rate limiting. Off-TTY it falls back to throttled newline lines; suppressed under --quiet/--json. Update() stores a cheap snapshot under a mutex, so it is safe on the serialized onResult hot path. The github username-reveal phase is no longer a blind wait: reveal.go gains RevealWith(ctx, emails, onProgress) (Reveal kept as a thin wrapper so existing callers/tests are unchanged) and the command drives a second bar over the commit-push loop. Adds golang.org/x/term for cross-platform TTY detection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/github): add `map` subcommand to correlate known emails to GitHub usernames (#202) `enum active github map` takes a list of emails you already believe are GitHub accounts and resolves them straight to usernames via the authenticated reveal step, SKIPPING the unauthenticated existence endpoint that github.com heavily rate-limits. Use it when you only need the email -> @username mapping for a known-good list. It requires a PAT (GITHUB_TOKEN or --token) with the "repo" and "delete_repo" scopes: it creates a temporary private repo, pushes one commit per email (email as commit author), resolves the author login (works even with email privacy enabled), and always deletes the repo. Only emails GitHub links to an account are reported as correlated. Refactors the parent command's email parse/trim/dedup into a shared collectGithubEmails helper (DRY); parent behavior is unchanged. Output, summary, and JSONL helpers are reused as-is. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/github): add --no-reveal to run existence-only (skip username mapping) (#203) `enum active github` auto-runs the authenticated username-reveal step after existence enumeration whenever a token is present. --no-reveal opts out of that: it runs existence-only and never creates the temporary repo, even when GITHUB_TOKEN/--token is set. Default is false, so current behavior is unchanged. The `map` subcommand (reveal-only) is unaffected. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum): authenticated HTTP/HTTPS proxy support + --rotating-proxy (#204) * feat(enum): support authenticated HTTP/HTTPS proxies for HTTP enum sources Extend the previously SOCKS5-only proxy plumbing to accept http/https proxies with authenticated credentials (e.g. Bright Data), and wire the proxy through every HTTP-based `brutus enum` source. CLI: - --proxy now accepts http, https, socks5, socks5h. A bare host:port defaults to http (curl-compatible). - --proxy-user user:pass (curl-style) supplies credentials; takes precedence over userinfo embedded in --proxy. - Misconfigured proxy intent fails closed (no silent direct connection). Core (pkg/brutus): - proxy.go: BuildProxyURL merges --proxy/--proxy-user into one canonical URL; parseProxyURL validates schemes. - http.go: ProxyTransport wires socks5 via DialContext and http/https via Transport.Proxy (Go injects Proxy-Authorization from URL userinfo). - net.go: NewProxyDialFunc stays socks5-only (raw TCP); credentials redacted in errors via url.Redacted(). Enum: - NewEnumHTTPClientWithProxy; one pooled proxied client per run shared via context to plugin oracles (google/microsoft365/custom). - dehashed/lusha/apollo/hunter NewClient take a proxyURL and return error. - github/google/teams already routed through NewHTTPClientWithProxy and now honor http/https proxies automatically. Tests: BuildProxyURL/ProxyTransport tables, end-to-end Proxy-Authorization through an httptest forward proxy, context helpers, and a fail-closed regression for --proxy-user without --proxy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/github): add --rotating-proxy to cut existence-check 429 backoff When --proxy rotates exit IPs (e.g. Bright Data), a per-IP HTTP 429 from the unauthenticated GitHub existence endpoint isn't a persistent block — the next retry egresses from a fresh IP. --rotating-proxy shortens the per-429 backoff (2s -> 100ms) and raises the retry ceiling (5 -> 15) for the existence path only. The authenticated reveal path (commit push) is token-rate-limited, where a rotating proxy does not help, so it keeps the conservative 2s/5 defaults unchanged. Also routes the new `enum github map` subcommand through resolveProxyURL so it honors --proxy-user and fails closed, consistent with the other enum sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/oracles): add github to the active oracles pipeline (#205) * feat(enum): support authenticated HTTP/HTTPS proxies for HTTP enum sources Extend the previously SOCKS5-only proxy plumbing to accept http/https proxies with authenticated credentials (e.g. Bright Data), and wire the proxy through every HTTP-based `brutus enum` source. CLI: - --proxy now accepts http, https, socks5, socks5h. A bare host:port defaults to http (curl-compatible). - --proxy-user user:pass (curl-style) supplies credentials; takes precedence over userinfo embedded in --proxy. - Misconfigured proxy intent fails closed (no silent direct connection). Core (pkg/brutus): - proxy.go: BuildProxyURL merges --proxy/--proxy-user into one canonical URL; parseProxyURL validates schemes. - http.go: ProxyTransport wires socks5 via DialContext and http/https via Transport.Proxy (Go injects Proxy-Authorization from URL userinfo). - net.go: NewProxyDialFunc stays socks5-only (raw TCP); credentials redacted in errors via url.Redacted(). Enum: - NewEnumHTTPClientWithProxy; one pooled proxied client per run shared via context to plugin oracles (google/microsoft365/custom). - dehashed/lusha/apollo/hunter NewClient take a proxyURL and return error. - github/google/teams already routed through NewHTTPClientWithProxy and now honor http/https proxies automatically. Tests: BuildProxyURL/ProxyTransport tables, end-to-end Proxy-Authorization through an httptest forward proxy, context helpers, and a fail-closed regression for --proxy-user without --proxy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/github): add --rotating-proxy to cut existence-check 429 backoff When --proxy rotates exit IPs (e.g. Bright Data), a per-IP HTTP 429 from the unauthenticated GitHub existence endpoint isn't a persistent block — the next retry egresses from a fresh IP. --rotating-proxy shortens the per-429 backoff (2s -> 100ms) and raises the retry ceiling (5 -> 15) for the existence path only. The authenticated reveal path (commit push) is token-rate-limited, where a rotating proxy does not help, so it keeps the conservative 2s/5 defaults unchanged. Also routes the new `enum github map` subcommand through resolveProxyURL so it honors --proxy-user and fails closed, consistent with the other enum sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/oracles): add github to the active ora…
fix(enum): record results on early return + clamp threads<=0 in batch… … EnumerateWith (#223) The batch EnumerateWith method (microsoft365 + google mirror) had two latent bugs flagged during #221 review: 1. threads==0 deadlocked errgroup.SetLimit(0) — no goroutine could ever acquire a slot. A negative value meant unbounded. Clamp to a safe serial default: `if threads <= 0 { threads = 1 }`. 2. The per-email worker returned nil WITHOUT recording a result on three early-exit paths (ctx.Done guard, limiter.Wait error, jitter cancel), leaving zero-value Result{} slots and skipping the per-index callback — violating the "one ordered result + one callback per input email" contract. Each path now records Result{Email, Error} before returning. Both fixes applied identically to microsoft365 and google (the two enumerators share the same worker-pool shape). Adds race-enabled regression tests in both packages: an already-canceled context fills every slot in order and fires the callback once per email, and threads=0/-1 completes without hanging (5s guard). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brutus v1.7.0 Feature release: adds the enum HUMINT/recon subsystem — Apollo.io, Lusha, and DeHashed sources, GitHub account enumeration + email→username mapping — plus authenticated HTTP/HTTPS proxy support (--rotating-proxy), a live progress bar with rate/ETA for active scans, and the 'enum active'/'enum passive' command hierarchy. Microsoft 365 account detection is now exposed as a public pkg/enum/microsoft365 library for external consumers. (#214)
Brutus v1.6.1 Patch release: fixes the Windows cross-compile that prevented v1.6.0 from producing release binaries (syscall.O_NOFOLLOW is undefined on Windows). Ships the full v1.6.0 feature set with working windows/linux/darwin/freebsd artifacts. (#198)
release: v1.6.0 — internet-scale logon scanning, detection accuracy, … …enum/Teams (#180) * perf(logon): admission-control concurrency + indeterminate≠clean (fix silent backdoor misses) (#170) * feat(logon): indeterminate outcome distinct from clean A non-stabilized RDP render or a failed connection no longer reads as a benign clean result. pumpSession now reports frame stabilization; a clean verdict on a non-stabilized render becomes 'indeterminate'. Surfaced as a loud [WARN] banner, a JSON indeterminate field, and a new exit code 2. Positive verdicts are never downgraded (false-negative guard). Implements fix #2 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): admission control bounds concurrent WASM decode sessions A process-wide weighted semaphore (~1.5x GOMAXPROCS) gates DetectBackdoors so only ~N-core sessions decode at once, regardless of --threads. The per-host timeout budget is no longer consumed while queued: a host blocks in Acquire (no socket, no clock) until it holds a slot. A cancelled-while- queued host returns indeterminate, never clean. --threads still drives network fan-out; a scan-path note warns when it greatly exceeds core count. Implements fix #1 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): wire --retries into detection path; pump honors timeout DetectBackdoors now retries the per-host detection while the result is indeterminate (up to --retries+1 attempts, holding the decode slot across retries, capped backoff). It never retries a found backdoor or a stabilized clean. The frame-pump budget in runSession/runUtilmanSession now honors the per-host timeout instead of hardcoded 5s/3s constants, so the timeout lever actually controls render completeness. Implements fix #4 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(logon): run sticky/utilman sequentially under one decode slot Replaces the two-goroutine-per-host detection with sequential execution (sticky then utilman) under the single held decode slot. The shared- connector design was structurally impossible (the IronRDP WASM FFI moves the connection result out on the first session_new), so each check keeps its own fresh connection — which also eliminates inter-check screen-state contamination. This makes the admission-control bound accurate (one concurrent decoder per slot, not two). Both checks always run and are reported (full attribution); no early-exit. Adds detectSticky/detectUtilman seam vars for deterministic sequencing tests. Implements fix #3 of the logon-scan-admission-control feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(logon): address review — gofmt, indeterminate on Test() path, cancelled hosts - gofmt struct alignment in brutus.go (B1, blocker) - formatStickyKeysBanner/formatUtilmanBanner now surface indeterminate as a loud [WARN] instead of dropping it (S1) — closes the starvation-masking bug on the rdp Plugin.Test() sibling path - runScanTargetsConcurrent emits logon.CancelledResults for rate-limiter/ ctx-cancelled hosts instead of nil, so a cancelled host is visible and indeterminate, never silently absent (S2/I6) - remove dead trailing return in DetectBackdoors Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(logon): cover cancelled-host, stabilization guard, runner cancel paths - TestDetectBackdoors_CancelledWhileQueued + TestCancelledResults: a queued- then-cancelled host yields indeterminate (not clean/silent) and never runs detection (GAP 1, invariants I5/I6). - Extract stabilizedVerdict helper (removes 2x duplication) + TestStabilizedVerdict: clean+unstable downgrades to indeterminate; positives never downgraded (GAP 2, I2). - runScanTargetsConcurrentCtx seam + TestRunScanTargetsConcurrent_CancelledIndexIndeterminate: runner cancel branches emit CancelledResults, no host silently vanishes (GAP 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): stickykeys/utilman subcommands + contamination-aware downgrade (#171) * feat(logon): stickykeys/utilman subcommands + contamination-aware downgrade Promote stickykeys and utilman from logon aliases to real single-check subcommands (each runs one check on a clean screen = reliable per-binary attribution). 'brutus logon' stays the combined both-check presence scan. A Check selector threads through DetectBackdoors/runDetection. In both mode, contamination can only occur after sticky pops, so if sticky is positive and utilman comes back clean, utilman is downgraded to indeterminate ('could not independently confirm — rerun: brutus utilman'). Verdict-based (not fragile image analysis); never downgrades a positive, never upgrades, never in single mode. Presence is never lost — a sticky positive flags the host regardless. --exec/--web on 'utilman' are rejected (interactive modes use the sticky- keys backdoor) to avoid silently driving the wrong vector. Aliases remapped: sethc/sticky-keys -> stickykeys, accessibility/ease-of-access -> utilman, winlogon stays on logon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): drop all aliases from the logon command logon is now alias-free (removed winlogon); brutus logon is the only spelling of the combined scan. Single-check aliases remain on their own subcommands (stickykeys: sticky-keys/sethc; utilman: accessibility/ease-of-access). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): remove all subcommand aliases The logon family is now exactly three commands with no aliases: logon, stickykeys, utilman. Dropped sticky-keys/sethc (stickykeys) and accessibility/ease-of-access (utilman). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): exit 0 on a completed scan regardless of findings A logon/stickykeys/utilman scan that completes is now success (exit 0) whether or not a backdoor was found — 'no backdoor' is no longer treated as a failure. Exit 2 still means at least one host was indeterminate (rerun), and now takes precedence over a found result so the operator knows to rerun the undetermined hosts. errNoSuccess is no longer used on this path (still used by other scan paths). scanExitError drops its now-irrelevant hasSuccess parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): corporate Teams user enumeration + posture audit (and device-code auth fixes) (#173) * fix(enum/teams): repair & harden device-code auth `brutus enum teams auth` was unusable. Fixes, in order of discovery: - Drop the `-t` shorthand on `--tenant`: it collided with the global persistent `--threads/-t` flag, which cobra merges into the subcommand at execute time, panicking before the command ran. - Default the tenant to `organizations` instead of `common`: the default Teams first-party client is not enabled for consumer (personal MSA) accounts, so `/common` and `/consumers` returned 401 AADSTS70002 invalid_client. `/organizations` is also the correct audience for Teams enumeration. Verified live: organizations -> 200, common/consumers -> 401. - Auto-open the verification URL in the default browser (validated to https Microsoft hosts only); add `--no-browser` for headless/SSH use. - Floor the OAuth HTTP timeout at 30s so interactive auth is not governed by the aggressive 10s per-target scan `--timeout`. - Route device-code/token requests through the SOCKS5 `--proxy` (reusing brutus.NewHTTPClientWithProxy, which still follows OAuth redirects); previously `--proxy` was silently ignored for teams auth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): corporate Teams user enumeration (TeamsEnum-style) Turn `enum teams` from a token-dumper into a real corporate user enumerator modeled on lucidra-security/TeamsEnum, and fix the auth so it produces a usable token. Auth fixes: - Scope: request the Skype/Teams resource the Teams first-party client is actually preauthorized for ("offline_access https://api.spaces.skype.com/.default") instead of Microsoft Graph, which the client cannot get a token for (AADSTS65002). offline_access is required so Azure AD returns a refresh token (needed for renewal and the enum 401-retry). - Add Client.RefreshAccessToken (refresh_token grant; single request). Enumeration (corporate only): - pkg/enum/teams/enum.go: Enumerator with tri-state EnumResult (yes/no/blocked/unknown) via teams.microsoft.com externalsearchv3 (existence, displayName, MRI) and optional presence (presence.teams.microsoft.com). Bounded concurrency (errgroup + SetLimit, honors --threads/--rate-limit/--jitter), one-shot serialized 401-refresh-retry, proxy support via brutus.NewHTTPClientWithProxy. Access token read through a mutex-guarded getter so concurrent workers never race the refresh write. - New `enum teams users` subcommand: targets via -e/-E, token reuse via --access-token/--refresh-token/--token-file, --presence, --no-browser. Credential store: - `enum teams auth` auto-saves the full token set to ~/.brutus/teams.json (dir 0700, file 0600, O_NOFOLLOW); `enum teams users` auto-loads it when no token flag is given. Terminal output stays truncated; full values only on disk or via --json/-o. Token values never logged or put in errors. Tests: externalsearchv3 status mapping, presence, 401-refresh, a concurrency+refresh regression test run under -race, RefreshAccessToken, credstore perms/round-trip/P0-1, and CLI flag/registration coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/generate): use ranked statistically-likely name list, kill N×N runaway `enum generate --format first.last` cross-multiplied 8,477 first × 8,175 last names = 69,299,475 combos, building the whole slice + dedup map in memory before printing — it appeared to hang and could exhaust RAM/disk (observed >10M lines, 300MB+). The same blowup hit `enum saas`/`enum custom` via GenerateUsernames/GenerateEmails. Replace the cross-product with a prebuilt, frequency-ranked list of statistically-likely first.last pairs (pkg/enum/wordlists/likely-names.txt.gz, 248,231 entries). All 8 username formats are derived from these ranked pairs by splitting on the first "." and reformatting, so output is bounded (≤248k), ranked by likelihood, and internally consistent. `first.last` now completes in ~2s instead of never. - Add `--limit N` to `enum generate` (default 0 = all) to cap output; since the list is ranked, --limit yields the N most-likely names. - Remove the now-orphaned firstnames.txt.gz / lastnames.txt.gz. - Attribute the name list (derived from insidetrust/statistically-likely-usernames) in pkg/enum/wordlists/SOURCES.md. Tests: format derivation (john.smith → jsmith/j.smith/smith.john/…), ranked head-of-list, dedup, multi-part surnames, bounded/non-empty for all formats, GenerateEmails, and capResults/--limit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/generate): add first_last (underscore) username format Add `first_last` format (paul_davis) alongside the existing first.last, flast, etc. Derived from the ranked name pairs like the others; last-name dots are stripped (juan.dela.cruz -> juan_delacruz). Wired into ListFormats, formatUsername, and the --format help on generate/saas/custom. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): report tenant config posture (cross-tenant chat, leakage) Surface Microsoft Teams misconfiguration/leakage from the externalsearchv3 + presence responses already fetched (no extra requests for the core signals): - External / cross-tenant chat: 403 => blocked, 200 + type:Federated => open. - Account metadata leak: tenantId, userPrincipalName, objectId, accountEnabled, featureSettings.coExistenceMode captured per user. - Presence + OOO leakage (under --presence): sourceNetwork (Federated/Unknown), outOfOfficeNote.message (parsed from presence or calendarData), availability, deviceType. - TenantPosture aggregation + always-on summary: external_chat_allowed (open|blocked|unknown), federated_observed, presence_visible, ooo_exposed, coexistence_mode — emitted as a posture block (human) and a teams_posture JSON line. EnumResult gains the new fields; outputs sanitize server-controlled strings (OOO notes, UPN, coexistence mode) and never include tokens. Refs SSE research (medium.com/sse-blog) on externalsearchv3 federation/leakage indicators. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): add `audit` subcommand — graded posture findings `brutus enum teams audit --email known.user@target.com` turns one seed user's enumeration result + derived tenant posture into graded security findings about external Teams exposure: - teams-external-access (Medium) — cross-tenant/federated chat enabled - teams-user-enumeration (Low) — externalsearchv3 oracle distinguishes users - teams-presence-disclosure (Low) — presence exposed externally (--presence) - teams-oof-disclosure (Low) — out-of-office note exposed externally (--presence) - teams-metadata-disclosure (Info)— UPN/objectId/tenantId returned to external party Reuses the `users` token-resolution / refresh / enumerator machinery (a shared teamsTokenSource struct now feeds both subcommands). Graded human report (severity-colored, sanitized evidence, remediation) plus a teams_finding JSONL line per finding. Tokens never appear in findings or output. Tests: Audit ruleset (presence/absence/severity/ordering, presence-gating, metadata), JSONL/human output (P0-1 token-safety, sanitization, truncation), and command registration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): stream results live, hits-only output, label account type Running `enum teams users` over a large list printed nothing until the process ended (results were buffered and dumped at the end), and printed a NOT FOUND row for every miss. Also, EXISTS hits with 8:live: MRIs (consumer/personal accounts) were indistinguishable from 8:orgid: (corporate) ones. - Stream results live: new Enumerator.EnumerateWith(..., onResult) invokes a callback per completed result (serialized under the results mutex; race-clean). Enumerate stays as a nil-callback wrapper. The CLI prints each hit as it lands. - Hits-only by default: print only EXISTS and BLOCKED (both positive signals); NOT FOUND / UNKNOWN are suppressed unless --verbose. A periodic "processed N/total — F found, B blocked" progress line goes to stderr. Final summary + posture print once at the end (rows no longer re-printed). - Account-type labeling: AccountType(mri) classifies corporate (8:orgid:) vs consumer (8:live:); shown on EXISTS rows and as account_type in JSONL — so personal-account noise is distinguishable from real corporate users. Tests: AccountType, EnumerateWith callback (per-result, nil, and concurrent 401-refresh under -race), result-line account-type + sanitization, JSONL account_type + token-safety. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): ignore consumer (8:live) accounts by default externalsearchv3 returns both corporate (8:orgid:) and consumer/personal (8:live:, "Teams For Life") accounts. A consumer account merely using a corporate email is usually noise (e.g. a personal Teams account on a work address), so treat those as misses by default. - EnumerateOne now prefers the corporate (8:orgid:) entry from the results and takes all metadata from it. If only a consumer (8:live:) match exists, it's reported as not found unless --include-consumer is set. Corporate users are returned by externalsearchv3 regardless of the consumer setting, so no real corporate hit is lost. - New --include-consumer flag (default false) on `users` and `audit` re-enables consumer matches (labeled "consumer") for cases where a real employee only surfaces via a personal account. (Trade-off: an employee whose corporate Teams is external-search-blocked and who only has a personal account won't show by default; --include-consumer recovers them.) Tests: consumer-only ignored by default / included with flag, corporate preferred over consumer in a mixed result (metadata from the corporate entry), corporate always found, and the --include-consumer flag on both subcommands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enum/teams): demote user-enumeration finding to Info Teams user enumeration via externalsearchv3 is inherent to Microsoft Teams and cannot be disabled by the tenant (it works even when external access is blocked — 403 differs from an empty 200). Reporting it as Low overstated it, since its own remediation says it can't be fixed. Reclassify it as Info (informational observation) and reword the description/remediation to match. The remediable findings (external-access Medium, presence/OOO Low) keep their severities. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): present 403 as EXISTS (details restricted) A 403 from externalsearchv3 reliably means the account exists but the tenant withholds its details, so for enumeration it's a confirmed hit — calling it "BLOCKED" undersold it. Present it as a hit instead (presentation/counting only; the internal Existence enum and tenant posture logic are unchanged, since posture still needs the 200-vs-403 distinction). - Human: a 403 renders as "[+] EXISTS (details restricted)" (no name/MRI). - Summary: Exists headline = with-details + details-restricted, e.g. "Exists: 1043 (28 with details, 1015 details-restricted)". - JSONL: 403 -> exists:"yes", details_restricted:true (no metadata fields). - Tenant posture still reports External chat: BLOCKED for the tenant config — separate from per-account existence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): generate candidate emails on the fly via --domain `enum teams users` can now generate the candidate wordlist internally instead of requiring a pipe from `enum generate`. Passing --domain produces the ranked statistically-likely emails for that domain (reusing enum.GenerateEmails), optionally capped with --limit and shaped with --format (default first.last). Generated candidates combine with any -e/-E inputs and are deduped, then flow through the same enumeration path. Invalid --format is rejected with the valid list. brutus enum teams users --domain target.com --format first.last --limit 5000 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/teams): gather presence/OOO by default, add --no-presence Presence (availability/device) and out-of-office data are useful, so collect them by default rather than behind an opt-in flag. Replaces `--presence` (default off) with `--no-presence` (default off => presence on) on both `enum teams users` and `enum teams audit`. Presence is a second request that only fires for users that resolve with an MRI, so on locked-down (all-403) tenants this adds no calls; --no-presence skips it for speed/stealth. For audit, the presence/out-of-office findings are now evaluated by default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(enum/teams): fix stale --presence comment and empty field comment Update the Audit doc comment to reference --no-presence (presence is default-on now), and fill in the dangling empty comment on Finding.Remediation. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/saas): identify Microsoft Teams as an oracle for M365 orgs (#174) * feat(enum/saas): identify Microsoft Teams as an oracle for M365 orgs When DNS TXT recon detects a Microsoft 365 tenant (MS=ms / spf.protection.outlook.com), surface `teams` as an available enumeration oracle for the org (display/inference only — it is not run as an unauthenticated oracle; the enum loop still filters to registered plugins). Output points the user to `brutus enum teams users` / `audit`, and the dns_recon JSON gains `teams_available:true`. `enum saas discover` additionally CONFIRMS the Teams oracle opportunistically: when a token is cached at ~/.brutus/teams.json, it runs the Teams enumerator against the --known-valid seed and reports working / exists-restricted / not-found / unconfirmed (reusing the teams enumerator + credstore; no new flags, no token in output). Without a cached token it reports "available (unconfirmed) — run `brutus enum teams auth`". pkg/enum/dns.go stays a pure DNS parser; the Teams inference/confirmation lives in the saas command layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/saas): always validate oracles (discover) and require --known-valid `enum saas` now requires --known-valid and always validates oracles against it before enumerating — enumeration runs only against oracles that confirmed the known-valid user, so broken/rate-limited oracles don't produce misleading results. The validation step now also confirms the Teams oracle (via the cached token, mirroring `discover`). The standalone `discover` subcommand is unchanged. Note: --known-valid is now required for all `enum saas` invocations (including DNS-recon-only runs), per the always-validate model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(enum): rename `enum saas` -> `enum oracles`, reframe output on oracles The command's purpose is identifying which account-existence ORACLES work for an org, not cataloguing SaaS services in use — so rename it (hard rename, no `saas` alias) and refocus the output: - `enum saas` -> `enum oracles` (file, symbols, flags, help, README all renamed). - Output now leads with an "Oracle Check" report: each oracle checked against --known-valid shown as [+] working / [-] not working (microsoft365, google, and the Teams oracle). DNS recon is demoted to a one-line "discovered candidate oracles" note (full TXT detail under --verbose / in JSON). - `discover` remains the validate-only subcommand under `enum oracles`. Behavior (required --known-valid, always-validate, enumerate-only-working, Teams confirmation, --domain generation) is unchanged. Tests renamed/updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(enum/google): add `enum google` — Workspace account enum + SSO/IdP New `brutus enum google` identifies Google Workspace / Gmail accounts (parallel to `enum teams users`, but unauthenticated — no token needed): - Targets via -e/-E or on-the-fly generation (--domain/--format/--limit, reusing the ranked statistically-likely list); streamed hits-only output + summary; JSON (type "google_account"). - Per account surfaces the detection method and federation detail: AccountChooser SSO redirect -> "workspace-sso (-> <IdP host>)"; Gmail GXLU cookie -> "gmail". Detection logic moved into a new reusable pkg/enum/google package (rich Result: exists + method + IdP, with errgroup/streaming EnumerateWith mirroring teams). The existing unauthenticated `google` oracle (internal/enumplugins/google) now delegates to it — one implementation shared by `enum oracles -s google` and `enum google`. Tests: CheckAccount (SAML-header / non-google-redirect -> workspace-sso+IdP, GXLU -> gmail, not-found, transport error), EnumerateWith under -race, command flags/registration, and output (JSON shape + IdP sanitization). All offline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(examples): add enum-custom demo — vulnerable app + oracle specs (#175) Add examples/enum-custom-demo/: a self-contained, intentionally account-enumeration-vulnerable HTTP server plus matching Brutus `enum custom` oracle specs, to teach/demo the declarative oracle spec format. - main.go: zero-dependency net/http target. /api/forgot-password leaks account existence (valid email -> 200 "reset link sent", invalid -> 404 "No account found"); /api/login leaks via distinct messages (401 vs 404). 4 hardcoded demo accounts; loud "intentionally vulnerable" banner; --addr flag. - forgot-password-oracle.yaml / login-oracle.yaml: schema-v1 custom specs whose match rules map those responses to exists/absent verdicts. - README.md: walkthrough (run target -> build brutus -> `enum custom`), the expected output, and a "how the spec works" mapping of the YAML to the app's behavior + a safety note. Verified end-to-end: `brutus enum custom -f forgot-password-oracle.yaml -e alice@demo.local,nobody@demo.local` -> alice EXISTS, nobody NOT FOUND (same for the login oracle). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): behavioral nonce-confirmation + region classifier + ambiguity→indeterminate (fix no-Vision specificity) (#172) * feat(rdp): color/position pre-filter + ambiguity->indeterminate (Levers 2+3) detectChangedRectangle now returns the changed-pixel bounding box. New pure classifyRegion (dark+large+top-left=console vs light+small+centered=dialog) and decideVerdict total function route an ambiguous backdoor_likely box to indeterminate instead of asserting a backdoor — killing the no-Vision false positives on non-NLA hosts. Cardinal rule: no backdoor_likely->clean path; clean only from a clean heuristic. No new deps. Implements Levers 2+3 of the logon-behavioral-detection feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): behavioral nonce-confirmation in detection (Lever 1) After a trigger produces a candidate window, type a benign read-only nonce (echo BRUTUS-<rand>) into it, re-capture, and verify a shell-like text render (verifyEcho pixel-diff scoped to the box). A real shell echoes; a legit dialog does not. runSession/runUtilmanSession thread the nonceResult into decideVerdict: confirmed echo -> backdoor_confirmed; box-but-unconfirmed -> indeterminate (never clean). Any type/capture error -> nonceUnconfirmed (fail-safe). crypto/rand only; no new deps. Implements Lever 1 of the logon-behavioral-detection feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): map sticky Vision STICKY_KEYS_DIALOG to vulnerable runStickyKeysAnalysis was missing the Vision vulnerable branch that runUtilmanAnalysis has, so Vision-mode sticky over-flagged the legit accessibility dialog as backdoor_likely. Add the symmetric branch (after the backdoor_confirmed escalation, so it can't suppress a confirmed backdoor). Implements Phase C of the logon-behavioral-detection feature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): wire classifyRegion into confidence/banner + broaden nonce gate Review fixes (both cardinal-safe, verdict logic unchanged): - SF-2: gate behavioral nonce-confirmation on the heuristic verdict (backdoor_likely) instead of just rectangularity, so non-rectangular real backdoors also get confirmed instead of dropping to indeterminate. - SF-1: classifyRegion was dead (decideVerdict discarded it). It now feeds result Confidence + a diagnostic banner note (console-confirmed -> 0.95; geometry notes on indeterminate reruns) — never the verdict. Adds regionConfidenceAndNote (+ test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): dark-pixel-delta discriminator, remove behavioral confirmation Live matrix testing proved the behavioral pixel-diff (verifyEcho) false- confirms on real non-NLA hosts: the legit accessibility dialog reacts to typed keystrokes, so the coarse diff confirmed every host (incl. clean). Replace it with a Sticky-Keys-Slayer-style passive dark-pixel-delta: count the increase in dark pixels (brightness<60, covering black cmd + blue PowerShell) baseline->response. A dark console-sized region (~4-65% of frame) -> backdoor_likely; near-zero new dark (light dialog/nothing) -> clean; ambiguous (small below band / full-screen above) -> indeterminate (never clean). Thresholds scaled to 1024x768, to be calibrated on the matrix. Removes typeAndConfirm/verifyEcho/nonce plumbing; keeps the indeterminate safety net, region classifier (diagnostic), and Vision sticky fix. No deps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): require a settled, complete frame before analyzing Detection was capturing half-painted RDP frames: the settle check declared 'stable' after just 3 identical frame-available steps (no min pump time, no wall-clock quiet window), so a brief mid-paint pause short-circuited it; and the baseline pump ignored stabilization, so we captured/triggered during session init ('Please wait for the Local Session Manager'). Now pumpSession settles on a wall-clock quiet window (framebuffer unchanged >=1500ms) AND a minimum pump time (>=2s) before declaring stable; the baseline is settled before the trigger; stabilized = baselineStable && responseStable. A still-initializing or never-settling screen yields stabilized=false -> indeterminate (never a false clean). Adds settled() pure helper + TestSettled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(rdp): optional debug screenshot dump (BRUTUS_DEBUG_SCREENSHOT_DIR) When the env var is set, dump each baseline+response frame to PNG during logon detection (per host/check/phase). No behavior change when unset. Used to diagnose what's actually on screen; also useful as operator evidence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rdp): confirm sticky-keys prompt + noise-tolerant settle Sticky path: after Shift x5, send Enter to confirm the legit 'turn on Sticky Keys?' dialog (verified present on Server 2016/2019/2022) so the hijacked sethc.exe actually runs -> cmd. utilman (Win+U) needs no prompt, unchanged. Settle: replace exact crc32-equality with an inter-frame changed-pixel threshold (settleNoisePixels=2000) so a blinking console cursor / spinner no longer prevents settling (the main cause of the indeterminate flood). A real window repaint is far above the threshold, so detection is unaffected. Adds framesQuiet pure helper + TestFramesQuiet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(rdp): drop spurious sticky-keys confirm keystroke Commit 9ec5d0a added a post-Shift x5 confirm keystroke (later switched Enter -> 'Y') in runSession to dismiss the "Sticky Keys?" prompt. Live testing showed a properly-hijacked sethc (binary replacement) yields a direct cmd with no prompt, so the confirm keystroke is unnecessary and spurious. Restore runSession's sticky trigger to the pre-experiment flow: 5x Shift -> post-trigger sleep (1500ms) -> pumpSession settle -> captureFrame, structurally matching runUtilmanSession. Remove the extra ~1s sleep and the now-unused yKeyScancode const. Unchanged: dark-delta verdict, quiet-window/min-pump settled(), noise-tolerant framesQuiet, BRUTUS_DEBUG_SCREENSHOT_DIR dump. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): internet-scale logon scanning (re-land #176 onto dev) (#179) * feat(logon): NLA negotiation pre-filter + --timeout settle-floor warning Add a pure-Go RDP negotiation probe that runs before the expensive WASM decode and classifies NLA-required hosts as a terminal, non-retryable 'nla_required' verdict, skipping the session on hosts whose pre-auth logon screen is unreachable without credentials. The probe requests SSL-only and returns nla_required strictly on NEG_FAILURE HYBRID_REQUIRED_BY_SERVER; every other outcome fails open to the full WASM scan, so it can never skip a scannable host. It runs network-bound before decode-slot acquisition, so unscannable hosts cost ~1 RTT and never touch the core-bounded decoder pool. Disable with --no-nla-probe. Also warn when --timeout is below the settle floor (minPump + quietWindow = 3.5s, exported as rdp.MinViableTimeout): too short a timeout forces every host to INDETERMINATE and wastes the full retry budget for zero signal. Validated live on a Server 2016/2019/2022 x {both,sticky,util,clean,NLA} matrix: 10/10 armed backdoors detected [HIGH], 0 false positives, NLA host correctly classified nla_required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): --connect-timeout + terminal "unreachable" verdict for dead hosts Make a dead/filtered RDP host the cheapest case, not the most expensive. Adds --connect-timeout (default 3s), separate from the per-host settle --timeout, for all scan TCP dials. A failed TCP connect is now a terminal, NON-retryable 'unreachable' verdict (Success=false, Indeterminate=false) instead of a retryable indeterminate: the NLA probe short-circuits unreachable hosts BEFORE acquiring a decode slot (new rdp.NegoUnreachable), and the WASM-path dial failure maps to unreachable via a new Unreachable flag set only at the dial site — WASM-connector/init/session failures stay indeterminate. Validated live on GCP: a filtered host (SYN black-holed) drops from ~100s (full timeout + retry, holding a decode slot) to ~3.5s — no slot, no retry. --connect-timeout directly controls the dial wait (3s→3.5s, 15s→15.5s); both probe and WASM paths yield terminal unreachable; closed/RST hosts resolve in ~0.5s. backend-reviewer + backend-security both APPROVED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(logon): rename --timeout to --scan-timeout for logon/stickykeys/utilman The logon family now uses --scan-timeout for the post-connect settle/scan deadline, distinct from --connect-timeout (the TCP dial). Passing --timeout to these commands is a hard error pointing to the new flags. --timeout is unchanged for all other commands (creds/enum/etc.), where it remains the generic per-target timeout. Resolves the confusion of two timeouts where --timeout no longer described what it does in a scan. BREAKING (logon family only): brutus {logon,stickykeys,utilman} --timeout <d> must become --scan-timeout <d>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(logon): --fast survey mode (never-clean) + console-region precision gate Add opt-in --fast triage for logon/stickykeys/utilman: a short SettleBudget for internet-scale sweeps, made safe by a never-clean invariant — fast reports only HIGH/CRITICAL or indeterminate, NEVER clean (a slow-rendering backdoor that hasn't painted is flagged indeterminate, never mis-reported clean). ~5x faster on clean hosts. Also harden the HIGH verdict with a console-region gate: a backdoor_likely from the dark-pixel-COUNT delta alone now requires real console evidence (confidence floor OR a dark+large+genuinely-dark-filled changed region via darkBoxFraction); otherwise it downgrades to indeterminate. Kills a false positive where a login-wallpaper shift put the dark-pixel count in the console band with no actual console (0%-confidence [HIGH]), and hardens precision in BOTH fast and careful modes. Validated live (cmd + PowerShell binary-replacement + real-frame fixtures): never-clean holds (0 clean verdicts on fast); no false positives (clean -> indeterminate, not HIGH); recall: cmd + windowed-PowerShell -> HIGH, careful recall fully restored, slow sticky-path renders -> indeterminate (recoverable via careful re-sweep), never clean. Real captured frames committed as permanent regression fixtures (testdata/realframes): wallpaper FP -> not HIGH, real consoles -> HIGH. Dark-delta band thresholds unchanged (additive gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PreviousNext