v0.4.4: security release — 24 issues found by reviewing v0.4.3 and then v0.4.4 itself - #2
Merged
Merged
Conversation
Kimi's third round found nothing, so I reviewed v0.4.3 myself: mutation-tested its own regression tests, then ran two independent adversarial reviewers and re-verified every finding by execution. 24/24 mutations caught (revert any fix, its test fails). Suite 129 -> 173. Most of these sat in an architectural blind spot, not in one function: - every audit round hardened the SNAPSHOT boundary; diff-time enrichment had no isolation and no input validation (main catches only KeyboardInterrupt); - redact()'s "show" paths exempted the entire rest of the line; - undo hints were quoted for the shell, not for the invoked program's own option parser. CRITICAL - the tool's own remediation advice could execute attacker code: - osascript re-parsed the login-item name as ITS options, so a login item named `-e property zz : (do shell script "...")` ran at load: the "delete this login item" hint executed the malware author's command while the delete no-opped on an empty argv (verified with a benign marker). Every hint whose value can start with '-' now ends option parsing with `--`. HIGH: - one planted plist killed the daily digest permanently: a valid plist with a non-dict root made plutil emit ["x"], .get raised, nothing isolated it, and NO snapshot was saved - so it stayed "added" and failed identically every day. - the daily job was blind to software changes AND the flood hid real installs: launchd's PATH has no /opt/homebrew/bin or ~/.local/bin, so brew went 192->0 with no error recorded; 212 phantom findings pushed a genuine new package past the render cap. Fixed with need() tool declarations, a capability guard in the diff (tool identity is now stamped, schema 4), and a pinned PATH in both units. A transient `brew list` timeout caused the same flood, so the guard is the fix. - difflib was quadratic on a planted rc file (32s from a 0.74MB .zshrc). - redact() fully concealed SSH_AUTH_SOCK / *_ASKPASS / PGPASSFILE hijacks. - the v0.4.3 sudoers PASSWD: carve-out was half-done (tag chains, comma lists). - three cleartext leaks: `sshpass -p secret`, https://<PAT>@github.com, MYSQL_PWD=. MEDIUM/LOW: codesign blocked 10s per planted FIFO .app; three state-dir reads still hung on a FIFO; a "show" decision leaked a later --api-key= on the line; Key=value sshd directives were redacted; every cask undo hint named a nonexistent formula; /Library/LaunchAgents got an rm that cannot succeed; a wrong-type collector value crashed the diff; blobs capped (9.6GB -> 293MB). SECURITY.md's PATH paragraph was wrong by omission - it sold the job's minimal PATH as pure safety when it also caused the blindness. It now states the trade. Verified: 173 tests; 24/24 mutations; 46/46 adversarial redact cases both directions, no perf regression; pinned-PATH snapshot parity (0 differences, 0 phantom findings); every subcommand end-to-end on a real machine; py3.9 syntax. Errors corrected mid-work are recorded in PENDING.md - notably my first PATH fix resolved a different npm than the shell, and its rewrite used bash-4 syntax that silently no-ops on macOS's bash 3.2, which would have re-created the blindness. Upgrade note: pre-v0.4.4 snapshots carry no tool stamp, so brew/npm/pip comparisons are skipped once, with a note, until a new baseline exists. Co-Authored-By: Claude <noreply@anthropic.com>
Found by reviewing the capability guard added earlier in this release. The guard correctly stops the phantom-removal flood, but reported the skip as a passive `note:` — and measurement showed neither the old 50-line flood nor the note ever reaches the --notify threshold (both below ORANGE). An attacker who breaks `brew`, or swaps the binary so the tool-identity stamp differs, therefore bought silence for their own install: strictly worse than the noisy flood it replaced. A category that was visible in the baseline and is not now yields an ORANGE "LOST VISIBILITY" finding that ranks under "worth a look" and fires the notification. Benign cases stay quiet notes: a first run, a tool absent in both snapshots, and the one-time pre-v0.4.4 tool-stamp transition — all three verified, plus mutation tests V23/V24 (report-as-note, and cry-wolf-on-every-skip both get caught). Suite 173 -> 176. Also recorded: the Linux undo-hint `--` guards are now verified in a throwaway ubuntu:24.04 container rather than assumed — systemctl and modprobe reject the crafted name as an option WITHOUT `--` and accept it as a name WITH it, and apt-get parses a leading-dash arg as a package name. I first concluded `--` broke flatpak, from a single test using an invalid leading-dash id; a normal id disproved it (flatpak rejects such names by design). No code change needed there. Co-Authored-By: Claude <noreply@anthropic.com>
Second route into the same suppression gap, found by re-attacking the fix for the first one. The guard reported `a different tool answered: /opt/homebrew/bin/brew vs ~/.local/bin/brew` as a passive note, and since install.sh must pin a job PATH that includes user-writable directories (that is the only way the job can see brew/npm/pip at all), planting a shim there is the cheapest suppression available: no error is raised, the tool identity simply changes, the category is skipped, and nothing ever reaches the notify threshold. A swapped tool is now lost coverage exactly like a vanished one: ORANGE "LOST VISIBILITY" that ranks and notifies. Benign cases still stay quiet notes - a first run, a tool absent from both snapshots, and the one-time pre-v0.4.4 tool-stamp transition (a Homebrew reinstall or python upgrade will fire it once, which is worth one look). Verified in all four directions; mutations V25 (treat a swap as benign) and V26 (cry wolf on the pre-stamp transition) are both caught. Suite 176 -> 177, 28/28 mutations cumulatively. Co-Authored-By: Claude <noreply@anthropic.com>
Re-attacked v0.4.4 before publishing it (two adversarial reviewers plus my own passes), then verified every claim by execution against v0.4.3 as control. The fix batch had introduced its own problems, one of them worse than anything it fixed. Suite 177 -> 221; 40/40 mutations caught cumulatively. CRITICAL - the new redact() tail rescan was an unprivileged kill switch. It recursed once per credential-ish key on a line, so `# ` + `_pwd ` x819 (2.5KB, UNDER _REDACT_MAX so the cap did not help) raised RecursionError. Nothing catches it: the digest died before saving a snapshot, the line stayed "added", and every later run died identically. One line appended to ~/.zshrc disabled the tool forever - the same self-perpetuating class I had just fixed for plists, and cheaper to trigger. The rescan is now depth-bounded and fails SAFE (redact); cost is ~1.4x v0.4.3 instead of quadratic. Also fixed: - a skipped category no longer becomes its own baseline (the blind day's snapshot did, so an install during the blind window was never reported by any run, while the report said "Nothing changed"); it is now diffed against the newest earlier snapshot with the same tool, and the all-clear no longer lies. - need() proved a tool RESOLVES, not that it RAN, so the `brew list` timeout that motivated the guard still produced the flood; collectors use a checked runner. - the guard crashed on a wrong-typed tools/errors field in a baseline - the same class fixed twice already in this release - with no snapshot saved, forever. - the blob storage cap silently disabled RED escalation: a payload past BLOB_MAX never reached the diff text. Malicious patterns are now scanned over the full content at snapshot time and diffed as flags (schema 5). - plutil was handed unbounded input (500MB plist -> 2.27GB RSS); now size-gated. - a malicious LaunchAgent was labelled "Apple-signed" because ["/bin/sh","-c", "curl ...|sh"] resolves to /bin/sh, which is; the argv is now scanned and outranks the interpreter's signature, and an overwritten existing plist is trust-checked instead of being a quiet YELLOW hash change. Three of my new tests were theater and the mutation matrix caught them: two asserted behaviour that a different fix also provided, and one tested a helper without pinning that the collectors use it. Fixed by adding the cases that actually discriminate. Verified: 221 tests; 40/40 mutations; 43/43 adversarial redact cases both directions; kill-switch payload now completes and saves; snapshot parity with the previous commit (0 collector differences); every command e2e on a real machine; py3.9 syntax; py_compile; bash -n. Co-Authored-By: Claude <noreply@anthropic.com>
…ratic)
Found by measuring the round-2 fix instead of trusting it. Scanning MALICIOUS_
PATTERNS over full file content (so a payload past BLOB_MAX still escalates to
RED) fed two unbounded `.*` runs up to 8MB, and those are quadratic in the number
of trigger tokens on a single line: repeated `base64 -d ` measured 4x tokens ->
16x time, 55s at 375KB, extrapolating to hours at MAX_READ — at SNAPSHOT time,
before anything is saved, i.e. another silent watchdog death from one planted
line. Latent in the pre-v0.4.4 diff-text escalation path as well.
Both patterns now use bounded runs ([^\n]{0,400}?) — a real decode-and-run chain
is adjacent, not megabytes apart — and the scan is line-wise with a per-line cap,
so a future pattern with an unbounded run cannot reopen it. Flat ~3ms regardless
of token count (was 55s), 8MB benign file 505ms, 8MB adversarial single line 4ms,
and every real payload still detected. A test asserts no pattern contains `.*`.
Suite 221 -> 227; mutations X1 (unbound the pattern) and X2 (scan the whole blob
at once) both caught.
Co-Authored-By: Claude <noreply@anthropic.com>
… other half CI caught this, my local gate could not: test_interpreter_argv_escalates_over_ its_signature parses a real plist through `plutil`, which does not exist on Linux, so all three ubuntu jobs failed while all three macOS jobs passed. The CODE is correct on Linux — a systemd unit key is not a regular file, so the size/shape gate short-circuits before plutil is ever invoked (0 subprocesses) — but the test was not portable. Skipped when plutil is absent, and the platform-independent half of that fix (an overwritten EXISTING startup item is escalated instead of being a quiet YELLOW hash change) now has its own test that runs everywhere, so Linux does not lose coverage of it. Lesson for the record: I verified py3.9 SYNTAX locally and called the gate green, but the gate command only ever ran on macOS. Cross-platform behaviour is CI's job, and a green local run says nothing about it. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #1. Fixes 24 issues, every one reproduced by execution before being fixed and pinned by a regression test that was mutation-tested (revert the fix → confirm the test fails: 42/42 caught). Suite 129 → 227.
Two-thirds of these were found not in the original code but in the fix batches themselves — the first review round found 16 issues in v0.4.3, and re-attacking those fixes before publishing found 8 more, including a worse bug than any of them.
Root causes, not just symptoms
_enrichwas not, andmaincatches onlyKeyboardInterrupt.redact()'s "show" decisions exempted the entire rest of the line; its=/:branch redacted unconditionally.CRITICAL
The tool's own remediation advice executed attacker code.
osascriptre-parsed the login-item name as its options, so an item named-e property zz : (do shell script "…")ran at load: the "delete this login item" hint executed the malware author's command while the delete silently no-opped on an emptyargv(verified with a benign marker file). Every hint whose value can begin with-now ends option parsing with--, verified per tool —systemctl/modprobe/apt-getin a throwawayubuntu:24.04container,rm/npm/pip3/osascriptnatively.The
redact()rework introduced an unprivileged kill switch (round 2). Its tail rescan recursed once per credential-ish key, so a 2.5 KB comment of repeated_pwd— under the 4 KB cap — raisedRecursionError. Nothing catches it: the digest died before saving a snapshot, so the planted line stayed "added" and every later run died identically. One line appended to~/.zshrcdisabled the tool forever.HIGH
plutilemit["x"];.geton that raised, unisolated, saving no snapshot — so it recurred every day./opt/homebrew/bin, sobrewwent 192 → 0 with no error recorded: 212 phantom findings, and a genuine new package was pushed past the 40-line render cap and never shown.brew— or planting~/.local/bin/brew, which the pinned job PATH must include — bought silence. It is now an ORANGELOST VISIBILITYfinding that ranks and notifies.difflibwas quadratic on a planted rc file (32 s from a 0.74 MB~/.zshrc), and the malicious-pattern scan was quadratic too (55 s at 375 KB, hours at 8 MB, at snapshot time).redact()concealed path-valued env hijacks (SSH_AUTH_SOCK,*_ASKPASS,PGPASSFILE) and, after the first fix round, leaked;-chained secrets and/-leading ones.sshpass -p 'secret',https://<token>@github.com/,MYSQL_PWD=,mysql -u root -pX.MEDIUM / LOW
codesignblocked 10 s per planted FIFO.app· three state-dir reads hung on a FIFO · a malicious LaunchAgent was labelled "signature: Apple-signed" because["/bin/sh","-c","curl …|sh"]resolves to/bin/sh· an overwritten existing plist was a quiet YELLOW hash change · every cask undo hint named a nonexistent formula ·/Library/LaunchAgentsgot anrmthat cannot succeed · wrong-typed snapshot fields crashed the diff (twice) ·plutilhanded 500 MB → 2.27 GB RSS · blobs capped (9.6 GB → 293 MB) · sudoersPASSWD:NOEXEC:andALL, !/usr/bin/suwere redacted away.How it was verified
py_compile;bash -n install.sh; py3.9 syntax checked.Upgrade note
Snapshots predating this release carry no tool-identity stamp, so
brew/npm/pipcomparisons are skipped once, with a note, until a new baseline exists — the same fail-closed transition the privilege stamp used. Schema 3 → 5.🤖 Generated with Claude Code