CoursesSoftware supply chain securityin-toto attestations & provenance

in-toto attestations & provenance

Signed statements about how an artifact was made.

Advanced14 min · lesson 12 of 18

A wax seal on a letter proves exactly one thing. Someone holding the right signet ring closed this envelope. It tells you nothing about what the letter says, who dictated it, or when it was written. A cryptographic signature on a container image is that wax seal. It proves an identity vouched for these exact bytes, and it stops there.

An attestation staples a notarized page to the seal. The page describes what the artifact is and how it came to be, and it is folded into the sealed envelope so the page and the seal cannot be pulled apart without breaking both. in-toto (Latin for 'as a whole', a framework for supply-chain integrity) defines the shape of that envelope. Provenance is the most useful page you can put inside it: a machine-readable record of how the artifact was built, naming the source, the build system, the parameters, and every input. It is the artifact's birth certificate, signed by whoever delivered it, and glued to the artifact's fingerprint (its content digest, a SHA-256 hash that changes if a single byte changes) so it can never be re-stapled onto something else.

What sits inside the envelope

Open one up and you find three layers nested like a box inside a box inside a box. The outer box is a DSSE envelope (Dead Simple Signing Envelope, a thin wrapper that carries a payload and the signatures over it). The signature does not cover the payload bytes alone. It also covers a short label naming the payload's type, folded in before anything is signed. For in-toto attestations that label is always application/vnd.in-toto+json, so nobody can lift these signed bytes out and pass them off as some other kind of signed object. Which flavor of claim this actually is, a bill of materials or a build record, lives one layer deeper in a field called predicateType that you will meet in a moment, and it sits inside the signed payload too. Sign a bill of materials today and nobody can replay it next week as provenance, because the field that says which one it is got signed along with everything else.

Inside the envelope sits the in-toto Statement. It has three fields you will read again and again. subject names the artifact and pins its digest. predicateType is a URL naming which kind of claim this is. predicate is the claim itself, in whatever shape that type defines. Here is a provenance statement in the SLSA (Supply-chain Levels for Software Artifacts, pronounced 'salsa') version 1 format, trimmed to the load-bearing parts.

provenance.json
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "registry.acme.internal/payments-api",
"digest": { "sha256": "9f2a3c1b7d4e...e71b" }
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://gitlab.com/gitlab-org/gitlab-runner",
"externalParameters": {
"source": "git+https://gitlab.acme.internal/acme/payments@refs/heads/main",
"entryPoint": ".gitlab-ci.yml"
},
"resolvedDependencies": [
{
"uri": "git+https://gitlab.acme.internal/acme/payments@refs/heads/main",
"digest": { "gitCommit": "3f7c1e2b9a..." }
}
]
},
"runDetails": {
"builder": { "id": "https://gitlab.acme.internal/ci" },
"metadata": {
"invocationId": "https://gitlab.acme.internal/acme/payments/-/jobs/184423",
"startedOn": "2026-07-17T09:14:02Z",
"finishedOn": "2026-07-17T09:17:36Z"
}
}
}
}

Read it top down. The subject is payments-api pinned to one sha256 digest, so this whole document is bound to that one image and no other. predicateType says the predicate is SLSA provenance version 1. Inside predicate, buildDefinition.externalParameters.source records the git repository and ref the build started from. resolvedDependencies lists the inputs the builder actually pulled, each pinned by commit or digest, so 'we built main' becomes 'we built commit 3f7c1e2'. runDetails.builder.id is the identity of the machine that ran the build, and metadata timestamps the run and links back to the exact job.

Pull one off a real image and read it

Attestations do not live in a sidecar file you can lose. They live in the OCI (Open Container Initiative, the standard for container images and registries) registry right next to the image, indexed by the image's digest. Pull the image and the evidence is already there to fetch. cosign, the Sigstore signing tool, reads it for you.

terminal
$ cosign download attestation "$IMAGE@$DIGEST" | jq 'keys'
output
[
"payload",
"payloadType",
"signatures"
]
terminal
$ cosign download attestation "$IMAGE@$DIGEST" \
| jq -r '.payload' | base64 -d \
| jq '{type: ._type, subject, predicateType}'
output
{
"type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "registry.acme.internal/payments-api",
"digest": {
"sha256": "9f2a3c1b7d4e...e71b"
}
}
],
"predicateType": "https://slsa.dev/provenance/v1"
}

