CoursesSoftware supply chain in depthBuild provenance in practice

Build provenance in practice

What it asserts and why the build must be hardened.

Advanced30 min · lesson 4 of 15

It is 02:00 and a container in your cluster is sending traffic to a host nobody recognises. The image is ghcr.io/acme/api:latest, pulled an hour ago, and the registry insists it is yours. Fine. But who built it? From which commit? On which runner? Without provenance the honest answer is 'we have no idea', and that blind spot is exactly what the SolarWinds attackers used: a backdoor slipped in while the software was being built, then signed and shipped as a genuine release that every customer downstream trusted. A shipping container carries a paper trail: which factory packed it, on what day, from which raw materials. Build provenance is that paper trail for software. It is a signed, machine-checkable record that ties one exact set of bytes, named by digest (a cryptographic fingerprint of the file, never a tag anyone can move), to the source, the builder and the process that made them. With it, 'where did this running thing come from?' has an answer a machine can check. The gap between a five-minute containment and a week of forensic archaeology is usually whether that answer exists.

What a provenance attestation actually is

An attestation is a signed note about a thing. in-toto (an open standard for signed statements about software, from the Latin for 'in full') gives that note a fixed shape: a Statement whose subject names the artifact by digest, and whose predicate carries the actual claim. Provenance is one kind of predicate. For build provenance the predicateType is https://slsa.dev/provenance/v1 and the body follows the SLSA v1.0 schema (SLSA is Supply-chain Levels for Software Artifacts, the industry ladder of build-integrity requirements). That body has two halves. buildDefinition is the order form: the buildType, the externalParameters a user controlled (repository, ref, workflow path), and the resolvedDependencies (source and inputs, each pinned by digest). runDetails is the receipt: the builder that actually ran the job, plus metadata such as the continuous integration (CI) run id. The split is the whole point. Anything under externalParameters is request data, so someone with repository access could influence it, while runDetails.builder.id is stamped by the platform itself and is the field you anchor trust to. in-toto is the envelope; SLSA is the specific form inside it, and knowing which layer owns which field is what makes verification precise. Because the subject is a digest, the claim is welded to those exact bytes. Nobody can peel it off and stick it on a look-alike tag or a rebuilt image.

generate SLSA v1.0 provenance in CI (.github/workflows/release.yml)
# After building & pushing, capture the image digest, then attest provenance.
# actions/attest-build-provenance signs KEYLESS via Fulcio + Rekor and, with
# push-to-registry, attaches the attestation to the image as an OCI referrer.
# Grant these at the job level, or the first run fails before it signs anything:
permissions:
contents: read
id-token: write # mint the OIDC token Fulcio checks
attestations: write # store the attestation against the repo
packages: write # only for push-to-registry against GHCR
steps:
# ...build and push the image first, then:
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/acme/api
subject-digest: sha256:9f2c8b1e4d... # the exact bytes you pushed
push-to-registry: true
or attest a predicate you generated yourself (any builder)
# Non-GitHub builders: produce a SLSA v1.0 predicate, then attest it with cosign.
# --type slsaprovenance1 => predicateType https://slsa.dev/provenance/v1
# no --key given => keyless (ephemeral Fulcio cert + Rekor entry)
# attest the DIGEST, never a tag
cosign attest --yes \
--type slsaprovenance1 \
--predicate provenance.json \
ghcr.io/acme/api@sha256:9f2c8b1e4d...
→ Fulcio issues a cert, Rekor records the attestation
Using payload from: provenance.json
Generating ephemeral keys...
Retrieving signed certificate from Fulcio...
Successfully verified SCT...
tlog entry created with index: 148203771

So how does the signing work when there is no key to guard? cosign, and actions/attest-build-provenance underneath it, sign keyless. The client generates a throwaway keypair, presents an OIDC token (OpenID Connect, the same family of identity token behind 'sign in with Google') proving which workflow is asking, and Fulcio returns a short-lived X.509 certificate, good for roughly ten minutes, binding that throwaway public key to the identity: the workflow URI (its unique address) sits in the certificate's SAN (Subject Alternative Name, the field that says who a certificate is for) and the OIDC issuer sits in a custom extension. The signature and a Rekor transparency-log inclusion proof (Rekor is a public, append-only ledger of signing events) are stored next to the artifact, so there is no long-lived private key to steal or rotate. The Statement itself rides inside a DSSE envelope (Dead Simple Signing Envelope), a JSON object with payloadType application/vnd.in-toto+json, the base64 payload, and a signatures array of {keyid, sig}. One detail is easy to skim past: the signature is computed over a Pre-Authentication Encoding, DSSEv1 <len> payloadType <len> payload, which folds payloadType into the signed bytes, so a payload signed as one media type cannot be replayed as another. That payloadType is application/vnd.in-toto+json for every in-toto attestation, though, so it is not the field that separates provenance from an SBOM or a VEX document. predicateType does that, and it is protected the plain way: it sits inside the payload, and the payload is what gets signed. Fulcio and Rekor each get their own lesson. Here, the field you have to read and pin is the predicate.

Inside the predicate: buildDefinition and runDetails

