Skip to content

feat: v0.2.0 — incident response, dev-credential surface, TCC inventory, baseline integrity - #2

Merged
DareDev256 merged 3 commits into
mainfrom
feat/v0.2.0-incident-response
Jul 29, 2026
Merged

DareDev256 merged 3 commits into
mainfrom
feat/v0.2.0-incident-response

Conversation

@DareDev256

Copy link
Copy Markdown
Owner

The kit was born from a breach and, until this release, had nothing for the hour after one. Repo-wide grep before this branch: "revoke" 0 hits, "forwarding rule" 0, "deploy key" 0, "reinstall" 0. canary/README.md ended at "you find out" and handed off to no procedure — a user whose decoy fires at 2am was holding an alert with no next step, on the compromised machine.

Human

./panic.sh --short                 # the ordered checklist, phone-sized
./panic.sh --triage                # ...plus live "did anything get root?" probes
./preserve.sh -o /Volumes/USB      # capture evidence before cleaning up
./exposurescan/exposurescan.py --tcc
zsh tests/run-corpus.zsh           # 78/78
bash tests/test-watchpost-baseline.sh   # 10/10
cd exposurescan && python3 -m unittest discover -s tests   # 72 (was 47)

Run --tcc on your own machine. It will tell you which binaries already hold Full Disk Access, Accessibility and Screen Recording — the grants a pasted payload inherits for free.

Agent

Intent. Close the three gaps the v0.2.0 plan ranked highest by real-victim impact, and nothing else. No new tools; the kit stays at six.

The ordering in INCIDENT.md is the deliverable, not the prose. Two steps are commonly inverted and both inversions waste the entire effort:

  1. Crypto before anything. It is the only irreversible loss. A seed phrase is the wallet — "change the password" does nothing.
  2. Kill sessions before changing passwords. A stolen cookie authenticates without the password and without 2FA, and a password change does not reliably invalidate live sessions. Reset first and stop there, and the attacker is still signed in, behind a password that now protects nothing. exposurescan.py already said cookies "bypass password + MFA"; the kit never turned that into a procedure.

Developer tokens are ordered by blast radius — npm/PyPI publish tokens first, because that is where a personal breach becomes a supply-chain breach; cloud keys disable-then-delete so the audit trail survives deletion.

WatchPost could be blinded by one rm. Deleting baseline.json was read as a first run: the next run printed "No diffing on first run" and silently absorbed whatever had just been planted as legitimate. An .armed marker makes deletion alertable; an HMAC tag catches pre-seeding the file so a later plant diffs as already-known; the baseline is 0600 in a 0700 dir (it was 0644, and it enumerates every persistence entry on the machine). Test C in the new suite is the one I care about most: a genuinely new plant is still reported after all the hardening — it is easy to lock a monitor down until it stops monitoring.

What was deliberately NOT built, and why, because the roadmap's credibility depends on this section being unflattering:

  • preserve.sh defers wholly to Jamf Aftermath when installed and stops. Free, Swift, purpose-built, collects a superset. The value here is the ClickFix-specific ordering, not the collection.
  • No general hardening scanner. FileVault, firewall, update settings, sudoers, the CIS sweep — mSCP and Pareto own that and own it better. Only the narrow ClickFix-relevant slice (--tcc) ships, and the README cites them for the rest.

Honest limits documented rather than papered over:

  • The WatchPost HMAC key sits beside the baseline under the same user, so anyone already running as you can forge it. This is tamper-evidence, not tamper-proofing. The root-owned /var/db/watchpost variant that would be proof is named and explicitly not claimed.
  • Safari login counts are not obtainable without a keychain prompt. The report names the surface and says why rather than printing a fabricated number.
  • redact()'s proximity rule over-fires on labels containing a bare auth/token/cookie, and on filenames ≥20 chars. The tool's own strings were reworded rather than weakening the rule, per the project's stated policy; the residual over-fire is documented.

