Skip to content

fix(security)!: flag elided audit records, block host-availability commands, ship arm64 (0.8.1) - #59

Merged
blackaxgit merged 4 commits into
mainfrom
fix/audit-log-tripwire-and-arm64
Sep 7, 2026
Merged

blackaxgit merged 4 commits into
mainfrom
fix/audit-log-tripwire-and-arm64

Conversation

@blackaxgit

Copy link
Copy Markdown
Owner

Six issues, all surfaced by end-to-end testing against a live disposable SSH host rather than by the unit suite. 833 tests, 90% coverage, all gates exit 0. Every new guard is proven fail-on-mutation.

Version 0.8.1: nothing here turns a configuration that started on 0.8.0 into one that refuses to start, which is the test 0.8.0 itself recorded for taking the minor.

1. The audit log could silently omit an executed command — HIGH

Redaction replaces from the credential marker to the end of the whitespace-delimited token — which is what stops a partially quoted secret leaking — but shell separators are not whitespace:

sent:   echo --password=X;touch /tmp/CHAINED
logged: echo --password={REDACTED} /tmp/CHAINED     ← "touch" gone
sent:   echo --password=X;reboot
logged: echo --password={REDACTED}                   ← "reboot" gone entirely

Proven, not inferred: the elided touch created its file on the host. The tripwire still saw the real string, so rm -rf / stayed blocked — but anything the tripwire did not cover executed and vanished from the record.

Narrowing the replacement to the separator was rejected: a secret containing ; would then have its tail printed. The value stays fully hidden and the record stops being silent instead — text that could chain a command now yields {REDACTED+ELIDED}.

The marker errs toward flagging: a secret that merely contains ; is flagged too, because distinguishing those needs shell parsing this module refuses to do. A noisy marker is recoverable; a truncated audit record is not. Hypothesis found that false positive immediately, so the property test now derives its expected placeholder from the generated secret — exact equality preserved, not loosened.

Newline and CR were removed from the separator set after asserting them failed: whitespace has already split the token, so they were unreachable branches in a security-relevant predicate.

2. The tripwire covered data destruction, not availability destruction — MEDIUM

BLOCKED       rm -rf /tmp/build-cache    ← routine
reached host  reboot / poweroff / init 0 / systemctl poweroff
reached host  iptables -F                ← severs access to the host
reached host  userdel -r / passwd -l

Inverted priorities for a tool whose execute_on_group fans one command across a fleet. Now covered, anchored on command position rather than the bare word — a plain \breboot\b blocks last reboot and grep reboot /var/log/messages, and blocking read-only diagnostics is how a tripwire teaches operators to route around it. Accepted and documented cost: sudo -n reboot slips past.

_is_dangerous_command now searches two renderings, because collapsing a newline to a space is right for one pattern class and wrong for the other: rm -rf\n/ needs the whitespace reading, a second line reboot needs the command-position one. With the space rendering alone, echo hi\nreboot was measurably not caught.

rm -rf was deliberately NOT narrowed. It blocks any absolute path today, a false positive on rm -rf /tmp/cache — but loosening a security denylist is the owner's decision, not a drive-by, and rm -rf /etc would pass under the narrower form.

3. upload_file on a fifo hung, killing all SFTP — MEDIUM

O_NONBLOCK was missing, so the existing S_ISREG refusal was unreachable for exactly the file type it rejects. Sixteen concurrent fifo uploads exhausted the default executor and every later to_thread call stalled until restart, while execute kept working.

The test asserts the flag rather than uploading a real fifo, and that shape was measured: the end-to-end version goes red and then hangs the run for 120 s, because the blocked executor thread is non-daemon. A mutation that hangs CI is strictly worse than one that fails it.

4. The image is now linux/amd64 and linux/arm64 — MEDIUM

