ci: guard that every fail-closed rejection is pinned by a test - #113
Merged
Conversation
Follow-up to pitfall #59, where a resource cap `return`ed instead of raising and shipped a live payload to the sanitised bucket while the whole suite stayed green — nothing asserted the bound was load-bearing. Two parts. Audited every remaining cap's failure direction by running it (monkeypatched low, hostile input, observe the verdict) rather than reading it, since #53 is a docstring that claimed FAIL CLOSED while the code did not. No second instance: _EXTERNAL_REF_SCAN_MAX_NODES, _DecompressionBudget, _MAX_ZIP_ENTRIES, _MAX_ENTRY_BYTES, _MAX_FILE_BYTES, _MAX_IMAGE_FRAMES and _MAX_TOTAL_IMAGE_PIXELS all reject. _SNS_REMOVED_BYTE_BUDGET truncates, which is correct — it caps a report about an already-sanitised file and must never reject one. That exception is why the new guard is mutation-based rather than a grep for `return` inside a bounded loop: the right failure direction depends on what the bound protects, so a syntactic rule would flag the one cap that should truncate. What is not a judgement call is whether a rejection that exists is load-bearing. scripts/check_fail_closed.py mutates each `raise CdrReject` to a silent `pass` and re-runs the suite. All 9 sites currently fail their mutant, so existing coverage was sound; the guard keeps that true for rejections added later. The guard needed two things itself: a baseline green run before any mutant counts (which immediately caught the harness exporting SANITISED_BUCKET/ QUARANTINE_BUCKET against the documented bare-pytest rule), and a negative control proving it detects a deliberately unpinned rejection. src/lambda_function.py is untouched — enforcement only. Recorded as pitfall #60. Co-Authored-By: Claude Opus 5 <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.
Follow-up to #112 (pitfall #59), where a resource cap
returned instead of raising and shipped a live payload to the sanitised bucket while the whole suite stayed green. Nothing asserted the bound was load-bearing, so the tests passed identically with the hole open and closed.Two parts: audit the other caps, then make the property enforceable so it outlives the session that checked it.
Part 1 — every cap's failure direction, audited
Verified by running each one (monkeypatched low, hostile input, observe the verdict) rather than reading it — #53 is a docstring that claimed FAIL CLOSED while the code did not.
_PDF_WALK_MAX_NODESCdrReject_EXTERNAL_REF_SCAN_MAX_NODESreturn True(= external → subtree removed)_DecompressionBudget/_MAX_TOTAL_ENTRY_BYTESCdrReject_MAX_ZIP_ENTRIES_validate_zip_structurehard-fail_MAX_ENTRY_BYTES,_MAX_FILE_BYTES_MAX_IMAGE_FRAMES,_MAX_TOTAL_IMAGE_PIXELSCdrReject_SNS_REMOVED_BYTE_BUDGETNo second instance of the #59 bug.
Part 2 — the guard
That last row is why this is mutation-based rather than a grep for
return/breakinside a bounded loop._SNS_REMOVED_BYTE_BUDGETcaps a report about an already-sanitised file; rejecting there would discard a successful sanitisation over a long removed-list. The right failure direction depends on what the bound protects, so a syntactic rule would flag the one cap that is correct to truncate.What is not a judgement call is whether a rejection that exists is load-bearing.
scripts/check_fail_closed.pymutates eachraise CdrRejectinto a silentpassand re-runs the suite:All 9 currently fail their mutant, so existing coverage was sound — the guard exists so that stays true for rejections added later, which is exactly the path by which #59 shipped.
The guard needed two things not to become the defect it checks for
A baseline green run before any mutant counts. Without it a broken checkout reports every mutant "caught" and passes vacuously (#57). This fired immediately and usefully: my first version exported
SANITISED_BUCKET/QUARANTINE_BUCKET, but the test module defaults them withos.environ.setdefault, which yields to the environment — five tests asserting the literaltest-sanitised/test-quarantinenames failed. The documented "run pytest BARE" rule, rediscovered by writing a tool that broke it.A negative control on the guard itself. Injected a deliberately unpinned
raise CdrReject; the guard named it by line:A checker never shown failing is an assumption.
Notes
src/lambda_function.pyis untouched — enforcement only, no behaviour change.tests.yml; it costs one suite run per rejection (~9 runs).General rule: when a review finds a bug class rather than a bug, the deliverable is the executable check, not the fixed instance. This repo already had that instinct for docs (
check_test_count.py,check_cap_defaults.py,check_pitfalls_index.py,check_iac_parity.py); security invariants deserve it more, because a doc drifting is embarrassing and a fail-open shipping is not.🤖 Generated with Claude Code