Because the attestation is stored under the image's digest, it travels with the image through every registry mirror and cache. Swap the image for a different one and the digest changes, so the old attestation no longer points at it. That binding is the whole game.

Provenance, the birth certificate

This is where a defender earns their pay. A green build is not evidence of anything on its own. Provenance turns 'trust me' into a set of fields you can check against what you expect. Does source name the repository you actually trust, on a branch you actually ship from, or does it name a fork nobody reviewed? Does builder.id match your CI (continuous integration, the automated system that builds and tests every change), or does it say the image was built on a laptop and pushed by hand? Are the resolvedDependencies pinned to commits and digests, or are they floating tags an attacker could quietly move under you?

An attacker who slips a backdoored image into your registry has to answer those questions in the paperwork. Build from a private fork, and the source field gives them away. Build outside the pipeline, and the builder identity is wrong. Hand-edit the JSON (the plain-text format the statement is written in) to make it lie, and the signature over it breaks. Provenance does not stop an attacker from pushing an image. It stops the image from passing a check that reads the provenance.

Beyond provenance, the evidence folder

Provenance is one page. The same envelope carries any signed statement about the same digest, and a mature artifact collects a folder of them. An SBOM (software bill of materials, an ingredient list of every component inside) attested as a signed claim about contents. A vulnerability scan result attested as a signed claim that the artifact passed scanning at a moment in time. A test report. A human review sign-off before release. Each is a structured statement, bound to the same digest, signed by whoever produced it. A consumer then asks narrow questions, was this scanned, what is in it, who approved it, and gets an answer it can verify instead of a dashboard it has to take on faith.

Make one, then verify it

Creating an attestation and checking it is two commands. Start with signing. The file you hand cosign is not the whole statement you read earlier. It holds only the predicate body, the buildDefinition and runDetails. cosign reads the image to fill in the subject, takes the predicateType from the --type slsaprovenance1 flag, assembles the full statement you saw above, wraps it in a DSSE envelope, and signs that. You describe the build. cosign builds the paperwork around it.

terminal
$ export IMAGE=registry.acme.internal/payments-api
$ export DIGEST=sha256:9f2a3c1b7d4e...e71b
$ cosign attest --yes --predicate predicate.json \
--type slsaprovenance1 "$IMAGE@$DIGEST"
output
Generating ephemeral keys...
Retrieving signed certificate...
tlog entry created with index: 74839201