0.8.0 was amd64-only, so docker run failed outright on Apple Silicon and Graviton — verified against the published manifest. Two consequences would otherwise have shipped a green-but-blind gate:

  • Trivy resolves an index to the runner's platform, so one scan step would publish arm64 uninspected. There is now one step per platform via TRIVY_PLATFORM.
  • steps.build.outputs.digest is now an index digest whose children are what imagetools create copies into the tag, so verification derives its expected set from the scanned reference. Hardcoding the digest would have failed every publish run — the same shape as the 2026-07-26 incident, one level down.

5. Typing — LOW

_lifespan was -> Any, producing two real Pyright errors; now -> AsyncGenerator[None]. The three deprecated Iterator forms under @contextmanager became Generator. Fixing the last surfaced a genuine narrowing hole: _transfer_root yielded an int | None attribute instead of a checked local. ssh.py reports zero Pyright errors; server.py is down from seven to two venv-resolution artefacts that mypy resolves.

6. Honest output and one prose correction — LOW

  • An unknown group no longer renders as Executing on group 'x' (1 servers)..., which asserted both that the group existed and that it had a member.
  • A symlink at a download destination now says so, instead of reading as "a regular file is in the way".
  • The 0.7.0 claim that all four security floors protect pip install consumers is corrected: only mcp and asyncssh reach the wheel's METADATA; cryptography and click are uv-only constraints.

Verification

Live-smoked on the real binary: availability blocks fire, last reboot / grep reboot / systemctl status / iptables -L still run, and the audit lines read {REDACTED+ELIDED} exactly where a command was swallowed while an ordinary secret still reads {REDACTED}. Fixture (two containerised sshd instances, throwaway key, sandboxed HOME) torn down; no real host or ~/.ssh was touched.

New mutation-proven guards: the redaction marker (7 cases + the deliberate false positive), 19 availability blocks and 13 must-still-run diagnostics, the newline double-rendering, the multi-arch platform/scan equality, the upload O_NONBLOCK flag, and the group-header shape (including two tests that keep the fix from over-reaching).

Found by end-to-end testing against a live SSH host, not by the unit
suite. `_upload_impl` has always fstat-ed the descriptor and refused
anything that is not a regular file, but for a fifo that refusal was
unreachable: a plain O_RDONLY open blocks until a writer appears, so the
check never ran for exactly the file type it exists to reject. The
existing non-regular test uses a directory, which opens fine and
therefore does reach the guard -- which is why the gap survived.

Measured, not inferred. The open runs in a worker thread, so the event
loop survived and `execute` kept working; the thread did not. Sixteen
concurrent fifo uploads exhausted the default executor (min(32, cpu+4) =
14 on the test box) and every later asyncio.to_thread call -- all SFTP in
the process -- then stalled until restart. Reaching it needs a
non-regular file beneath transfer_root, which is 0700 and owner-only, so
this is a local availability bug, not a remote one. Same defect class
0.8.0 fixed for SSH_MCP_HTTP_TOKEN_FILE; the SFTP path was missed.

The flag is referenced bare rather than via getattr because this
subsystem is POSIX-only by design: _transfer_root() awaits
paths.py::ensure_root first, which fails closed without
O_NOFOLLOW/O_DIRECTORY, so the line is unreachable on a platform lacking
the constant. server.py needs getattr for the opposite reason -- the HTTP
transport has never been documented POSIX-only.

Two tests, and the shape of them was measured rather than assumed. The
obvious end-to-end version -- mkfifo, upload, asyncio.wait_for -- does go
red on mutation and then HANGS the run: the blocked executor thread is
non-daemon, so pytest cannot exit (observed 120s, killed externally). A
mutation that hangs CI is strictly worse than one that fails it, so the
mutation detector asserts the flag reaching open_beneath (red in 0.49s),
and a second main-thread test with a SIGALRM deadline pins the
assumption it rests on -- that O_NONBLOCK is what makes a writer-less
fifo open return.

Verified live after the fix: fifo refused in 0.08s with the documented
message, four concurrent fifos cost nothing, SFTP unaffected.

783 tests, 90% coverage, all gates exit 0.
…mands, ship arm64

