Skip to content

fix(verifier): fail closed when a required attestation is absent - #248

Merged
samuv merged 2 commits into
mainfrom
t3code/fix-attestation-fail-closed
Aug 21, 2026
Merged

samuv merged 2 commits into
mainfrom
t3code/fix-attestation-fail-closed

Conversation

@samuv

@samuv samuv commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

The bug

isVerificationResultMatchingServerProvenance gated the attestation comparison behind a four-way AND (container/verifier/verifier.go:170):

// If the attestations are not set, we can skip this check
if p.Attestation != nil && r.Statement != nil && p.Attestation.Predicate != nil && r.Statement.Predicate != nil {
    if p.Attestation.PredicateType != r.Statement.PredicateType {
        return false
    }
    return reflect.DeepEqual(p.Attestation.Predicate, r.Statement.Predicate)
}

return true

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 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.Predicate is decoded registry data. r.Statement.Predicate is a *structpb.Struct (in-toto/attestation@v1.2.0, statement.pb.go:34). reflect.DeepEqual on two different dynamic types is unconditionally false, so any entry that set predicate was unverifiable. Confirmed empirically:

expected dyn type: map[string]interface {}
actual   dyn type: *structpb.Struct
DeepEqual(expected, actual) = false

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 convention compareBaseProperties already uses — an unset expectation means "don't constrain that dimension", not "skip the check entirely":

Expected Behaviour
p.Attestation == nil unconstrained, skip
p.Attestation != nil require r.Statement != nil
PredicateType != "" must equal r.Statement.PredicateType
Predicate != nil both sides normalized through structpb, then compared
&VerifiedAttestation{} must carry some attestation, without constraining which

The explicit r.Statement.Predicate == nil check is load-bearing: structpb's AsMap() is nil-safe and returns an empty map, so without it an expected {} would match a missing predicate.

VerifyServer's signature is unchanged and compareBaseProperties keeps 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 as float64; encoding/json decodes numbers to float64, but yaml.v3 decodes them to int. Two semantically identical predicates would verify from JSON and fail from YAML. registry.Provenance carries both json and yaml tags and registry data is YAML-decodable, so that is reachable, not theoretical.

So both sides round through structpb before comparison:

expected, err := normalizeAttestationPredicate(p.Attestation.Predicate)
if err != nil {
    // An expected predicate we cannot normalise is one we cannot
    // confirm, so treat it as a mismatch rather than a pass.
    slog.Error("cannot normalize expected attestation predicate", "error", err)
    return false
}
if !reflect.DeepEqual(expected, r.Statement.Predicate.AsMap()) {
    return false
}

normalizeAttestationPredicate asserts the expectation is an object, builds a *structpb.Struct, and returns its AsMap(). Comparing AsMap() on both sides yields plain Go maps with consistent types — which also avoids reflect.DeepEqual on 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:

yaml: map[string]interface {}{"attempt":1,   "list":[]interface {}{3},   "nested":map[string]interface {}{"count":7}}
json: map[string]interface {}{"attempt":1.0, "list":[]interface {}{3.0}, "nested":map[string]interface {}{"count":7.0}}
raw DeepEqual        = false
normalized DeepEqual = true

Normalisation introduces two failure modes, and neither may recreate the old fall-through. Both return no-match:

  • Non-object expectation — an in-toto predicate is always an object, so a scalar expectation cannot match one.
  • Unrepresentable expectation — a value structpb cannot encode is one we cannot confirm.

Behaviour change

A server entry that declares provenance.attestation and passes today against an unattested artifact will now fail. That's the intent.

I grepped toolhive-catalog (both registries/ and pkg/catalog/toolhive/data/*.json) and toolhive/pkg for an "attestation": key under provenance — 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 toolhive picks this up on a go.mod bump.

Tests

isVerificationResultMatchingServerProvenance had no direct test. Added table-driven coverage for the guard clauses and every attestation branch.

Reverting verifier.go to the pre-fix logic fails 7 cases, covering both directions:

  • Fail-open: expected attestation but statement absent (the regression), empty expected attestation but statement absent, expected predicate but statement carries none, predicate type mismatch
  • Never-matched: predicate type and predicate both match, predicate matches with predicate type unconstrained, numeric predicate matches as float64

Decoder 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:

require.NotEqual(t, fromJSON.Attestation.Predicate, fromYAML.Attestation.Predicate,
    "decoders expected to disagree on numeric types; test is vacuous otherwise")

Alongside it, numeric predicate matches as int and matches as float64 both assert a match, and numeric predicate still mismatches on value pins that normalisation widens what is accepted without weakening value comparison. The non-object and unrepresentable predicates are covered too.

task passes clean (lint 0 issues, all tests green). task license-check passes. Package coverage 79.6%, with isVerificationResultMatchingServerProvenance, compareAttestation, and normalizeAttestationPredicate all at 100%. No live Sigstore or network needed — verifier_integration_test.go is separate and network-gated.

One thing worth a reviewer's eye

go.mod churn. github.com/in-toto/attestation and google.golang.org/protobuf move from // indirect to direct — same versions, no new modules, go.sum unchanged. Unavoidable: VerificationResult.Statement is *in_toto.Statement, so any direct test of this function must import it, and the normalisation uses structpb in non-test code. Flagging it against the "don't modify go.mod deps" rule since it's go mod tidy bookkeeping rather than a dependency decision.

Follow-on (not in this PR)

Once this lands, skill.schema.json's $defs/provenance can accept attestation again — #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": false regardless: 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

samuv added 2 commits August 21, 2026 14:28
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>
@samuv
samuv merged commit ba7fa03 into main Aug 21, 2026
5 checks passed
@samuv
samuv deleted the t3code/fix-attestation-fail-closed branch August 21, 2026 12:51
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>
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.

2 participants