From 89400c104b745b10ac4e3631d5f7303993882625 Mon Sep 17 00:00:00 2001 From: lywinged <48041247+lywinged@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:40:41 +1200 Subject: [PATCH] fix(provenance): anchor with \Z, so a trailing newline is not part of an identifier In Python `$` matches at the end of the string *and* immediately before a single trailing newline. Both patterns in this module are anchored `^...$`, so every field they guard accepted its own value with a newline glued to the end, and `verify_record` returned cleanly. Four fields, since two patterns are reused: publisher "did:web:acme.example\n" accepted artifact.digest "sha256:...\n" accepted endpoint.spki_sha256 "sha256:...\n" accepted tool_catalog.hash "sha256:...\n" accepted by verify_record Only the last has anything downstream to catch it, and only because `check_tool_catalog` recomputes the digest and compares. The other three are accepted and nothing later disagrees. `\Z` is the end of the string and nothing else. One comment on the pair says why, because the two anchors look interchangeable and are not. Four parametrized cases, one per field. They fail on all four before this change and pass after, which I checked rather than assumed. 327 passed, 1 skipped; ruff and mypy clean. Co-Authored-By: Claude Opus 5 Signed-off-by: lywinged <48041247+lywinged@users.noreply.github.com> --- src/agentrust_trace/provenance.py | 6 +++-- tests/test_provenance.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/agentrust_trace/provenance.py b/src/agentrust_trace/provenance.py index 7ad39326..3e00fe06 100644 --- a/src/agentrust_trace/provenance.py +++ b/src/agentrust_trace/provenance.py @@ -40,8 +40,10 @@ #: Closed on purpose: the value of the field is that a verifier can key on it. KINDS = ("publisher-asserted", "observer-attested", "tee-attested") -_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") -_PUBLISHER_RE = re.compile(r"^(did:[a-z0-9]+:.+|spiffe://[^/]+/.+)$") +# `\Z`, not `$`: in Python `$` also matches immediately before a single trailing newline, +# so `^...$` accepts "did:web:acme.example\n" and every digest with a newline glued to it. +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}\Z") +_PUBLISHER_RE = re.compile(r"^(did:[a-z0-9]+:.+|spiffe://[^/]+/.+)\Z") class ProvenanceError(ValueError): diff --git a/tests/test_provenance.py b/tests/test_provenance.py index d92f695a..29c1c12c 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -316,3 +316,45 @@ def test_a_well_formed_record_still_verifies() -> None: key = generate_key() signed = sign_record(_record(), key) verify_record(signed, key_to_jwk(key)) + + +# --- anchoring: a trailing newline is not part of an identifier ------------ + + +@pytest.mark.parametrize( + "field,value", + [ + ("publisher", "did:web:acme.example\n"), + ("artifact.digest", DIGEST + "\n"), + ("endpoint.spki_sha256", DIGEST + "\n"), + ("tool_catalog.hash", DIGEST + "\n"), + ], +) +def test_a_trailing_newline_does_not_satisfy_a_pattern(field: str, value: str) -> None: + """`$` in Python matches before a single trailing newline; these need `\\Z`. + + Four fields are anchored with the same two patterns. Before this, every one of them + accepted its own value with a newline glued to the end, and `verify_record` returned + cleanly on all four. Only `tool_catalog.hash` had anything downstream to catch it, + and only because `check_tool_catalog` recomputes and compares. + """ + key = generate_key() + record = { + "format": FORMAT, + "kind": "publisher-asserted", + "identity": {"artifact": _artifact()}, + "publisher": "did:web:acme.example", + "tool_catalog": {"hash": tool_catalog_hash(TOOLS), "tool_count": len(TOOLS)}, + "issued_at": 1760000000, + } + if field == "publisher": + record["publisher"] = value + elif field == "artifact.digest": + record["identity"]["artifact"]["digest"] = value + elif field == "endpoint.spki_sha256": + record["identity"] = {"endpoint": {"url": "https://acme.example/mcp", "spki_sha256": value}} + else: + record["tool_catalog"]["hash"] = value + + with pytest.raises(ProvenanceError): + verify_record(sign_record(record, key), key_to_jwk(key))