Skip to content

v0.4.5: restructure redact(), add property tests, verify every Linux path on real hardware - #3

Merged
deva0x merged 9 commits into
mainfrom
fix/v0.4.5-round3
Jul 25, 2026
Merged

v0.4.5: restructure redact(), add property tests, verify every Linux path on real hardware#3
deva0x merged 9 commits into
mainfrom
fix/v0.4.5-round3

Conversation

@deva0x

@deva0x deva0x commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Follows #2. 24 findings from two more adversarial rounds against v0.4.4, plus a restructure of the function that produced most of them, plus its first property tests. Every finding was reproduced by execution before being fixed and is pinned by a mutation-tested regression test: 72/72 mutations caught. Suite 228 → 463.

The restructure is the headline

redact()'s matcher took the value as (\S.*)$rest of line — and nearly every leak and hidden attack in this project's history followed from that one choice:

  • a "show" decision exempted every later secret on the line
  • a "mask" decision swallowed the rest of the attack
  • the recursive rescan added to patch the first half became an unprivileged kill switch (RecursionError from ~800 credential keys in one line → the digest died before saving a snapshot, so it recurred every day forever)

The value is now a single token (or an atomic quoted run). re.sub continues after each match, so every assignment is decided independently — no recursion, no depth cap, no tail semantics. The property that proves it:

redact("a; b") == redact("a") + "; " + redact("b")     2700 random compositions, 0 mismatches

Property tests found what 239 example tests missed

tests/test_redact_properties.py — stdlib-only with fixed seeds, so no new dependency and failures replay from the printed seed. Properties: no-leak, no-hide, idempotence, totality, bounded cost, marker-independence, pre-filter soundness, compositionality.

They immediately found three real bugs, including redact("") raising IndexErrorline[:1] in "+-" is true for the empty string. A crash on the most trivial input imaginable, in the most-audited function in the tool, after five review rounds.

Fixed

Leaks: //registry.npmjs.org/_authToken=<secret> and https://host/api_key=<secret> (a /-preceded key exempted real assignments) · a token passed as argv to a credential-named script · ;-chained secrets after a shown path · /- and $-leading values under *_FILE/*_PATH keys · sshpass -p, mysql -u root -p, https://<token>@github.com, MYSQL_PWD= · two sudoers exemption bypasses.

