CoursesSoftware supply chain in depthVerification summary & policy

Verification summary & policy

VSAs and requiring multiple claims before trust.

Expert25 min · lesson 6 of 15

In early 2024 a trusted maintainer's credentials were used to hide a backdoor inside XZ Utils. The poisoned release was built, signed and published exactly like every clean one before it, and the whole chain of automated checks waved it through. What saved the ecosystem was one engineer downstream who bothered to look at the evidence instead of trusting the signature. Now scale that job up. You run a platform with 60 Kubernetes clusters, several of them air-gapped (no network route out to the internet at all), and every one of them is supposed to check an image's provenance, its SBOM (software bill of materials, the list of what went into the build) and its scan results before letting the workload run. Each of those checks needs the raw attestations, the Fulcio root certificates, and a live lookup against the Rekor transparency log, which is Sigstore's public append-only ledger of everything that has been signed. That is expensive at the edge and flatly impossible where there is no egress. A Verification Summary Attestation (VSA) works like the inspection certificate stapled inside a lift: one authority you trust does the full inspection once, and everyone after that reads the signed verdict instead of pulling the panels off again.

Verify once, trust cheaply after

A VSA is an in-toto attestation, meaning a signed statement about one specific artifact written in the shared format all the supply-chain tools speak, and its predicate type is https://slsa.dev/verification_summary/v1. Three documents, three different questions. SLSA provenance (Supply-chain Levels for Software Artifacts, the framework that grades how tamper-resistant a build was) answers 'how was this built'. An SBOM answers 'what is inside'. A VSA answers something else entirely: 'did a verifier I trust already check this artifact against a named policy, and what did it decide?' Two fields carry all the weight. verificationResult is either PASSED or FAILED. verifiedLevels lists the SLSA levels the artifact actually reached, for example SLSA_BUILD_LEVEL_3. The consumer stops assembling provenance and SBOM and scan results for itself. It checks one signature and reads one verdict. You pay for that convenience with concentrated trust. A VSA is worth exactly as much as the authority that signed it and the policy that authority ran, so that authority stops being a convenience script somebody wrote on a Friday afternoon and becomes reviewed, load-bearing infrastructure. Every cluster downstream is now standing on it.

What each field in the predicate is doing

Every field in there earns its place. The Statement's subject nails the verdict to one exact artifact digest, so nobody can peel a VSA off one image and stick it on another. Inside the predicate, verifier.id names who is making the claim. policy.uri and policy.digest pin the precise policy version that ran, so when you rotate the policy the old VSAs stop speaking for the new rules, which is the behaviour you want. inputAttestations records the digests of the provenance, SBOM and scan attestations that were genuinely evaluated, which keeps the decision auditable: anyone can fetch those same inputs, run the evaluation again and see whether they land on the same answer. verifiedLevels lists the tracks the artifact reached. dependencyLevels can summarise how many transitive dependencies met each level. slsaVersion fixes which schema is in play, and timeVerified stamps when the check ran. What the VSA leaves out on purpose is the evidence itself, and that missing bulk is the entire reason it fits down a thin pipe into a locked-down cluster.

vsa.json — the VSA v1 in-toto Statement (plaintext, pre-signing)
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "ghcr.io/acme/api",
"digest": { "sha256": "6f8e2c1b9a...d41" } }
],
"predicateType": "https://slsa.dev/verification_summary/v1",
"predicate": {
"verifier": { "id": "https://acme.dev/platform/verifier" },
"timeVerified": "2026-07-16T09:14:52Z",
"resourceUri": "oci://ghcr.io/acme/api@sha256:6f8e2c1b9a...d41",
"policy": {
"uri": "https://acme.dev/policy/prod-admission@v7",
"digest": { "sha256": "b2d4a1...9f0" }
},
"inputAttestations": [
{ "uri": "provenance.intoto.jsonl", "digest": { "sha256": "1a2b3c...d3e" } },
{ "uri": "sbom.cdx.intoto.jsonl", "digest": { "sha256": "4e5f6a...a6b" } },
{ "uri": "scan.vex.intoto.jsonl", "digest": { "sha256": "7c8d9e...e9f" } }
],
"verificationResult": "PASSED",
"verifiedLevels": [ "SLSA_BUILD_LEVEL_3" ],
"slsaVersion": "1.0"
}
}

That Statement never gets signed as it stands. Every in-toto attestation travels inside a DSSE envelope (Dead Simple Signing Envelope). The JSON Statement is base64-encoded into a payload field, labelled with a payloadType, and then the signature is computed over something slightly different from the payload: its Pre-Authentication Encoding, or PAE. The PAE is the byte string DSSEv1 SP len(payloadType) SP payloadType SP len(payload) SP payload, where SP is a single space and each len is the byte length written out in ASCII decimal. It works like writing the document type and the exact page count on the outside of a sealed envelope. Because the type and both lengths sit inside the signed bytes, an attacker cannot lift a signature made over one predicate type and re-present those identical bytes claiming a different type, and cannot splice fields from two payloads together. With keyless signing there is no static key id at all. Riding alongside the signature instead is a short-lived certificate from Fulcio, Sigstore's certificate authority, which issues certificates that live about ten minutes and record the identity that asked for one. That certificate is exactly what the downstream gate will pin against.

