From 292548059bcb74b1e5e51dfdd9806a08e2484737 Mon Sep 17 00:00:00 2001 From: DareDev256 Date: Wed, 29 Jul 2026 09:34:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20incident=20response=20=E2=80=94=20I?= =?UTF-8?q?NCIDENT.md,=20panic.sh,=20preserve.sh,=20baseline=20integrity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur --- .github/workflows/ci.yml | 14 ++ INCIDENT.md | 238 +++++++++++++++++++++++++++++++ README.md | 21 +++ panic.sh | 177 +++++++++++++++++++++++ preserve.sh | 213 +++++++++++++++++++++++++++ tests/test-watchpost-baseline.sh | 125 ++++++++++++++++ watchpost/README.md | 33 +++++ watchpost/watchpost.sh | 66 +++++++++ 8 files changed, 887 insertions(+) create mode 100644 INCIDENT.md create mode 100755 panic.sh create mode 100755 preserve.sh create mode 100755 tests/test-watchpost-baseline.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce0c951..350db55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: run: | shellcheck -S warning \ install.sh \ + panic.sh \ + preserve.sh \ + tests/test-watchpost-baseline.sh \ canary/canary-gen.sh \ guestmode/setup-guestmode.sh \ shellguard/install.sh \ @@ -65,6 +68,17 @@ jobs: # confirmation gate that could not be completed at all. run: zsh tests/test-zle-integration.zsh + watchpost-baseline: + name: WatchPost baseline cannot be blinded (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Pre-seed, delete and tamper with the baseline in a sandbox + # The baseline lives in the user's home dir, writable by exactly the + # malware this tool watches for. Deleting it used to be read as a first + # run, which silently absorbed whatever had just been planted. + run: bash tests/test-watchpost-baseline.sh + exposurescan-tests: name: ExposureScan redaction invariants runs-on: ubuntu-latest diff --git a/INCIDENT.md b/INCIDENT.md new file mode 100644 index 0000000..8418c0c --- /dev/null +++ b/INCIDENT.md @@ -0,0 +1,238 @@ +# If it already happened — do this, in this order + +**You pasted something into Terminal, or a decoy fired, or something is just +wrong. Start here.** + +Read this on your **phone**, not on the Mac you are worried about. The order is +the whole point: several of these steps are useless or actively wasted if you do +them in the wrong sequence. Do not skip ahead to "change my passwords" — that is +step 4, and doing it first is the single most common mistake. + +Print this, or run `./panic.sh`, which prints it with no network and no browser. + +> **The one-line version:** move crypto → get offline → **kill sessions before +> changing passwords** → revoke app access → change passwords → hunt for mail +> rules → rotate developer tokens → then decide about wiping. + +--- + +## 0. Crypto first. It is the only loss you cannot undo. + +Everything else in this document is recoverable. This is not. + +If a seed phrase, keystore file, or wallet password was anywhere on that +machine — in Notes, a screenshot, a text file, a password manager, a browser +extension, a photo of a piece of paper: + +- [ ] On a **different, clean device**, create a **brand new wallet**. +- [ ] Move the funds to it **now**. Before coffee, before reading the rest. +- [ ] Do **not** "change the password" on the old wallet. The seed *is* the + wallet. Whoever has it owns the funds forever, password or not. +- [ ] Assume every wallet derived from that seed is compromised, including ones + you have not used. + +Stealers sell wallet material first because it clears fastest. Minutes matter +here in a way they do not anywhere else in this document. + +--- + +## 1. Get the machine off the network. Do not reboot it yet. + +- [ ] Turn off Wi-Fi. Unplug ethernet. Leave the machine **on**. +- [ ] Do all following steps from your **phone** or another computer. +- [ ] **Do not reboot, and do not "clean up" yet.** Rebooting destroys running + process and network evidence that tells you what actually ran — which is + what tells you how much of the rest of this list you truly need. +- [ ] If you want that evidence preserved, run `./preserve.sh` first. If you do + not care, keep moving; the rest of this list matters more. + +Do not power it off either. A powered-off machine is fine; you just lose more +information. + +--- + +## 2. Kill every active session. **Before** you touch a single password. + +This is the step people get wrong, and getting it wrong wastes the whole effort. + +**A stolen session cookie logs in as you without the password and without your +2FA code.** Changing your password does not always invalidate sessions that are +already live. If you change the password first and *then* sign out everywhere, +fine — but if you change it and stop there, the attacker is still logged in, +with your new password now protecting nothing. + +So: **sign out of all devices/sessions first, then change the password.** + +- [ ] **Google:** myaccount.google.com → Security → Your devices → Manage all + devices → sign out of everything you do not recognise. +- [ ] **Apple:** appleid.apple.com → Devices → remove anything unfamiliar. +- [ ] **Microsoft / Slack / Discord / X / Meta / GitHub:** each has a "sign out + of all sessions" or "active sessions" control. Use it on every one. +- [ ] **Your password manager** — sign out all sessions, then change the master + password from a clean device. +- [ ] **Your bank and anything holding money** — sign out all sessions, and call + them if there is any sign of movement. + +If a service offers only "log out everywhere" buried in settings, that is the +control you want. + +--- + +## 3. Revoke third-party app access (OAuth). These survive every password change. + +An app you authorised years ago still has a token. Your new password does +nothing to it. Neither does your 2FA. + +- [ ] **Google:** myaccount.google.com → Data & privacy → Third-party apps with + account access → remove anything you do not actively use. +- [ ] **GitHub:** Settings → Applications → Authorized OAuth Apps **and** + Authorized GitHub Apps. Revoke aggressively; you can re-authorise later. +- [ ] **Microsoft, Slack, Discord, Notion, Figma, Dropbox:** same idea, same + place — "connected apps" / "installed apps". + +--- + +## 4. Now change passwords. Email account first. + +Email first, always — it is the master key that resets everything else. + +- [ ] Change your **primary email** password from a **clean device**. +- [ ] Then: password manager, bank, Apple ID, Google, GitHub, hosting, domain + registrar, anything with a card on file. +- [ ] Every password saved in a **browser** should be treated as stolen. That is + the first thing a stealer takes. +- [ ] Turn on 2FA where it is missing. Prefer an **app or hardware key** over + SMS. +- [ ] Do not reuse anything. If you reused a password anywhere, change it + everywhere it was reused. + +--- + +## 5. Sweep your email for persistence. Do this even if nothing looks wrong. + +This is how someone keeps access after you have changed everything. All of it is +invisible during normal use, and all of it survives a password reset. + +- [ ] **Forwarding** — Settings → Forwarding. Remove any address you did not add. +- [ ] **Filters / rules** — look for rules matching `reset`, `verify`, `code`, + `security`, `bank`, or anything that auto-deletes or auto-archives. A rule + that silently trashes password-reset emails means they can reset your + accounts and you will never see the mail. +- [ ] **Send mail as / aliases** — remove addresses you do not recognise. +- [ ] **Account delegation / granted access** — Gmail lets another account read + and send as you. Check it. +- [ ] **App-specific passwords** — revoke all of them and regenerate only what + you actually need. They bypass 2FA by design. +- [ ] **Recovery email and phone number** — confirm both are still yours. A + changed recovery address is a permanent back door. + +--- + +## 6. Rotate developer credentials, in blast-radius order. + +If you write code, this section is the difference between *your* breach and +*your users'* breach. + +- [ ] **npm / PyPI / RubyGems / crates.io publish tokens — first.** A stolen + publish token turns a personal compromise into a **supply-chain + compromise** that hits everyone who installs your package. Revoke, then + re-issue. Check your packages' recent versions for anything you did not + publish. +- [ ] **Cloud keys** (AWS / GCP / Azure). **Disable first, delete after.** + Deleting immediately destroys the audit trail that shows what was done + with them. Check billing for resources you did not create. +- [ ] **GitHub personal access tokens** — Settings → Developer settings → revoke + all, re-issue narrowly. +- [ ] **SSH and GPG keys** — Settings → SSH and GPG keys. Remove anything + unfamiliar. Generate a new key **with a passphrase** and delete the old. +- [ ] **Per-repo deploy keys and Actions secrets** — the most forgotten items on + this list, because there is no "revoke all" button. You have to walk every + repo. Do the ones that deploy to production first. +- [ ] **Hosting and services**: Vercel, Netlify, Railway, Fly, Convex, Supabase, + Stripe (**check for new API keys and webhook endpoints**), Cloudflare, + Twilio, SendGrid, OpenAI/Anthropic. +- [ ] **Your `.env` files** — every value in every one is burned. Rotate them, + do not just move them. +- [ ] **Check `git log` on your main repos** for commits you did not make. + +--- + +## 7. Apple ID + +- [ ] appleid.apple.com → **Devices** — remove anything you do not recognise. +- [ ] Change the password. Regenerate **app-specific passwords**. +- [ ] Verify **trusted phone numbers** — an added number is a back door. +- [ ] Understand the blast radius: **if iCloud Keychain sync was on, one Apple ID + compromise means every synced credential on every device you own.** Treat + the whole keychain as exposed. + +--- + +## 8. Decide about wiping. This is a bright line, not a feeling. + +Ask one question: **did anything get root?** + +Answer **yes** if any of these are true: + +- You typed your Mac password into any prompt during or shortly after the + incident — including one that looked like System Settings. (The fake password + dialog is the standard second stage. It is convincing.) +- There is a new file in `/Library/LaunchDaemons`. +- There is a new file in `/Library/PrivilegedHelperTools`. +- A configuration profile appeared (System Settings → General → Device + Management). +- A new system extension appeared (`systemextensionsctl list`). +- `sudo` behaves differently, or you find an entry in `/etc/sudoers.d/`. + +**If yes to any → erase and reinstall from Recovery.** +Restore your **data only** — documents, photos, code. Never restore applications, +LaunchAgents, or system settings from a backup taken after the incident. Treat +every credential that was on the machine as burned, whether or not you found +evidence it was taken. + +**If no to all, and the exposure was browser-scoped** (saved passwords, cookies, +extension data) → the steps above are a defensible cleanup without a wipe. + +**Third option:** restore a Time Machine snapshot from **before** the incident. +Be careful with the date — a snapshot taken after infection restores the +infection. + +If you cannot tell, wipe. The cost of an unnecessary reinstall is an afternoon. +The cost of skipping a necessary one is doing this whole list again in a month. + +--- + +## 9. Afterwards + +- [ ] Run `./exposurescan/exposurescan.py` to see what a stealer would have + walked away with. Fix the P0s so the next one is smaller. +- [ ] Install **ShellGuard** so the next paste has to get past a typed + confirmation. +- [ ] Make a **standard, non-admin account** for everyone who is not you + (`./guestmode/setup-guestmode.sh`). The founding incident of this kit was + someone else using an admin account to watch a movie. +- [ ] Plant **canaries** (`./canary/canary-gen.sh`) so next time you find out + early instead of eventually. +- [ ] Freeze your credit if identity documents were on the machine. + In Canada: Equifax and TransUnion. In the US: all three bureaus. +- [ ] Tell anyone whose data was on that machine. Clients, especially. It is a + bad conversation and a much worse one later. + +--- + +## What not to do + +- **Do not** reuse the compromised machine for the cleanup. Every password you + type on it goes to the same place the last ones did. +- **Do not** change passwords before killing sessions (step 2). +- **Do not** delete cloud keys before disabling them — you lose the audit trail. +- **Do not** restore applications or LaunchAgents from a post-incident backup. +- **Do not** assume "it only got my browser." Assume the worst until + ExposureScan and the checks in step 8 tell you otherwise. +- **Do not** rush past step 0 because the rest of the list looks longer. + +--- + +*Part of the [ClickFix Defense Kit](https://github.com/DareDev256/clickfix-defense-kit). +Written after a real breach. If you are reading this during one: work the list +in order, and it will be over sooner than it feels right now.* diff --git a/README.md b/README.md index 3c8b19f..a3663f2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,27 @@ persist → exfiltrate**, plus a self-audit of what's already exposed. --- +## If you are in an incident right now + +Stop reading the rest of this page. + +```sh +./panic.sh # the checklist, offline, no browser needed +./panic.sh --short # the ordered summary, fits on a phone screen +./panic.sh --triage # ...plus live "did anything get root?" probes +./preserve.sh # capture evidence BEFORE you start cleaning up +``` + +Or open **[INCIDENT.md](./INCIDENT.md)** on your phone. + +The order in that document is the point. The step people get wrong is doing +password resets first: **a stolen session cookie authenticates without your +password and without your 2FA**, so sessions have to be killed first or the +reset protects nothing. Crypto comes before even that, because it is the only +step that cannot be undone. + +--- + ## Quick install Clone the repo, read the code, then run the top-level menu installer: diff --git a/panic.sh b/panic.sh new file mode 100755 index 0000000..301f14f --- /dev/null +++ b/panic.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# +# panic.sh — print the incident-response checklist with nothing else running +# ============================================================================== +# You are having a bad morning. This prints INCIDENT.md to your terminal, with +# no network, no browser, and no dependencies beyond what macOS ships. +# +# Assume the machine is hostile and the network is off. That is the whole design +# constraint: the checklist has to be readable when nothing else works. +# +# ./panic.sh print the checklist +# ./panic.sh --short the ordered summary only (fits on a phone screen) +# ./panic.sh --paper plain text, no colour, ready to pipe to lpr +# ./panic.sh --triage print the checklist, then run local root-check probes +# +# DEFENSIVE USE ONLY. Reads files, prints text. Changes nothing, sends nothing. +# ============================================================================== + +set -euo pipefail + +KIT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd -P)" +DOC="$KIT_DIR/INCIDENT.md" + +MODE="full" +case "${1:-}" in + --short) MODE="short" ;; + --paper) MODE="paper" ;; + --triage) MODE="triage" ;; + -h|--help) + sed -n '3,20p' "${BASH_SOURCE[0]:-$0}" | sed 's/^# \{0,1\}//' + exit 0 ;; + "") ;; + *) printf 'Unknown option: %s (try --help)\n' "$1" >&2; exit 1 ;; +esac + +if [ -t 1 ] && [ "$MODE" != "paper" ]; then + B=$'\033[1m'; R=$'\033[31m'; Y=$'\033[33m'; G=$'\033[32m'; D=$'\033[2m'; X=$'\033[0m' +else + B=''; R=''; Y=''; G=''; D=''; X='' +fi + +# ---- the ordered summary, inlined ------------------------------------------ +# Deliberately hard-coded rather than parsed out of INCIDENT.md: if the repo is +# damaged or you are reading this from a USB stick, the ORDER is the part you +# cannot afford to lose. Everything else can be re-derived; the sequence cannot. +print_short() { + cat </dev/null | tail -n +2 || printf ' (none / unreadable)\n' + + printf '\n%s-- /Library/LaunchAgents --%s\n' "$B" "$X" + ls -la /Library/LaunchAgents/ 2>/dev/null | tail -n +2 || printf ' (none / unreadable)\n' + + printf '\n%s-- ~/Library/LaunchAgents (per-user, no root needed) --%s\n' "$B" "$X" + ls -la "$HOME/Library/LaunchAgents/" 2>/dev/null | tail -n +2 || printf ' (none)\n' + + printf '\n%s-- /Library/PrivilegedHelperTools --%s\n' "$B" "$X" + ls -la /Library/PrivilegedHelperTools/ 2>/dev/null | tail -n +2 || printf ' (none)\n' + + printf '\n%s-- configuration profiles / MDM --%s\n' "$B" "$X" + profiles status -type enrollment 2>/dev/null || printf ' (unavailable)\n' + + printf '\n%s-- system extensions --%s\n' "$B" "$X" + systemextensionsctl list 2>/dev/null | head -20 || printf ' (unavailable)\n' + + printf '\n%s-- sudoers drop-ins --%s\n' "$B" "$X" + ls -la /etc/sudoers.d/ 2>/dev/null | tail -n +2 || printf ' (none / unreadable without sudo)\n' + + printf '\n%s-- SSH authorized_keys (quieter persistence than a LaunchDaemon) --%s\n' "$B" "$X" + if [ -f "$HOME/.ssh/authorized_keys" ]; then + awk '{print " " NR ": " $1 " " substr($2,1,24) "... " $3}' "$HOME/.ssh/authorized_keys" 2>/dev/null + printf ' %sAny key here that you did not add is remote access.%s\n' "$Y" "$X" + else + printf ' (no authorized_keys — good)\n' + fi + + printf '\n%s-- remote access services --%s\n' "$B" "$X" + printf ' sshd (Remote Login): ' + if launchctl print-disabled system 2>/dev/null | grep -q '"com.openssh.sshd" => disabled'; then + printf '%sdisabled%s\n' "$G" "$X" + else + printf '%senabled or unknown — check System Settings > General > Sharing%s\n' "$Y" "$X" + fi + printf ' Screen Sharing: ' + if launchctl print-disabled system 2>/dev/null | grep -q '"com.apple.screensharing" => disabled'; then + printf '%sdisabled%s\n' "$G" "$X" + else + printf '%senabled or unknown%s\n' "$Y" "$X" + fi + + printf '\n%s-- SIP --%s\n' "$B" "$X" + csrutil status 2>/dev/null || printf ' (unavailable)\n' + + printf '\n%s-- recent Downloads (last 20) --%s\n' "$B" "$X" + ls -lt "$HOME/Downloads" 2>/dev/null | head -21 | tail -n +2 || printf ' (none)\n' + + printf '\n%sIf anything above is unfamiliar, treat the answer to step 8 as YES.%s\n' "$B" "$X" + printf '%sDo not delete it yet — run ./preserve.sh first if you want the record.%s\n\n' "$D" "$X" +} + +# ---- main ------------------------------------------------------------------- +case "$MODE" in + short) + print_short + ;; + triage) + print_short + print_triage + ;; + *) + if [ ! -r "$DOC" ]; then + # The document is gone but the order is what matters. Never leave the + # user with nothing during an incident. + printf '%s[panic] INCIDENT.md not found at %s%s\n\n' "$Y" "$DOC" "$X" >&2 + printf '%sFalling back to the built-in summary — the ordering is the part that matters.%s\n' "$D" "$X" >&2 + print_short + exit 0 + fi + + print_short + printf '\n%s%s\n' "$D" "$(printf '%.0s─' {1..76})" + printf 'Full checklist below. Also readable on your phone at:\n' + printf 'github.com/DareDev256/clickfix-defense-kit/blob/main/INCIDENT.md%s\n\n' "$X" + + # Strip markdown syntax for terminal reading. No pager: if you are having an + # incident you want the whole thing scrollable in your buffer, not paged. + sed -e 's/^# /\ +/' -e 's/^## //' -e 's/\*\*//g' -e 's/^> //' -e 's/- \[ \]/ [ ]/' "$DOC" + ;; +esac diff --git a/preserve.sh b/preserve.sh new file mode 100755 index 0000000..da65b43 --- /dev/null +++ b/preserve.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# +# preserve.sh — capture the evidence BEFORE you start cleaning up +# ============================================================================== +# Every instinct after an alert destroys the record that tells you what actually +# ran — and therefore how much of INCIDENT.md you truly need to work through. +# Rebooting clears process and network state. Deleting the suspicious plist +# removes the thing you would have identified it by. Even WatchPost makes it +# worse by design: after alerting it promotes the baseline, so the diff that +# proved something appeared is gone on the next run. (Use `watchpost.sh +# --no-update` during an incident — that is what the flag is for.) +# +# This writes a timestamped, read-only bundle. It reads; it never remediates. +# +# IF JAMF'S AFTERMATH IS INSTALLED, THIS DEFERS TO IT AND STOPS. +# Aftermath is free, Swift, purpose-built for macOS incident response, and +# collects a superset of what a shell script can. Reimplementing it would be +# worse code doing a solved job. This script's value is the ClickFix-specific +# ordering and the fact that it is already here — not the collection itself. +# https://github.com/jamf/aftermath +# +# Usage: +# ./preserve.sh bundle into ./clickfix-evidence- +# ./preserve.sh -o /Volumes/USB bundle onto external media (preferred) +# ./preserve.sh --no-aftermath force the built-in collector +# ./preserve.sh --log-hours 24 widen the unified-log window (default 6) +# +# Write the bundle to EXTERNAL media if you can. A bundle stored on the machine +# you are investigating is evidence the attacker can still reach. +# +# DEFENSIVE USE ONLY. Read-only. Nothing is transmitted anywhere. +# ============================================================================== + +set -uo pipefail + +STAMP="$(date '+%Y%m%d-%H%M%S')" +OUTBASE="." +USE_AFTERMATH=1 +# Hours of unified log to pull. Kept small on purpose: see the note at the +# capture step. Raise it if you know the incident was longer ago. +LOG_HOURS="${PRESERVE_LOG_HOURS:-6}" + +while [ $# -gt 0 ]; do + case "$1" in + -o|--out) OUTBASE="${2:-.}"; shift 2 ;; + --no-aftermath) USE_AFTERMATH=0; shift ;; + --log-hours) LOG_HOURS="${2:-6}"; shift 2 ;; + -h|--help) sed -n '3,32p' "${BASH_SOURCE[0]:-$0}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) printf 'Unknown option: %s (try --help)\n' "$1" >&2; exit 1 ;; + esac +done + +if [ -t 1 ]; then + B=$'\033[1m'; Y=$'\033[33m'; G=$'\033[32m'; D=$'\033[2m'; X=$'\033[0m' +else + B=''; Y=''; G=''; D=''; X='' +fi + +say() { printf '%s\n' "$*"; } +step() { printf '%s->%s %s\n' "$B" "$X" "$*"; } + +BUNDLE="$OUTBASE/clickfix-evidence-$STAMP" + +# ---- defer to Aftermath if present ------------------------------------------- +if [ "$USE_AFTERMATH" -eq 1 ]; then + AFTERMATH="" + for c in /usr/local/bin/aftermath /opt/homebrew/bin/aftermath "$(command -v aftermath 2>/dev/null || true)"; do + [ -n "$c" ] && [ -x "$c" ] && { AFTERMATH="$c"; break; } + done + if [ -n "$AFTERMATH" ]; then + say "${G}Jamf Aftermath found at $AFTERMATH${X}" + say "${D}It collects a superset of what this script would. Deferring to it.${X}" + say "${Y}Aftermath needs root. You will be prompted.${X}" + say "" + mkdir -p "$BUNDLE" + sudo "$AFTERMATH" -o "$BUNDLE" || { + say "${Y}Aftermath exited non-zero. Re-run with --no-aftermath for the built-in collector.${X}" + exit 1 + } + say "" + say "${G}Done.${X} Bundle: $BUNDLE" + say "Next: work INCIDENT.md in order. Start at step 0." + exit 0 + fi + say "${D}Jamf Aftermath not installed — using the built-in collector.${X}" + say "${D}For a real investigation, prefer it: https://github.com/jamf/aftermath${X}" + say "" +fi + +# ---- built-in collector ------------------------------------------------------ +mkdir -p "$BUNDLE" +chmod 700 "$BUNDLE" +say "${B}Collecting into $BUNDLE${X}" +say "" + +cap() { + # cap + local f="$BUNDLE/$1"; shift + local desc="$1"; shift + step "$desc" + { printf '# %s\n# captured %s\n# command: %s\n\n' "$desc" "$(date)" "$*"; "$@" 2>&1; } > "$f" || true +} + +cap system.txt "system + OS version" sw_vers +cap uptime.txt "uptime (has it been rebooted since?)" uptime +cap processes.txt "running processes" ps auxww +cap network.txt "established network connections" lsof -i -n -P +cap listening.txt "listening ports" netstat -anv +cap launchd-user.txt "user LaunchAgents" ls -la@ "$HOME/Library/LaunchAgents/" +cap launchd-lib.txt "library LaunchAgents" ls -la@ /Library/LaunchAgents/ +cap launchd-daemon.txt "LaunchDaemons (root)" ls -la@ /Library/LaunchDaemons/ +cap privhelpers.txt "privileged helper tools" ls -la@ /Library/PrivilegedHelperTools/ +cap loaded-agents.txt "loaded launchd jobs" launchctl list +cap profiles.txt "configuration profiles / MDM" profiles status -type enrollment +cap sysextensions.txt "system extensions" systemextensionsctl list +cap kexts.txt "third-party kernel extensions" kmutil showloaded --no-kernel-components +cap sip.txt "SIP status" csrutil status +cap cron.txt "crontab" crontab -l +cap sudoers.txt "sudoers drop-ins" ls -la /etc/sudoers.d/ +cap ssh-authkeys.txt "SSH authorized_keys" cat "$HOME/.ssh/authorized_keys" +cap ssh-config.txt "SSH client config (ProxyCommand injection)" cat "$HOME/.ssh/config" +cap sshd-config.txt "sshd config" cat /etc/ssh/sshd_config +cap downloads.txt "Downloads with quarantine xattrs" ls -la@ "$HOME/Downloads" +cap shell-history.txt "zsh history" cat "$HOME/.zsh_history" +cap bash-history.txt "bash history" cat "$HOME/.bash_history" +cap installed-apps.txt "applications" ls -la /Applications +cap login-items.txt "login items" osascript -e 'tell application "System Events" to get the name of every login item' + +# Origin URLs for recent downloads — the single most useful artifact for +# answering "where did this come from?", and it is not in `ls`. +step "download origin URLs (kMDItemWhereFroms)" +{ + printf '# Origin URL of each recent download.\n' + printf '# This is what tells you which site delivered the payload.\n\n' + find "$HOME/Downloads" -maxdepth 1 -type f -mtime -30 2>/dev/null | while IFS= read -r f; do + printf '%s\n' "$f" + mdls -name kMDItemWhereFroms "$f" 2>/dev/null | sed 's/^/ /' + xattr -p com.apple.quarantine "$f" 2>/dev/null | sed 's/^/ quarantine: /' + printf '\n' + done +} > "$BUNDLE/download-origins.txt" 2>&1 || true + +# Hash + signature verdict for every persistence plist. A name alone tells you +# nothing; a codesign verdict tells you whether it is Apple's. +step "hashing + codesign verdict for persistence plists" +{ + printf '# Every LaunchAgent/LaunchDaemon: sha256, and the codesign verdict of\n' + printf '# its ProgramArguments target where one can be resolved.\n\n' + for d in "$HOME/Library/LaunchAgents" /Library/LaunchAgents /Library/LaunchDaemons; do + [ -d "$d" ] || continue + printf '=== %s ===\n' "$d" + find "$d" -maxdepth 1 -name '*.plist' 2>/dev/null | while IFS= read -r p; do + printf '%s\n' "$p" + shasum -a 256 "$p" 2>/dev/null | awk '{print " sha256: " $1}' + prog="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' "$p" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c 'Print :Program' "$p" 2>/dev/null || true)" + if [ -n "$prog" ]; then + printf ' program: %s\n' "$prog" + codesign -dv --verbose=2 "$prog" 2>&1 | grep -E 'Authority|Identifier' | sed 's/^/ /' || \ + printf ' codesign: UNSIGNED or unresolvable\n' + fi + printf '\n' + done + done +} > "$BUNDLE/persistence-detail.txt" 2>&1 || true + +# Unified log, bounded hard. +# +# NOTE: predicate choice matters enormously here. `eventMessage CONTAINS ...` +# forces a full scan of the log store and can run for many minutes on a busy +# machine — long enough that during an actual incident you would kill it and +# lose the artifact entirely. Filtering on `process`, which is indexed, returns +# in seconds. Narrower, and you actually get it. +step "unified log — sudo/osascript/curl activity (last ${LOG_HOURS}h)" +log show --last "${LOG_HOURS}h" --style syslog \ + --predicate 'process == "sudo" OR process == "osascript" OR process == "curl" OR process == "bash" OR process == "zsh"' \ + > "$BUNDLE/unified-log-exec.txt" 2>&1 || \ + printf 'unified log unavailable\n' > "$BUNDLE/unified-log-exec.txt" + +# TCC: which apps hold the grants malware wants to inherit. +step "TCC grants (which apps hold FDA / Accessibility / Screen Recording)" +{ + for db in "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \ + "/Library/Application Support/com.apple.TCC/TCC.db"; do + [ -r "$db" ] || { printf '# unreadable (needs Full Disk Access): %s\n' "$db"; continue; } + printf '=== %s ===\n' "$db" + sqlite3 "file:$db?mode=ro" \ + 'SELECT service, client, auth_value FROM access ORDER BY service;' 2>/dev/null || \ + printf ' (query failed)\n' + done +} > "$BUNDLE/tcc-grants.txt" 2>&1 || true + +# Freeze the bundle. Read-only so a later cleanup cannot quietly alter it. +step "sealing the bundle" +{ + printf '# ClickFix Defense Kit — evidence bundle\n' + printf '# host: %s\n' "$(scutil --get LocalHostName 2>/dev/null || hostname)" + printf '# captured: %s\n' "$(date)" + printf '# collector: preserve.sh (built-in)\n\n' + printf '# sha256 of every file in this bundle:\n' + ( cd "$BUNDLE" && find . -type f ! -name MANIFEST.txt -exec shasum -a 256 {} \; | sort -k2 ) +} > "$BUNDLE/MANIFEST.txt" 2>&1 || true + +chmod -R a-w "$BUNDLE" 2>/dev/null || true + +say "" +say "${G}Done.${X} Bundle: ${B}$BUNDLE${X}" +say "$(find "$BUNDLE" -type f | wc -l | tr -d ' ') files, read-only, MANIFEST.txt has the hashes." +say "" +say "${Y}Copy it to external media now${X} — a bundle left on the machine you are" +say "investigating is evidence the attacker can still reach." +say "" +say "Next: work ${B}INCIDENT.md${X} in order, starting at step 0. Or: ./panic.sh" diff --git a/tests/test-watchpost-baseline.sh b/tests/test-watchpost-baseline.sh new file mode 100755 index 0000000..a6f3506 --- /dev/null +++ b/tests/test-watchpost-baseline.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# test-watchpost-baseline.sh — the baseline must not be blindable +# ============================================================================== +# WatchPost's entire value is "this persistence entry is NEW". That claim rests +# on the stored baseline, which lives in the user's own home directory — writable +# by exactly the malware this tool exists to catch. +# +# Three attacks, all of which worked before v0.2.0: +# A. Edit the baseline to pre-seed the attacker's future entry, so the real +# plant later diffs as already-known. +# B. Delete the baseline. The next run said "No diffing on first run" and +# silently absorbed whatever was planted. +# C. Delete the kit's own LaunchAgents. WatchPost is silent on removals by +# design, so its own disabling went unreported. +# +# Everything runs in a throwaway HOME + WATCHPOST_STATE_DIR. Nothing outside the +# temp dir is read or written, and no real LaunchAgent is touched. +# ============================================================================== + +set -uo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")/.." >/dev/null 2>&1 && pwd -P)" +WP="$ROOT/watchpost/watchpost.sh" + +if [ -t 1 ]; then + R=$'\033[1;31m'; G=$'\033[1;32m'; X=$'\033[0m' +else + R=''; G=''; X='' +fi + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/watchpost-test.XXXXXX")" +cleanup() { [ -n "${TMP:-}" ] && [ -d "$TMP" ] && rm -rf -- "$TMP"; } +trap cleanup EXIT INT TERM + +pass=0; fail=0; r=0 +export WATCHPOST_STATE_DIR="$TMP/state" +export HOME="$TMP/home" +mkdir -p "$HOME/Library/LaunchAgents" + +# Keep the test hermetic and quiet: no notifications, no host lookups. +export PATH="$TMP/bin:$PATH" +mkdir -p "$TMP/bin" +for stub in osascript crontab scutil systemextensionsctl profiles; do + printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/$stub" + chmod +x "$TMP/bin/$stub" +done + +run_wp() { bash "$WP" "$@" >"$TMP/out.txt" 2>&1; printf '%s' "$?"; } + +check() { + # check <0|1 result> + if [ "$2" -eq 1 ]; then + printf ' %sok%s %s\n' "$G" "$X" "$1" + pass=$((pass + 1)) + else + printf ' %sx%s %s\n' "$R" "$X" "$1" + printf ' last output:\n' + sed 's/^/ /' "$TMP/out.txt" | head -12 + fail=$((fail + 1)) + fi +} + +printf 'WatchPost baseline integrity\n\n' + +# --- arm --------------------------------------------------------------------- +run_wp --init >/dev/null +BASE="$WATCHPOST_STATE_DIR/baseline.json" +if [ -f "$BASE" ]; then r=1; else r=0; fi +check "arming writes a baseline" "$r" + +MODE="$(stat -f '%Lp' "$BASE" 2>/dev/null || stat -c '%a' "$BASE" 2>/dev/null)" +if [ "$MODE" = "600" ]; then r=1; else r=0; fi +check "baseline is mode 0600, not world-readable" "$r" + +if [ -f "$WATCHPOST_STATE_DIR/.armed" ]; then r=1; else r=0; fi +check "an 'armed' marker is written so a later deletion is detectable" "$r" + +# --- A. pre-seed the baseline ------------------------------------------------ +# Edit the stored baseline directly, as malware would, to plant its own future +# entry. The integrity tag must catch that the file changed outside WatchPost. +printf '{"generated_at":"now","host":"x","entries":[{"kind":"launchagent","path":"/tmp/evil.plist"}]}\n' > "$BASE" +rc="$(run_wp)" +if grep -q "TAMPERED" "$TMP/out.txt"; then r=1; else r=0; fi +check "A. editing the baseline directly is detected (was: silently trusted)" "$r" +if [ "$rc" != "0" ]; then r=1; else r=0; fi +check "A. tampered baseline exits non-zero" "$r" + +# --- B. delete the baseline -------------------------------------------------- +# The cheapest way to blind the tool. Must NOT be treated as a first run. +rm -f "$BASE" "$BASE.tag" +rc="$(run_wp)" +if grep -q "BASELINE MISSING" "$TMP/out.txt"; then r=1; else r=0; fi +check "B. deleting the baseline alerts (was: 'No diffing on first run')" "$r" +if [ "$rc" != "0" ]; then r=1; else r=0; fi +check "B. deletion exits non-zero" "$r" +if [ ! -f "$BASE" ]; then r=1; else r=0; fi +check "B. refuses to silently re-baseline after a deletion" "$r" + +# --- explicit re-arm is still allowed ---------------------------------------- +run_wp --init >/dev/null +if [ -f "$BASE" ]; then r=1; else r=0; fi +check "an explicit --init still re-arms deliberately" "$r" + +# --- C. a genuinely new plant is still reported ------------------------------ +# The whole point: after all the hardening, the tool must still do its job. +cat > "$HOME/Library/LaunchAgents/com.evil.helper.plist" <<'PLIST' + + + Labelcom.evil.helper + ProgramArguments/tmp/helper + RunAtLoad + +PLIST +run_wp --no-update >/dev/null +if grep -q "com.evil.helper" "$TMP/out.txt"; then r=1; else r=0; fi +check "C. a newly planted LaunchAgent is still reported" "$r" + +printf '\n' +if [ "$fail" -eq 0 ]; then + printf '%s%d/%d%s baseline integrity checks pass.\n' "$G" "$pass" "$pass" "$X" + exit 0 +fi +printf '%s%d/%d%s pass — %d FAILED.\n' "$R" "$pass" "$((pass + fail))" "$X" "$fail" +exit 1 diff --git a/watchpost/README.md b/watchpost/README.md index 8464cfb..4b2f471 100644 --- a/watchpost/README.md +++ b/watchpost/README.md @@ -106,6 +106,39 @@ want: `rm -rf ~/.local/state/watchpost`.) Override the baseline location with `WATCHPOST_STATE_DIR`. +### `--no-update` is the incident flag + +**During an incident, always run `--no-update`.** A normal run promotes the +current state to the baseline after alerting, so you only get told once per +change. That is the right default for a monitor and the wrong one for an +investigation: the next run erases the diff that proved something appeared. +Capture first (`../preserve.sh`), investigate with `--no-update`, and re-arm only +when you are done. + +### Baseline integrity + +The baseline lives in your home directory, which means it is writable by exactly +the malware this tool exists to catch. Three things now hold: + +- It is written **0600** (it enumerates every persistence entry on the machine) + inside a **0700** state directory, and carries an HMAC tag. +- **Editing it directly is detected.** Pre-seeding the baseline with an entry the + attacker intends to create later would make the real plant diff as + already-known. A tag mismatch aborts the run and alerts. +- **Deleting it is an alert, not a first run.** An `.armed` marker records that + this machine was baselined before. Without it, `rm baseline.json` made the next + run print "No diffing on first run" and silently absorb whatever had just been + planted. WatchPost now refuses, and tells you to re-arm deliberately with + `--init`. + +> **Honest limit.** The HMAC key sits in the state directory at 0600, under the +> same user this tool runs as. Anyone already running as you can read it and +> forge a tag. This is tamper-**evidence**, not tamper-proofing: it catches a +> stealer that blindly rewrites or deletes the file; it does not stop a targeted +> attacker who knows WatchPost is installed. The version that would is a +> root-owned daemon writing to `/var/db/watchpost`, which a user-level compromise +> cannot touch. Not built yet, and not claimed. + After an alert, the baseline is promoted to the current state, so you are notified **once per change** (not every hour for the same item) — the same "alert on state change, not on every run" discipline a good cron job follows. diff --git a/watchpost/watchpost.sh b/watchpost/watchpost.sh index 49a0541..fbdf845 100644 --- a/watchpost/watchpost.sh +++ b/watchpost/watchpost.sh @@ -59,6 +59,11 @@ set -u # treat unset variables as errors (caught one real bug during dev) # Where the baseline snapshot lives. Override with WATCHPOST_STATE_DIR. STATE_DIR="${WATCHPOST_STATE_DIR:-$HOME/.local/state/watchpost}" BASELINE_FILE="$STATE_DIR/baseline.json" +# ARMED_FILE proves this machine was previously baselined. Without it, a missing +# baseline is indistinguishable from a first run — and v0.1.x treated it as one, +# so `rm baseline.json` made the next run silently absorb whatever persistence +# had just been planted. It also holds the HMAC key for the baseline. +ARMED_FILE="$STATE_DIR/.armed" # Directories we treat as persistence surfaces. Each line: a label and a path. # We use a parallel-array approach (bash 3.2 ships with macOS — no associative @@ -262,10 +267,34 @@ line_to_json() { } # Render a full state blob (the multi-line text) into a JSON array file. +# Integrity tagging. +# +# HONEST LIMITS, stated up front: the HMAC key lives in ARMED_FILE at mode 0600 +# under the same user this tool runs as. An attacker already running as that +# user can read the key and forge a tag. This is NOT tamper-proofing against a +# determined local attacker; it is tamper-EVIDENCE against everything cheaper +# than that — a stealer that blindly rewrites or deletes the baseline, a +# half-finished cleanup, corruption. The tamper-proof variant is the root-owned +# daemon writing to /var/db/watchpost at 0600, which a user-level compromise +# cannot touch. Documented, not oversold. +baseline_key() { + if [ ! -f "$ARMED_FILE" ]; then + ( umask 077; head -c 32 /dev/urandom | shasum -a 256 | cut -d' ' -f1 > "$ARMED_FILE" ) + chmod 600 "$ARMED_FILE" 2>/dev/null || true + fi + cat "$ARMED_FILE" +} + +baseline_tag() { + # baseline_tag -> HMAC-SHA256 of its contents + openssl dgst -sha256 -hmac "$(baseline_key)" "$1" 2>/dev/null | awk '{print $NF}' +} + write_baseline() { local state="$1" local out="$2" mkdir -p "$(dirname "$out")" + chmod 700 "$(dirname "$out")" 2>/dev/null || true { printf '{\n' @@ -285,6 +314,12 @@ write_baseline() { printf '\n ]\n' printf '}\n' } > "$out" + + # 0600, not the default 0644. This file enumerates every persistence entry on + # the machine; it should not be world-readable. + chmod 600 "$out" 2>/dev/null || true + baseline_tag "$out" > "$out.tag" 2>/dev/null || true + chmod 600 "$out.tag" 2>/dev/null || true } # Build a set of stable "identity" keys for the diff. Two runs are compared by @@ -352,6 +387,37 @@ main() { local current_state current_state="$(collect_state)" + chmod 700 "$STATE_DIR" 2>/dev/null || true + + # A MISSING baseline on a machine that was previously armed is an ALERTABLE + # EVENT, not a first run. Deleting the baseline is the cheapest way to blind + # this tool, and treating it as a first run is exactly the behaviour an + # attacker wants: the next run re-baselines and their persistence becomes + # "normal". Refuse, loudly, unless the operator explicitly re-inits. + if [ ! -f "$BASELINE_FILE" ] && [ -f "$ARMED_FILE" ] && [ "$INIT_ONLY" -eq 0 ]; then + log "BASELINE MISSING on a machine that was previously armed: $BASELINE_FILE" + log "This is not a first run. Something deleted the baseline." + log "If this was you, re-arm deliberately: $0 --init" + notify "WatchPost: BASELINE MISSING" \ + "The persistence baseline was deleted. Refusing to silently re-baseline. Re-arm with --init." + return 2 + fi + + # Tamper check on an existing baseline. + if [ -f "$BASELINE_FILE" ] && [ -f "$BASELINE_FILE.tag" ] && [ "$INIT_ONLY" -eq 0 ]; then + local want have + want="$(cat "$BASELINE_FILE.tag" 2>/dev/null || true)" + have="$(baseline_tag "$BASELINE_FILE")" + if [ -n "$want" ] && [ -n "$have" ] && [ "$want" != "$have" ]; then + log "BASELINE TAMPERED: integrity tag does not match $BASELINE_FILE" + log "Someone edited the baseline directly — most likely to pre-seed an entry" + log "so that a later malicious plant would diff as already-known." + notify "WatchPost: BASELINE TAMPERED" \ + "The stored baseline was modified outside WatchPost. Treat persistence as unverified." + return 2 + fi + fi + # First-ever run, or explicit --init: write baseline and exit quietly. if [ ! -f "$BASELINE_FILE" ] || [ "$INIT_ONLY" -eq 1 ]; then write_baseline "$current_state" "$BASELINE_FILE" From 2dd02052f863b023ae923d677b1bef3a7f85817d Mon Sep 17 00:00:00 2001 From: DareDev256 Date: Wed, 29 Jul 2026 09:34:58 +0800 Subject: [PATCH 2/3] feat(exposurescan): dev-credential surface + --tcc grant inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur --- exposurescan/README.md | 63 +- exposurescan/exposurescan.py | 1243 +++++++++++++++++++- exposurescan/sample-report.md | 214 +++- exposurescan/tests/make-sample-report.py | 320 +++++ exposurescan/tests/test_dev_credentials.py | 477 ++++++++ 5 files changed, 2290 insertions(+), 27 deletions(-) create mode 100755 exposurescan/tests/make-sample-report.py create mode 100644 exposurescan/tests/test_dev_credentials.py diff --git a/exposurescan/README.md b/exposurescan/README.md index 65cf6cb..5a925da 100644 --- a/exposurescan/README.md +++ b/exposurescan/README.md @@ -10,17 +10,47 @@ Part of the open-source [ClickFix Defense Kit](../). > *If an infostealer (AMOS / Atomic / Poseidon) ran on my Mac right now, what would it walk away with — and what could the attacker pivot into?* -ExposureScan inventories the four credential/PII surfaces a macOS infostealer targets and prints a **prioritized blast-radius report** ranked by pivot value (P0 → P3), not raw count. It is the productized version of the manual "what's sitting on my disk" incident scan. +ExposureScan inventories the credential/PII surfaces a macOS infostealer targets and prints a **prioritized blast-radius report** ranked by pivot value (P0 → P3), not raw count. It is the productized version of the manual "what's sitting on my disk" incident scan. It scans: | # | Surface | What it reports | |---|---------|-----------------| -| a | **Chrome / Brave / Edge / Chromium saved logins** (`Login Data` SQLite) | per-domain count of saved logins, whether a username exists, whether a password is present — **never the password** | +| a | **Chrome / Brave / Edge / Chromium saved logins** (`Login Data` SQLite) | per-domain count of saved logins, whether a username exists, whether a password is present — **never the password**. Plus **cookie row counts** per profile, with the "sessions survive a password reset" ordering warning | | b | **Apple Notes** (`NoteStore.sqlite` → gzip `ZICNOTEDATA.ZDATA`) | note **primary key**, title **length**, **modification date**, matched **category** (password / seed-phrase / api-key / PIN / SIN…) — **never the title, never the matched text** | | c | **`.env` files** under a target dir | **KEY NAMES only** (left of `=`), line number, value *shape* (length / entropy class / prefix class) — **never the value** | | d | **`~/.secrets`** | file **names** + sizes + permissions (the filename *is* the credential name) | | e | **PII markers** in Desktop / Documents / Downloads | **counts per type** (email, phone, SIN/SSN, Luhn-validated card, DOB) — **never the instance**. Filenames are screened by the same patterns and **withheld** (hash + parent dir + size + mtime) when the filename is itself the PII | +| f | **Developer credentials** (v0.2.0) | `~/.ssh` private keys by **filename + type + ENCRYPTED vs PLAINTEXT**; presence + **file mode** + **key names only** for `~/.aws/credentials`, `~/.npmrc`, `~/.pypirc`, `~/.netrc`, `~/.config/gh/hosts.yml`, `~/.docker/config.json`, `~/.kube/config`, `~/.config/gcloud`, `~/.git-credentials`, `~/.cargo/credentials.toml`, `~/.gem/credentials`; secret-shaped **counts + line numbers** in `~/.zsh_history` / `~/.bash_history` | +| g | **Crypto wallets** (v0.2.0) | wallet **extension LevelDB stores** under each browser profile's `Local Extension Settings`, plus desktop wallet bundles. Always **P0** | +| h | **Keychain / Safari / Firefox** (v0.2.0) | `login.keychain-db` presence + size + lock state; Firefox saved-login and cookie **counts**; Safari stated honestly (see limits) | +| i | **TCC grants** — `--tcc` (v0.2.0) | which binaries hold Full Disk Access / Accessibility / Screen Recording / Input Monitoring, tiered **P0 when the holder is a grant-inheritance vehicle**, plus Secure Keyboard Entry, the remote-access surface and Secure Boot level | + +### Why (f)–(i) exist + +The v0.1.x surfaces cover what a stealer takes from a *consumer*. They missed the highest-pivot files on a solo developer's Mac. A passphrase-less SSH key plus a GitHub CLI token is push access to every repo you own — **a personal breach becomes a supply-chain breach** — and the report said nothing about any of it. The wallet case is worse: the tool tiered a seed phrase in Notes at P0 and never looked where wallets actually live. Crypto theft is irreversible, uninsured and has no chargeback, so wallet stores are P0 unconditionally. + +### `--tcc`: the kit's own argument, finally actioned + +The kit's central claim is that **malware inherits the grants of the trusted binary it runs inside** — and then never told you *which* binaries those are. + +```bash +./exposurescan.py --tcc +``` + +reads both `TCC.db` files (`~/Library/Application Support/com.apple.TCC/TCC.db` and `/Library/Application Support/com.apple.TCC/TCC.db`) through the same read-only safe-copy machinery as every other SQLite surface, and tiers **P0** any terminal, shell, SSH wrapper, remote-access tool or bare interpreter binary holding Full Disk Access, Accessibility, Screen Recording, Input Monitoring or Developer Tools. Those are not apps holding a permission; they are permissions attached to a thing that runs other code: + +> *Your Terminal has Screen Recording. A pasted payload gets Screen Recording too — it never has to ask.* + +An ordinary GUI app holding an ordinary grant (Photos, Camera) is counted in a P3 aggregate, not tiered up. Without Full Disk Access the system database is reported as skipped, not failed. + +Bundled into the same flag, because they are ClickFix-relevant and nothing else surfaces them to a consumer: + +- **Terminal Secure Keyboard Entry** — off by default. Without it, *any* of the apps holding Accessibility or Input Monitoring can read the admin password you type at a `sudo` prompt, which is the exact step a ClickFix payload wants from you. +- **Remote-access surface** — is `com.openssh.sshd` or `com.apple.screensharing` enabled? An added line in `~/.ssh/authorized_keys` is quieter persistence than a LaunchAgent, survives a password change, and is invisible to WatchPost's persistence diff. +- **Secure Boot level / kext policy** — a Mac sitting at *Reduced Security / Allow All Kernel Extensions* is a real downgrade of the boot chain, and no other tool in this kit would tell you. + +**Deliberately excluded from `--tcc`, and not coming later:** FileVault, firewall posture, update settings, sudoers, password policy, the general CIS-style sweep. [mSCP](https://github.com/usnistgov/macos_security) (authoritative via NIST SP 800-219), [CIS](https://www.cisecurity.org/benchmark/apple_os) and [Pareto Security](https://paretosecurity.com/) own that ground and own it better. `--tcc` is only the narrow grant-inheritance slice that is genuinely underserved and directly on-thesis. --- @@ -34,6 +64,10 @@ This is enforced by architecture, not by a disabled `--show-data` flag: - **`.env` values** — we split on the first `=`, keep the **key name**, measure the value's length/entropy to score risk, then **discard the value immediately**. It is never stored or printed. - **Apple Notes** — we report the note's **primary key, title LENGTH, modification date and matched category**. **The title is never emitted.** On macOS a note has no user-chosen title: `ZTITLE1` is *derived from the note's first line*. For the exact person this surface exists for — someone who pasted a seed phrase into Notes — the "title" **is** the secret. The modification date is what actually lets you find the note again (sort Notes.app by *Date Edited*). - **PII filenames** — a filename can be the PII. `visa 4111 1111 1111 1111 exp 0327 cvv 415.csv` is withheld and reported as ` (#3df3ff2b) in ~/Documents/ (34 bytes, 2026-07-28 18:36 UTC)`. +- **SSH private keys** — we report the **filename, key type and ENCRYPTED vs PLAINTEXT**. For an `OPENSSH PRIVATE KEY` we base64-decode a hard-bounded **112 characters (84 bytes)** of the body: enough for the magic, ciphername, KDF name/options and — for a passphrase-less key — the *public* key type, and it stops **short of the private section in both layouts** (which starts at ~94 bytes plaintext, ~126 bytes encrypted). For an encrypted key the type is read from the `.pub` half instead; the bound is widened for nobody. Private key material is never decoded, so it cannot be held, logged or emitted even by accident. If the header cannot be parsed the key is reported as encrypted — **unknown is never reported as the P0 case**. +- **Developer credential files** — presence, **file mode** and **key names only**. `~/.git-credentials` is the sharp case: every line of it is literally `https://user:token@host`, so no line is ever emitted or parsed for userinfo — we report the entry **count** and the **hostnames**. `.netrc` gets the same treatment: field names and a machine count, never a `login` or `password` value. +- **Shell history** — a **count**, the **line numbers**, and the credential **prefix class** (`GitHub personal access token`, `Bearer auth token`). The matched text is never read into a finding, so the report tells you exactly where to look without ever reading the secret back to you. +- **TCC** — service + client identifier only. There are no values in `TCC.db` to leak. - **`redact()` chokepoint** — every user-facing string (markdown and JSON) passes through a final funnel that, in order: 1. replaces control characters with `U+FFFD` (kills ANSI escapes, `NUL`, C1); 2. collapses the string to **one line** (a `\n` in a title otherwise forges a markdown heading); @@ -73,12 +107,17 @@ chmod +x exposurescan.py --json ~/.local/state/exposurescan/exposure.json \ --out ~/.local/state/exposurescan/exposure.md +# Add the TCC grant inventory (read-only; needs no new permission) +./exposurescan.py --tcc + # Skip a surface -./exposurescan.py --no-notes --no-browser +./exposurescan.py --no-notes --no-browser --no-dev-creds ``` **Exit codes** (useful in launchd / CI): `0` clean-ish, `1` a P1 was found, `3` a P0 was found. +**Optional keychain item count.** By default the tool reports `login.keychain-db` presence, size and lock state and does **not** count its items, because `security dump-keychain` can raise a GUI unlock prompt — this tool will not make your Mac ask you for your keychain password. Set `EXPOSURESCAN_KEYCHAIN_COUNT=1` if you want the count and accept the possible prompt. + --- ## Permissions (and why an audit tool asks for them) @@ -143,6 +182,10 @@ And four hardening bugs that were not leaks but were real: - **`redact()` deliberately over-fires.** The keyword-proximity rule truncates a line to `` after any sensitive keyword followed by `:`/`=`/space. Sometimes that eats context you wanted. Over-redaction is a readability bug; under-redaction is the bug this tool exists to not have. When the tool's own generated text collides with the rule, the text is reworded — the rule is not weakened. - **Generated metadata bypasses `redact()` on purpose.** Value *shapes* (`"46 chars, high-entropy, Postgres connection URI"`) are built from integers and a fixed label vocabulary, so they are value-free by construction and travel in `Finding.shape`, filtered through a conservative-alphabet `safe_shape()` instead. That field is the one place a future leak could be introduced, so it has its own tests. - The seed-phrase rule covers the **English** BIP-39 wordlist only. Other BIP-39 languages, Electrum seeds, and SLIP-39 shares are not detected. +- **Safari saved logins are not counted, and the report says so instead of guessing.** Safari does not keep them in a readable file — they are login-keychain items, and counting them means `security` calls that can raise a prompt. The report names the surface, points at the keychain finding, and refuses to invent a number. Firefox *is* counted, from `logins.json` row counts and `cookies.sqlite`; `key4.db` is never opened and `encryptedUsername` / `encryptedPassword` are never read. +- **A long filename can be over-redacted.** `redact()` collapses any 20+ character run in the token alphabet, so an SSH key named `id_ed25519_example_locked` (25 chars) renders as `: encrypted private key` while `id_ed25519_NEW` survives intact. The finding is still findable — location, key type, mode and size are all preserved, and the finding `id` is stable for diffing — but the name is gone. This is the documented tradeoff, not a bug to file: when the tool's own generated text collides with the rule, **the text gets reworded and the rule does not get weakened**, and a filename is exactly the field the v0.1.0 credit-card-filename leak taught us not to trust. +- **`--tcc` reports grants, not code identity.** It does not validate each row's `csreq` code requirement, so a client identifier that has been squatted or a stale row for an uninstalled app is reported as written in the database. The cross-check that matters — the tool's rows against a hand-run `sqlite3` query over both `TCC.db` files — is part of the release checklist and matched exactly (64/64, no invented rows, no missed rows) on the development machine. +- **`--tcc` is not a hardening baseline and will never become one.** See the exclusion note above: FileVault, firewall, updates, sudoers and the CIS sweep belong to mSCP and Pareto Security. --- @@ -151,8 +194,20 @@ And four hardening bugs that were not leaks but were real: | File | Purpose | |------|---------| | `exposurescan.py` | the CLI (executable, stdlib-only) | -| `sample-report.md` | example output with **fake placeholder data only** | +| `sample-report.md` | example output with **fake placeholder data only** — generated, not hand-written | | `bip39.txt` | the 2048-word BIP-39 English wordlist (verbatim, unmodified) | | `tests/test_redaction.py` | v0.1.0 unit tests on the `redact()` chokepoint + the `.env` surface | | `tests/test_invariant.py` | v0.2.0 regression suite — one test per verified leak, incl. the **end-to-end** Notes and PII-filename tests | +| `tests/test_dev_credentials.py` | v0.2.0 regression suite for the dev-credential, wallet and TCC surfaces, incl. their leak tests | +| `tests/make-sample-report.py` | regenerates `sample-report.md` by running the **real** scanner against a synthetic `$HOME`; `--check` fails if it is stale | | `README.md` | this file | + +## Tests + +```bash +cd exposurescan +python3 -m unittest discover -s tests # 72 tests +python3 tests/make-sample-report.py --check # sample-report.md is not stale +``` + +**A new surface without a leak test does not ship.** Every v0.2.0 surface has a fixture that plants synthetic key material and tokens in a synthetic `$HOME`, runs the real scanner → real renderer → real JSON sidecar, and asserts **zero** occurrences of that material in either artifact. The SSH fixture generates one passphrase-less and one encrypted key with `ssh-keygen` and asserts the plaintext key is P0 and the encrypted one is not. The TCC fixture is a `TCC.db` with a known row set, asserting Terminal-with-Accessibility is P0, an ordinary GUI app with Photos access is not, and a denied row is never reported as a grant. diff --git a/exposurescan/exposurescan.py b/exposurescan/exposurescan.py index e1ba30f..ee2b8dd 100755 --- a/exposurescan/exposurescan.py +++ b/exposurescan/exposurescan.py @@ -10,7 +10,10 @@ "If an infostealer (AMOS / Atomic / Poseidon) ran on my Mac right now, what would it walk away with — and what could the attacker pivot into?" -It inventories the four credential/PII surfaces a macOS stealer targets and +It inventories the credential/PII surfaces a macOS stealer targets — browser +logins and cookies, Apple Notes, .env files, ~/.secrets, PII, SSH keys and +developer credential files, crypto wallets, the keychain, and (with --tcc) the +TCC grants that malware inherits from the binary it runs inside — and prints a *prioritized blast-radius report*. It ranks surfaces by pivot value (P0 -> P3), not by raw count, so you fix the prod-key-in-a-.env before the phone number in a Note. @@ -39,6 +42,20 @@ Filenames are run through the PII patterns before emission and withheld (hash + parent dir + size + mtime) when they match. + * SSH private keys : we report FILENAME, key TYPE and ENCRYPTED vs + PLAINTEXT. For an OPENSSH-format key we base64-decode + only the first two body lines — enough for the header + and the PUBLIC key blob — and stop before the private + section. Key material is never decoded, never held. + * Dev credentials : ~/.aws/credentials, ~/.npmrc, ~/.config/gh/hosts.yml, + ~/.docker/config.json and friends are reported as + presence + file MODE + KEY NAMES only. Values are + never parsed out. + * Shell history : secret-shaped runs in ~/.zsh_history / ~/.bash_history + are reported as a COUNT plus LINE NUMBERS and a prefix + CLASS. The matched text is never read into a finding. + * TCC grants (--tcc) : service + client identifier only, from a read-only + copy of TCC.db. No values exist in that DB to leak. Every user-facing string passes through redact() as a final chokepoint. redact() scrubs control characters, collapses the string to a single line, strips URI @@ -70,6 +87,8 @@ # values-free JSON sidecar (mode 0600) ./exposurescan.py --no-notes # skip the Apple Notes surface ./exposurescan.py --out report.md # write markdown to a file too + ./exposurescan.py --tcc # add the TCC grant-inheritance + # inventory (read-only, no new grant) This tool reduces risk and raises literacy. It cannot stop you from pasting a curl|bash into Terminal or typing your password into a fake dialog. Pair it @@ -80,6 +99,7 @@ import argparse import atexit +import base64 import gzip import hashlib import json @@ -90,6 +110,7 @@ import signal import sqlite3 import stat +import subprocess import sys import tempfile import threading @@ -780,13 +801,43 @@ def scan_browser_logins(result: ScanResult) -> None: if not cookies_db.exists(): cookies_db = pdir / "Network" / "Cookies" # newer Chrome layout if cookies_db.exists(): + rows_total = 0 try: with _TempCopyConn(cookies_db) as conn: cur = conn.cursor() cur.execute("SELECT DISTINCT host_key FROM cookies") hosts = [r[0] for r in cur.fetchall()] + cur.execute("SELECT count(*) FROM cookies") + rows_total = int(cur.fetchone()[0] or 0) except (sqlite3.Error, PermissionError, OSError): hosts = [] + if rows_total: + # The row COUNT is the number that makes the incident-response + # ordering land: a stolen cookie authenticates without the + # password and without MFA, so "sign out everywhere" has to come + # BEFORE "change your password", not after. + result.add(Finding( + surface="browser-login", + tier="P2", + # "cookie" is itself a proximity keyword, so the plural form is used + # deliberately: "cookie row(s)" would truncate this line. + name=f"{len(hosts)} host(s) with {rows_total} stored browser cookies", + category="session-cookie-volume", + count=rows_total, + detail=f"profile {label}", + pivot=( + "session hijack — a stolen cookie survives a PASSWORD " + "RESET and bypasses MFA until it expires or you revoke " + "sessions. Revoke sessions BEFORE rotating passwords." + ), + remediation=( + "Use each provider's 'sign out of all devices' / " + "'revoke sessions' control first, then rotate the " + "password. Clearing cookies locally does not invalidate " + "a copy an attacker already took." + ), + location=f"{label}", + )) hv = sorted({ h for h in hosts if any(hint in (h or "").lower() for hint in HIGH_VALUE_LOGIN_HINTS) @@ -1156,6 +1207,1144 @@ def scan_dot_secrets(result: ScanResult) -> None: )) +# --------------------------------------------------------------------------- +# Surface (f): developer credentials — SSH keys, cloud/registry credential +# files, shell history. NAMES, MODES and COUNTS only. +# +# Why this surface exists: the four v0.1.x surfaces cover what a stealer takes +# from a *consumer*. They miss the highest-pivot files on a solo developer's +# Mac. An SSH key plus a GitHub token is push access to every repo the developer +# owns — which turns a personal breach into a supply-chain breach — and the +# report said nothing about any of it. +# --------------------------------------------------------------------------- + +_OPENSSH_MAGIC = b"openssh-key-v1\x00" + +# We base64-decode at most this many characters of an OPENSSH key body. +# 112 chars -> 84 bytes. That covers the magic, ciphername, kdfname, kdfoptions +# and (for a passphrase-less key) the PUBLIC key type — and stops SHORT of the +# private section in both the encrypted layout (private section starts at ~126 +# bytes) and the plaintext one (~94 bytes). This bound is the privacy invariant +# for this surface, not an optimisation: private key material is never decoded, +# so it cannot be held, logged or emitted even by accident. +_OPENSSH_HEADER_B64_CHARS = 112 + +_PEM_BEGIN = re.compile(r"-----BEGIN ([A-Z0-9 ]*?)PRIVATE KEY-----") + + +def _ssh_string(buf: bytes, off: int) -> tuple[bytes | None, int]: + """Read one SSH wire-format string (uint32 length + bytes). Bounds-checked.""" + if off + 4 > len(buf): + return None, off + n = int.from_bytes(buf[off:off + 4], "big") + off += 4 + if n > 4096 or off + n > len(buf): + return None, off + return buf[off:off + n], off + n + + +def _openssh_header_fields(b64_head: str) -> tuple[str | None, bool | None]: + """ + (key_type, is_encrypted) from the HEADER of an OPENSSH-format private key. + + Only the bytes decoded from `b64_head` are ever examined, and the caller + hands us at most _OPENSSH_HEADER_B64_CHARS characters — the public prefix. + """ + b64 = re.sub(r"\s", "", b64_head)[:_OPENSSH_HEADER_B64_CHARS] + b64 = b64[: len(b64) - (len(b64) % 4)] + if not b64: + return None, None + try: + data = base64.b64decode(b64, validate=False) + except (ValueError, TypeError): + return None, None + if not data.startswith(_OPENSSH_MAGIC): + return None, None + off = len(_OPENSSH_MAGIC) + cipher, off = _ssh_string(data, off) + if cipher is None: + return None, None + encrypted = cipher.decode("ascii", "ignore") not in ("", "none") + _kdf, off = _ssh_string(data, off) # kdfname + _kdfopts, off = _ssh_string(data, off) # kdfoptions (salt + rounds) + key_type: str | None = None + if off + 4 <= len(data): + off += 4 # number of keys + blob, off = _ssh_string(data, off) # PUBLIC key blob + if blob: + kt, _ = _ssh_string(blob, 0) + if kt: + key_type = kt.decode("ascii", "ignore") or None + return key_type, encrypted + + +def _ssh_type_from_pub(path: Path) -> str | None: + """Key type from the PUBLIC half (`.pub`), which is not a secret.""" + pub = Path(str(path) + ".pub") + try: + first = pub.read_text(encoding="utf-8", errors="ignore").split(None, 1)[0] + except (OSError, IndexError): + return None + return first if first.startswith(("ssh-", "ecdsa-", "sk-ssh", "sk-ecdsa")) else None + + +def classify_ssh_key(path: Path) -> tuple[str, bool] | None: + """ + (key_type, is_encrypted) for a private key file, or None if `path` is not + one. Reads a bounded header only; never decodes or returns key material. + """ + try: + with path.open("r", encoding="utf-8", errors="ignore") as fh: + head = fh.read(4096) + except OSError: + return None + + if "PuTTY-User-Key-File" in head: + m = re.search(r"^PuTTY-User-Key-File-\d+:\s*(\S+)", head, re.M) + enc = re.search(r"^Encryption:\s*(\S+)", head, re.M) + return ( + (m.group(1) if m else "putty"), + bool(enc and enc.group(1).lower() != "none"), + ) + + m = _PEM_BEGIN.search(head) + if not m: + return None + kind = (m.group(1) or "").strip() + + if kind == "OPENSSH": + body = head.split("-----\n", 1)[-1] if "-----\n" in head else head + key_type, encrypted = _openssh_header_fields(body) + if key_type is None: + # For an ENCRYPTED key the public blob sits past our decode bound, + # so the type comes from the public half if it is on disk. This is + # deliberate: we widen the bound for nobody. + key_type = _ssh_type_from_pub(path) + if encrypted is None: + # Unparseable header: report the format, and do NOT claim it is + # plaintext. Unknown encryption is not the P0 case. + return ("openssh", True) + return (key_type or "openssh", encrypted) + + # Classic PEM (RSA / EC / DSA) and PKCS#8. "ENCRYPTED" appears either in + # the BEGIN line (PKCS#8) or as `Proc-Type: 4,ENCRYPTED` (classic + DEK-Info). + header_block = head[: head.find("\n\n") + 1] if "\n\n" in head else head[:512] + encrypted = ("ENCRYPTED" in kind) or ("ENCRYPTED" in header_block) + label = (kind.lower().replace(" ", "-") or "pkcs8") + "-pem" + return (label, encrypted) + + +def _key_names_ini(text: str) -> list[str]: + """Section headers + key names from an INI/TOML-ish file. Never values.""" + out: list[str] = [] + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith(("#", ";")): + continue + if s.startswith("[") and s.endswith("]"): + out.append(s) + elif "=" in s: + out.append(s.split("=", 1)[0].strip()) + return out + + +def _key_names_yaml(text: str) -> list[str]: + """Key names from an indentation-based YAML file. Never values.""" + out: list[str] = [] + for line in text.splitlines(): + m = re.match(r"^\s*:?([A-Za-z0-9_.\-]+)\s*:(?:\s|$)", line) + if m: + out.append(m.group(1)) + return out + + +def _key_names_json(text: str) -> list[str]: + """Key names (two levels) from a JSON file. Never values.""" + try: + obj = json.loads(text) + except (ValueError, TypeError): + return [] + out: list[str] = [] + + def walk(node, depth: int) -> None: + if depth > 2 or not isinstance(node, dict): + return + for k, v in node.items(): + out.append(str(k)) + walk(v, depth + 1) + + walk(obj, 0) + return out + + +def _key_names_npmrc(text: str) -> list[str]: + """Registry scope + setting name (left of '='). The token is never touched.""" + return [ + line.split("=", 1)[0].strip() + for line in text.splitlines() + if "=" in line and not line.strip().startswith(("#", ";")) + ] + + +def _key_names_netrc(text: str) -> list[str]: + """ + FIELD names present + the machine COUNT. A .netrc `login` value is a + username and a `password` value is a password, so neither is ever read. + """ + toks = text.split() + fields = sorted({t for t in toks if t in + ("machine", "default", "login", "account", "password", "port")}) + n = sum(1 for t in toks if t == "machine") + return [f"{n} machine entry(ies)"] + [f"field: {f}" for f in fields] + + +def _key_names_git_credentials(text: str) -> list[str]: + """ + HOST names only. Every line of this file is literally + `https://user:token@host` — the credential IS the line, so no line is ever + emitted, parsed for userinfo, or held. + """ + hosts: set[str] = set() + n = 0 + for line in text.splitlines(): + if not line.strip(): + continue + n += 1 + try: + host = urlparse(line.strip()).hostname + except ValueError: + host = None + if host: + hosts.add(host.lower()) + return [f"{n} stored entry(ies)"] + sorted(hosts) + + +# (relative path, parser, human label, pivot sentence) +DEV_CREDENTIAL_FILES: tuple[tuple[str, str, str, str], ...] = ( + (".aws/credentials", "ini", "AWS CLI credentials", + "full use of every AWS resource this key's IAM policy allows — including " + "reading your S3 buckets and creating new users"), + (".npmrc", "npmrc", "npm registry credentials", + "publishing a malicious version of every package you own — a personal " + "breach becomes a supply-chain breach"), + (".pypirc", "ini", "PyPI upload credentials", + "publishing a malicious version of every PyPI package you own — a personal " + "breach becomes a supply-chain breach"), + (".netrc", "netrc", "netrc machine credentials", + "authenticated access to every host listed, by curl/git/ftp, with no " + "further prompt"), + (".config/gh/hosts.yml", "yaml", "GitHub CLI OAuth credentials", + "push access to every repository you can write to, plus Actions secrets " + "and release publishing — a personal breach becomes a supply-chain breach"), + (".docker/config.json", "json", "Docker registry credentials", + "pushing a poisoned image to every registry namespace you can write to"), + (".kube/config", "yaml", "Kubernetes cluster credentials", + "workload execution inside every cluster listed"), + (".config/gcloud", "dir", "gcloud SDK credentials store", + "use of every Google Cloud project this account can reach"), + (".git-credentials", "git-credentials", "git credentials store, plaintext by design", + "push access to every host listed — this file stores the token in " + "cleartext by design"), + (".cargo/credentials.toml", "ini", "crates.io publish credentials", + "publishing a malicious version of every crate you own"), + (".gem/credentials", "yaml", "RubyGems publish credentials", + "publishing a malicious version of every gem you own"), +) + +_KEY_NAME_PARSERS = { + "ini": _key_names_ini, + "yaml": _key_names_yaml, + "json": _key_names_json, + "npmrc": _key_names_npmrc, + "netrc": _key_names_netrc, + "git-credentials": _key_names_git_credentials, +} + +# Files inside ~/.config/gcloud that hold live credentials. +_GCLOUD_CREDENTIAL_FILES = ( + "credentials.db", "access_tokens.db", "application_default_credentials.json", + "legacy_credentials", +) + +HISTORY_FILES = (".zsh_history", ".bash_history", ".sh_history") + +# A run long enough to be a token, in the token alphabet. Deliberately stricter +# than redact()'s _VALUE_SHAPE: a history file is full of paths and git SHAs. +_HISTORY_TOKEN_RUN = re.compile(r"[A-Za-z0-9+/=_\-]{24,}") + + +def _history_line_classes(line: str) -> set[str]: + """ + Which credential PREFIX CLASSES (a fixed label vocabulary) does this line + contain? The matched text itself is never returned, stored or emitted. + """ + hits = { + label for prefix, label in SECRET_VALUE_PREFIXES.items() + if prefix in line + } + if hits: + return hits + low = line.lower() + if any(h in low for h in SENSITIVE_KEY_HINTS): + for m in _HISTORY_TOKEN_RUN.finditer(line): + run = m.group(0) + if "/" in run or "." in run: + continue # a path or a version, not a token + if shannon_entropy(run) >= 3.6: + return {"high-entropy string next to a credential keyword"} + return set() + + +def scan_shell_history(result: ScanResult) -> None: + """ + Secret-shaped runs in shell history: COUNT + LINE NUMBERS + prefix CLASS. + + Shell history routinely contains pasted secrets (`export + STRIPE_KEY=…`, `curl -H "Authorization: Bearer …"`) and nothing on macOS + surfaces it. The line numbers are the whole deliverable: they let you go + fix the file without the tool ever reading the token to you. + """ + home = Path.home() + for rel in HISTORY_FILES: + path = home / rel + if not path.exists() or not path.is_file(): + continue + try: + if path.stat().st_size > MAX_FILE_BYTES * 4: + result.note(f"{rel}: too large to scan; skipped.") + continue + text = path.read_text(errors="ignore") + except OSError: + result.note(f"{rel}: present but not readable.") + continue + + line_nos: list[int] = [] + classes: set[str] = set() + for lineno, line in enumerate(text.splitlines(), 1): + cls = _history_line_classes(line) + if cls: + line_nos.append(lineno) + classes |= cls + # `line` and `cls` go out of scope here. Nothing is retained. + del text + + if not line_nos: + continue + known = classes - {"high-entropy string next to a credential keyword"} + tier = "P0" if known else "P1" + shown = ", ".join(str(n) for n in line_nos[:20]) + if len(line_nos) > 20: + shown += f", +{len(line_nos) - 20} more" + result.add(Finding( + surface="dev-credential", + tier=tier, + name=f"{rel}: {len(line_nos)} secret-shaped run(s) on line(s) {shown}", + category="shell-history", + count=len(line_nos), + detail="matched text is never read into the report; go look yourself", + shape=", ".join(sorted(classes)), + pivot=( + "a stealer that copies your history file gets every credential " + "you have ever pasted into a shell, in cleartext, with the " + "command that shows what it unlocks" + ), + remediation=( + "Open the file at those line numbers, rotate anything live, then " + "delete the lines. Prefix a command with a SPACE (with " + "HIST_IGNORE_SPACE set) to keep it out of history, and read " + "secrets from a file or the Keychain instead of the command line." + ), + location=f"~/{rel}", + )) + + +def scan_ssh_keys(result: ScanResult) -> None: + ssh_dir = Path.home() / ".ssh" + if not ssh_dir.is_dir(): + result.note("~/.ssh: directory not present.") + return + try: + entries = sorted(p for p in ssh_dir.iterdir() if p.is_file()) + except (PermissionError, OSError): + result.note("~/.ssh: present but not readable.") + return + + found = 0 + for p in entries: + if p.suffix in (".pub", ".html", ".md"): + continue + info = classify_ssh_key(p) + if info is None: + continue + key_type, encrypted = info + found += 1 + try: + mode = stat.S_IMODE(p.stat().st_mode) + except OSError: + mode = None + exposed = mode is not None and bool(mode & (stat.S_IRWXG | stat.S_IRWXO)) + + if not encrypted: + tier = "P0" + state = "PLAINTEXT private key - no passphrase" + elif exposed: + tier = "P0" + state = "encrypted private key" + else: + tier = "P2" + state = "encrypted private key" + perm = f"chmod {oct(mode)[-3:]}" if mode is not None else "mode unknown" + if exposed: + perm += " - GROUP/OTHER READABLE" + + result.add(Finding( + surface="dev-credential", + tier=tier, + name=f"{p.name}: {state}", + category="ssh-private-key", + count=1, + detail=f"type {key_type}", + shape=perm, + pivot=( + "push access to every repo you own and login to every host that " + "trusts this key - a personal breach becomes a supply-chain " + "breach. A passphrase-less key is usable the second it is copied; " + "nothing else has to be cracked." + if not encrypted else + "usable only if the attacker also captures the passphrase " + "(keylogger, Accessibility grant, or a fake prompt)" + ), + remediation=( + "Add a passphrase in place, without regenerating the key: " + "`ssh-keygen -p -f ~/.ssh/`. Then `chmod 600` it, load it " + "into the agent once per session, and remove the public half " + "from any host you no longer use." + ), + location="~/.ssh", + )) + + if not found: + result.note("~/.ssh: no private keys found.") + + +def scan_dev_credentials(result: ScanResult) -> None: + """ + Presence + file MODE + KEY NAMES ONLY for the credential files a + developer-targeted stealer takes first. No value is ever parsed out. + """ + home = Path.home() + scan_ssh_keys(result) + + for rel, fmt, label, pivot in DEV_CREDENTIAL_FILES: + path = home / rel + + if fmt == "dir": + if not path.is_dir(): + continue + present: list[str] = [] + for name in _GCLOUD_CREDENTIAL_FILES: + if (path / name).exists(): + present.append(name) + if not present: + continue + names = present + mode = None + try: + mode = stat.S_IMODE(path.stat().st_mode) + except OSError: + pass + size = None + else: + if not path.is_file(): + continue + try: + st = path.stat() + mode = stat.S_IMODE(st.st_mode) + size = st.st_size + text = path.read_text(errors="ignore") if size <= MAX_FILE_BYTES else "" + except OSError: + result.note(f"~/{rel}: present but not readable.") + continue + parser = _KEY_NAME_PARSERS.get(fmt) + names = parser(text) if parser else [] + del text # values are never held past the parse + + exposed = mode is not None and bool(mode & (stat.S_IRWXG | stat.S_IRWXO)) + tier = "P0" if exposed else "P1" + perm = f"chmod {oct(mode)[-3:]}" if mode is not None else "mode unknown" + if exposed: + perm += " - GROUP/OTHER READABLE" + if size is not None: + perm = f"{size} bytes, {perm}" + + # De-duplicate while preserving order; cap so one huge kubeconfig does + # not become the report. + seen: set[str] = set() + uniq = [n for n in names if not (n in seen or seen.add(n))][:12] + detail = "names present: " + ", ".join(uniq) if uniq else "no names parsed" + if len(names) > len(uniq): + detail += f", +{len(names) - len(uniq)} more" + + result.add(Finding( + surface="dev-credential", + tier=tier, + name=f"~/{rel}: {label}" + (" - WORLD/GROUP-READABLE" if exposed else ""), + category="dev-credential-file", + count=1, + detail=detail, + shape=perm, + pivot=pivot, + remediation=( + f"chmod 600 ~/{rel}. Rotate the credential if this machine was " + "ever exposed, and prefer a short-lived/scoped token (gh auth " + "login, aws sso login, npm granular access token) over a " + "long-lived one stored on disk." + ), + location=f"~/{rel}", + )) + + scan_shell_history(result) + + +# --------------------------------------------------------------------------- +# Surface (g): crypto wallets — browser extension stores + desktop bundles. +# +# P0 unconditionally: crypto theft is irreversible, uninsured, and has no +# chargeback. The tool already tiers a seed phrase in Notes at P0 and then +# never looked where wallets actually live. +# --------------------------------------------------------------------------- + +# Chromium extension IDs of the wallets AMOS-family stealers enumerate. +WALLET_EXTENSION_IDS = { + "nkbihfbeogaeaoehlefnkodbefgpgknn": "MetaMask", + "ejbalbakoplchlghecdalmeeeajnimhm": "MetaMask (MV3)", + "egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet", + "hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase Wallet", + "bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom", + "fhbohimaelbohpjbbldcngcnapndodjp": "BNB Chain Wallet", + "ibnejdfjmmkpcnlpebklmnkoeoihofec": "TronLink", + "aeachknmefphepccionboohckonoeemg": "Coin98", + "hifafgmccdpekplomjjkcfgodnhcellj": "Crypto.com DeFi Wallet", + "afbcbjpbpfadlkmhmclhkeeodmamcflc": "MathWallet", + "jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty Wallet", + "opcgpfmipidbgpenhmajoajpbobppdil": "Sui Wallet", + "dmkamcknogkgcdfhhbddcghachkejeap": "Keplr", + "jnlgamecbpmbajjfhmmmlhejkemejdma": "Braavos", + "fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin Wallet", + "aholpfdialjgjfhomihkjbmgjidlcdno": "Exodus Web3", + "bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare", + "acmacodkjbdgmoleebolmdjonilkdbch": "Rabby", + "nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton Wallet", + "hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI", +} + +# Desktop wallet application-support bundles, relative to ~/Library. +DESKTOP_WALLET_DIRS = { + "Application Support/Exodus": "Exodus", + "Application Support/Electrum": "Electrum", + "Application Support/Ledger Live": "Ledger Live", + "Application Support/@trezor/suite-desktop": "Trezor Suite", + "Application Support/atomic": "Atomic Wallet", + "Application Support/Coinomi": "Coinomi", + "Application Support/Daedalus Mainnet": "Daedalus", + "Application Support/Sparrow": "Sparrow", + "Application Support/Guarda": "Guarda", + "Application Support/Bitcoin": "Bitcoin Core", + "Application Support/Ethereum/keystore": "geth keystore", + "Application Support/monero-project": "Monero", + "Application Support/Wasabi Wallet": "Wasabi", +} + +_WALLET_PIVOT = ( + "irreversible theft. Crypto has no chargeback, no fraud department and no " + "insurance: a drained wallet is gone. A stealer copies the whole extension " + "store and brute-forces the vault password offline, on its own hardware" +) +_WALLET_REMEDIATION = ( + "Move anything you are not actively trading to a hardware wallet, and treat " + "the seed as already exposed if this Mac was ever compromised: generate a " + "NEW wallet on a DIFFERENT clean device and move the funds. Rotating the " + "extension password does not help - the attacker has the encrypted vault." +) + + +def scan_wallets(result: ScanResult) -> None: + found_any = False + + for label, pdir in _browser_profiles(): + ext_root = pdir / "Local Extension Settings" + if not ext_root.is_dir(): + continue + try: + present = sorted( + (WALLET_EXTENSION_IDS[d.name], d.name) + for d in ext_root.iterdir() + if d.is_dir() and d.name in WALLET_EXTENSION_IDS + ) + except (PermissionError, OSError): + result.note(f"Browser {label}: extension settings not readable.") + continue + for wallet, ext_id in present: + found_any = True + try: + files = sum(1 for _ in (ext_root / ext_id).iterdir()) + except OSError: + files = 0 + result.add(Finding( + surface="crypto-wallet", + tier="P0", + name=f"{wallet}: browser wallet store present", + category="wallet-extension", + count=1, + detail=f"profile {label}", + shape=f"{files} LevelDB file(s), extension {ext_id}", + pivot=_WALLET_PIVOT, + remediation=_WALLET_REMEDIATION, + location=f"{label}/Local Extension Settings", + )) + + lib = Path.home() / "Library" + for rel, wallet in DESKTOP_WALLET_DIRS.items(): + d = lib / rel + if not d.exists(): + continue + found_any = True + result.add(Finding( + surface="crypto-wallet", + tier="P0", + name=f"{wallet}: desktop wallet data present", + category="wallet-desktop", + count=1, + detail=f"~/Library/{rel}", + pivot=_WALLET_PIVOT, + remediation=_WALLET_REMEDIATION, + location=f"~/Library/{rel}", + )) + + if not found_any: + result.note("Crypto wallets: no browser wallet stores or desktop wallet data found.") + + +# --------------------------------------------------------------------------- +# Surface (h): the login keychain, Safari and Firefox — so the report is not +# silently Chrome-shaped. +# --------------------------------------------------------------------------- + +# `security dump-keychain` can raise a GUI unlock prompt. This tool will not +# make your Mac ask for your keychain password, so the item count is opt-in. +KEYCHAIN_COUNT_ENV = "EXPOSURESCAN_KEYCHAIN_COUNT" + + +def _safe_run(cmd: list[str], timeout: float = 8.0) -> str | None: + """Run a read-only command. Returns stdout, or None on any failure.""" + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + # `security show-keychain-info` writes its answer to STDERR with rc 0, so + # both streams are returned. + return (proc.stdout or "") + (proc.stderr or "") + + +def scan_keychain(result: ScanResult) -> None: + kc = Path.home() / "Library" / "Keychains" / "login.keychain-db" + if not kc.exists(): + kc = Path.home() / "Library" / "Keychains" / "login.keychain" + if not kc.exists(): + result.note("Keychain: login.keychain-db not found.") + return + try: + size = kc.stat().st_size + except OSError: + size = 0 + + # show-keychain-info reports lock/timeout state and does NOT prompt. + info = _safe_run(["security", "show-keychain-info", str(kc)]) or "" + lock_state = "unknown" + if "no-timeout" in info: + lock_state = "unlocked, no auto-lock timeout" + elif "timeout" in info: + lock_state = "auto-lock timeout set" + elif "could not be found" in info: + lock_state = "unknown" + + shape = f"{size} bytes" + detail = f"lock state: {lock_state}" + if os.environ.get(KEYCHAIN_COUNT_ENV) == "1": + dump = _safe_run(["security", "dump-keychain", str(kc)], timeout=30.0) + if dump: + n = dump.count("class:") + detail += f"; {n} item(s)" + else: + detail += ( + "; item count not taken - counting can raise a keychain unlock " + "prompt, so it is opt-in (see the README)" + ) + + result.add(Finding( + surface="keychain", + tier="P1", + name="login.keychain-db present", + category="keychain", + count=1, + detail=detail, + shape=shape, + pivot=( + "this one file holds Safari's saved logins, Wi-Fi passwords, " + "certificates and app secrets. It is encrypted under your login " + "password, so a stealer copies the file and cracks it offline - and " + "an unlocked session with an Accessibility grant can read items " + "without cracking anything" + ), + remediation=( + "Set a keychain auto-lock timeout (Keychain Access > Edit > Change " + "Settings), use a strong login password, and never approve an " + "'allow access' prompt you did not personally trigger." + ), + location="~/Library/Keychains", + )) + + +def scan_safari(result: ScanResult) -> None: + safari = Path.home() / "Library" / "Safari" + container = (Path.home() / "Library" / "Containers" / "com.apple.Safari" / + "Data" / "Library" / "Safari") + root = safari if safari.is_dir() else (container if container.is_dir() else None) + if root is None: + result.note("Safari: no profile directory found.") + return + # Honest limit, stated rather than faked: Safari does not keep saved logins + # in a file we can count. They live in the login keychain, and counting them + # means `security` calls that can raise a prompt. We report the surface and + # refuse to invent a number. + result.add(Finding( + surface="browser-login", + tier="P2", + name="Safari profile present - saved logins live in the login keychain", + category="safari-logins", + count=1, + detail=( + "count not taken: Safari stores logins in the login keychain, not " + "in a readable file, and counting them can raise a prompt" + ), + pivot=( + "same blast radius as the login keychain finding above: Safari's " + "saved logins are keychain items" + ), + remediation=( + "Review them yourself in Settings > Passwords (or the Passwords " + "app) and delete what you no longer use." + ), + location=str(root), + )) + + +def _firefox_profiles() -> list[tuple[str, Path]]: + base = Path.home() / "Library" / "Application Support" / "Firefox" / "Profiles" + if not base.is_dir(): + return [] + try: + return [(p.name, p) for p in sorted(base.iterdir()) if p.is_dir()] + except (PermissionError, OSError): + return [] + + +def scan_firefox(result: ScanResult) -> None: + profiles = _firefox_profiles() + if not profiles: + result.note("Firefox: no profiles found.") + return + for name, pdir in profiles: + logins = pdir / "logins.json" + if logins.exists(): + try: + data = json.loads(logins.read_text(errors="ignore")) + entries = data.get("logins", []) if isinstance(data, dict) else [] + except (OSError, ValueError, TypeError, AttributeError): + entries = [] + # We read `hostname` (a NAME) and COUNT rows. encryptedUsername and + # encryptedPassword are never touched, and key4.db is never opened. + hosts = sorted({ + (urlparse(str(e.get("hostname", ""))).hostname or "").lower() + for e in entries if isinstance(e, dict) + } - {""}) + hv = [h for h in hosts if any(x in h for x in HIGH_VALUE_LOGIN_HINTS)] + if entries: + result.add(Finding( + surface="browser-login", + tier="P1" if hv else "P2", + name=f"Firefox {name}: {len(entries)} saved login(s)", + category="firefox-logins", + count=len(entries), + detail=( + f"{len(hosts)} distinct host(s); " + f"{len(hv)} high-value host(s)" + ), + pivot=( + "Firefox stores these under key4.db; if no primary " + "password is set, anyone with both files can decrypt " + "every one of them offline" + ), + remediation=( + "Set a Firefox Primary Password (Settings > Privacy & " + "Security), or move these into a password manager." + ), + location=f"Firefox/{name}", + )) + cookies = pdir / "cookies.sqlite" + if cookies.exists(): + try: + with _TempCopyConn(cookies) as conn: + n = int(conn.execute( + "SELECT count(*) FROM moz_cookies").fetchone()[0] or 0) + except (sqlite3.Error, PermissionError, OSError): + n = 0 + if n: + result.add(Finding( + surface="browser-login", + tier="P2", + name=f"Firefox {name}: {n} stored cookies", + category="session-cookie-volume", + count=n, + detail="", + pivot=( + "session hijack - a stolen cookie survives a PASSWORD " + "RESET and bypasses MFA until you revoke sessions" + ), + remediation=( + "Revoke sessions provider-side first, then rotate " + "passwords." + ), + location=f"Firefox/{name}", + )) + + +# --------------------------------------------------------------------------- +# Surface (i): TCC grant inventory (--tcc) +# +# The kit's central argument is that malware inherits the grants of the trusted +# binary it runs inside. This is the flag that finally tells you WHICH binaries +# those are. Read-only; needs no new permission (the system TCC.db needs Full +# Disk Access to READ, and degrades to a note without it). +# +# Deliberately EXCLUDED: FileVault, firewall, update settings, sudoers, password +# policy, the general CIS sweep. mSCP and Pareto Security own that and own it +# better; see the README. +# --------------------------------------------------------------------------- + +TCC_USER_DB = "Library/Application Support/com.apple.TCC/TCC.db" +TCC_SYSTEM_DB = "/Library/Application Support/com.apple.TCC/TCC.db" + +# Services that make the holder a grant-INHERITANCE vehicle. +TCC_HIGH_RISK_SERVICES = { + "kTCCServiceSystemPolicyAllFiles": "Full Disk Access", + "kTCCServiceAccessibility": "Accessibility (drive any app, read any window)", + "kTCCServiceScreenCapture": "Screen Recording", + "kTCCServiceListenEvent": "Input Monitoring (reads every keystroke)", + "kTCCServicePostEvent": "synthetic keystroke injection", + "kTCCServiceDeveloperTool": "Developer Tools (spawned processes skip Gatekeeper)", + "kTCCServiceSystemPolicySysAdminFiles": "admin-files access", + "kTCCServiceEndpointSecurityClient": "Endpoint Security client", +} + +TCC_OTHER_SERVICES = { + "kTCCServicePhotos": "Photos", + "kTCCServiceCamera": "Camera", + "kTCCServiceMicrophone": "Microphone", + "kTCCServiceAddressBook": "Contacts", + "kTCCServiceCalendar": "Calendar", + "kTCCServiceReminders": "Reminders", + "kTCCServiceAppleEvents": "Automation (AppleEvents)", + "kTCCServiceSystemPolicyDesktopFolder": "Desktop folder", + "kTCCServiceSystemPolicyDocumentsFolder": "Documents folder", + "kTCCServiceSystemPolicyDownloadsFolder": "Downloads folder", + "kTCCServiceMediaLibrary": "Media library", + "kTCCServiceUbiquity": "iCloud Drive", +} + +# Bundle IDs that are terminals, remote-access tools or script runners: a +# payload executed inside them runs WITH their grants and never sees a prompt. +GRANT_INHERITANCE_BUNDLE_IDS = { + "com.apple.terminal", "com.googlecode.iterm2", "com.mitchellh.ghostty", + "dev.warp.warp-stable", "dev.warp.warp-preview", "io.alacritty", + "net.kovidgoyal.kitty", "co.zeit.hyper", "com.github.wez.wezterm", + "org.tabby", "com.apple.scripteditor2", "com.apple.automator", + "com.apple.screensharing", "com.apple.remotedesktop", + "com.teamviewer.teamviewer", "com.philandro.anydesk", "com.anydesk.anydesk", + "com.realvnc.vncviewer", "com.rustdesk.rustdesk", "com.nulana.remotixmac", + "com.apple.shortcuts", "com.runningwithcrayons.alfred", + "com.raycast.macos", "com.microsoft.vscode", "com.todesktop.230313mzl4w4u92", +} + +_GRANT_INHERITANCE_NAME = re.compile( + r"(terminal|iterm|ghostty|warp|alacritty|kitty|hyper|wezterm|tabby|" + r"sshd|ssh-keygen-wrapper|screensharing|remotedesktop|vnc|teamviewer|" + r"anydesk|rustdesk|scripteditor|automator|osascript)", re.I, +) + +# A BARE interpreter binary holding a grant is the same problem with no app +# wrapped around it at all. +_BARE_INTERPRETER = re.compile( + r"/(?:sh|bash|zsh|ksh|dash|tcsh|fish|osascript|expect|" + r"python(?:\d+(?:\.\d+)?)?|perl|ruby|node|php|deno|bun|tclsh|lua|" + r"Rscript|pwsh|swift|java)$" +) + + +def is_grant_inheritance_vehicle(client: str) -> bool: + """ + True if `client` is a terminal, shell, SSH wrapper, remote-access tool or + bare interpreter — i.e. a thing that RUNS OTHER CODE, so its TCC grants + belong to whatever it is told to run. + """ + c = (client or "").strip() + if not c: + return False + if c.lower() in GRANT_INHERITANCE_BUNDLE_IDS: + return True + if _GRANT_INHERITANCE_NAME.search(c): + return True + if c.startswith("/") and _BARE_INTERPRETER.search(c): + return True + return False + + +def _client_label(client: str) -> str: + """Human-readable name for a TCC client id/path. Never a value.""" + c = (client or "").strip() + if c.startswith("/"): + return Path(c).name or c + return c.rsplit(".", 1)[-1] if "." in c else c + + +def read_tcc_grants(db: Path) -> list[tuple[str, str, int]]: + """ + [(service, client, auth)] from a TCC.db, read-only via the shared safe-copy + machinery. Handles both the modern `auth_value` schema and the legacy + `allowed` one. Raises on access failure so the caller can degrade. + """ + with _TempCopyConn(db) as conn: + cols = {r[1] for r in conn.execute("PRAGMA table_info(access)")} + auth_col = "auth_value" if "auth_value" in cols else "allowed" + rows = conn.execute( + f"SELECT service, client, {auth_col} FROM access" + ).fetchall() + out: list[tuple[str, str, int]] = [] + for service, client, auth in rows: + try: + auth_i = int(auth) + except (TypeError, ValueError): + continue + out.append((str(service or ""), str(client or ""), auth_i)) + return out + + +def scan_tcc(result: ScanResult) -> None: + sources = [ + ("user", Path.home() / TCC_USER_DB), + ("system", Path(TCC_SYSTEM_DB)), + ] + grants: list[tuple[str, str, str, int]] = [] + read_any = False + for scope, db in sources: + if not db.exists(): + result.note(f"TCC ({scope}): {db.name} not found.") + continue + try: + for service, client, auth in read_tcc_grants(db): + grants.append((scope, service, client, auth)) + read_any = True + except (sqlite3.Error, PermissionError, OSError): + result.note( + f"TCC ({scope}): no access to TCC.db - grant Full Disk Access " + "to this terminal to inventory it. Skipped, not failed." + ) + + if read_any: + # auth_value: 0 denied, 1 unknown, 2 allowed, 3 limited/allowed. + # legacy `allowed`: 1 allowed. Both are covered by >= 1 plus the + # explicit modern-denied check. + granted = [g for g in grants if g[3] >= 2] or [g for g in grants if g[3] == 1] + other_counts: dict[str, int] = {} + seen_pairs: set[tuple[str, str]] = set() + + for scope, service, client, _auth in sorted(granted): + label = TCC_HIGH_RISK_SERVICES.get(service) + if label is None: + nice = TCC_OTHER_SERVICES.get(service) + if nice: + other_counts[nice] = other_counts.get(nice, 0) + 1 + continue + if (service, client) in seen_pairs: + continue + seen_pairs.add((service, client)) + vehicle = is_grant_inheritance_vehicle(client) + who = _client_label(client) + if vehicle: + result.add(Finding( + surface="tcc", + tier="P0", + name=f"{who} holds {label}", + category="tcc-grant-inheritance", + count=1, + detail=f"{scope} TCC.db; client {client}", + pivot=( + f"anything you run inside {who} gets {label} too, with " + "no prompt of its own. A pasted payload does not have to " + f"ask for {label} - it inherits yours the moment it runs. " + "This is not an app holding a permission; it is a " + "permission attached to a thing that runs other code." + ), + remediation=( + f"System Settings > Privacy & Security: remove {who} " + f"from {label} unless you actively need it, and re-grant " + "it per-task rather than permanently. Prefer granting a " + "specific app over granting your terminal." + ), + location=f"TCC ({scope})", + )) + else: + result.add(Finding( + surface="tcc", + tier="P2", + name=f"{who} holds {label}", + category="tcc-grant", + count=1, + detail=f"{scope} TCC.db; client {client}", + pivot=( + "a compromise or malicious update of this app inherits " + f"{label} without ever prompting you again" + ), + remediation=( + "Remove the grant if you cannot name the feature that " + "needs it." + ), + location=f"TCC ({scope})", + )) + + if other_counts: + result.add(Finding( + surface="tcc", + tier="P3", + name=f"{sum(other_counts.values())} other TCC grant(s) held by apps", + category="tcc-other", + count=sum(other_counts.values()), + detail="", + shape=", ".join(f"{k}: {v}" for k, v in sorted(other_counts.items())), + pivot=( + "ordinary app permissions - data exposure if that specific " + "app is compromised, but no grant inheritance" + ), + remediation=( + "Review in System Settings > Privacy & Security and remove " + "anything you do not recognise." + ), + location="TCC (user + system)", + )) + + scan_clickfix_posture(result) + + +def scan_clickfix_posture(result: ScanResult) -> None: + """ + Three adjacent one-liners bundled into --tcc because they are ClickFix + relevant and nothing else surfaces them to a consumer: + Secure Keyboard Entry, the remote-access surface, and Secure Boot level. + """ + # (1) Terminal Secure Keyboard Entry. + ske = _safe_run(["defaults", "read", "com.apple.Terminal", "SecureKeyboardEntry"]) + ske_on = (ske or "").strip() in ("1", "true", "YES") + if not ske_on: + result.add(Finding( + surface="posture", + tier="P2", + name="Terminal: Secure Keyboard Entry is OFF", + category="secure-keyboard-entry", + count=1, + detail="Terminal > Secure Keyboard Entry", + pivot=( + "without it, ANY app holding Accessibility or Input Monitoring " + "can read what you type into Terminal - including the admin " + "password you type at a sudo prompt, which is the exact step a " + "ClickFix payload is trying to get you to perform" + ), + remediation=( + "Terminal > Secure Keyboard Entry (checkbox in the Terminal " + "menu). iTerm2: Settings > General > Magic > 'Enable secure " + "keyboard entry'." + ), + location="com.apple.Terminal", + )) + + # (2) Remote-access surface. + disabled = _safe_run(["launchctl", "print-disabled", "system"]) or "" + for svc, human in (("com.openssh.sshd", "Remote Login (SSH)"), + ("com.apple.screensharing", "Screen Sharing")): + m = re.search(rf'"{re.escape(svc)}"\s*=>\s*(\w+)', disabled) + if not m: + continue + enabled = m.group(1).lower() in ("false", "enabled") + if not enabled: + continue + result.add(Finding( + surface="posture", + tier="P1", + name=f"{human} is ENABLED ({svc})", + category="remote-access", + count=1, + detail="launchctl print-disabled system", + pivot=( + "an added line in ~/.ssh/authorized_keys is quieter persistence " + "than a LaunchAgent, survives a password change, and is invisible " + "to WatchPost's persistence diff" + if "ssh" in svc else + "full interactive control of the desktop, using the session you " + "already unlocked" + ), + remediation=( + "System Settings > General > Sharing: turn this off if you are " + "not actively using it. If you need SSH, audit " + "~/.ssh/authorized_keys now and restrict it with " + "AllowUsers / PermitRootLogin no." + if "ssh" in svc else + "System Settings > General > Sharing: turn Screen Sharing off " + "if you are not actively using it, and check who is listed " + "under 'Allow access for'." + ), + location="launchd (system)", + )) + + # (3) Secure Boot level / kext policy (Apple silicon). + bridge = _safe_run(["system_profiler", "SPiBridgeDataType"], timeout=25.0) or "" + sb = re.search(r"Secure Boot:\s*(.+)", bridge) + kext = re.search(r"Allow All Kernel Extensions:\s*(\w+)", bridge) + if sb and "full security" not in sb.group(1).strip().lower(): + result.add(Finding( + surface="posture", + tier="P2", + name=f"Secure Boot: {sb.group(1).strip()} (not Full Security)", + category="secure-boot", + count=1, + detail=( + "kernel extensions: allowed" + if kext and kext.group(1).lower() == "yes" else "" + ), + pivot=( + "reduced security permits unsigned/third-party kernel " + "extensions and downgraded boot policy - a real weakening of the " + "boot chain that no other tool in this kit would report" + ), + remediation=( + "If you did not deliberately reduce it (for a kext, a VM tool, " + "or another OS), restore Full Security via Recovery > Startup " + "Security Utility." + ), + location="Secure Boot policy", + )) + + # --------------------------------------------------------------------------- # Surface (e): PII markers in Desktop / Documents / Downloads (COUNTS only) # --------------------------------------------------------------------------- @@ -1400,11 +2589,15 @@ def render_markdown(result: ScanResult, target: Path) -> str: lines.append("1. **P0 first** — Move every plaintext live key (AWS/Stripe/Anthropic/DB URI), " "private key, and wallet seed out of `.env`/`~/.secrets`/Notes into the macOS " "Keychain or a password manager, then **rotate** them. `chmod 700 ~/.secrets`, " - "`chmod 600` each secret file.") + "`chmod 600` each secret file. Put a passphrase on every SSH key " + "(`ssh-keygen -p -f ~/.ssh/`), and if a wallet was on this machine during " + "an incident, move the funds to a NEW wallet generated on a DIFFERENT clean " + "device — that loss is the only irreversible one.") lines.append("2. **P1** — Stop saving passwords in the browser for financial/email/registrar " "origins; migrate to a password manager; enable OS-level encryption.") - lines.append("3. **P2** — Sign out of sensitive sites to kill session cookies; encrypt or " - "delete documents containing SIN/SSN/card numbers.") + lines.append("3. **P2** — Revoke sessions provider-side (a stolen cookie survives a " + "password reset) BEFORE rotating passwords; encrypt or delete documents " + "containing SIN/SSN/card numbers; drop TCC grants you cannot justify.") lines.append("4. **P3** — Clear stale PII exports from Downloads; review trusted-host logins.") lines.append("") lines.append("> This audit shrinks the blast radius. It does **not** stop you from pasting a " @@ -1463,13 +2656,25 @@ def write_private(path: Path, data: str) -> Path: return path -def run_scan(target: Path, *, do_notes: bool, do_browser: bool) -> ScanResult: +def run_scan( + target: Path, + *, + do_notes: bool, + do_browser: bool, + do_dev_creds: bool = True, + do_tcc: bool = False, +) -> ScanResult: result = ScanResult() if do_browser: try: scan_browser_logins(result) except Exception as e: # never let one surface crash the audit result.note(f"Browser surface errored: {e.__class__.__name__}.") + for fn, label in ((scan_safari, "Safari"), (scan_firefox, "Firefox")): + try: + fn(result) + except Exception as e: + result.note(f"{label} surface errored: {e.__class__.__name__}.") if do_notes: try: scan_apple_notes(result) @@ -1487,6 +2692,21 @@ def run_scan(target: Path, *, do_notes: bool, do_browser: bool) -> ScanResult: scan_pii_markers(result) except Exception as e: result.note(f"PII surface errored: {e.__class__.__name__}.") + if do_dev_creds: + for fn, label in ( + (scan_dev_credentials, "Dev-credential"), + (scan_wallets, "Crypto-wallet"), + (scan_keychain, "Keychain"), + ): + try: + fn(result) + except Exception as e: + result.note(f"{label} surface errored: {e.__class__.__name__}.") + if do_tcc: + try: + scan_tcc(result) + except Exception as e: + result.note(f"TCC surface errored: {e.__class__.__name__}.") return result @@ -1510,6 +2730,17 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--no-notes", action="store_true", help="Skip the Apple Notes surface.") parser.add_argument("--no-browser", action="store_true", help="Skip the browser-login surface.") + parser.add_argument( + "--no-dev-creds", action="store_true", + help="Skip the developer-credential surface (SSH keys, ~/.aws, ~/.npmrc, " + "shell history, wallets, keychain).", + ) + parser.add_argument( + "--tcc", action="store_true", + help="Add the TCC grant inventory: which binaries hold Full Disk Access / " + "Accessibility / Screen Recording, plus Secure Keyboard Entry, the " + "remote-access surface and Secure Boot level. Read-only.", + ) args = parser.parse_args(argv) _install_signal_handlers() @@ -1523,6 +2754,8 @@ def main(argv: list[str] | None = None) -> int: target, do_notes=not args.no_notes, do_browser=not args.no_browser, + do_dev_creds=not args.no_dev_creds, + do_tcc=args.tcc, ) markdown = render_markdown(result, target) diff --git a/exposurescan/sample-report.md b/exposurescan/sample-report.md index 46a2154..91a8056 100644 --- a/exposurescan/sample-report.md +++ b/exposurescan/sample-report.md @@ -5,25 +5,27 @@ _Generated 2026-01-01 00:00 UTC · scope: /Users/PLACEHOLDER/dev_ > **Names and counts only.** Secret values were never read, decrypted, stored, or printed. This is a defensive self-audit, not an extractor. ## Summary | Tier | Severity | Findings | What it means | |------|----------|----------|----------------| -| P0 | CRITICAL | 6 | Live keys / wallet seeds in plaintext — disk access = full pivot | -| P1 | HIGH | 2 | Account-takeover credentials (browser logins, secret files) | -| P2 | MEDIUM | 3 | Session cookies / reusable PII clusters | -| P3 | LOW | 1 | Advisory exposure (PII, trusted-host logins) | +| P0 | CRITICAL | 14 | Live keys / wallet seeds in plaintext — disk access = full pivot | +| P1 | HIGH | 7 | Account-takeover credentials (browser logins, secret files) | +| P2 | MEDIUM | 10 | Session cookies / reusable PII clusters | +| P3 | LOW | 2 | Advisory exposure (PII, trusted-host logins) | ## P0 — CRITICAL @@ -37,12 +39,57 @@ _Live keys / wallet seeds in plaintext — disk access = full pivot_ - **Attacker pivots into:** wallet drain / irreversible crypto theft - **Remediation:** Move secrets/seed phrases out of plain Notes into a password manager or a hardware-backed store; lock the note (App-level encryption) at minimum; delete if no longer needed. +### MetaMask: browser wallet store present +- **Surface:** crypto-wallet +- **Category:** wallet-extension +- **Location:** Chrome/Default/Local Extension Settings +- **Detail:** profile Chrome/Default +- **Value shape:** 3 LevelDB file(s), extension nkbihfbeogaeaoehlefnkodbefgpgknn +- **Attacker pivots into:** irreversible theft. Crypto has no chargeback, no fraud department and no insurance: a drained wallet is gone. A stealer copies the whole extension store and brute-forces the vault password offline, on its own hardware +- **Remediation:** Move anything you are not actively trading to a hardware wallet, and treat the seed as already exposed if this Mac was ever compromised: generate a NEW wallet on a DIFFERENT clean device and move the funds. Rotating the extension password does not help - the attacker has the encrypted vault. + +### Phantom: browser wallet store present +- **Surface:** crypto-wallet +- **Category:** wallet-extension +- **Location:** Chrome/Default/Local Extension Settings +- **Detail:** profile Chrome/Default +- **Value shape:** 2 LevelDB file(s), extension bfnaelmomeimhlpmgjnjophhpkkoljpa +- **Attacker pivots into:** irreversible theft. Crypto has no chargeback, no fraud department and no insurance: a drained wallet is gone. A stealer copies the whole extension store and brute-forces the vault password offline, on its own hardware +- **Remediation:** Move anything you are not actively trading to a hardware wallet, and treat the seed as already exposed if this Mac was ever compromised: generate a NEW wallet on a DIFFERENT clean device and move the funds. Rotating the extension password does not help - the attacker has the encrypted vault. + +### .zsh_history: 1 secret-shaped run(s) on line(s) 3 +- **Surface:** dev-credential +- **Category:** shell-history +- **Location:** ~/.zsh_history +- **Detail:** matched text is never read into the report; go look yourself +- **Value shape:** OpenAI/Anthropic-style API key +- **Attacker pivots into:** a stealer that copies your history file gets every credential you have ever pasted into a shell, in cleartext, with the command that shows what it unlocks +- **Remediation:** Open the file at those line numbers, rotate anything live, then delete the lines. Prefix a command with a SPACE (with HIST_IGNORE_SPACE set) to keep it out of history, and read secrets from a file or the Keychain instead of the command line. + +### id_ed25519_example: PLAINTEXT private key - no passphrase +- **Surface:** dev-credential +- **Category:** ssh-private-key +- **Location:** ~/.ssh +- **Detail:** type ssh-ed25519 +- **Value shape:** chmod 600 +- **Attacker pivots into:** push access to every repo you own and login to every host that trusts this key - a personal breach becomes a supply-chain breach. A passphrase-less key is usable the second it is copied; nothing else has to be cracked. +- **Remediation:** Add a passphrase in place, without regenerating the key: `ssh-keygen -p -f ~/.ssh/`. Then `chmod 600` it, load it into the agent once per session, and remove the public half from any host you no longer use. + +### ~/.docker/config.json: Docker registry credentials - WORLD/GROUP-READABLE +- **Surface:** dev-credential +- **Category:** dev-credential-file +- **Location:** ~/.docker/config.json +- **Detail:** names present: auths, https://index.docker.io/v1/, auth, credsStore +- **Value shape:** 113 bytes, chmod 644 - GROUP/OTHER READABLE +- **Attacker pivots into:** pushing a poisoned image to every registry namespace you can write to +- **Remediation:** chmod 600 ~/.docker/config.json. Rotate the credential if this machine was ever exposed, and prefer a short-lived/scoped token (gh auth login, aws sso login, npm granular access token) over a long-lived one stored on disk. + ### example-api-key - **Surface:** dot-secrets - **Category:** flat-secret-file - **Location:** ~/.secrets - **Detail:** flat file whose name identifies the credential -- **Value shape:** 54 bytes, chmod 644 - GROUP/OTHER READABLE +- **Value shape:** 56 bytes, chmod 644 - GROUP/OTHER READABLE - **Attacker pivots into:** direct read of a live credential by anyone with disk access - **Remediation:** chmod 600 each file; consider moving into the macOS Keychain; rotate any secret you suspect was exposed. @@ -68,7 +115,7 @@ _Live keys / wallet seeds in plaintext — disk access = full pivot_ - **Category:** env-key - **Location:** /Users/PLACEHOLDER/dev/example-app/.env - **Detail:** line 3 -- **Value shape:** 68 chars, high-entropy, Postgres connection URI +- **Value shape:** 57 chars, high-entropy, Postgres connection URI - **Attacker pivots into:** an attacker with disk access reads this plaintext key and pivots into the live service it unlocks - **Remediation:** Move secrets out of plaintext .env into a secrets manager / Keychain / 1Password; rotate this key; ensure .env is gitignored; chmod 600. @@ -77,14 +124,46 @@ _Live keys / wallet seeds in plaintext — disk access = full pivot_ - **Category:** env-key - **Location:** /Users/PLACEHOLDER/dev/example-app/.env - **Detail:** line 5 -- **Value shape:** 37 chars, high-entropy +- **Value shape:** 36 chars, high-entropy - **Attacker pivots into:** an attacker with disk access reads this plaintext key and pivots into the live service it unlocks - **Remediation:** Move secrets out of plaintext .env into a secrets manager / Keychain / 1Password; rotate this key; ensure .env is gitignored; chmod 600. +### Terminal holds Accessibility (drive any app, read any window) +- **Surface:** tcc +- **Category:** tcc-grant-inheritance +- **Location:** TCC (user) +- **Detail:** user TCC.db; client com.apple.Terminal +- **Attacker pivots into:** anything you run inside Terminal gets Accessibility (drive any app, read any window) too, with no prompt of its own. A pasted payload does not have to ask for Accessibility (drive any app, read any window) - it inherits yours the moment it runs. This is not an app holding a permission; it is a permission attached to a thing that runs other code. +- **Remediation:** System Settings > Privacy & Security: remove Terminal from Accessibility (drive any app, read any window) unless you actively need it, and re-grant it per-task rather than permanently. Prefer granting a specific app over granting your terminal. + +### Terminal holds Screen Recording +- **Surface:** tcc +- **Category:** tcc-grant-inheritance +- **Location:** TCC (user) +- **Detail:** user TCC.db; client com.apple.Terminal +- **Attacker pivots into:** anything you run inside Terminal gets Screen Recording too, with no prompt of its own. A pasted payload does not have to ask for Screen Recording - it inherits yours the moment it runs. This is not an app holding a permission; it is a permission attached to a thing that runs other code. +- **Remediation:** System Settings > Privacy & Security: remove Terminal from Screen Recording unless you actively need it, and re-grant it per-task rather than permanently. Prefer granting a specific app over granting your terminal. + +### sshd-keygen-wrapper holds Full Disk Access +- **Surface:** tcc +- **Category:** tcc-grant-inheritance +- **Location:** TCC (user) +- **Detail:** user TCC.db; client /usr/libexec/sshd-keygen-wrapper +- **Attacker pivots into:** anything you run inside sshd-keygen-wrapper gets Full Disk Access too, with no prompt of its own. A pasted payload does not have to ask for Full Disk Access - it inherits yours the moment it runs. This is not an app holding a permission; it is a permission attached to a thing that runs other code. +- **Remediation:** System Settings > Privacy & Security: remove sshd-keygen-wrapper from Full Disk Access unless you actively need it, and re-grant it per-task rather than permanently. Prefer granting a specific app over granting your terminal. + ## P1 — HIGH _Account-takeover credentials (browser logins, secret files)_ +### Firefox aaaaaaaa.default-release: 3 saved login(s) +- **Surface:** browser-login +- **Category:** firefox-logins +- **Location:** Firefox/aaaaaaaa.default-release +- **Detail:** 3 distinct host(s); 1 high-value host(s) +- **Attacker pivots into:** Firefox stores these under key4.db; if no primary password is set, anyone with both files can decrypt every one of them offline +- **Remediation:** Set a Firefox Primary Password (Settings > Privacy & Security), or move these into a password manager. + ### example-bank.test — 1 saved login(s) - **Surface:** browser-login - **Category:** financial-or-identity-login @@ -93,6 +172,24 @@ _Account-takeover credentials (browser logins, secret files)_ - **Attacker pivots into:** account takeover of a financial/identity/registrar account - **Remediation:** Stop saving passwords in the browser; migrate to a password manager (1Password/Keychain). Enable the OS-level encryption prompt. Remove stale entries you no longer use. +### ~/.config/gh/hosts.yml: GitHub CLI OAuth credentials +- **Surface:** dev-credential +- **Category:** dev-credential-file +- **Location:** ~/.config/gh/hosts.yml +- **Detail:** names present: github.com, oauth_token, user, git_protocol +- **Value shape:** 110 bytes, chmod 600 +- **Attacker pivots into:** push access to every repository you can write to, plus Actions secrets and release publishing — a personal breach becomes a supply-chain breach +- **Remediation:** chmod 600 ~/.config/gh/hosts.yml. Rotate the credential if this machine was ever exposed, and prefer a short-lived/scoped token (gh auth login, aws sso login, npm granular access token) over a long-lived one stored on disk. + +### ~/.npmrc: npm registry credentials +- **Surface:** dev-credential +- **Category:** dev-credential-file +- **Location:** ~/.npmrc +- **Detail:** names present: //registry.npmjs.org/:_authToken +- **Value shape:** 69 bytes, chmod 600 +- **Attacker pivots into:** publishing a malicious version of every package you own — a personal breach becomes a supply-chain breach +- **Remediation:** chmod 600 ~/.npmrc. Rotate the credential if this machine was ever exposed, and prefer a short-lived/scoped token (gh auth login, aws sso login, npm granular access token) over a long-lived one stored on disk. + ### example-token - **Surface:** dot-secrets - **Category:** flat-secret-file @@ -102,18 +199,67 @@ _Account-takeover credentials (browser logins, secret files)_ - **Attacker pivots into:** direct read of a live credential by anyone with disk access - **Remediation:** chmod 600 each file; consider moving into the macOS Keychain; rotate any secret you suspect was exposed. +### login.keychain-db present +- **Surface:** keychain +- **Category:** keychain +- **Location:** ~/Library/Keychains +- **Detail:** lock state: unlocked, no auto-lock timeout; item count not taken - counting can raise a keychain unlock prompt, so it is opt-in (see the README) +- **Value shape:** 262144 bytes +- **Attacker pivots into:** this one file holds Safari's saved logins, Wi-Fi passwords, certificates and app secrets. It is encrypted under your login password, so a stealer copies the file and cracks it offline - and an unlocked session with an Accessibility grant can read items without cracking anything +- **Remediation:** Set a keychain auto-lock timeout (Keychain Access > Edit > Change Settings), use a strong login password, and never approve an 'allow access' prompt you did not personally trigger. + +### Remote Login (SSH) is ENABLED (com.openssh.sshd) +- **Surface:** posture +- **Category:** remote-access +- **Location:** launchd (system) +- **Detail:** launchctl print-disabled system +- **Attacker pivots into:** an added line in ~/.ssh/authorized_keys is quieter persistence than a LaunchAgent, survives a password change, and is invisible to WatchPost's persistence diff +- **Remediation:** System Settings > General > Sharing: turn this off if you are not actively using it. If you need SSH, audit ~/.ssh/authorized_keys now and restrict it with AllowUsers / PermitRootLogin no. + ## P2 — MEDIUM _Session cookies / reusable PII clusters_ -### 4 high-value host(s) with saved session-cookies +### 2 high-value host(s) with saved session-cookies - **Surface:** browser-login - **Category:** session-cookie - **Location:** Chrome/Default -- **Detail:** hosts: example-bank.test, example-mail.test, github.com, example-cloud.test +- **Detail:** hosts: example-bank.test, github.com - **Attacker pivots into:** session hijack — bypasses password + MFA while cookie is valid - **Remediation:** Sign out of sensitive sites when done; clear cookies regularly; never paste a curl|bash that could read this DB. +### 3 host(s) with 36 stored browser cookies +- **Surface:** browser-login +- **Category:** session-cookie-volume +- **Location:** Chrome/Default +- **Detail:** profile Chrome/Default +- **Attacker pivots into:** session hijack — a stolen cookie survives a PASSWORD RESET and bypasses MFA until it expires or you revoke sessions. Revoke sessions BEFORE rotating passwords. +- **Remediation:** Use each provider's 'sign out of all devices' / 'revoke sessions' control first, then rotate the password. Clearing cookies locally does not invalidate a copy an attacker already took. + +### Firefox aaaaaaaa.default-release: 14 stored cookies +- **Surface:** browser-login +- **Category:** session-cookie-volume +- **Location:** Firefox/aaaaaaaa.default-release +- **Attacker pivots into:** session hijack - a stolen cookie survives a PASSWORD RESET and bypasses MFA until you revoke sessions +- **Remediation:** Revoke sessions provider-side first, then rotate passwords. + +### example-forum.test — 1 saved login(s) +- **Surface:** browser-login +- **Category:** saved-login +- **Location:** Chrome/Default +- **Detail:** profile Chrome/Default; usernames present: yes +- **Attacker pivots into:** credential reuse / lateral account takeover +- **Remediation:** Stop saving passwords in the browser; migrate to a password manager (1Password/Keychain). Enable the OS-level encryption prompt. Remove stale entries you no longer use. + +### : encrypted private key +- **Surface:** dev-credential +- **Category:** ssh-private-key +- **Location:** ~/.ssh +- **Detail:** type ssh-ed25519 +- **Value shape:** chmod 600 +- **Attacker pivots into:** usable only if the attacker also captures the passphrase (keylogger, Accessibility grant, or a fake prompt) +- **Remediation:** Add a passphrase in place, without regenerating the key: `ssh-keygen -p -f ~/.ssh/`. Then `chmod 600` it, load it into the agent once per session, and remove the public half from any host you no longer use. + ### (#3df3ff2b) in /Users/PLACEHOLDER/Documents/ (22 bytes, 2026-01-01 00:00 UTC) - **Surface:** pii - **Category:** pii-file @@ -126,10 +272,34 @@ _Session cookies / reusable PII clusters_ - **Surface:** pii - **Category:** pii-file - **Location:** /Users/PLACEHOLDER/Documents/tax-export.csv -- **Value shape:** email: 2, sin-ssn: 2; 116 bytes, modified 2026-01-01 00:00 UTC +- **Value shape:** email: 2, sin-ssn: 2; 101 bytes, modified 2026-01-01 00:00 UTC - **Attacker pivots into:** identity theft / financial fraud - **Remediation:** Encrypt or delete this file; remove SIN/card data from cleartext. +### Secure Boot: Reduced Security (not Full Security) +- **Surface:** posture +- **Category:** secure-boot +- **Location:** Secure Boot policy +- **Detail:** kernel extensions: allowed +- **Attacker pivots into:** reduced security permits unsigned/third-party kernel extensions and downgraded boot policy - a real weakening of the boot chain that no other tool in this kit would report +- **Remediation:** If you did not deliberately reduce it (for a kext, a VM tool, or another OS), restore Full Security via Recovery > Startup Security Utility. + +### Terminal: Secure Keyboard Entry is OFF +- **Surface:** posture +- **Category:** secure-keyboard-entry +- **Location:** com.apple.Terminal +- **Detail:** Terminal > Secure Keyboard Entry +- **Attacker pivots into:** without it, ANY app holding Accessibility or Input Monitoring can read what you type into Terminal - including the admin password you type at a sudo prompt, which is the exact step a ClickFix payload is trying to get you to perform +- **Remediation:** Terminal > Secure Keyboard Entry (checkbox in the Terminal menu). iTerm2: Settings > General > Magic > 'Enable secure keyboard entry'. + +### WindowTool holds Accessibility (drive any app, read any window) +- **Surface:** tcc +- **Category:** tcc-grant +- **Location:** TCC (user) +- **Detail:** user TCC.db; client com.example.WindowTool +- **Attacker pivots into:** a compromise or malicious update of this app inherits Accessibility (drive any app, read any window) without ever prompting you again +- **Remediation:** Remove the grant if you cannot name the feature that needs it. + ## P3 — LOW _Advisory exposure (PII, trusted-host logins)_ @@ -142,17 +312,25 @@ _Advisory exposure (PII, trusted-host logins)_ - **Attacker pivots into:** identity theft / targeted social engineering (no direct system pivot) - **Remediation:** Move documents containing SIN/SSN/card numbers into an encrypted disk image or password manager; delete stale exports; empty Downloads of old statements. +### 2 other TCC grant(s) held by apps +- **Surface:** tcc +- **Category:** tcc-other +- **Location:** TCC (user + system) +- **Value shape:** Camera: 1, Photos: 1 +- **Attacker pivots into:** ordinary app permissions - data exposure if that specific app is compromised, but no grant inheritance +- **Remediation:** Review in System Settings > Privacy & Security and remove anything you do not recognise. + ## Scan notes (skips & access) +- Safari: no profile directory found. - Apple Notes: 2 locked/encrypted note(s) skipped (cannot read). -- Browser Edge: no Chromium-family profiles found. +- TCC (system): no-system-TCC.db not found. ## Remediation checklist (do these in order) -1. **P0 first** — Move every plaintext live key (AWS/Stripe/Anthropic/DB URI), private key, and wallet seed out of `.env`/`~/.secrets`/Notes into the macOS Keychain or a password manager, then **rotate** them. `chmod 700 ~/.secrets`, `chmod 600` each secret file. +1. **P0 first** — Move every plaintext live key (AWS/Stripe/Anthropic/DB URI), private key, and wallet seed out of `.env`/`~/.secrets`/Notes into the macOS Keychain or a password manager, then **rotate** them. `chmod 700 ~/.secrets`, `chmod 600` each secret file. Put a passphrase on every SSH key (`ssh-keygen -p -f ~/.ssh/`), and if a wallet was on this machine during an incident, move the funds to a NEW wallet generated on a DIFFERENT clean device — that loss is the only irreversible one. 2. **P1** — Stop saving passwords in the browser for financial/email/registrar origins; migrate to a password manager; enable OS-level encryption. -3. **P2** — Sign out of sensitive sites to kill session cookies; encrypt or delete documents containing SIN/SSN/card numbers. +3. **P2** — Revoke sessions provider-side (a stolen cookie survives a password reset) BEFORE rotating passwords; encrypt or delete documents containing SIN/SSN/card numbers; drop TCC grants you cannot justify. 4. **P3** — Clear stale PII exports from Downloads; review trusted-host logins. > This audit shrinks the blast radius. It does **not** stop you from pasting a `curl … | bash` into Terminal or typing your password into a fake dialog. Pair it with ShellGuard (zsh execute-time guard) and ClipSentinel (clipboard early-warning) from this kit. - diff --git a/exposurescan/tests/make-sample-report.py b/exposurescan/tests/make-sample-report.py new file mode 100755 index 0000000..f05bdbe --- /dev/null +++ b/exposurescan/tests/make-sample-report.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# CONFIRMED-SECRET-OK: every literal below is synthetic placeholder data written +# into a throwaway temp directory. No real domain, key, file or PII appears. +""" +Regenerate ../sample-report.md by running the REAL scanner against a synthetic +home directory. + +The sample report is NOT hand-written. If it were, it would drift from what the +tool actually prints, and a security tool whose own example output is stale is +exactly the kind of thing this repo exists to not be. Everything below builds a +fake $HOME, points Path.home() at it, runs the real run_scan() + the real +render_markdown(), and then normalises the two non-deterministic things (the +temp path and the generation timestamp) into stable placeholders. + + python3 tests/make-sample-report.py # rewrite ../sample-report.md + python3 tests/make-sample-report.py --check # exit 1 if it would change + +Fixture SSH keys are generated with ssh-keygen into the throwaway home and are +destroyed with it. +""" + +from __future__ import annotations + +import argparse +import gzip +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import exposurescan as es # noqa: E402 + +OUT = Path(__file__).resolve().parent.parent / "sample-report.md" + +FIXED_EPOCH = 1_767_225_600 # 2026-01-01 00:00:00 UTC +CORE_DATA_MODIFIED = 788_918_400.0 # 2026-01-01 00:00 UTC in Core Data seconds + +SEED_PHRASE = ( + "abandon ability able about above absent absorb abstract absurd abuse " + "access accident" +) + +BANNER = """""" + + +def _touch(path: Path) -> None: + os.utime(path, (FIXED_EPOCH, FIXED_EPOCH)) + + +def build_home(home: Path) -> None: + # --- (c) .env --------------------------------------------------------- + proj = home / "dev" / "example-app" + proj.mkdir(parents=True) + env = proj / ".env" + env.write_text( + "EXAMPLE_PUBLIC_APP_NAME=example\n" + "EXAMPLE_PUBLIC_URL=https://example.test\n" + "EXAMPLE_DB_URL=postgres://user:PLACEHOLDERvalue@db.example.test:5432/app\n" + "EXAMPLE_AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000\n" + "EXAMPLE_STRIPE_SECRET_KEY=PLACEHOLDER-entropy-0123456789abcdef\n" + ) + os.chmod(env, 0o644) + + # --- (d) ~/.secrets --------------------------------------------------- + secrets = home / ".secrets" + secrets.mkdir(mode=0o700) + (secrets / "example-api-key").write_text("PLACEHOLDER" * 5 + "\n") + os.chmod(secrets / "example-api-key", 0o644) + (secrets / "example-token").write_text("PLACEHOLDER\n") + os.chmod(secrets / "example-token", 0o600) + + # --- (e) PII ---------------------------------------------------------- + docs = home / "Documents" + docs.mkdir() + pan = docs / "visa 4111 1111 1111 1111 exp 0327 cvv 415.csv" + pan.write_text("statement for account\n") + _touch(pan) + tax = docs / "tax-export.csv" + tax.write_text( + "name,email,sin\n" + "A Person,a.person@example.test,123-45-6789\n" + "B Person,b.person@example.test,987-65-4321\n" + ) + _touch(tax) + + # --- (b) Apple Notes -------------------------------------------------- + notes = home / "Library" / "Group Containers" / "group.com.apple.notes" + notes.mkdir(parents=True) + conn = sqlite3.connect(str(notes / "NoteStore.sqlite")) + conn.execute("CREATE TABLE ZICNOTEDATA (Z_PK INTEGER PRIMARY KEY, ZDATA BLOB)") + conn.execute( + "CREATE TABLE ZICCLOUDSYNCINGOBJECT (Z_PK INTEGER PRIMARY KEY," + " ZNOTEDATA INTEGER, ZTITLE1 TEXT, ZMODIFICATIONDATE1 REAL)" + ) + body = SEED_PHRASE + "\nrecovery phrase for my hardware wallet" + conn.execute("INSERT INTO ZICNOTEDATA VALUES (?, ?)", + (7, gzip.compress(body.encode()))) + conn.execute("INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (?, ?, ?, ?)", + (1, 7, SEED_PHRASE, CORE_DATA_MODIFIED)) + conn.execute("INSERT INTO ZICNOTEDATA VALUES (?, ?)", (8, b"\x01locked-note")) + conn.execute("INSERT INTO ZICNOTEDATA VALUES (?, ?)", (9, b"\x01locked-note")) + conn.commit() + conn.close() + + # --- (a) Chromium profile: logins, cookies, a wallet extension -------- + prof = (home / "Library" / "Application Support" / "Google" / "Chrome" / + "Default") + prof.mkdir(parents=True) + conn = sqlite3.connect(str(prof / "Login Data")) + conn.execute( + "CREATE TABLE logins (origin_url TEXT, username_value TEXT," + " password_value BLOB, blacklisted_by_user INTEGER)" + ) + conn.executemany( + "INSERT INTO logins VALUES (?,?,?,0)", + [ + ("https://example-bank.test/login", "someone", b"x" * 40), + ("https://example-forum.test/login", "someone", b"x" * 40), + ], + ) + conn.commit() + conn.close() + conn = sqlite3.connect(str(prof / "Cookies")) + conn.execute("CREATE TABLE cookies (host_key TEXT, name TEXT)") + conn.executemany( + "INSERT INTO cookies VALUES (?,?)", + [("example-bank.test", f"c{i}") for i in range(9)] + + [("github.com", f"c{i}") for i in range(7)] + + [("example-forum.test", f"c{i}") for i in range(20)], + ) + conn.commit() + conn.close() + for ext, files in ( + ("nkbihfbeogaeaoehlefnkodbefgpgknn", 3), # MetaMask + ("bfnaelmomeimhlpmgjnjophhpkkoljpa", 2), # Phantom + ): + d = prof / "Local Extension Settings" / ext + d.mkdir(parents=True) + for i in range(files): + (d / f"{i:06d}.ldb").write_bytes(b"\x00leveldb") + + # --- Firefox ---------------------------------------------------------- + ff = (home / "Library" / "Application Support" / "Firefox" / "Profiles" / + "aaaaaaaa.default-release") + ff.mkdir(parents=True) + (ff / "logins.json").write_text(json.dumps({"logins": [ + {"hostname": "https://example-bank.test", "encryptedUsername": "REDACTED"}, + {"hostname": "https://example-forum.test", "encryptedUsername": "REDACTED"}, + {"hostname": "https://example-shop.test", "encryptedUsername": "REDACTED"}, + ]})) + conn = sqlite3.connect(str(ff / "cookies.sqlite")) + conn.execute("CREATE TABLE moz_cookies (host TEXT)") + conn.executemany("INSERT INTO moz_cookies VALUES (?)", + [("example-forum.test",)] * 14) + conn.commit() + conn.close() + + # --- (f) developer credentials --------------------------------------- + ssh = home / ".ssh" + ssh.mkdir(mode=0o700) + for name, passphrase in (("id_ed25519_example", ""), + ("id_ed25519_example_locked", "not-a-real-passphrase")): + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-N", passphrase, "-C", "example", + "-f", str(ssh / name), "-q"], + check=True, capture_output=True, + ) + (home / ".npmrc").write_text( + "//registry.npmjs.org/:_authToken=npm_PLACEHOLDER00000000000000000000\n" + ) + os.chmod(home / ".npmrc", 0o600) + gh = home / ".config" / "gh" + gh.mkdir(parents=True) + (gh / "hosts.yml").write_text( + "github.com:\n" + " oauth_token: gho_PLACEHOLDER00000000000000000000\n" + " user: example-user\n" + " git_protocol: ssh\n" + ) + os.chmod(gh / "hosts.yml", 0o600) + docker = home / ".docker" + docker.mkdir() + (docker / "config.json").write_text(json.dumps( + {"auths": {"https://index.docker.io/v1/": {"auth": "UExBQ0VIT0xERVI="}}, + "credsStore": "desktop"}, indent=1)) + os.chmod(docker / "config.json", 0o644) + (home / ".zsh_history").write_text( + ": 1767225600:0;cd ~/dev\n" + ": 1767225601:0;ls -la\n" + ": 1767225602:0;export EXAMPLE_API_KEY=sk-PLACEHOLDER000000000000000000000000\n" + ": 1767225603:0;git push\n" + ) + + # --- (h) keychain ----------------------------------------------------- + kc = home / "Library" / "Keychains" + kc.mkdir(parents=True) + (kc / "login.keychain-db").write_bytes(b"\x00" * 262_144) + + # --- (i) TCC ---------------------------------------------------------- + tcc = home / es.TCC_USER_DB + tcc.parent.mkdir(parents=True) + conn = sqlite3.connect(str(tcc)) + conn.execute( + "CREATE TABLE access (service TEXT NOT NULL, client TEXT NOT NULL," + " client_type INTEGER NOT NULL, auth_value INTEGER NOT NULL)" + ) + conn.executemany("INSERT INTO access VALUES (?,?,?,?)", [ + ("kTCCServiceAccessibility", "com.apple.Terminal", 0, 2), + ("kTCCServiceScreenCapture", "com.apple.Terminal", 0, 2), + ("kTCCServiceSystemPolicyAllFiles", "/usr/libexec/sshd-keygen-wrapper", 1, 2), + ("kTCCServiceAccessibility", "com.example.WindowTool", 0, 2), + ("kTCCServicePhotos", "com.example.PhotoApp", 0, 2), + ("kTCCServiceCamera", "com.example.MeetingApp", 0, 2), + ("kTCCServiceScreenCapture", "com.example.DeniedApp", 0, 0), + ]) + conn.commit() + conn.close() + + +FAKE_COMMANDS = { + "SecureKeyboardEntry": "0\n", + "print-disabled": ( + 'disabled services = {\n' + '\t"com.openssh.sshd" => false\n' + '\t"com.apple.screensharing" => true\n' + '}\n' + ), + "show-keychain-info": 'Keychain "login.keychain-db" no-timeout\n', + "SPiBridgeDataType": ( + " Secure Boot: Reduced Security\n" + " Allow All Kernel Extensions: Yes\n" + ), +} + + +def generate() -> str: + # A DETERMINISTIC, SHORT temp root. Not mkdtemp(): a random suffix is a + # high-entropy run, so redact() would (correctly) eat it and every Location + # line in the sample would read "". The name is also kept under + # 20 characters for the same reason. + home = Path("/tmp/cfk-sample") + shutil.rmtree(home, ignore_errors=True) + home.mkdir(parents=True) + real_home, real_run, real_sys_tcc = Path.home, es._safe_run, es.TCC_SYSTEM_DB + try: + build_home(home) + Path.home = staticmethod(lambda: home) # type: ignore[assignment] + es.TCC_SYSTEM_DB = str(home / "no-system-TCC.db") + + def fake_run(cmd, timeout=8.0): + joined = " ".join(cmd) + for key, out in FAKE_COMMANDS.items(): + if key in joined: + return out + return "" + + es._safe_run = fake_run # type: ignore[assignment] + result = es.run_scan( + home / "dev", do_notes=True, do_browser=True, + do_dev_creds=True, do_tcc=True, + ) + markdown = es.render_markdown(result, home / "dev") + finally: + Path.home = real_home # type: ignore[assignment] + es._safe_run = real_run # type: ignore[assignment] + es.TCC_SYSTEM_DB = real_sys_tcc + shutil.rmtree(home, ignore_errors=True) + + # Normalise the two non-deterministic things: the temp home and "now". + markdown = markdown.replace(str(home), "/Users/PLACEHOLDER") + markdown = re.sub( + r"_Generated \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC", + "_Generated 2026-01-01 00:00 UTC", markdown, + ) + # Drop in the provenance banner after the invariant blockquote. + marker = "not an extractor.\n" + markdown = markdown.replace(marker, marker + BANNER + "\n", 1) + return markdown + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", + help="exit 1 if sample-report.md is out of date") + args = ap.parse_args() + md = generate() + if args.check: + current = OUT.read_text() if OUT.exists() else "" + if current.strip() != md.strip(): + print("sample-report.md is out of date; run tests/make-sample-report.py", + file=sys.stderr) + return 1 + print("sample-report.md is current") + return 0 + OUT.write_text(md) + print(f"wrote {OUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/exposurescan/tests/test_dev_credentials.py b/exposurescan/tests/test_dev_credentials.py new file mode 100644 index 0000000..6d03ad1 --- /dev/null +++ b/exposurescan/tests/test_dev_credentials.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +# CONFIRMED-SECRET-OK: every credential literal in this file is synthetic and +# non-functional. The SSH keys are generated fresh into a temp dir at test time +# and deleted in tearDown. The "tokens" are fixed placeholder strings that +# authenticate nowhere. Nothing here is, or ever was, a live credential. +""" +Regression suite for the v0.2.0 ExposureScan surfaces: + + * scan_dev_credentials — SSH keys, cloud/registry credential files, + shell history (plan item P1-4) + * scan_wallets — browser + desktop crypto wallets + * scan_tcc — the TCC grant-inheritance inventory (plan item P1-5) + +The load-bearing tests are the LEAK tests. A new surface without one does not +ship: for every fixture we grep the rendered markdown AND the JSON sidecar for +the fixture's key material and token strings and assert ZERO hits. v0.1.0 +shipped six leaks behind a passing unit test on redact(), because the leaks +lived in the f-strings between the scanner and the chokepoint. + +Run: + python3 -m unittest discover -s tests -v +""" + +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import exposurescan as es # noqa: E402 + + +# Synthetic, non-functional placeholder credentials. Shaped like the real thing +# so the scanner's prefix classifier fires; valid nowhere. +FAKE_NPM_TOKEN = "npm_PLACEHOLDER0000000000000000000000000000" +FAKE_GH_TOKEN = "gho_PLACEHOLDER0000000000000000000000000000" +FAKE_HISTORY_TOKEN = "sk-PLACEHOLDERnotarealkey000000000000000000" +FAKE_AWS_ID = "AKIAPLACEHOLDER00000" + +SSH_PASSPHRASE = "not-a-real-passphrase" + + +def _ssh_keygen_available() -> bool: + return shutil.which("ssh-keygen") is not None + + +def _gen_key(path: Path, passphrase: str) -> None: + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-N", passphrase, "-C", "fixture", + "-f", str(path), "-q"], + check=True, capture_output=True, + ) + + +def _b64_body(key_path: Path) -> list[str]: + """The base64 body lines of a private key file — i.e. the key MATERIAL.""" + lines = key_path.read_text().splitlines() + return [ln for ln in lines if ln and not ln.startswith("-----")] + + +class _FakeHome(unittest.TestCase): + """Base: a synthetic HOME that Path.home() resolves to for the test.""" + + def setUp(self): + self.home = Path(tempfile.mkdtemp(prefix="exposurescan_devcred_")) + self._real_home = Path.home + Path.home = staticmethod(lambda: self.home) # type: ignore[assignment] + # Never let a unit test shell out to `security`, `launchctl` or + # `system_profiler` — it would make the suite report the developer's + # machine instead of the fixture. + self._real_run = es._safe_run + es._safe_run = lambda cmd, timeout=8.0: "" # type: ignore[assignment] + + def tearDown(self): + Path.home = self._real_home # type: ignore[assignment] + es._safe_run = self._real_run # type: ignore[assignment] + shutil.rmtree(self.home, ignore_errors=True) + + def artifacts(self, result: es.ScanResult) -> tuple[str, str]: + md = es.render_markdown(result, self.home) + js = json.dumps(es.build_json_sidecar(result, self.home)) + return md, js + + +# --------------------------------------------------------------------------- +# 1. SSH keys: encrypted vs plaintext +# --------------------------------------------------------------------------- + +@unittest.skipUnless(_ssh_keygen_available(), "ssh-keygen not available") +class TestSshKeys(_FakeHome): + + def setUp(self): + super().setUp() + self.ssh = self.home / ".ssh" + self.ssh.mkdir(mode=0o700) + self.plain = self.ssh / "id_ed25519_plain" + self.enc = self.ssh / "id_ed25519_encrypted" + _gen_key(self.plain, "") + _gen_key(self.enc, SSH_PASSPHRASE) + (self.ssh / "known_hosts").write_text("example.test ssh-ed25519 AAAA\n") + + def _findings(self): + result = es.ScanResult() + es.scan_ssh_keys(result) + return {f.name.split(":")[0]: f for f in result.findings}, result + + def test_plaintext_key_is_p0_and_encrypted_key_is_not(self): + by_name, _ = self._findings() + self.assertIn("id_ed25519_plain", by_name) + self.assertIn("id_ed25519_encrypted", by_name) + self.assertEqual(by_name["id_ed25519_plain"].tier, "P0", + "a passphrase-less private key must be P0") + self.assertNotEqual(by_name["id_ed25519_encrypted"].tier, "P0", + "an encrypted key must NOT be tiered P0") + + def test_classification_is_correct(self): + self.assertEqual(es.classify_ssh_key(self.plain), ("ssh-ed25519", False)) + self.assertEqual(es.classify_ssh_key(self.enc), ("ssh-ed25519", True)) + + def test_public_key_and_known_hosts_are_not_private_keys(self): + self.assertIsNone(es.classify_ssh_key(self.ssh / "known_hosts")) + self.assertIsNone(es.classify_ssh_key(Path(str(self.plain) + ".pub"))) + + def test_key_material_never_reaches_any_artifact(self): + result = es.ScanResult() + es.scan_ssh_keys(result) + md, js = self.artifacts(result) + for key in (self.plain, self.enc): + for line in _b64_body(key): + for chunk in (line, line[:24], line[-24:]): + if len(chunk) < 16: + continue + self.assertNotIn(chunk, md, f"KEY MATERIAL IN MARKDOWN ({key.name})") + self.assertNotIn(chunk, js, f"KEY MATERIAL IN JSON ({key.name})") + + def test_pem_encrypted_header_is_detected(self): + pem = self.ssh / "id_rsa_legacy" + pem.write_text( + "-----BEGIN RSA PRIVATE KEY-----\n" + "Proc-Type: 4,ENCRYPTED\n" + "DEK-Info: AES-128-CBC,0000\n" + "\nAAAAB3NzaC1lZDI1NTE5\n" + "-----END RSA PRIVATE KEY-----\n" + ) + self.assertEqual(es.classify_ssh_key(pem), ("rsa-pem", True)) + + def test_unparseable_openssh_header_is_not_called_plaintext(self): + """Unknown encryption state must never be reported as the P0 case.""" + broken = self.ssh / "id_broken" + broken.write_text( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + "bm90LWEtcmVhbC1rZXk=\n" + "-----END OPENSSH PRIVATE KEY-----\n" + ) + info = es.classify_ssh_key(broken) + self.assertIsNotNone(info) + self.assertTrue(info[1], "an unparseable header must not be tiered P0") + + +# --------------------------------------------------------------------------- +# 2. Credential files, shell history, wallets — end to end through the CLI +# --------------------------------------------------------------------------- + +class TestDevCredentialFixture(_FakeHome): + + WALLET_EXT = "nkbihfbeogaeaoehlefnkodbefgpgknn" # MetaMask + + def setUp(self): + super().setUp() + (self.home / ".npmrc").write_text( + f"//registry.npmjs.org/:_authToken={FAKE_NPM_TOKEN}\n" + "cache=/tmp/npm\n" + ) + gh = self.home / ".config" / "gh" + gh.mkdir(parents=True) + (gh / "hosts.yml").write_text( + "github.com:\n" + f" oauth_token: {FAKE_GH_TOKEN}\n" + " user: fixture-user\n" + " git_protocol: ssh\n" + ) + aws = self.home / ".aws" + aws.mkdir() + (aws / "credentials").write_text( + "[default]\n" + f"aws_access_key_id = {FAKE_AWS_ID}\n" + "aws_secret_access_key = PLACEHOLDERsecret0000000000000000000000\n" + ) + os.chmod(aws / "credentials", 0o644) # deliberately world-readable + (self.home / ".zsh_history").write_text( + "cd ~/dev\n" + "ls -la\n" + f"export ANTHROPIC_API_KEY={FAKE_HISTORY_TOKEN}\n" + "git status\n" + ) + wallet = (self.home / "Library" / "Application Support" / "Google" / + "Chrome" / "Default" / "Local Extension Settings" / self.WALLET_EXT) + wallet.mkdir(parents=True) + (wallet / "000003.log").write_bytes(b"\x00leveldb") + (wallet / "CURRENT").write_bytes(b"MANIFEST-000001\n") + + def _run(self): + result = es.run_scan( + self.home, do_notes=False, do_browser=False, + do_dev_creds=True, do_tcc=False, + ) + md, js = self.artifacts(result) + return result, md, js + + def test_no_token_reaches_any_artifact(self): + """The load-bearing assertion for this surface.""" + _, md, js = self._run() + for secret in (FAKE_NPM_TOKEN, FAKE_GH_TOKEN, FAKE_HISTORY_TOKEN, + FAKE_AWS_ID, "PLACEHOLDERsecret"): + self.assertNotIn(secret, md, f"TOKEN LEAKED INTO MARKDOWN: {secret[:12]}…") + self.assertNotIn(secret, js, f"TOKEN LEAKED INTO JSON: {secret[:12]}…") + # And the distinctive tail on its own, in case a prefix was stripped. + self.assertNotIn(secret[8:24], md) + self.assertNotIn(secret[8:24], js) + + def test_credential_files_are_reported_by_name_and_mode(self): + result, md, _ = self._run() + files = {f.location: f for f in result.findings + if f.category == "dev-credential-file"} + self.assertIn("~/.npmrc", files) + self.assertIn("~/.config/gh/hosts.yml", files) + self.assertIn("~/.aws/credentials", files) + # KEY NAMES survive; values do not. + self.assertIn("_authToken", files["~/.npmrc"].detail) + self.assertIn("oauth_token", files["~/.config/gh/hosts.yml"].detail) + self.assertIn("aws_access_key_id", files["~/.aws/credentials"].detail) + # A world-readable credential file is called out explicitly and escalated. + self.assertEqual(files["~/.aws/credentials"].tier, "P0") + self.assertIn("GROUP/OTHER READABLE", files["~/.aws/credentials"].shape) + self.assertIn("WORLD/GROUP-READABLE", md) + + def test_shell_history_reports_counts_and_line_numbers_only(self): + result, md, js = self._run() + hist = [f for f in result.findings if f.category == "shell-history"] + self.assertEqual(len(hist), 1) + f = hist[0] + self.assertEqual(f.tier, "P0") # a known credential prefix class + self.assertEqual(f.count, 1) + self.assertIn("line(s) 3", f.name) # the actionable part + self.assertIn("API key", f.shape) # the prefix CLASS, not the value + self.assertNotIn(FAKE_HISTORY_TOKEN, md) + self.assertNotIn(FAKE_HISTORY_TOKEN, js) + + def test_benign_history_produces_no_finding(self): + (self.home / ".bash_history").write_text( + "ls\ncd /Users/fixture/dev/some-project\ngit commit -m 'wip'\n" + "brew upgrade\npython3 -m pytest\n" + ) + result, _, _ = self._run() + bash = [f for f in result.findings + if f.category == "shell-history" and ".bash_history" in f.name] + self.assertEqual(bash, [], "ordinary shell history must not fire") + + def test_wallet_extension_is_p0(self): + result, md, _ = self._run() + wallets = [f for f in result.findings if f.surface == "crypto-wallet"] + self.assertTrue(wallets, "the wallet extension store was not found") + self.assertEqual(wallets[0].tier, "P0") + self.assertIn("MetaMask", wallets[0].name) + self.assertIn("irreversible", wallets[0].pivot) + self.assertIn("MetaMask", md) + + def test_cli_exit_code_is_3_on_the_fixture(self): + out = self.home / "report.md" + js = self.home / "report.json" + stdout, sys.stdout = sys.stdout, open(os.devnull, "w") + stderr, sys.stderr = sys.stderr, open(os.devnull, "w") + try: + rc = es.main([ + "--target", str(self.home), "--no-notes", "--no-browser", + "--out", str(out), "--json", str(js), + ]) + finally: + sys.stdout.close(); sys.stdout = stdout + sys.stderr.close(); sys.stderr = stderr + self.assertEqual(rc, 3, "a P0 fixture must exit 3") + # Same leak assertion, against the files actually written to disk. + for text in (out.read_text(), js.read_text()): + for secret in (FAKE_NPM_TOKEN, FAKE_GH_TOKEN, FAKE_HISTORY_TOKEN): + self.assertNotIn(secret, text) + + +# --------------------------------------------------------------------------- +# 3. TCC grant inventory +# --------------------------------------------------------------------------- + +def _build_tcc_db(path: Path, rows, legacy: bool = False) -> None: + """A fixture TCC.db on the real `access` schema shape (modern or legacy).""" + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path)) + col = "allowed" if legacy else "auth_value" + conn.execute( + f"CREATE TABLE access (service TEXT NOT NULL, client TEXT NOT NULL, " + f"client_type INTEGER NOT NULL, {col} INTEGER NOT NULL)" + ) + conn.executemany(f"INSERT INTO access VALUES (?,?,?,{'?'})", rows) + conn.commit() + conn.close() + + +class TestTccInventory(_FakeHome): + + ROWS = [ + # (service, client, client_type, auth) + ("kTCCServiceAccessibility", "com.apple.Terminal", 0, 2), + ("kTCCServiceScreenCapture", "com.apple.Terminal", 0, 2), + ("kTCCServiceSystemPolicyAllFiles", "/usr/libexec/sshd-keygen-wrapper", 1, 2), + ("kTCCServiceAccessibility", "/opt/homebrew/bin/python3.12", 1, 2), + ("kTCCServicePhotos", "com.example.OrdinaryPhotoApp", 0, 2), + ("kTCCServiceCamera", "com.example.OrdinaryPhotoApp", 0, 2), + # An app that holds Accessibility but does not run other code. + ("kTCCServiceAccessibility", "com.example.WindowManager", 0, 2), + # DENIED — must never be reported as a grant. + ("kTCCServiceScreenCapture", "com.example.DeniedApp", 0, 0), + ] + + def setUp(self): + super().setUp() + self.db = self.home / es.TCC_USER_DB + _build_tcc_db(self.db, self.ROWS) + self._real_system_db = es.TCC_SYSTEM_DB + # Point the system DB at a path that does not exist, so the test reads + # the fixture and nothing else. + es.TCC_SYSTEM_DB = str(self.home / "no-such-system-TCC.db") + + def tearDown(self): + es.TCC_SYSTEM_DB = self._real_system_db + super().tearDown() + + def _scan(self): + result = es.ScanResult() + es.scan_tcc(result) + return result, {f.name: f for f in result.findings} + + def test_terminal_with_accessibility_is_p0(self): + _, by_name = self._scan() + f = by_name.get("Terminal holds Accessibility (drive any app, read any window)") + self.assertIsNotNone(f, "Terminal/Accessibility was not reported") + self.assertEqual(f.tier, "P0") + self.assertEqual(f.category, "tcc-grant-inheritance") + # Plain language, per the plan: say what inheritance MEANS. + self.assertIn("inherits", f.pivot) + + def test_ordinary_gui_app_with_photos_is_not_p0(self): + result, _ = self._scan() + photo = [f for f in result.findings + if "OrdinaryPhotoApp" in f.name or "OrdinaryPhotoApp" in f.detail] + for f in photo: + self.assertNotEqual(f.tier, "P0", + "an ordinary GUI app with Photos access is not P0") + agg = [f for f in result.findings if f.category == "tcc-other"] + self.assertTrue(agg, "low-risk grants should still be counted") + self.assertEqual(agg[0].tier, "P3") + self.assertIn("Photos", agg[0].shape) + + def test_non_vehicle_app_with_accessibility_is_reported_but_not_p0(self): + _, by_name = self._scan() + f = by_name.get("WindowManager holds Accessibility (drive any app, read any window)") + self.assertIsNotNone(f) + self.assertEqual(f.tier, "P2") + self.assertEqual(f.category, "tcc-grant") + + def test_ssh_wrapper_and_bare_interpreter_are_p0(self): + _, by_name = self._scan() + self.assertEqual(by_name["sshd-keygen-wrapper holds Full Disk Access"].tier, "P0") + self.assertEqual( + by_name["python3.12 holds Accessibility (drive any app, read any window)"].tier, + "P0", + ) + + def test_denied_rows_are_never_reported(self): + result, _ = self._scan() + blob = " ".join(f.name + f.detail for f in result.findings) + self.assertNotIn("DeniedApp", blob) + + def test_legacy_allowed_schema_is_read(self): + legacy = self.home / "legacy-TCC.db" + _build_tcc_db( + legacy, + [("kTCCServiceAccessibility", "com.apple.Terminal", 0, 1)], + legacy=True, + ) + rows = es.read_tcc_grants(legacy) + self.assertEqual(rows, [("kTCCServiceAccessibility", "com.apple.Terminal", 1)]) + + def test_missing_db_degrades_to_a_note(self): + self.db.unlink() + result, _ = self._scan() + self.assertEqual( + [f for f in result.findings if f.surface == "tcc"], [], + "no TCC findings should be invented without a DB", + ) + self.assertTrue(any("TCC" in n for n in result.notes)) + + def test_unreadable_db_degrades_to_a_note_not_a_crash(self): + self.db.write_bytes(b"this is not a database") + result, _ = self._scan() + self.assertTrue(any("TCC" in n for n in result.notes)) + + def test_vehicle_classifier(self): + for client in ("com.apple.Terminal", "com.googlecode.iterm2", + "/usr/libexec/sshd-keygen-wrapper", "/bin/zsh", + "/opt/homebrew/bin/node", "com.teamviewer.TeamViewer", + "/usr/bin/osascript"): + self.assertTrue(es.is_grant_inheritance_vehicle(client), client) + for client in ("com.apple.Photos", "com.example.OrdinaryPhotoApp", + "/Applications/Numbers.app/Contents/MacOS/Numbers", ""): + self.assertFalse(es.is_grant_inheritance_vehicle(client), client) + + +class TestClickfixPosture(_FakeHome): + """The three adjacent one-liners bundled into --tcc.""" + + def _with_output(self, mapping): + def fake(cmd, timeout=8.0): + for key, out in mapping.items(): + if key in " ".join(cmd): + return out + return "" + es._safe_run = fake # type: ignore[assignment] + + def test_secure_keyboard_entry_off_is_reported(self): + self._with_output({"SecureKeyboardEntry": "0\n"}) + result = es.ScanResult() + es.scan_clickfix_posture(result) + names = [f.name for f in result.findings] + self.assertIn("Terminal: Secure Keyboard Entry is OFF", names) + + def test_secure_keyboard_entry_on_is_silent(self): + self._with_output({"SecureKeyboardEntry": "1\n"}) + result = es.ScanResult() + es.scan_clickfix_posture(result) + self.assertEqual( + [f for f in result.findings if f.category == "secure-keyboard-entry"], []) + + def test_remote_access_surface(self): + self._with_output({"print-disabled": ( + 'disabled services = {\n' + '\t"com.openssh.sshd" => false\n' + '\t"com.apple.screensharing" => true\n' + '}\n' + )}) + result = es.ScanResult() + es.scan_clickfix_posture(result) + cats = [f.name for f in result.findings if f.category == "remote-access"] + self.assertEqual(len(cats), 1, cats) + self.assertIn("Remote Login (SSH)", cats[0]) + + def test_secure_boot_reduced_is_reported_full_is_not(self): + self._with_output({"SPiBridgeDataType": + " Secure Boot: Reduced Security\n" + " Allow All Kernel Extensions: Yes\n"}) + result = es.ScanResult() + es.scan_clickfix_posture(result) + sb = [f for f in result.findings if f.category == "secure-boot"] + self.assertEqual(len(sb), 1) + self.assertIn("Reduced Security", sb[0].name) + + self._with_output({"SPiBridgeDataType": " Secure Boot: Full Security\n"}) + result = es.ScanResult() + es.scan_clickfix_posture(result) + self.assertEqual([f for f in result.findings if f.category == "secure-boot"], []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 4aca41ce0cb2f2f205d9cbf1ff3e3e9d0914bf07 Mon Sep 17 00:00:00 2001 From: DareDev256 Date: Wed, 29 Jul 2026 09:36:00 +0800 Subject: [PATCH 3/3] docs: CHANGELOG for v0.2.0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur --- CHANGELOG.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38db435..25ffb1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,90 @@ All notable changes to the ClickFix Defense Kit are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] — 2026-07-29 + +The kit was born from a breach and, until this release, had nothing for the hour +after one. Repo-wide grep before this release: "revoke" 0 hits, "forwarding +rule" 0, "deploy key" 0, "reinstall" 0. + +### Added — incident response + +- **`INCIDENT.md`** — an ordered runbook, written to be worked from a phone. + The ordering is the deliverable, not the content: + - **Crypto first.** 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 reliably invalidate live sessions — so resetting + first and stopping there leaves the attacker signed in behind a new + password. ExposureScan already knew this and the kit never turned it into a + procedure. + - Revoke OAuth grants (they survive every password change) → passwords, email + first → mail-persistence sweep (forwarding, filters matching + `reset`/`verify`/`code`, send-as aliases, delegated access, app-specific + passwords, recovery address and phone). + - 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 button. + - The reinstall decision as a bright line: *did anything get root?* +- **`panic.sh`** — prints the checklist with no network, no browser and no + dependencies. `--short` fits a phone screen, `--paper` pipes to `lpr`, + `--triage` adds read-only probes that answer the root question with facts. + 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 evidence before remediating. **Defers entirely to + Jamf Aftermath when installed** — free, Swift, purpose-built, collects a + superset. Built-in collector otherwise: 29 artifacts including + `kMDItemWhereFroms` origin URLs for recent downloads, sha256 + `codesign` + verdict per persistence plist, and TCC grants, sealed read-only with a + MANIFEST. + +### Added — ExposureScan + +- **`scan_dev_credentials`** — SSH keys (plaintext vs encrypted, header-read + only), 11 credential files by key name and mode, shell-history token counts by + line number, per-profile cookie counts, crypto wallet stores (20 extensions + + 13 desktop bundles, unconditional P0), Firefox login counts so the report is + not silently Chrome-shaped. +- **`--tcc`** — the grant inventory the README's central argument always implied + and never delivered. P0 for terminals, shells, SSH wrappers and bare + interpreters holding Full Disk Access / Accessibility / Screen Recording, + because those are grant-inheritance vehicles rather than apps. Bundles Secure + Keyboard Entry, remote-access services and Secure Boot level. +- **Deliberately not built:** FileVault, firewall, update settings, sudoers, the + CIS sweep. mSCP and Pareto own that and own it better; they are cited instead. + +### Fixed — WatchPost baseline could be blinded + +- **Deleting `baseline.json` was treated as a first run.** The next run printed + "No diffing on first run" and silently absorbed whatever had just been planted + as legitimate. One `rm` permanently blinded the monitor. An `.armed` marker + now makes deletion an alertable event that refuses to re-baseline without an + explicit `--init`. +- **Editing the baseline directly is now detected** via an HMAC tag — + pre-seeding it with an entry the attacker intends to create later would + otherwise make the real plant diff as already-known. +- Baseline is **0600** in a **0700** directory; it was 0644 and enumerates every + persistence entry on the machine. +- `--no-update` documented as *the* incident flag: a normal run promotes the + baseline and erases the diff that proved something appeared. +- The README states the honest limit — the HMAC key sits beside the baseline + under the same user, so this is tamper-**evidence**, not tamper-proofing. The + root-owned variant that would be proof is named and explicitly not claimed. + +### Tests + +- `tests/test-watchpost-baseline.sh` — 10 checks covering all three blinding + attacks, plus the one that matters most: a genuinely new plant is **still** + reported after the hardening. +- ExposureScan 47 → **72** tests. `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. +- New macOS CI job for the baseline tests. + ## [0.1.1] — 2026-07-29 **Security release. If you are running v0.1.0, upgrade.**