Repository navigation
fix(verifier): fail closed when a required attestation is absent - #248
Merged
Merged
Conversation
isVerificationResultMatchingServerProvenance gated the attestation
comparison behind a four-way AND:
if p.Attestation != nil && r.Statement != nil &&
p.Attestation.Predicate != nil && r.Statement.Predicate != nil {
When the provenance declared an attestation but the artifact carried
none (r.Statement == nil, or a statement with no predicate), the guard
was false, control fell through to `return true`, and the artifact
verified. The constraint was skipped in exactly the case it exists to
catch. The "attestations not set, skip the check" comment is only sound
for the expected side; applying it to the actual side was the defect.
The same expression hid a second bug in the opposite direction. When
both sides were populated the comparison could never succeed:
p.Attestation.Predicate is decoded registry data (map[string]any for a
JSON object) while r.Statement.Predicate is a *structpb.Struct, so
reflect.DeepEqual compared two different dynamic types and was
unconditionally false. Any entry that set `predicate` was
unverifiable.
Extract the logic into compareAttestation and follow the per-field
convention already used by compareBaseProperties, where an unset
expectation means "do not constrain that dimension" rather than "skip
the check entirely":
- p.Attestation == nil -> unconstrained
- p.Attestation != nil -> require r.Statement != nil
- PredicateType != "" -> must match
- Predicate != nil -> must DeepEqual Predicate.AsMap()
- &VerifiedAttestation{} -> must carry some attestation,
without constraining which
Comparing against AsMap() normalises the two sides so a declared
predicate is actually enforceable. Note AsMap renders every number as
float64, matching encoding/json but not YAML's integer decoding; both
directions are pinned by tests.
VerifyServer's signature is unchanged and compareBaseProperties keeps
its existing semantics.
The function had no direct test. Add table-driven coverage for the
guard clauses and for each attestation branch. Reverting verifier.go to
the previous logic fails seven of the new cases, covering both the
fail-open and the never-matched directions. Package coverage is 79.1%,
with isVerificationResultMatchingServerProvenance and
compareAttestation at 100%.
Behaviour change: a server entry that declares `provenance.attestation`
and passes today against an unattested artifact will now fail. No entry
in toolhive-catalog or toolhive sets the key, so nothing regresses on
upgrade.
go.mod: github.com/in-toto/attestation and google.golang.org/protobuf
move from indirect to direct at the same versions. VerificationResult
exposes *in_toto.Statement, so testing this function requires importing
it; go.sum is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
The previous commit normalized only the actual side, comparing the
expected predicate against r.Statement.Predicate.AsMap(). That left
equality dependent on how the registry entry was decoded: AsMap renders
every number as float64, encoding/json decodes numbers to float64, but
yaml.v3 decodes them to int. Two semantically identical predicates would
therefore verify when loaded from JSON and fail when loaded from YAML.
registry.Provenance carries both json and yaml tags and registry data is
YAML-decodable, so this was a reachable correctness bug rather than a
theoretical one. Documenting it as a known boundary was not sufficient.
Round the expected predicate through structpb.NewStruct and compare the
AsMap output of both sides, so the comparison is on normalized values
rather than on whichever types the decoder happened to produce. Verified
that yaml.v3 and encoding/json disagree on the raw values and agree once
normalized, including for nested objects and lists.
Two failure modes fail closed rather than passing:
- an expected predicate that is not an object cannot match an in-toto
predicate, which is always an object
- an expected predicate structpb cannot represent is one we cannot
confirm, so it is a mismatch
Tests: the integer case flips from "does not match" to "matches". Add
TestIsVerificationResultMatchingServerProvenance_PredicateDecoderIndependence,
which decodes the same provenance from JSON and from YAML and asserts
both verify against one statement. It guards its own premise by first
asserting the two decoders really do produce different Go values, so it
cannot silently become vacuous. Add cases for the non-object and
unrepresentable predicates.
Reverting verifier.go to the previous commit fails the decoder
independence test and the integer case. Package coverage is 79.6%, with
compareAttestation and normalizeAttestationPredicate at 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
rdimitrov
approved these changes
Aug 21, 2026
samuv
added a commit
that referenced
this pull request
Aug 21, 2026
…ce (#251) * feat(registry/types): allow attestation constraints on skill provenance #247 stripped `attestation` from the skill provenance contract because the verifier could silently skip it: an expected attestation was only compared when the artifact also carried one, so the constraint was dropped exactly when it mattered. #248 closed that gap -- `compareAttestation` now fails when the provenance asks for an attestation and the statement is absent. That removed the reason for the restriction but not the restriction, and left two comments on main asserting the opposite of what the code does. Both the schema description and the `Skill.Provenance` doc comment still claimed a declared attestation "would be silently skipped", and the Go comment's "until that gap is fixed" read as permanent. Re-add the key and correct both. The skill `verified_attestation` definition is deliberately stricter than the server one in publisher-provided.schema.json: `predicate` is typed as an object. The verifier normalizes an expected predicate through `structpb.NewStruct`, which requires `map[string]any`: string(str) -> err=expected predicate is string, want an object int(42) -> err=expected predicate is int, want an object []interface{}([a]) -> err=expected predicate is []interface {}, want an object map[string]any{k:v} -> ok A non-object predicate therefore mismatches every artifact. Untyped, the schema would accept a constraint that can never be satisfied and the failure would surface at install time as an unexplained verification failure. Typing it makes that a validation error at authoring time. `additionalProperties: false` is kept on both definitions -- it was added for typo protection rather than for the attestation restriction, and it now also catches a misspelled `predicatetype`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com> * docs(registry/types): clarify that an empty Attestation still constrains Review nit: "an empty one is not checked" contradicted the next sentence, since an empty Attestation is deliberately checked and requires the artifact to be attested. Scope the rule to empty strings and call out Attestation as the exception, noting that its own PredicateType and Predicate go back to the usual constrain-only-when-set behaviour. The schema's provenance description carried the same contradiction ("an absent or empty value leaves that dimension unconstrained", which is false for `attestation: {}`), so fix it there too. Comments only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com> --------- Signed-off-by: Samuele Verzi <samu@stacklok.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.
The bug
isVerificationResultMatchingServerProvenancegated the attestation comparison behind a four-way AND (container/verifier/verifier.go:170):When the provenance declared an attestation but the artifact carried none —
r.Statement == nil, or a statement with no predicate — the guard was false, control fell through toreturn true, and the artifact verified. The constraint was skipped in exactly the case it exists to catch.The comment is only sound for the expected side. Applying it to the actual side is the defect.
A second bug in the opposite direction
The same expression hid a second problem: when both sides were populated, the comparison could never succeed.
p.Attestation.Predicateis decoded registry data.r.Statement.Predicateis a*structpb.Struct(in-toto/attestation@v1.2.0,statement.pb.go:34).reflect.DeepEqualon two different dynamic types is unconditionally false, so any entry that setpredicatewas unverifiable. Confirmed empirically:So the one expression was fail-open when the artifact had no attestation, and fail-closed-but-unusable when it did.
The fix
Extracted into
compareAttestation, following the per-field conventioncompareBasePropertiesalready uses — an unset expectation means "don't constrain that dimension", not "skip the check entirely":p.Attestation == nilp.Attestation != nilr.Statement != nilPredicateType != ""r.Statement.PredicateTypePredicate != nilstructpb, then compared&VerifiedAttestation{}The explicit
r.Statement.Predicate == nilcheck is load-bearing:structpb'sAsMap()is nil-safe and returns an empty map, so without it an expected{}would match a missing predicate.VerifyServer's signature is unchanged andcompareBasePropertieskeeps its existing semantics.Predicate equality is independent of serialisation format
Normalising only the actual side would leave equality dependent on how the registry entry was decoded.
AsMap()renders every number asfloat64;encoding/jsondecodes numbers tofloat64, butyaml.v3decodes them toint. Two semantically identical predicates would verify from JSON and fail from YAML.registry.Provenancecarries bothjsonandyamltags and registry data is YAML-decodable, so that is reachable, not theoretical.So both sides round through
structpbbefore comparison:normalizeAttestationPredicateasserts the expectation is an object, builds a*structpb.Struct, and returns itsAsMap(). ComparingAsMap()on both sides yields plain Go maps with consistent types — which also avoidsreflect.DeepEqualon protobuf messages and their internal state fields.Verified the two decoders disagree before normalisation and agree after, for nested objects and lists as well as top-level scalars:
Normalisation introduces two failure modes, and neither may recreate the old fall-through. Both return no-match:
structpbcannot encode is one we cannot confirm.Behaviour change
A server entry that declares
provenance.attestationand passes today against an unattested artifact will now fail. That's the intent.I grepped
toolhive-catalog(bothregistries/andpkg/catalog/toolhive/data/*.json) andtoolhive/pkgfor an"attestation":key underprovenance— zero hits. The only matches anywhere are the word "attestations" inside MCP tool descriptions and swagger$defs. Nothing regresses on upgrade, so this needs a release note for downstream authors rather than a migration.Downstream
toolhivepicks this up on a go.mod bump.Tests
isVerificationResultMatchingServerProvenancehad no direct test. Added table-driven coverage for the guard clauses and every attestation branch.Reverting
verifier.goto the pre-fix logic fails 7 cases, covering both directions:expected attestation but statement absent(the regression),empty expected attestation but statement absent,expected predicate but statement carries none,predicate type mismatchpredicate type and predicate both match,predicate matches with predicate type unconstrained,numeric predicate matches as float64Decoder independence gets a dedicated test. It decodes the same provenance from a JSON literal and a YAML literal — numbers at top level, nested in an object, and inside a list — and asserts both verify against one statement. It guards its own premise first, so it cannot silently go vacuous if the decoders ever converge:
Alongside it,
numeric predicate matches as intandmatches as float64both assert a match, andnumeric predicate still mismatches on valuepins that normalisation widens what is accepted without weakening value comparison. The non-object and unrepresentable predicates are covered too.taskpasses clean (lint 0 issues, all tests green).task license-checkpasses. Package coverage 79.6%, withisVerificationResultMatchingServerProvenance,compareAttestation, andnormalizeAttestationPredicateall at 100%. No live Sigstore or network needed —verifier_integration_test.gois separate and network-gated.One thing worth a reviewer's eye
go.modchurn.github.com/in-toto/attestationandgoogle.golang.org/protobufmove from// indirectto direct — same versions, no new modules,go.sumunchanged. Unavoidable:VerificationResult.Statementis*in_toto.Statement, so any direct test of this function must import it, and the normalisation usesstructpbin non-test code. Flagging it against the "don't modify go.mod deps" rule since it'sgo mod tidybookkeeping rather than a dependency decision.Follow-on (not in this PR)
Once this lands,
skill.schema.json's$defs/provenancecan acceptattestationagain — #247 removed it deliberately because of this bug, as a stopgap. That is better done as a follow-up than by rebasing #247 onto this, so the security fix isn't gated on the schema change. Suggest keeping"additionalProperties": falseregardless: it turns a misspelled constraint key into a loud error instead of a silently dropped security guarantee.The separate question of renaming
VerifyServer→Verify, now that it has non-server callers, is its own discussion and is not folded in here.🤖 Generated with Claude Code