vsa.intoto.jsonl — the Statement above, DSSE-wrapped and signed keyless
{
"payloadType": "application/vnd.in-toto+json",
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLC4uLn0=",
"signatures": [
{
"keyid": "",
"sig": "MEUCIQDx8k...base64-ecdsa-P256...Qm9Zr2==",
"cert": "-----BEGIN CERTIFICATE-----\nMIIC...Fulcio-issued, ~10 min validity, SAN = signing workflow identity...\n-----END CERTIFICATE-----"
}
]
}
# The signed bytes are the PAE, NOT the raw payload:
# DSSEv1 SP 28 SP application/vnd.in-toto+json SP 812 SP <payload-bytes>
# payloadType is folded into the signed PAE -> a sig cannot be replayed under another type.

Minting the VSA at the authority

The authority pays the full cost, once. Its verifier job starts by genuinely checking the provenance for the target digest with slsa-verifier, confirming the builder identity, the source repository, and that the provenance's own signature really is recorded in Rekor. In a real pipeline it checks the SBOM and the scan attestations the same way. Only when all of that passes does it build the VSA and sign it keyless with cosign attest, which uploads the signed DSSE envelope to Rekor and attaches it to the image. If verification fails, no VSA comes out, or one comes out carrying verificationResult FAILED. Either way the verdict is honest, because the signer only ever signs what it actually checked. A verifier that signs first and checks later is worse than having no verifier at all, since it manufactures confidence that nobody earned and hands it to 60 clusters.

verifier job: check provenance, then emit + sign the VSA
# 1) Actually verify the SLSA provenance for this exact digest
$ slsa-verifier verify-image ghcr.io/acme/api@sha256:6f8e2c1b9a...d41 \
--source-uri github.com/acme/api \
--builder-id 'https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml'
Verified signature against tlog entry index 148203402 at URL: https://rekor.sigstore.dev/api/v1/log/entries/24296fb2...c3f0
Verified build using builder https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0 at commit a3f0c7e1b2...
PASSED: Verified SLSA provenance
# 2) Only on PASS: sign the VSA keyless and log it to Rekor.
# cosign builds the in-toto Statement wrapper itself (subject = the image,
# predicateType = --type), so feed it ONLY the predicate body:
$ jq '.predicate' vsa.json > vsa.predicate.json
$ cosign attest --yes --predicate vsa.predicate.json \
--type https://slsa.dev/verification_summary/v1 \
ghcr.io/acme/api@sha256:6f8e2c1b9a...d41
Generating ephemeral keys...
Retrieving signed certificate...
Successfully verified SCT...
tlog entry created with index: 148203471

At the gate: one signature, one decision

Downstream, the gate re-derives nothing. It runs cosign verify-attestation pinned to the verifier's certificate identity and OIDC issuer (OpenID Connect, the identity system that vouched for whoever asked Fulcio for a certificate). That pinning is the load-bearing step, because a VSA's whole authority comes from who signed it. The pinned identity lives in the certificate's SAN, the Subject Alternative Name field. For a verifier running as a GitHub Actions reusable workflow the SAN is the workflow URI, .../mint-vsa.yml@refs/heads/main, paired with the token.actions.githubusercontent.com issuer. That pairing is a cryptographic fact the signer cannot invent, and it is deliberately separate from the free-form verifier.id string sitting inside the predicate, which is only a human-facing label. Anybody can type any string into verifier.id. Nobody can fake the certificate. cosign hands back only attestations whose subject matches the image digest being admitted, so digest binding comes for free and a VSA cannot be replayed onto another artifact. The decoded predicate then goes to a policy engine, which makes the real admit or deny call on two conditions: verificationResult is PASSED, and verifiedLevels contains the required SLSA_BUILD_LEVEL_3. Everything else denies. A missing VSA denies, a VSA signed by the wrong identity denies, a FAILED result denies, a level under the bar denies. One signature check plus one policy evaluation replaces fetching and validating three separate attestations against a live transparency log, which is precisely what makes this workable inside an air-gapped cluster.

