Skip to content

Tags: praetorian-inc/brutus

Tags

v1.10.0

Toggle v1.10.0's commit message
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)

v1.9.0

Toggle v1.9.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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…

v1.8.2

Toggle v1.8.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.8.1

Toggle v1.8.1's commit message
v1.8.1: M365 NewChecker rotating-proxy support (#220)

v1.8.0

Toggle v1.8.0's commit message
v1.8.0: expose Lusha Contact FirstName/LastName (#219)

v1.7.0

Toggle v1.7.0's commit message
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)

v1.6.1

Toggle v1.6.1's commit message
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)

v1.6.0

Toggle v1.6.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.5.1

Toggle v1.5.1's commit message
fix(release): update Go version to 1.26.0 to match go.mod

The go.mod was bumped to Go 1.26 by dependency updates, but the
release workflow still used Go 1.25.0, which would fail to build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

v1.5.0

Toggle v1.5.0's commit message
v1.5.0: CLI subcommands, Nerva integration, UPX-compressed releases