Hidden attacks: SSH_AUTH_SOCK/*_ASKPASS/PGPASSFILE hijacks · PasswordAuthentication=yes · sudoers command specs including the account being reset · a payload past BLOB_MAX lost its RED escalation, and the escalation was suppressible by planting one benign decoy comment (flags are now counts) · a malicious LaunchAgent reported signature: Apple-signed — that is the interpreter's signature, and padding argv[0] evaded the scan.

Availability: two kill switches · curl[^\n|]*\| still unbounded and quadratic in line length (20.2 s → 13 ms on 8 MB, at snapshot time) · a planted snapshot filename became the baseline · safe_load validated presence but not types.

The capability guard's own bugs: recover_baselines, added in 0.4.4, recovered ephemeral categories across a privilege mismatch — 27 fabricated ORANGE findings that fired the notification, re-opening the exact flood the guard exists to prevent. run_checked covered half of _mac_brew and none of _linux_packages; CAT_TOOLS stamped brew on Linux where the category is dpkg/rpm/pacman, making the guard a no-op there.

install.sh silently disabled all monitoring: [ -d "$1" ] || return returns 1 for a PATH entry that doesn't exist (/snap/bin without snapd), and set -euo pipefail aborted the installer right after the first snapshot — no prompt, no units, no daily job, no error message.

Verified on real hardware, not just CI

CI's ubuntu runners run the suite; they cannot run install.sh, systemd timers or dpkg. On a real Ubuntu 24.04 box (systemd 255):

systemd --user timer installs, arms (enabled/active, next fire 09:00), and runsResult=success — with its pinned Environment=PATH=
headless guard writes units + prints finishing steps instead of aborting
_systemctl_execstart parses real systemctl show output; a genuine drop-in ExecStart override changes the fingerprint (then restored)
Linux collectors all 8 work — 763 packages, autostart (system) tags, service ExecStart hashes
LOST VISIBILITY fires when dpkg-query breaks, with the right label
undo hints every -- guard correct on the real box

Two of the bugs above (install.sh, and a non-portable test fixture that assumed a non-root runner) were only findable there. The box was left clean — units removed, timer gone, test clones and state deleted.

Upgrade note

Schema 5. Snapshots predating v0.4.4 carry no tool-identity stamp, so brew/npm/pip comparisons are skipped once, with a note, until a new baseline exists.

🤖 Generated with Claude Code

deva0x and others added 9 commits July 25, 2026 12:43
…nments

Found by attacking my own v0.4.4 change (round 3, my pass). The rule "a key
preceded by '/' is a filename component, not an assignment target, so show it"
was too broad: it also exempted genuine assignments whose key happened to follow
a slash. Six shapes printed the secret in cleartext to the terminal, daily.log
and --json, all regressions introduced in v0.4.4 and LIVE in the published tag:

  //registry.npmjs.org/_authToken=<secret>   (a real .npmrc spelling)
  //npm.pkg.github.com/_password=<secret>
  https://host/api_key=<secret>
  /etc/foo/password=<secret>
  source /opt/x/secret=<secret>
  PATH=/usr/bin:/x/token=<secret>

The exemption now requires a WHITESPACE separator, which is the shape it was
written for (`NOPASSWD: /usr/bin/passwd backdoor2026` — a command basename, whose
following token is an argument, not a value). An '=' or ':' after a '/'-preceded
key is still an assignment and redacts unconditionally as before.

The suite passed WITH the leak: it had no case where a '/' sits immediately
before a credential key. Added 11 (each on bare/+/- markers), plus mutations Y1
(widen back to any separator) and Y2 (drop the exemption) — both caught.
Suite 228 -> 239; 44/44 mutations cumulatively.

Co-Authored-By: Claude <noreply@anthropic.com>
The root cause of nearly every redact() bug in this project's history was one
design choice: the matcher's value group was `(\S.*)$` — rest-of-line. From that
followed the "show exempts every later secret on the line" leaks, the "mask
swallows the rest of the attack" hides, and the recursive tail rescan added to
patch the first half, which became an unprivileged RecursionError kill switch.

The value is now a SINGLE token (or a quoted run, atomic), stopping at whitespace
or a shell separator. `re.sub` then continues scanning after each match, so every
assignment on a line is decided INDEPENDENTLY — no recursion, no depth cap, no
tail semantics. The decision table is written out in four numbered rules, each
with the leak or hidden-attack that motivates it. Auth-scheme values (the only
multi-token secrets that occur in practice, `Authorization: Token <t>`) moved to
the shape pass, which is what lets the assignment scan stay single-token.

Verified property: redact("a; b") == redact("a") + "; " + redact("b") over 2700
random compositions. That is precisely what the old design made impossible.

The property tests (tests/test_redact_properties.py, stdlib-only with fixed seeds
— no Hypothesis, so CI is unchanged and failures are replayable) immediately found
three real bugs the 239 example tests missed:
- redact("") raised IndexError: `line[:1] in "+-"` is True for the empty string.
- `-AuthorizedKeysFile .ssh/authorized_keys` was REDACTED while the `+` form was
  shown: '-' is in the key char class, so the marker joined the key and turned the
  SOFT Authorized* directive into a HARD `[_-]auth` match. Classification now
  happens on a marker-free copy of the key; the output keeps the original.
- `CREDENTIAL /t_wvSrE+2I.23-VqI33q` printed in cleartext: the whitespace branch
  still used the loose "starts with / ~ $" test. Now keyword/short/single-class
  only, plus a strict full-string $VAR reference (which is deliberately NOT
  honoured for assignments — `SSHPASS=$ecretPassw0rd` shipped as a leak once).

Properties: no-leak, no-hide, idempotence, totality (never raises on arbitrary
unicode/control input), bounded cost, marker preservation, pre-filter soundness,
no unbounded regex run, no rescan loop, and compositionality. 239 -> 415 tests;
6 structural mutations of the new design all caught; the 31-case adversarial
battery (every historical finding, both directions) passes with 0 failures.

Co-Authored-By: Claude <noreply@anthropic.com>
…-time stall

Third adversarial round, against the code the second round produced. Every finding
reproduced by execution and pinned to a revision as control. The restructure had
already fixed the reviewer's worst case (a `--api-key=` inside a sudoers grant);
these are what survived it.

LEAKS
- A token passed as argv to a credential-named script printed in cleartext:
  `*/5 * * * * /opt/bin/refresh_token <secret>`, `/etc/foo/api_key <secret>`.
  The path-component exemption is REMOVED. There is no structural difference
  between that and `/tmp/token_stealer.sh <host>`, so the tie is broken toward not
  leaking: only the immediately following token is masked, and the script path plus
  anything after it survive. The case the exemption was added for
  (`NOPASSWD: /usr/bin/passwd backdoor2026`) is unaffected — the sudoers rule
  consumes the command path as its value, so the account name is never scanned.
- `_SUDO_TAG_RE` was case-insensitive, so a lowercase token posed as "a further
  tag" and exempted the rest of the line. Sudoers tags are uppercase by spec.
- `_SUDO_CMD_RE`'s lookahead accepted `ALL=` and `ALL:`, so `NOPASSWD: ALL=<secret>`
  was treated as a command spec and exempted.

DETECTION
- The per-line 4096 cap in malicious_hits re-opened the hole blob_flags exists to
  close: a payload after 300KB of padding ON ONE LINE was never flagged, so past
  BLOB_MAX the finding lost RED and its "why" entirely. The scan now CHUNKS with a
  512-byte overlap (> the longest bounded run) instead of truncating.
- `SSH_ASKPASS=/evil` was masked: a single-segment absolute path is a real hijack
  shape. Allowed when its entropy is low; `SECRET_FILE=/hunter2Xyz9` still masks.

PERFORMANCE
- `curl[^\n|]*\|` and `wget[^\n|]*\|` were still unbounded — `[^\n|]*` is
  unbounded in exactly the way `.*` is, it merely excludes two characters — and so
  quadratic in LINE LENGTH: 8MB of 4096-column `curl ` lines cost 20.2s at SNAPSHOT
  time, before anything is saved, from one appended line. Each pattern now carries
  a required literal checked with str.find before the regex runs: 20183ms -> 13ms
  at 8MB, with detection parity verified on all 8 real payloads.

Two of my own tests let PERF-1 through and are replaced: the linearity test used a
single 600KB line, which the per-line cap truncated to nothing (it now uses
multi-line 4096-column payloads), and the "no unbounded runs" test only checked for
`.*`. In their place: every pattern must have a required literal, each literal must
appear in its own pattern source, removing the literal from a payload must kill the
match, and the chunk overlap must exceed the longest bounded run.

Suite 415 -> 426; mutations R1-R7 (revert each fix) all caught, 57/57 cumulative.

Co-Authored-By: Claude <noreply@anthropic.com>
…tches, 3 half-fixes

Second half of round 3, against the guard machinery round 2 introduced. Every
finding reproduced by execution with v0.4.3 and v0.4.4-r1 as controls.

The worst is mine twice over: `recover_baselines`, added to stop a blind day from
becoming its own baseline, recovered EPHEMERAL categories across a PRIVILEGE
mismatch. A 3-day-old root-taken `listening` set produced 27 fabricated ORANGE
findings and fired the notification — re-opening the precise phantom flood the
capability guard exists to prevent, and bypassing the euid guard including the
unstamped case it deliberately fails closed on. Recovery is now restricted to
durable inventory (cls == "software", never PRIV_SENSITIVE_CATS), requires a
matching and STAMPED privilege level, and may not reach forward of the requested
baseline (with `--since 8d` it recovered a snapshot NEWER than the baseline, hiding
a change inside the window while claiming it had compared).

Also fixed:
- a non-container `blob_flags` VALUE was an uncaught TypeError: no snapshot saved,
  same poisoned baseline re-read, dead every day. Fourth instance of that class in
  this release; the outer dict was guarded, the value was not.
- the flag escalation was suppressible by poisoning the baseline: flags are pattern
  DESCRIPTIONS and `curl|sh` shares one with `wget|sh`, so a single benign decoy
  comment marked it present forever. Flags are now COUNTS; any increase escalates.
- `curl[^\n|]*` / `wget[^\n|]*` were still unbounded, so >4KB of curl arguments
  pushed the `|` past every scan window and defeated the chunked scan. Bounded.
- `run_checked` covered only HALF of _mac_brew (the cask list) and NONE of
  _linux_packages, so the phantom flood stayed fully reachable for casks and for
  every package on Linux.
- CAT_TOOLS stamped `brew` on Linux, where that category is dpkg/rpm/pacman — so
  the guard was a no-op there: no LOST VISIBILITY when the package manager broke,
  and recovery could never fire for the most important inventory category.
- a malicious LaunchAgent still reported `signature: Apple-signed` (that is the
  INTERPRETER's signature); now it names the interpreter and says the signature is
  not meaningful. The argv is scanned in full via malicious_hits — padding argv[0]
  past the old 4KB cap had evaded the scan entirely.
- dead `skip` reassignment removed; a recovered category no longer emits both
  "comparison skipped" and "compared against", so the all-clear stops claiming
  something could not be compared when it was successfully recovered.

A NameError I introduced while fixing the above (a note reading `recovered` before
assignment) survived a green 454-test run because NOTHING drove cmd_diff with a
skipped category. There are now CLI-level tests that do.

Four more of my tests were theater, caught by the matrix: the unchecked-run scan
matched only `run([`, the package-tool test asserted a tautology on macOS, and the
blob_flags cases never put a non-int under a MATCHING description key. All four
now discriminate.

Suite 426 -> 454; mutations G1-G10 all caught, 67/67 cumulative.

Co-Authored-By: Claude <noreply@anthropic.com>
The three deferred items, all pre-existing since v0.4.3 and all in the same
crash-permanence class this release has now fixed five times:

- `safe_load` checked that `created`/`epoch` were PRESENT, not their types. An int
  `created` reaches datetime.fromisoformat in render() and a non-str blob value
  reaches .splitlines() in build_findings — both raise out of an unisolated path,
  so no snapshot is saved and the same bad file is re-read tomorrow. Types are now
  validated and blob values filtered.
- A snapshot claiming a FUTURE epoch is refused: it is a planted or clock-broken
  file, never a baseline.
- Snapshots are ordered by FILENAME, and the state dir is user-writable, so a
  planted `99999999T999999-9999999999.json` sorted last and simply became the
  baseline — the attacker choosing what "unchanged" means. Names must now match the
  format we write, and the future-epoch check covers the all-nines case that is
  syntactically legal. Both defenses are needed; the test asserts the OUTCOME
  (which baseline resolve_baseline picks) rather than one layer.

Also fixed two test fixtures that used unrealistic snapshot names (`-1.json`),
which the name validation correctly rejected.

Suite 454 -> 461; mutations H1-H4 caught, 71/71 cumulative.

Co-Authored-By: Claude <noreply@anthropic.com>
Found on the real Linux box, not by CI. The fixture hardcoded root=False, but the
suite runs as root on a VPS, so the recovery privilege gate added earlier in this
release correctly refused the synthetic baseline and the test failed. The gate is
right; the fixture was not portable. It now defaults to since.IS_ROOT.

Co-Authored-By: Claude <noreply@anthropic.com>
Found on the real Linux box; neither CI nor my container check could see it. In the
JOB_PATH rewrite I wrote `[ -d "$1" ] || return`, which returns status 1 when a PATH
entry does not exist as a directory — /snap/bin on a box without snapd. That is a
simple command in a for-loop body, so `set -euo pipefail` aborted install.sh
immediately after the first snapshot: no prompt, no units, no daily job, exit 1,
and no error message. Every `since` install on such a machine silently ended up
with no monitoring.

My earlier "verification" of JOB_PATH ran the loop as a standalone `bash -c`
snippet where the trailing exit status was discarded, which is exactly why it
looked fine. _add_dir now returns 0 on every path.

Co-Authored-By: Claude <noreply@anthropic.com>
…oring

A shell bug, but it aborted the installer under set -e right after the first
snapshot on any box with a missing PATH entry — no prompt, no units, no daily job,
no error message. That is a total loss of monitoring, so it gets a regression test:
_add_dir is extracted from install.sh and run under `set -euo pipefail` against a
missing dir, a present dir, a duplicate and a relative path, asserting exit 0; plus
`bash -n` and a check for the exact `|| return` shape that caused it.

Verified by mutation: reverting install.sh to `[ -d "$1" ] || return` fails it.

Co-Authored-By: Claude <noreply@anthropic.com>
…ied on a real box

Bumps to 0.4.5 with the full CHANGELOG entry for rounds 2 and 3 of the self-review
(24 findings), the redact() restructure and its property tests, and the real-Linux
verification that discharges the three items outstanding since v0.4.0/v0.4.4.

Suite 228 -> 463 (287 example-based + 176 property); 72/72 mutations caught.

Co-Authored-By: Claude <noreply@anthropic.com>
@deva0x
deva0x merged commit c612ceb into main Jul 25, 2026
6 checks passed
@deva0x
deva0x deleted the fix/v0.4.5-round3 branch July 25, 2026 08:58
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