provenance.json — the SLSA v1.0 predicate (in-toto Statement)
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "ghcr.io/acme/api",
"digest": { "sha256": "9f2c8b1e4d..." } }
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://actions.github.io/buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"repository": "https://github.com/acme/api",
"ref": "refs/heads/main",
"path": ".github/workflows/release.yml"
}
},
"internalParameters": {
"github": { "event_name": "push", "repository_id": "778..." }
},
"resolvedDependencies": [
{ "uri": "git+https://github.com/acme/api@refs/heads/main",
"digest": { "gitCommit": "a3f0c7e1b9..." } }
]
},
"runDetails": {
"builder": {
"id": "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main"
},
"metadata": {
"invocationId": "https://github.com/acme/api/actions/runs/10293847561/attempts/1"
}
}
}
}

Read that file top to bottom. buildType is an address naming the schema of everything underneath externalParameters (https://actions.github.io/buildtypes/workflow/v1 for a GitHub Actions build), so a verifier knows how to interpret those fields instead of guessing. externalParameters.workflow records the repository, the ref and the path of the workflow file: the human-readable 'who asked for this build'. resolvedDependencies pins what actually went in, by digest. The source commit shows up as a gitCommit digest, alongside any base images or fetched materials, and that is the part which lets you prove the artifact came from reviewed code rather than an attacker's branch or a swapped dependency. internalParameters holds context the platform set for itself (event name, repository id), which is gold during an incident. Then runDetails. builder.id is the trust anchor: for this buildType it is the fully qualified workflow that produced and signed the provenance, here the caller's own release.yml at its ref, because actions/attest-build-provenance runs as a step inside that same workflow. metadata.invocationId points straight back at the exact CI run for audit. One thing decides the SLSA level, and it is not the schema. It is where the provenance gets generated. A record emitted by the same job that runs your build (attest-build-provenance, roughly Level 2) is weaker than one generated in an isolated job the build cannot touch. Delegate to a generator such as slsa-github-generator and builder.id then names that isolated workflow at a pinned tag, which is Level 3. Identical fields, very different trust.

read the predicate back from the registry
# cosign download attestation returns the DSSE envelope; decode the base64
# payload and read the predicate fields a policy engine would compare.
cosign download attestation ghcr.io/acme/api@sha256:9f2c8b1e4d... \
| jq -r '.payload | @base64d | fromjson
| { subject: .subject[0].digest.sha256,
buildType: .predicate.buildDefinition.buildType,
source: .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit,
builder: .predicate.runDetails.builder.id }'
→ the decoded predicate fields a policy engine reads
{
"subject": "9f2c8b1e4d...",
"buildType": "https://actions.github.io/buildtypes/workflow/v1",
"source": "a3f0c7e1b9...",
"builder": "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main"
}

Verifying: the identity pin is the real control

Generating provenance changes nothing until something checks it. And on its own, a valid keyless signature proves only that some GitHub Actions workflow signed this image, because every Actions token comes from the same Fulcio-trusted issuer. The signature never says which workflow. The security control is identity pinning: --certificate-identity-regexp fixed to your exact workflow and ref, plus --certificate-oidc-issuer fixed to https://token.actions.githubusercontent.com. cosign then runs three independent checks: the DSSE signature against the Fulcio certificate, the Rekor inclusion proof, and the certificate's identity against your pin. Note what is missing from that list: none of the three reads the predicate body, so cosign never compares builder.id or buildType against a value you supplied. slsa-verifier does check the builder, via --source-uri and --builder-id, for slsa-github-generator output, and if you want a gate that pins predicate fields from any builder, that is a policy engine's job: Kyverno or Sigstore's Policy Controller reading the decoded payload above. Verify by digest, and require the predicate's subject digest to equal the digest you are about to run. If the deployed digest and the attested subject ever disagree, treat the image as unprovenanced and fail closed, because a gate that shrugs and admits when the attestation is missing protects nothing at all. The cryptography itself is offline, signature checks plus Merkle-proof arithmetic (a Merkle proof is the small set of hashes showing an entry really sits in the log), a few milliseconds of work. What needs the network is everything around it: pulling the attestation down from the registry and keeping the Sigstore trust root current. On a connected runner it is still cheap enough to run at every gate: release, admission, and again at deploy.

verify with identity + issuer pinned (anchored)
cosign verify-attestation \
--type slsaprovenance1 \
--certificate-identity-regexp '^https://github\.com/acme/api/\.github/workflows/release\.yml@refs/heads/main$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
ghcr.io/acme/api@sha256:9f2c8b1e4d...
→ three independent checks pass
Verification for ghcr.io/acme/api@sha256:9f2c8b1e4d... --
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
Certificate subject: https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main
Certificate issuer URL: https://token.actions.githubusercontent.com
Verify the digest, never the tag
Point cosign verify-attestation at image:tag and cosign re-resolves that tag at the moment it runs. An attacker who can push to the registry can repoint :latest to an unprovenanced image in the gap between your check and your pull. That gap has a name: a TOCTOU race (time of check, time of use). Resolve the tag to image@sha256:... first, verify that exact digest, deploy that same digest, and pin digests in your Kubernetes manifests. Provenance protects one exact set of bytes; a mutable tag hands the attacker the bytes.

In production, what breaks is trust configuration, not cryptography. The classic footgun is an unanchored identity regex: github.com/acme also matches a fork, a pull_request workflow, and a repository called github.com/acme-evil. Anchor it, ^...@refs/heads/main$, and always pin the issuer. Prefer keyless so there is no long-lived signing key sitting around waiting to be stolen; if you must use keys, keep them in a KMS (Key Management Service) or an HSM (Hardware Security Module), never in a CI environment variable. Budget for the transparency-log dependency too. Air-gapped or offline verifiers need the Rekor proof bundled with the artifact and the Sigstore trust root refreshed through TUF (The Update Framework, the mechanism that safely distributes those root keys), and your verifiers should be watching Fulcio and Rekor availability, because a hard dependency on a free public-good instance is an availability risk. A cached trust bundle or a private Sigstore deployment takes that risk back. Provenance is one predicate among several: the same in-toto Statement envelope also carries SBOMs (Software Bill of Materials files), test results and VEX (Vulnerability Exploitability eXchange) documents. The next lesson picks up right there, showing how in-toto attestations compose so a single gate can demand 'built by our workflow AND has an SBOM AND passed scanning' before anything is allowed to run.

The verification decision at the gate
cosign verify-attestation image@sha256:…
run at the release + admission gate
DSSE sig / Fulcio cert invalid
REJECT
tampered payload, or not signed via Fulcio: cert chain or Rekor proof fails
no matching attestation
REJECT
unprovenanced image, e.g. a swapped :latest with no provenance
identity ≠ pinned workflow/issuer
REJECT
built by a fork, a PR, or another repo: wrong builder.id or OIDC issuer
all three checks pass
ADMIT
these exact bytes were built by your workflow from reviewed source
A valid signature is necessary but not sufficient. The identity pin is what turns 'someone signed this' into 'our workflow built this'.
Quick check
01You run cosign download attestation for ghcr.io/acme/api@sha256:9f2c8b1e4d... and read the decoded payload. A teammate wants the admission policy to pin externalParameters.workflow.path rather than runDetails.builder.id, since both spell out .github/workflows/release.yml. Why is that pin weaker?
Correct — That is the split the predicate is built around. Anyone who can push to the repository can influence the request side, so policy anchors on the field the build platform fills in for itself.
Incorrect — The path is recorded for any workflow build, and the sample predicate shows it alongside an event_name of push. The weakness is who can influence the value, not when it shows up.
Incorrect — buildType tells a verifier how to read what sits under externalParameters. It does not vouch for those values, and cosign takes identity from the signing certificate rather than from the predicate body.
Incorrect — The whole in-toto Statement, predicate included, is the payload that gets signed. The signature does cover those fields; signing them just does not make request data trustworthy.
02Your release workflow attests with actions/attest-build-provenance@v2, and the decoded builder.id comes back as https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main. An auditor puts that at roughly SLSA Level 2 and asks for Level 3. What actually gets you there?
Incorrect — Where the key lives does not move the level, and a signing key a build job can read caps assurance rather than raising it. Keyless exists so there is no long-lived key to steal.
Incorrect — Pinning inputs by digest is worth doing and pays off during an incident, but the level is not scored on how many entries the list holds.
Correct — The build stops writing its own report card. A separate workflow the build cannot reach produces and signs the record, and builder.id then names that generator at a pinned tag.
Incorrect — You should be doing that anyway, but it hardens how you check whatever provenance exists. It changes nothing about how the record was produced in the first place.
03A contractor's fork of acme/api runs its own Actions workflow and pushes an image into your registry. Your gate runs cosign verify-attestation with --certificate-oidc-issuer https://token.actions.githubusercontent.com and --certificate-identity-regexp 'github.com/acme'. The fork's image is admitted. Which change stops it?
Incorrect — That swaps an identity problem for a key management problem, and a long-lived key sitting in CI is exactly what keyless removes. Pin who signed, not what flavour of key they used.
Correct — Unanchored, the pattern matches anything containing that text: a fork, a pull_request run, even a repository called acme-evil. Wrapping the exact workflow and ref in ^ and $ is the control.
Incorrect — --type selects which predicate you are checking, and the fork's build provenance carries the same predicateType as yours. It filters the kind of claim, never the identity behind it.
Incorrect — Every keyless signature gets logged, the fork's included. Rekor proves an event happened and when, not that you approved the workflow that caused it.

Try this

Attest one throwaway image with the workflow above, then run the verify command against its digest and watch the three checks pass. Now break the pin deliberately: change refs/heads/main to refs/heads/anything inside --certificate-identity-regexp and run it again. cosign exits non-zero with a 'no matching attestations' error, and that is the result worth sitting with, because it is the same failure you get from a fork, from a pull_request run, and from an image nobody ever attested. Your gate cannot tell those three apart, so whatever it does on failure it has to be willing to do on all of them.

Takeaway

The trap worth remembering here: verify the digest, never the tag. 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