That was keyless signing, and it runs like the front desk of a locked building. You show the guard your ID, they print a visitor badge good for the next hour, and they write your name in a sign-in book whose pages cannot be torn out. cosign runs that same errand for your build. It asks your CI for an OIDC (OpenID Connect, a standard way for one machine to prove to another that it is who it claims to be) token, hands that token to Fulcio (Sigstore's certificate authority, the badge printer in this story), and gets back a short-lived certificate that ties the signature to your build's identity for about ten minutes. Then it records the whole exchange in Rekor (Sigstore's append-only transparency log, the sign-in book you can read but never edit). No long-lived private key sits on a disk waiting to be stolen.

terminal
$ cosign verify-attestation --type slsaprovenance1 \
--certificate-identity-regexp "https://gitlab.acme.internal/acme/.*" \
--certificate-oidc-issuer https://gitlab.acme.internal \
"$IMAGE@$DIGEST" | jq '.payloadType'
output
Verification for registry.acme.internal/payments-api@sha256:9f2a3c1b7d4e...e71b --
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
"application/vnd.in-toto+json"

Verification runs five checks, and every one has to pass. The certificate has to chain to Fulcio's trusted root. The identity in the certificate has to match your regular expression. The OIDC issuer has to be the one you named. An entry for it has to exist in Rekor. And the subject digest has to match the image you are verifying. Loosen any one of them and you have weakened the gate.

Point the same command at an image an attacker pushed, built under a different identity on a public runner, and watch it refuse.

terminal
$ cosign verify-attestation --type slsaprovenance1 \
--certificate-identity-regexp "https://gitlab.acme.internal/acme/.*" \
--certificate-oidc-issuer https://gitlab.acme.internal \
registry.acme.internal/payments-api@sha256:c0ffee11bad2...bad1
output
Error: no matching attestations: none of the expected identities matched what was in the certificate
main.go:74: error during command execution: no matching attestations: none of the expected identities matched what was in the certificate

That single non-zero exit is the difference between a registry full of images and a registry full of images whose origin you can prove.

Let the builder write the certificate

One rule decides whether provenance is worth anything. You do not let travelers stamp their own passports. Border control does, in a booth the traveler cannot reach into. Provenance works the same way. If your build steps generate and sign their own provenance, then a single compromised step, a poisoned dependency, a malicious test script, sits inside the same environment that describes the build, and it can write whatever flattering history it likes and sign it with the build's own identity.

SLSA frames this as build levels. Build Level 1 means provenance exists. Level 2 means it is signed and comes from a hosted build service. Level 3 means the build platform's control plane generates the provenance in isolation from the steps it runs, so the steps cannot forge it. Moving provenance generation out of the job and into the platform is the jump from 'the build says it built the right thing' to 'the platform, which the build cannot tamper with, says so'.

A loose identity regexp verifies nothing
Two flags carry the weight in verify-attestation, and both fail quietly. --certificate-identity-regexp ".*" will happily accept an attestation signed by anyone, including an attacker's public-CI identity, and still print a green banner. And the --type shorthands map to specific predicate versions: slsaprovenance is v0.2, slsaprovenance1 is v1. Ask for the wrong one and cosign returns 'no matching attestations', which looks identical to having no attestation at all. Pin the type to what you produce, scope the identity to your own build system, and then confirm that a deliberately wrong identity actually fails.
One provenance attestation, from build to gate
1Build platform runs the job
control plane isolated from the steps
2Builder emits provenance
source, pinned inputs, builder id
3Fulcio issues a certificate
short-lived, bound to the OIDC identity
4Rekor logs the entry
append-only transparency record
5Attestation pushed to registry
indexed by the image digest
6Admission gate verifies
type + identity + digest, refuse on fail
Quick check
01Your build job generates provenance.json inside the job itself and signs it with the pipeline identity. One dependency the job pulls turns out to be poisoned. Which outcome is SLSA Build Level 3 designed to rule out?
Incorrect — A valid signature proves the bytes were not edited after signing. It carries no opinion on whether what they say is true.
Incorrect — Rekor pages cannot be torn out, so an entry stays readable once written. Hiding evidence is not the hole Level 3 plugs.
Correct — Anything running inside the job can shape the story the job tells about itself, right down to the identity that signs it. Level 3 moves generation into the platform control plane, where the steps cannot reach.
Incorrect — The subject field pins one sha256 digest, so a statement cannot follow a different image around. A compromised step does not loosen that.
02Both the type label 'application/vnd.in-toto+json' and the predicateType field end up inside the bytes the signature covers rather than beside them. A colleague asks what that placement buys you. What do you tell them?
Correct — Picture the type printed on the sealed page instead of on a note clipped outside it. Change what the document claims to be and the seal stops matching.
Incorrect — Registries file attestations under the image digest and return what is stored there. Type indexing is not the property this placement protects.
Incorrect — Run the download command, pipe the payload through base64 -d, and read every field. These envelopes are signed, not encrypted.
Incorrect — Each statement names one subject and pins its digest, so covering several images means several statements, whatever label they share.
03Your pipeline signs with 'cosign attest --type slsaprovenance1'. A teammate verifies the same digest with 'cosign verify-attestation --type slsaprovenance' and gets 'no matching attestations'. What is the most likely cause?
Incorrect — verify-attestation is the command built for attestations, and it is the one the lesson runs against provenance. The tool choice was fine here.
Incorrect — The Fulcio certificate is short lived, but the log entry it produced stays put. Verification asks whether an entry exists, not how old it is.
Incorrect — You already pass the digest on the command line, and it is a public identifier rather than a secret. Nothing here needs a side channel.
Correct — Two shorthands, two predicate versions. Ask for the one you did not produce and cosign reports nothing matching, which reads exactly like an image that was never attested at all.

Put the verify call in the place that admits artifacts, pin --type to the exact predicate version you produce, and keep --certificate-identity-regexp as tight as your build identities allow. Generate and sign in the pipeline, verify and refuse at the gate. The next section wires that verify call into an admission controller, so a missing or wrong attestation blocks the deploy instead of printing a warning nobody reads.

Try this

Run cosign download attestation "$IMAGE@$DIGEST" | jq 'keys' on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a loose identity regexp verifies nothing. 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