fix(content-marking): build_assertion and verify_assertion crash on non-object record_bytes - #227
Conversation
Signed-off-by: rajnisht7 <rajnishtiwari9787@gmail.com>
f6f46a7 to
6ce6255
Compare
lywinged
left a comment
There was a problem hiding this comment.
Approving. I checked the defect, the fix, and the tests separately rather than reading the argument, since a fix for "raises the wrong exception type" is easy to write in a way that passes its own tests without closing the path.
The defect reproduces on main. All seven inputs escape the documented contract:
build_assertion(b"[1,2,3]" / b'"s"' / b"42" / b"null" / b"true") -> AttributeError
verify_assertion(hash-matching non-object body) -> AttributeError
verify_assertion(hash-matching non-JSON body) -> JSONDecodeError
The fix closes all seven, each to ContentMarkingError.
The tests are load-bearing. Running this PR's tests/test_content_marking.py against main's source gives 7 failed, 17 passed. They fail for the reason claimed, not incidentally. Ruff clean, 24 pass on the branch.
The docstring on test_verify_assertion_refuses_non_object_record_at_the_url is the part I would keep verbatim. It explains why the path stays reachable after build_assertion refuses to produce such bytes, which is the question a reader asks next, and the answer is the right one: verify_assertion is written against the spec rather than against this module's own producer.
One gap of the same class, in the same function
build_assertion validates the type of record_bytes:
if not isinstance(record_bytes, bytes | bytearray) or not record_bytes:
raise ContentMarkingError(...)verify_assertion does not, and on this branch:
record_bytes |
result |
|---|---|
"a string" |
TypeError |
None |
TypeError |
["x"] |
TypeError |
5 |
RecordMismatch |
The last row is the one worth fixing before the others. bytes(5) is b"\x00\x00\x00\x00\x00", so five zero bytes get hashed and compared, and the caller is told:
the record at https://example.com/r.json does not match the assertion: computed sha256:8855508a... The record changed after the asset was signed, or the URL is serving a different one.
Nothing is wrong with the record or the URL. The caller passed an int. This is worse than the AttributeError this PR removes: a crash says the call was wrong, this says someone else's server is serving a different record, and it says so specifically, with a digest attached.
It is one line, in the function this PR is already editing, and the sibling function already has it.
Nit: the changelog entry does not parse
Valid JSON is not always an object that is an array, a string, a number,
`null`, and a bool are all valid top-level JSON and `record_bytes` is ...
"an object that is an array" states the opposite of the point. The line contains no dashes and carries a leftover double space at "JSON and", where the sentence evidently read:
... not always an object - an array, a string, a number, `null`, and a bool
are all valid top-level JSON - and `record_bytes` is ...
Worth fixing before it ships in a release.
Generated by Claude Code
…ument (#6) Both dereferenced their first argument with .get(...) before establishing it was a mapping. Measured across twelve non-object inputs, eleven left each function as AttributeError, which is not the ValueError either one documents and is therefore missed by the except ValueError a caller writes against the contract. verify_record's argument is the one input in this library untrusted by definition: json.loads of an attacker-supplied body returns a list, a string, a number or None as readily as a dict, and every one of those made the rejection branch unreachable, raising past the caller instead of returning a failing verdict. Twenty-six tests, each shown load-bearing: twenty-five fail with the guards removed, and the ordering test fails when the guard is hoisted above the accepted_profiles check. Re-run of the fuzz that found this reports 0 of 12. provenance.* and content_marking.verify_assertion are the same class and are deliberately excluded: open PRs agentrust-io#225 and agentrust-io#227 are editing those functions. 961 passed, 1 skipped. Ruff clean.
imran-siddique
left a comment
There was a problem hiding this comment.
Same class as #225 and right for the same reason: a documented exception contract that malformed input could route around.
json.loads() returning a non-dict is the case here, and verify_assertion is the one that matters. Its own docstring says record_bytes are "the record bytes actually retrieved from its URL", so the input is a network response the caller does not control. An array, a string, a number, null and a bool are all valid top-level JSON.
Two things worth calling out:
The second parse in verify_assertion, after the hash check, was not wrapped the way its build_assertion sibling already was. So bytes that are not JSON at all but happen to hash-match the declared digest raised JSONDecodeError. Narrow, and exactly the input an attacker who controls the URL would reach for.
The error message distinguishes this from RecordMismatch explicitly: it matched the declared hash, so this is what the record is at that URL, not a mismatch between two records. Getting that boundary right matters more than the fix itself, because RecordMismatch is what a caller acts on differently.
CI green. Tests cover each JSON shape.
Conflict was CHANGELOG.md only: this branch and agentrust-io#225 each added a Fixed entry next to the TraceAGTAdapter one. Kept all three. Took main's copy of the shared TraceAGTAdapter line rather than this branch's. The dash sweep in agentrust-io#230 rewrote it there, and the repository now has a CI check banning em dashes, so the branch's copy would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NL8o3PXq6kfs2SdmBv6ak
imran-siddique
left a comment
There was a problem hiding this comment.
Same class as #225 and right for the same reason: a documented exception contract that malformed input could route around.
json.loads() returning a non-dict is the case, and verify_assertion is where it matters. Its own docstring says record_bytes are "the record bytes actually retrieved from its URL", so the input is a network response the caller does not control. An array, a string, a number, null and a bool are all valid top-level JSON.
Two things worth calling out:
The second parse in verify_assertion, after the hash check, was not wrapped the way its build_assertion sibling already was. So bytes that are not JSON at all but happen to hash-match the declared digest raised JSONDecodeError. Narrow, and exactly what someone controlling the URL would reach for.
The error message distinguishes this from RecordMismatch explicitly: it matched the declared hash, so this is what the record is at that URL, not a disagreement between two records. That boundary matters more than the fix, because RecordMismatch is what a caller acts on differently.
I merged main in to clear a CHANGELOG conflict created by #225 landing first, and took main's copy of the shared line so the new no-dashes check passes. Nothing of yours changed.
Conflict was CHANGELOG.md only: agentrust-io#225, agentrust-io#227 and this branch each added a Fixed entry next to the TraceAGTAdapter one. Kept all four. Took main's copy of the shared TraceAGTAdapter line, since the dash sweep in agentrust-io#230 rewrote it there and CI now bans em dashes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014NL8o3PXq6kfs2SdmBv6ak
Completes what #6 began and deferred while upstream PRs agentrust-io#225 and agentrust-io#227 were editing those functions. Those landed. agentrust-io#229 closed provenance.tool_catalog_hash; the other three were untouched, because _as_object guards fields inside a record and no guard on a field can reach the record's own type. provenance.verify_record and provenance.check_tool_catalog leaked 11 AttributeErrors of 12 non-object inputs apiece, which is not the ProvenanceError verify_record documents. content_marking.verify_assertion never checked that record_bytes were bytes, and bytes(5) is five zero bytes, so an int was hashed, failed to match, and the caller was told the record at the URL had changed: a specific and false accusation about somebody else's server. Every public entry point in the package now reports zero leaks under the sweep. Thirty-two tests, each shown load-bearing by removing the guard it covers (12, 11 and 10 failures). Also repairs the changelog sentence the agentrust-io#230 em dash sweep broke into "an object that is an array", which check_dashes.py cannot catch because the removal was clean and the sentence is what broke. 1031 passed, 1 skipped. Ruff clean.
What this changes
content_marking.build_assertion() and content_marking.verify_assertion() both call .get(...) on the result of json.loads(record_bytes) without first checking that the parsed value is a dict. Valid JSON is not always an object that is an array, a string, a number, null, and a bool are all valid top-level JSON and record_bytes is exactly the kind of externally-sourced input this is likely to happen to: build_assertion() takes whatever bytes a caller hands it, and verify_assertion()'s own docstring says its record_bytes are "the record bytes actually retrieved from its URL," i.e. a network response the caller does not control. Either function raised an unhandled AttributeError instead of the documented ContentMarkingError.
verify_assertion() had a second, related gap: its second parse of record_bytes (the one after the hash check, used to compare subject and eat_profile) was not wrapped in a try/except the way its build_assertion sibling already is, so bytes that are not valid JSON at all but happen to hash-match the assertion's declared digest raised json.JSONDecodeError instead of ContentMarkingError.
Type of change
Spec section
None
Checklist
git commit -s)CHANGELOG.mdupdated (for any normative change)<!-- CHANGED: #NNN — description -->in spec text