Edge cases. SSH key encryption is read from the header only, bounded to 112 base64 chars — short of the private section in both the PEM and openssh-key-v1 layouts. An unparseable header reports as ENCRYPTED, because unknown must never render as the P0 case. preserve.sh's unified-log query filters on process rather than eventMessage CONTAINS, which forces a full log-store scan that runs for minutes — long enough that during a real incident you kill it and lose the artifact entirely.

Verification. ExposureScan's 64 TCC grants matched a hand-run sqlite3 over both databases exactly — none invented, none missed. Leak tests grep the rendered markdown and the JSON sidecar for fixture key bodies and every fake token, asserting zero hits. Both feature commits are independently green, so git bisect stays usable.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur

DareDev256 and others added 3 commits July 29, 2026 09:34
…e integrity

The kit was born from a breach and had nothing for the hour after one. Repo-wide
grep before this commit: "revoke" 0 hits, "forwarding rule" 0, "deploy key" 0,
"reinstall" 0. canary/README.md ended at "you find out" and handed off to no
procedure — a user whose decoy fires at 2am was holding an alert with no next
step, on the compromised machine.

INCIDENT.md — the ordering IS the deliverable
- Crypto first, because it is the only irreversible loss. A seed phrase is the
  wallet; "change the password" does nothing.
- Kill sessions BEFORE changing passwords. This is the step people invert. A
  stolen cookie authenticates without the password and without 2FA, and a
  password change does not always invalidate live sessions — so resetting first
  and stopping there leaves the attacker logged in behind a new password.
  exposurescan.py already said cookies "bypass password + MFA" and the kit never
  turned that insight into a procedure.
- Revoke OAuth grants (survive every password change), then passwords (email
  first), then the mail-persistence sweep: forwarding, filters matching
  reset/verify/code, send-as aliases, delegated access, app-specific passwords,
  recovery address and phone. All invisible in normal use, all survive a reset.
- Developer tokens in blast-radius order: npm/PyPI publish tokens FIRST (a
  personal breach becomes a supply-chain breach), cloud keys disable-then-delete
  so the audit trail survives, then PATs, SSH/GPG, and per-repo deploy keys +
  Actions secrets — the most forgotten items, because there is no revoke-all.
- The reinstall decision as a bright line, not a vibe: did anything get root?
  Written to be worked from a phone, in order, during a bad morning.

panic.sh — prints it with no network, no browser, no dependencies
- --short fits on a phone screen; --paper pipes to lpr; --triage adds read-only
  probes that answer step 8 (LaunchDaemons, PrivilegedHelperTools, config
  profiles, system extensions, sudoers.d, authorized_keys, sshd/screensharing,
  SIP) with facts instead of memory.
- The ordered summary is hard-coded rather than parsed out of INCIDENT.md: if the
  repo is damaged, the ORDER is the part you cannot afford to lose.

preserve.sh — capture before you clean up
- Defers to Jamf Aftermath entirely when installed. It is free, Swift,
  purpose-built and collects a superset; reimplementing it would be worse code
  doing a solved job. The value here is the ClickFix-specific ordering.
- Built-in collector otherwise: 29 artifacts incl. kMDItemWhereFroms origin URLs
  for recent downloads (what tells you which site delivered it), sha256 +
  codesign verdict per persistence plist, and TCC grants. Sealed read-only with
  a MANIFEST.
- Unified log filtered on `process`, not `eventMessage CONTAINS`. The latter
  forces a full log-store scan that runs for minutes — long enough that during a
  real incident you kill it and lose the artifact entirely.

WatchPost baseline can no longer be blinded
- Deleting baseline.json was read as a first run: the next run printed "No
  diffing on first run" and silently absorbed whatever had just been planted.
  An .armed marker now makes deletion an alertable event that refuses to
  re-baseline without an explicit --init.
- Editing the baseline directly (pre-seeding the attacker's future entry so the
  real plant diffs as already-known) is caught by an HMAC tag.
- Baseline is 0600 in a 0700 dir; it was 0644 and enumerates every persistence
  entry on the machine.
- README states the honest limit: the key sits next to the baseline under the
  same user, so this is tamper-evidence, not tamper-proofing. The root-owned
  variant is named and explicitly not claimed.
- --no-update documented as THE incident flag; a normal run promotes the
  baseline and erases the diff that proved something appeared.