Six issues, all found by end-to-end testing against a live disposable SSH
host rather than by the unit suite. Version 0.8.1: nothing here turns a
configuration that started on 0.8.0 into one that refuses to start, which
is the test 0.8.0 itself recorded for taking the minor.

1. The audit log could silently omit an executed command.

Redaction replaces from the credential marker to the end of the
whitespace-delimited token -- which is what stops a partially quoted
secret leaking -- but shell separators are not whitespace. So
`echo --password=X;reboot` was logged as `echo --password={REDACTED}`:
`reboot` was ABSENT from the record while the command ran on the host.
Proven, not inferred: the elided `touch /tmp/CHAINED` created its file.

Narrowing the replacement to the separator was rejected -- a secret
containing `;` would then have its tail printed. The value stays fully
hidden and the record stops being SILENT instead: text that could chain a
second command now yields `{REDACTED+ELIDED}`. The marker errs toward
flagging, so a secret merely containing `;` is flagged too, because
telling those apart needs shell parsing this module refuses to do. A
noisy marker is recoverable; a truncated audit record is not. Hypothesis
found that false positive immediately, which is why the property test now
derives its expected placeholder from the generated secret instead of
loosening to a substring check -- exact equality is preserved.

Newline and CR were dropped from the separator set after asserting them
FAILED: whitespace has already split the token, so they were unreachable
branches in a security-relevant predicate.

2. The dangerous-command tripwire covered data destruction and not
availability destruction.

`reboot`, `poweroff`, `init 0`, `systemctl poweroff`, `iptables -F`,
`nft flush ruleset`, `userdel -r` and `passwd -l` all reached the host,
while a routine `rm -rf /tmp/build-cache` was blocked -- inverted
priorities for a tool whose `execute_on_group` fans one command across a
fleet. Anchored on command position, not the bare word: a plain
`\breboot\b` blocks `last reboot` and `grep reboot /var/log/messages`,
and blocking read-only diagnostics is how a tripwire teaches operators to
route around it. Accepted cost, documented: `sudo -n reboot` slips past.

`_is_dangerous_command` now searches two renderings, because collapsing a
newline to a space is right for one pattern class and wrong for the
other: `rm -rf\n/` needs the whitespace reading, a second line `reboot`
needs the command-position one. With the space rendering alone,
`echo hi\nreboot` was measurably not caught.

`rm -rf` was deliberately NOT narrowed to the real root. It blocks any
absolute path today, which is a false positive on `rm -rf /tmp/cache` --
but loosening a security denylist is a decision for the owner, not a
drive-by, and `rm -rf /etc` would pass under the narrower form.

3. `upload_file` on a fifo hung, killing all SFTP in the process.

Fixed in this branch's first commit, eafb7b3. `O_NONBLOCK` was
missing, so the existing `S_ISREG` refusal was unreachable for exactly
the file type it rejects; 16 concurrent fifo uploads exhausted the
default executor and every later `to_thread` call stalled until restart.
The test asserts the flag rather than uploading a real fifo, and that
shape was measured: the end-to-end version goes red and then HANGS the
run for 120s, because the blocked executor thread is non-daemon.

4. The published image is now linux/amd64 AND linux/arm64.

0.8.0 was amd64-only, so `docker run` failed outright on Apple Silicon
and Graviton -- verified against the published manifest. arm64 builds
under QEMU; a native arm runner plus a merge job is faster but splits one
digest across two jobs, which the promote/scan/verify chain is built
around.

Two consequences that would have shipped a green-but-blind gate. Trivy
resolves an index to the runner's platform, so ONE scan step would have
published arm64 uninspected -- there is now one step per platform via
TRIVY_PLATFORM. And `steps.build.outputs.digest` is now an index digest
whose CHILDREN are what `imagetools create` copies into the tag, so the
verification step derives its expected set from the scanned reference
instead of comparing against that digest; hardcoding it would have failed
every publish run, the same shape as the 2026-07-26 incident one level
down.

