test(artifact): cover the RECOTEM_ARTIFACT_ROOT write-time containment call site - #397
Merged
Merged
Conversation
…t call site Removing the _assert_output_root_containment call from _write_atomic passed the whole suite. Measured against the same tree with only that line replaced by `pass`, a directory that becomes a symlink out of the artifact root after recipe load lets write_artifact complete and land bytes outside the root; with the guard it raises ArtifactError and writes nothing. The existing tests missed it two ways. Every other test around this guard calls _assert_output_root_containment directly, so the helper is covered but its use is not. The one test that drives _write_atomic accepts (ArtifactError, OSError), and its own setup rmtree's the directory holding the temp file, so os.replace raises OSError on a tree with no guard at all -- it passed on both arms. Tighten that assertion to ArtifactError matching RECOTEM_ARTIFACT_ROOT and assert nothing landed outside; the guard runs before os.replace, so on a tree that has it the ArtifactError always wins. Add two tests through the real write_artifact entry point -- parent directory symlinked out, and destination file itself symlinked out. The second is not redundant: a guard pointed at the temp path instead of at dest still refuses the first case, because the temp file is created inside the same escaped directory. A control asserts the same call succeeds against a real in-root directory, so the pair cannot be satisfied by a write_artifact that refuses everything. Mutation matrix at the call site: call deleted -> caught (3 tests); guard watches the temp path instead of dest -> caught (1 test); guard runs only when dest already exists -> caught (3 tests); unmutated -> 35 passed.
…OK appears CLAUDE.md said that moving the 4-byte header_len field "still passes verify_hmac (recotem inspect prints HMAC: OK) and is caught one layer later by the header JSON parse or the deserializer". Measured across ten boundary moves on a real signed artifact, with a tripwire on SafeUnpickler.load: - Only the three shrinking moves (header_len lowered, 0 included) reach verify_hmac and print HMAC: OK. The authenticated run is unchanged, so the verify passes; the header JSON parse then fails on the truncated slice. - All seven enlarging moves are refused earlier, inside parse_header_from_bytes, so verify_hmac is never called and HMAC: OK is never printed: the widened header slice swallows payload bytes and fails the UTF-8 decode, or the file is shorter than the claimed header, or the value trips the 64 KiB MAX_HEADER_LEN cap. - The deserializer never catches this. The header JSON parse always fires first, on the CLI path and on the serve path (ModelRegistry._build_entry decodes the header before unpickle_payload). The load-bearing parts held exactly: every move is exit 5, and the tripwire fired only for the untouched control. Only the description of which layer refuses, and of when HMAC: OK is the tell, was wrong. Rewrite that bullet to split the two directions so nobody reads a missing HMAC: OK as a different class of failure.
…he live URL _is_address_internal's docstring pointed a reader at "MAJOR-3 in :doc:`/security`" -- a Sphinx target in the docs/ tree this repo no longer carries, under an internal audit label that never appeared on the published page. It is the last :doc: reference left in src/. Point at https://recotem.org/2.1/docs/security and name the section heading a reader can actually find, matching how every other cross-reference in the tree now cites the site.
The reference added in this branch was written before the dev bump. On the current tree `check-release-tag.sh` section 4b refuses a tag whose scanned roots name a documentation line other than the version being released, and `src` is one of those roots -- so this line would have failed the v2.2.0 tag after the tag already existed. The cited section keeps its wording at the new path: 2.2/docs/security.md carries "IPv4-mapped IPv6 inputs are explicitly unwrapped" as a heading of its own. Also merges current main into the branch so its checks run against the tree this will land on rather than a base 34 commits old.
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.
Milestone 2.2.0 — independent of the v2.1.0 stack; merges cleanly onto it (verified with
git merge-treeagainst the stack tip).Three separate changes:
test(artifact): cover the RECOTEM_ARTIFACT_ROOT write-time containment call site
Removing the _assert_output_root_containment call from _write_atomic passed
the whole suite. Measured against the same tree with only that line replaced
by
pass, a directory that becomes a symlink out of the artifact root afterrecipe load lets write_artifact complete and land bytes outside the root; with
the guard it raises ArtifactError and writes nothing.
The existing tests missed it two ways. Every other test around this guard
calls _assert_output_root_containment directly, so the helper is covered but
its use is not. The one test that drives _write_atomic accepts
(ArtifactError, OSError), and its own setup rmtree's the directory holding the
temp file, so os.replace raises OSError on a tree with no guard at all -- it
passed on both arms.
Tighten that assertion to ArtifactError matching RECOTEM_ARTIFACT_ROOT and
assert nothing landed outside; the guard runs before os.replace, so on a tree
that has it the ArtifactError always wins. Add two tests through the real
write_artifact entry point -- parent directory symlinked out, and destination
file itself symlinked out. The second is not redundant: a guard pointed at the
temp path instead of at dest still refuses the first case, because the temp
file is created inside the same escaped directory. A control asserts the same
call succeeds against a real in-root directory, so the pair cannot be
satisfied by a write_artifact that refuses everything.
Mutation matrix at the call site: call deleted -> caught (3 tests); guard
watches the temp path instead of dest -> caught (1 test); guard runs only when
dest already exists -> caught (3 tests); unmutated -> 35 passed.
docs(claude-md): the header_len boundary claim overstates when HMAC: OK appears
CLAUDE.md said that moving the 4-byte header_len field "still passes
verify_hmac (recotem inspect prints HMAC: OK) and is caught one layer later by
the header JSON parse or the deserializer".
Measured across ten boundary moves on a real signed artifact, with a tripwire
on SafeUnpickler.load:
verify_hmac and print HMAC: OK. The authenticated run is unchanged, so the
verify passes; the header JSON parse then fails on the truncated slice.
parse_header_from_bytes, so verify_hmac is never called and HMAC: OK is
never printed: the widened header slice swallows payload bytes and fails the
UTF-8 decode, or the file is shorter than the claimed header, or the value
trips the 64 KiB MAX_HEADER_LEN cap.
first, on the CLI path and on the serve path (ModelRegistry._build_entry
decodes the header before unpickle_payload).
The load-bearing parts held exactly: every move is exit 5, and the tripwire
fired only for the untouched control. Only the description of which layer
refuses, and of when HMAC: OK is the tell, was wrong. Rewrite that bullet to
split the two directions so nobody reads a missing HMAC: OK as a different
class of failure.
docs(http-fetch): replace the stale :doc:
/securityreference with the live URL_is_address_internal's docstring pointed a reader at "MAJOR-3 in
:doc:
/security" -- a Sphinx target in the docs/ tree this repo no longercarries, under an internal audit label that never appeared on the published
page. It is the last :doc: reference left in src/.
Point at https://recotem.org/2.1/docs/security and name the section heading a
reader can actually find, matching how every other cross-reference in the tree
now cites the site.