Skip to content

v0.4.4: security release — 24 issues found by reviewing v0.4.3 and then v0.4.4 itself - #2

Merged
deva0x merged 7 commits into
mainfrom
fix/v0.4.4-self-review-findings
Jul 25, 2026
Merged

v0.4.4: security release — 24 issues found by reviewing v0.4.3 and then v0.4.4 itself#2
deva0x merged 7 commits into
mainfrom
fix/v0.4.4-self-review-findings

Conversation

@deva0x

@deva0x deva0x commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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

  • Every prior audit round hardened the snapshot boundary; diff-time enrichment had no isolation and no input validation. Collectors are individually failure-isolated; _enrich was not, and main catches only KeyboardInterrupt.
  • redact()'s "show" decisions exempted the entire rest of the line; its =/: branch redacted unconditionally.
  • Undo hints were quoted for the shell but not for the invoked program's own option parser.
  • A guard that suppresses noise silently becomes a suppression primitive for an attacker.

CRITICAL

The tool's own remediation advice executed attacker code. osascript re-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 empty argv (verified with a benign marker file). Every hint whose value can begin with - now ends option parsing with --, verified per tool — systemctl/modprobe/apt-get in a throwaway ubuntu:24.04 container, rm/npm/pip3/osascript natively.

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 — raised RecursionError. 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 ~/.zshrc disabled the tool forever.

HIGH

  • One malformed plist killed the daily digest permanently. A valid plist with a non-dict root made plutil emit ["x"]; .get on that raised, unisolated, saving no snapshot — so it recurred every day.
  • The daily job was blind to software changes, and the flood hid real installs. launchd's PATH lacks /opt/homebrew/bin, so brew went 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.
  • A skipped category became its own baseline — an install during the blind window was never reported by any run while the report said "Nothing changed. 🎉".
  • Losing sight of a category was silent. Neither the flood nor a note ever reached the notify threshold, so breaking brew — or planting ~/.local/bin/brew, which the pinned job PATH must include — bought silence. It is now an ORANGE LOST VISIBILITY finding that ranks and notifies.
  • difflib was 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.
  • Cleartext leaks in tracked files: sshpass -p 'secret', https://<token>@github.com/, MYSQL_PWD=, mysql -u root -pX.

MEDIUM / LOW

codesign blocked 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/LaunchAgents got an rm that cannot succeed · wrong-typed snapshot fields crashed the diff (twice) · plutil handed 500 MB → 2.27 GB RSS · blobs capped (9.6 GB → 293 MB) · sudoers PASSWD:NOEXEC: and ALL, !/usr/bin/su were redacted away.

How it was verified

  • 227 tests; 42/42 mutations caught. Three of my own new tests were theater and the matrix caught them — two asserted behaviour a different fix also provided, one tested a helper without pinning that callers use it.
  • 43/43 adversarial redaction cases pass in both directions (leak and hide), diffed against v0.4.3 as control to separate regressions from pre-existing gaps.
  • The kill-switch, blind-job, three-day-blind-window, FIFO and sparse-file scenarios all reproduced first, then re-run after the fix.
  • Snapshot parity: 0 collector differences against the previous commit, so none of the read-path changes altered output.
  • Every subcommand end-to-end on a real machine; py_compile; bash -n install.sh; py3.9 syntax checked.

Upgrade note

Snapshots predating this release carry no tool-identity stamp, so brew/npm/pip comparisons 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

deva0x and others added 7 commits July 25, 2026 11:42
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>
@deva0x
deva0x merged commit 23ae93a into main Jul 25, 2026
6 checks passed
@deva0x
deva0x deleted the fix/v0.4.4-self-review-findings branch July 25, 2026 07:10
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