fix(security)!: flag elided audit records, block host-availability commands, ship arm64 (0.8.1) - #59
Merged
Merged
Conversation
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.
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.
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:
Proven, not inferred: the elided
touchcreated its file on the host. The tripwire still saw the real string, sorm -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
Inverted priorities for a tool whose
execute_on_groupfans one command across a fleet. Now covered, anchored on command position rather than the bare word — a plain\breboot\bblockslast rebootandgrep 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 rebootslips past._is_dangerous_commandnow 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 linerebootneeds the command-position one. With the space rendering alone,echo hi\nrebootwas measurably not caught.rm -rfwas deliberately NOT narrowed. It blocks any absolute path today, a false positive onrm -rf /tmp/cache— but loosening a security denylist is the owner's decision, not a drive-by, andrm -rf /etcwould pass under the narrower form.3.
upload_fileon a fifo hung, killing all SFTP — MEDIUMO_NONBLOCKwas missing, so the existingS_ISREGrefusal was unreachable for exactly the file type it rejects. Sixteen concurrent fifo uploads exhausted the default executor and every laterto_threadcall stalled until restart, whileexecutekept 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/amd64andlinux/arm64— MEDIUM0.8.0 was amd64-only, so
docker runfailed outright on Apple Silicon and Graviton — verified against the published manifest. Two consequences would otherwise have shipped a green-but-blind gate:TRIVY_PLATFORM.steps.build.outputs.digestis now an index digest whose children are whatimagetools createcopies 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
_lifespanwas-> Any, producing two real Pyright errors; now-> AsyncGenerator[None]. The three deprecatedIteratorforms under@contextmanagerbecameGenerator. Fixing the last surfaced a genuine narrowing hole:_transfer_rootyielded anint | Noneattribute instead of a checked local.ssh.pyreports zero Pyright errors;server.pyis down from seven to two venv-resolution artefacts that mypy resolves.6. Honest output and one prose correction — LOW
Executing on group 'x' (1 servers)..., which asserted both that the group existed and that it had a member.pip installconsumers is corrected: onlymcpandasyncsshreach the wheel'sMETADATA;cryptographyandclickare uv-only constraints.Verification
Live-smoked on the real binary: availability blocks fire,
last reboot/grep reboot/systemctl status/iptables -Lstill 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, sandboxedHOME) torn down; no real host or~/.sshwas 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_NONBLOCKflag, and the group-header shape (including two tests that keep the fix from over-reaching).