tests/test-watchpost-baseline.sh — 10 checks covering all three attacks, plus
the one that matters most: a genuinely new plant is STILL reported after all the
hardening. New macOS CI job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur
Two additions from docs/v0.2.0-plan.md, both names-and-shape-only, both held to
the v0.1.1 invariant: no secret value reaches any artifact.

scan_dev_credentials — the highest-pivot files on a solo dev's Mac, previously
unscanned. An SSH key plus a GitHub token is push access to every repo you own,
which makes a personal breach a supply-chain breach, and the report said nothing
about any of it.
- SSH keys: filename + type + ENCRYPTED vs PLAINTEXT. Plaintext is P0 with that
  pivot spelled out. Encryption is read from the HEADER ONLY, bounded to 112
  base64 chars (84 bytes) — short of the private section in both the PEM and
  openssh-key-v1 layouts. For encrypted keys the type comes from the .pub half
  rather than widening the bound. An unparseable header reports as ENCRYPTED:
  unknown must never render as the P0 case.
- 11 credential files (~/.aws/credentials, .npmrc, .pypirc, .netrc,
  gh/hosts.yml, docker/config.json, .kube/config, gcloud, .git-credentials,
  .cargo/credentials.toml, .gem/credentials): presence, mode, KEY NAMES only.
  World/group-readable escalates to P0. .git-credentials and .netrc emit counts
  and hostnames only, because every line of them IS the credential.
- Shell history: counts + line numbers + the credential prefix CLASS, never the
  text. Pasted secrets routinely land in ~/.zsh_history and nothing surfaced it.
- Cookie row counts per profile, carrying the "revoke sessions BEFORE rotating
  passwords" pivot that INCIDENT.md step 2 depends on.
- Crypto wallets: 20 extension IDs + 13 desktop bundles, unconditional P0. The
  tool tiered seed phrases P0 in Notes and never looked where wallets live.
- Firefox login + cookie counts, so the report is not silently Chrome-shaped.

--tcc — the README's central argument, finally actioned. It has always said
malware inherits the grants of the trusted binary it runs inside, and never told
you which binaries those are. Reads both TCC.dbs through the existing safe-copy
path, handles modern auth_value and legacy allowed schemas, degrades to a note
without Full Disk Access. P0 for terminals, shells, SSH wrappers, remote-access
tools and bare interpreters holding FDA/Accessibility/ScreenCapture/ListenEvent/
PostEvent/DeveloperTool — those are grant-inheritance vehicles, not apps — said
in plain language: anything you run inside Terminal gets Screen Recording too.
Bundled: Secure Keyboard Entry state, sshd/screensharing, Secure Boot level.

Deliberately NOT built: FileVault, firewall, update settings, sudoers, password
policy, the CIS sweep. mSCP and Pareto own that and own it better; they are
cited in the README instead. This is the narrow ClickFix-relevant slice only.

Honest limits, documented rather than papered over:
- Safari login counts are not obtainable without a keychain prompt. The report
  names the surface and says why instead of printing a fabricated number.
  `security dump-keychain` stays opt-in behind EXPOSURESCAN_KEYCHAIN_COUNT=1.
- redact()'s proximity rule over-fires on labels containing a bare auth/token/
  credential/cookie, and on filenames >=20 chars. Reworded the tool's own
  strings rather than weakening the rule, per the project's stated policy, and
  documented the residual over-fire.

tests: 47 -> 72. New tests/make-sample-report.py regenerates sample-report.md
from the REAL scanner against a synthetic $HOME, with --check failing when
stale, so "generated, not hand-written" is enforced rather than asserted. Leak
tests grep the rendered markdown AND the JSON sidecar for the fixture's key
bodies and every fake token, asserting zero hits.

Verified against this machine: the tool's 64 TCC grants matched a hand-run
sqlite3 over both databases exactly — none invented, none missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur
@DareDev256
DareDev256 merged commit ed9c9b4 into main Jul 29, 2026
6 checks passed
@DareDev256
DareDev256 deleted the feat/v0.2.0-incident-response branch July 29, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant