Skip to content

fix(content-marking): build_assertion and verify_assertion crash on non-object record_bytes - #227

Merged
imran-siddique merged 2 commits into
agentrust-io:mainfrom
rajnisht7:fix-content-marking
Aug 27, 2026
Merged

fix(content-marking): build_assertion and verify_assertion crash on non-object record_bytes#227
imran-siddique merged 2 commits into
agentrust-io:mainfrom
rajnisht7:fix-content-marking

Conversation

@rajnisht7

Copy link
Copy Markdown
Contributor

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

  • Editorial (typo, link fix, clarification — no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

Spec section

None

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated (for any normative change)
  • Breaking changes marked with <!-- CHANGED: #NNN — description --> in spec text
  • Backward compatibility statement included (for breaking changes)

@rajnisht7
rajnisht7 requested a review from a team as a code owner August 27, 2026 09:18
Signed-off-by: rajnisht7 <rajnishtiwari9787@gmail.com>
@rajnisht7
rajnisht7 force-pushed the fix-content-marking branch from f6f46a7 to 6ce6255 Compare August 27, 2026 09:18
lywinged
lywinged previously approved these changes Aug 27, 2026

@lywinged lywinged left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 27, 2026
…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
imran-siddique previously approved these changes Aug 27, 2026

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
imran-siddique dismissed stale reviews from lywinged and themself via e31112c August 27, 2026 17:28

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@imran-siddique
imran-siddique merged commit c7958b9 into agentrust-io:main Aug 27, 2026
5 of 8 checks passed
imran-siddique added a commit to rajnisht7/trace-spec that referenced this pull request Aug 27, 2026
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
lywinged added a commit to lywinged/trace-spec that referenced this pull request Aug 27, 2026
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.
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.

3 participants