downstream gate — verify the VSA against the trusted signing identity
$ cosign verify-attestation \
--type https://slsa.dev/verification_summary/v1 \
--certificate-identity 'https://github.com/acme/platform/.github/workflows/mint-vsa.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/acme/api@sha256:6f8e2c1b9a...d41 \
| jq -r '.payload | @base64d | fromjson | .predicate
| "\(.verificationResult) \(.verifiedLevels[0])"'
Verification for ghcr.io/acme/api@sha256:6f8e2c1b9a...d41 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
PASSED SLSA_BUILD_LEVEL_3
vsa-gate.rego + opa — the admit/deny decision (OPA v1.0 syntax)
# vsa-gate.rego
package admission
default allow := false
allow if {
input.verificationResult == "PASSED"
"SLSA_BUILD_LEVEL_3" in input.verifiedLevels
}
# --- run it: feed the verified predicate straight into the gate ---
$ cosign verify-attestation --type https://slsa.dev/verification_summary/v1 \
--certificate-identity 'https://github.com/acme/platform/.github/workflows/mint-vsa.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/acme/api@sha256:6f8e2c1b9a...d41 2>/dev/null \
| jq '.payload | @base64d | fromjson | .predicate' \
| opa eval -I -f pretty -d vsa-gate.rego 'data.admission.allow'
true
# A FAILED result, a level below BUILD_LEVEL_3, or a signature from any other
# identity -> cosign errors or the query returns false -> the gate denies.
One signed verdict decides admission
Image ghcr.io/acme/api@sha256:6f8e2c... presented at the gate
the gate holds only the trusted verifier's certificate identity + OIDC issuer
no VSA, or signed by an untrusted identity
DENY
cosign verify-attestation fails identity pinning
signed by the verifier, verificationResult = FAILED
DENY
the policy rejects any non-PASSED verdict
verifier + PASSED, but verifiedLevels < BUILD_LEVEL_3
DENY
level sits below this environment's bar
verifier + PASSED + SLSA_BUILD_LEVEL_3
ADMIT
one signed decision satisfies the whole policy
Digest binding comes for free: cosign returns only attestations whose subject matches the image being presented, so a valid VSA cannot be replayed onto a different artifact.
Quick check
01Your admission gate admits any image that carries a VSA saying verificationResult is PASSED. An attacker pushes a malicious image and attaches a VSA they minted and signed themselves, which of course says PASSED. What made the gate exploitable?
Correct — A VSA carries no authority of its own, and its trust is entirely a question of who signed it. Pin certificate-identity and issuer to the one verifier you trust, and a self-signed PASSED verdict fails verification before the policy is ever consulted.
Incorrect — An SBOM lists components. It does nothing about a verdict forged by a stranger. The hole here is trust in the signer, not missing inventory.
Incorrect — The schema version has no say in trust. Any version verifies fine when the signer is one you accept, and fails when you pin an identity you do not.
Incorrect — Anyone with cosign can sign their own VSA. Unforgeability only means something relative to a signer you deliberately chose to trust, which is exactly what pinning sets up.
02A Verification Summary Attestation deliberately does not carry the provenance, SBOM and scan attestations it was derived from. It records only their digests in inputAttestations. Why is leaving the raw evidence out the point of the design?
Correct — and that is the whole motivation. One signed verdict stands in for re-deriving provenance, SBOM and scan results, which is what makes admission possible where there is no egress.
Incorrect — No. policy.uri, policy.digest and inputAttestations leave the decision fully auditable. The omission is about size, not secrecy.
Incorrect — No. The authority signs the VSA on its own once it has verified the evidence. The consumer never sends anything back.
Incorrect — No. Replay protection comes from the Statement's subject binding the verdict to one exact digest, not from what was left out.
03Your gate runs the vsa-gate.rego policy (allow only when verificationResult == "PASSED" and "SLSA_BUILD_LEVEL_3" in input.verifiedLevels) after cosign verify-attestation pinned to the trusted verifier's identity. A VSA turns up correctly signed by that verifier, verificationResult is PASSED, but verifiedLevels is ["SLSA_BUILD_LEVEL_2"]. What does the gate do?
Incorrect — No. The rego asks for SLSA_BUILD_LEVEL_3 by name. PASSED on its own is not enough.
Correct — Identity pinning passes and PASSED passes, but the level sits below the bar for this environment, so allow stays false.
Incorrect — No. cosign checks the signature, the identity, the issuer and the digest. It returns success here, and comparing levels is the policy engine's job.
Incorrect — No. The level check in that rego is unconditional. There is no per-environment exemption anywhere in it.
PASSED means passed then, not passed now
verificationResult PASSED is true as of timeVerified, against one version of one policy, and no further than that. A CVE (Common Vulnerabilities and Exposures entry, a publicly catalogued flaw) disclosed the week after signing does not reach back and flip the verdict to FAILED. So a gate that trusts an old VSA forever will happily admit an image that was clean last month and is exploitable today. Treat VSAs like milk rather than like a birth certificate: enforce a maximum age at the gate, re-verify whenever the policy digest changes, and re-issue on a schedule or whenever fresh scan data lands. A stale PASSED is one of the quietest ways to put a known-vulnerable image back into production.

Every gram of trust in this design rests on one question: was this VSA signed by the verifier you trust, and by nobody else? Pin the wrong identity, or forget to pin at all, and the scheme flips inside out, because now anyone at all can mint a PASSED verdict for anything they like. That is why the next lesson, cosign: sign and verify by identity, opens up exactly how keyless signing ties a signature to a workflow identity, what really sits inside the Fulcio certificate, and how to pin certificate-identity and issuer so a self-signed attestation is thrown out on sight.

Try this

Work through “At the gate: one signature, one decision” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: pASSED means passed then, not passed now. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related