5. Typing: `_lifespan` was `-> Any`, which made two real Pyright errors.
Now `-> AsyncGenerator[None]`; the three deprecated `Iterator` forms
under `@contextmanager` became `Generator`. Fixing the last surfaced a
genuine narrowing hole -- `_transfer_root` yielded an `int | None`
attribute instead of a checked local. `ssh.py` reports zero Pyright
errors; `server.py` is down from seven to two venv-resolution artefacts
that mypy resolves.

6. Two honest-output fixes and one prose correction. An unknown group no
longer renders as `Executing on group 'x' (1 servers)...`, which asserted
both that the group existed and that it had a member. A symlink at a
download destination now says so, instead of reading as "a regular file
is in the way". And the 0.7.0 claim that all four security floors protect
`pip install` consumers is corrected: only `mcp` and `asyncssh` reach the
wheel's METADATA; `cryptography` and `click` are uv-only constraints.

833 tests (was 829 on 0.8.0's branch tip), 90% coverage, all gates exit 0.
Every new guard proven fail-on-mutation. Live-smoked on the real binary:
availability blocks fire, diagnostics still run, and the two audit lines
read `{REDACTED+ELIDED}` exactly where a command was swallowed while an
ordinary secret still reads `{REDACTED}`.
…ction rule

Two real defects in the previous commit, both found by CI's 200-example
Hypothesis profile on the first push and neither reachable at the local
50-example dev default. Both needed a generated secret containing `&`.

1. The marker depended on which rule matched. `--…-password=x&` was
flagged and `--…-token=x&` was not, because rule 3a's enumerated env
names (TOKEN, SECRET, …) matched the second one first and used the plain
placeholder -- the flag had only been wired into the two token-wise
sites. Every rule that replaces to the end of a whitespace-delimited run
now goes through `_placeholder_for`: the Authorization header, both
env-var rules, unquoted `-p`, `curl -u`, and `sshpass -p`.

The two bounded rules deliberately keep the plain placeholder. URL
basic-auth terminates at `@` and the quoted `-p'…'` form at its closing
quote, so neither can swallow a following command and flagging them
would be a pure false positive. That is the actual distinction -- not
which credential shape it is, but whether the replacement has a right
edge.

2. A later rule could downgrade the marker. Rule 3a flagged the value
correctly and then `_redact_long_flags` re-matched the same flag and
replaced `{REDACTED+ELIDED}` -- which contains no separator -- with the
plain placeholder. A marker a subsequent rule can erase is worth nothing,
so an existing one is now sticky. The plain placeholder is deliberately
NOT sticky: it carries no claim to lose, and making it so would invent a
marker on re-redaction of an ordinary secret.

An inconsistent marker is worse than no marker, because a reader takes
its absence as proof that nothing was cut.

Process fix recorded in AGENTS.md: `HYPOTHESIS_PROFILE=ci uv run pytest`
before pushing any change to `_redact_secrets` or `_DANGEROUS_PATTERNS`.
The 200-example run costs about a second locally; not running it cost a
red CI on a change whose whole point is audit-record integrity.

848 tests under the ci profile, 90% coverage, all gates exit 0.
Honest asymmetry rather than a silent one: `load: true` cannot load a
multi-platform result, so the PR build stays single-platform and an arm64
break surfaces on `main`. It publishes nothing when it does -- the digest
push is in the same failed step -- but an arch-sensitive change should be
built locally first, which is how 0.8.1's arm64 leg was verified before
merge (builds, reports aarch64, healthcheck exits 0). Both pinned bases
were confirmed multi-arch indexes at the same time; a single-platform
digest there is the failure this would otherwise find late.
@blackaxgit blackaxgit self-assigned this Sep 7, 2026
@blackaxgit
blackaxgit merged commit 1678718 into main Sep 7, 2026
7 checks passed
@blackaxgit
blackaxgit deleted the fix/audit-log-tripwire-and-arm64 branch September 7, 2026 16:49
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