Build Provenance and Verifiable Builds
Build provenance is a signed, machine-readable record of how an artifact was produced — which build platform ran, from which source, using which entrypoint and parameters, and which dependencies it pulled in. A verifiable build is one where that provenance exists and a downstream verifier can mechanically confirm that a given binary traces back to a trusted build of trusted source that meets a stated policy, rather than to an attacker’s laptop or a tampered pipeline (SLSA build provenance). This note owns the verification half of the story: given a provenance attestation, what does a verifier actually check, in what order, and what does each check defend against? Provenance turns the vague hope “we think this came from our CI” into the checkable assertion “this artifact’s hash appears as the subject of a provenance attestation, signed by a builder we trust, whose recorded source and parameters match our expectations.”
Boundary — producer vs verifier
This note is the verifier counterpart to the CI/CD leaf Build Provenance and Attestations, which owns producing provenance inside a pipeline (how GitHub Actions / the SLSA generator emits the attestation). Here the concern is the consumer side: reading that attestation and deciding whether to trust the artifact. The provenance format is an in-toto attestation with an SLSA predicate — the envelope/signing mechanics live in that note. The levels of assurance (Build L0–L3) belong to The SLSA Framework (forward-linked; next round). Reproducibility, the strongest anti-tamper property, is Reproducible Builds.
Mental Model — Provenance as a Checkable Chain of Custody
Think of provenance as a notarized chain-of-custody label riveted to the artifact by its hash. The label answers who made it, from what, how; verification is the act of a customs officer checking that the label is genuine, was issued by an authority they recognize, and describes goods that match the manifest they expected.
flowchart LR SRC["Source repo<br/>(commit digest)"] --> BUILD DEPS["Resolved deps<br/>(hashes)"] --> BUILD PARAMS["Build parameters<br/>(entrypoint, inputs)"] --> BUILD BUILD["Build platform<br/>(builder.id)"] --> ART["Artifact<br/>sha256:e3b0c4..."] BUILD --> PROV["Provenance attestation<br/>subject = artifact digest<br/>+ how it was built<br/>+ SIGNATURE"] PROV -.->|"binds by hash"| ART ART --> VER{"VERIFIER"} PROV --> VER POLICY["Expectations / policy:<br/>trusted builder ids,<br/>allowed source repo,<br/>buildType, params"] --> VER VER -->|match| ADMIT["Admit / deploy"] VER -->|mismatch| DENY["Reject"]
What it shows and the insight to take: every input to the build — source commit, dependencies, parameters — and the identity of the builder are captured in the provenance, which is bound to the artifact by the subject digest, exactly as in in-toto Attestations. Verification is not re-running the build; it is a comparison: does the signed record’s builder, source, and parameters match what this consumer expected for this package? The trust does not come from the provenance saying nice things — a liar can write nice things — it comes from the signature over the provenance being made by a builder the verifier independently trusts, plus the recorded facts matching the verifier’s own expectations. Trust flows from who signed and whether the facts match, never from the narrative alone.
The Provenance Predicate — What the Builder Records
SLSA v1.2 provenance is an in-toto Statement whose predicateType is https://slsa.dev/provenance/v1 and whose predicate has two top-level objects (build provenance). A representative attestation:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "app", "digest": { "sha256": "e3b0c44298fc1c14..." } }
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://slsa.dev/github-actions-buildtype/workflow/v1",
"externalParameters": {
"workflow": {
"repository": "https://github.com/acme/app",
"path": ".github/workflows/release.yml",
"ref": "refs/heads/main"
}
},
"internalParameters": { "runner": "ubuntu-24.04" },
"resolvedDependencies": [
{ "uri": "git+https://github.com/acme/app@refs/heads/main",
"digest": { "gitCommit": "abc123..." } }
]
},
"runDetails": {
"builder": {
"id": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0",
"version": { "generator": "v2.0.0" }
},
"metadata": {
"invocationId": "https://github.com/acme/app/actions/runs/123/attempts/1",
"startedOn": "2026-07-25T10:00:00Z",
"finishedOn": "2026-07-25T10:04:12Z"
},
"byproducts": [ { "name": "build.log", "digest": { "sha256": "..." } } ]
}
}
}The two objects and their fields (build provenance, provenance overview):
buildDefinition — the inputs, “all inputs necessary and sufficient to initialize and begin build execution”:
buildType(required at L1) — a URI naming a template that says how to interpret the rest of the build definition (e.g. “this is a GitHub Actions workflow build”). A verifier keys its interpretation off this.externalParameters(required at L1) — the user/tenant-controlled settings: which repo, which workflow file, which ref, which build flags. These “must be verified downstream” because they are exactly what an attacker would tamper with to inject behavior (point the build at a fork, a malicious workflow, a different ref).internalParameters(optional) — platform-controlled values (runner image, internal config) kept for debugging and reproducibility; not attacker-controlled, so less security-critical.resolvedDependencies(optional array of ResourceDescriptors) — the artifacts actually fetched during the build (source commit, downloaded packages), each with aurianddigest. This is the hook for recursive/transitive verification.
runDetails — the execution:
builder.id(required at L1) — a URI identifying the trusted build platform’s transitive closure. This is the single most important field for the verifier: it is what gets matched against the set of builders the verifier trusts.builder.versionandbuilder.builderDependenciesrefine it.metadata—invocationId(a link back to the build run),startedOn,finishedOn.byproducts— auxiliary outputs like logs.
The key mental split: externalParameters = “what a human/attacker could steer” (verify hard); builder.id = “who vouches for all of this” (must be independently trusted); resolvedDependencies = “what went in” (for deeper verification).
The Verification Algorithm — Step by Step
A verifier is bootstrapped with a root of trust: a map from recognized builder identities to the maximum SLSA level each is trusted to assert, plus that builder’s public keys/certificate identities (verifying artifacts). Verification then proceeds:
flowchart TD START["Have: artifact + provenance attestation + policy"] --> HASH{"Artifact hash ∈<br/>provenance subject[]?"} HASH -->|no| REJ1["REJECT — provenance is<br/>not about this artifact"] HASH -->|yes| SIG{"DSSE signature valid,<br/>and signer ∈ trusted<br/>roots of trust?"} SIG -->|no| REJ2["REJECT — unauthenticated<br/>or untrusted signer"] SIG -->|yes| BID{"builder.id ∈ trusted<br/>builder map?"} BID -->|no| REJ3["REJECT — unknown builder"] BID -->|yes| EXP{"externalParameters,<br/>source repo, buildType<br/>match expectations?"} EXP -->|no| REJ4["REJECT — built from wrong<br/>source / wrong params"] EXP -->|yes| LVL{"Asserted SLSA level ≥<br/>policy-required level?"} LVL -->|no| REJ5["REJECT — insufficient level"] LVL -->|yes| DEPS{"(optional) recurse into<br/>resolvedDependencies?"} DEPS --> PASS["ACCEPT — emit VSA"]
What it shows and the insight to take: the checks are ordered cheapest-and-most-fundamental first, and every one is a distinct defense (verifying artifacts):
- Subject-digest match. Compute the artifact’s hash; confirm it appears in
subject. Without this, an attacker attaches a genuine provenance for a different artifact. This is the binding from in-toto Attestations. - Authenticate the envelope + trust the signer. Verify the DSSE signature and confirm the signer is in the preconfigured roots of trust (for keyless Sigstore, this means the Fulcio certificate’s identity — issuer + subject — matches the trusted build workflow). A valid signature from an untrusted key means nothing.
- Builder identity. Match
builder.idagainst the trusted-builder map. This answers “was this built by a platform I trust to make honest provenance?” A self-hosted, attacker-controlled builder is rejected here even if its signature is valid. - Expectations matching. Compare, at minimum, the builder identity, the canonical source repository, the
buildType, and theexternalParametersagainst the values expected for this specific package (verifying artifacts). This is where “the binary was built by our trusted CI, but from a forked repo / a malicious workflow file / a different ref” is caught — the signature and builder are fine, but the inputs don’t match what this package is supposed to be built from. - Level check. Confirm the SLSA build level the builder is trusted to assert meets the policy’s required minimum (see The SLSA Framework).
- (Optional) recurse into
resolvedDependenciesto verify upstream provenance, mitigating transitive threats.
The essential lesson: a valid signature is necessary but nowhere near sufficient. The expectations check (step 4) is what most people miss — provenance that is authentic and from a trusted builder can still describe a compromised build if the source/parameters were tampered with, and only comparing against per-package expectations catches it.
Producer vs Verifier — Two Halves of One Guarantee
sequenceDiagram participant SRC as Source repo participant CI as Build platform (Producer) participant REG as Registry / Rekor participant VER as Verifier (Consumer side) participant POL as Policy / expectations SRC->>CI: commit (digest) Note over CI: build artifact CI->>CI: generate provenance<br/>(builder.id, externalParameters,<br/>resolvedDependencies) CI->>REG: sign (DSSE) + publish<br/>attestation beside artifact Note over CI,REG: PRODUCER side —<br/>[[Build Provenance and Attestations]] VER->>REG: fetch artifact + provenance VER->>POL: load expectations +<br/>trusted builders Note over VER: subject match → sig+signer →<br/>builder.id → expectations →<br/>level VER->>REG: emit VSA (PASSED, level) Note over VER: VERIFIER side — THIS NOTE
What it shows and the insight to take: the producer’s job is to record and sign the truth about the build; the verifier’s job is to check that record against an independent expectation and its own root of trust. Neither half is useful alone — provenance nobody verifies is decoration, and verification with nothing to verify is impossible. The clean seam is the signed attestation published to the registry/transparency log. The verifier’s decision is itself captured as a Verification Summary Attestation (VSA) — verificationResult: PASSED plus verifiedLevels — so downstream consumers can trust the verifier’s judgment without redoing the work (VSA spec); this is the delegated-trust pattern detailed in in-toto Attestations.
Verifiable Builds and the Level Ladder
Provenance is only as trustworthy as the platform that produced it, which is why “verifiable build” is a spectrum, graded by SLSA’s Build track (build track basics, Sonar SLSA overview):
| Build level | Requirement | Defends against |
|---|---|---|
| L0 | No provenance | nothing (dev/test only) |
| L1 | Provenance exists (may be unsigned/incomplete) | unintentional mistakes; gives awareness/documentation |
| L2 | Provenance generated and signed by a hosted build platform | tampering after the build (signature detects it) |
| L3 | Built on a hardened, isolated platform with tamper-resistant, non-falsifiable provenance and protected signing material | tampering during the build — insider threats, compromised build-time credentials |
The verifier’s expectations usually include a minimum level: a policy might demand “SLSA Build L3 from slsa-github-generator, source github.com/acme/*.” The strongest anti-tamper backing sits under L3 — Reproducible Builds, where an independent rebuilder produces a bit-for-bit identical artifact from the same inputs, letting the provenance be corroborated rather than merely trusted. (v1.2 also reintroduced a Source track for revision integrity; the full ladder is in The SLSA Framework.)
Tooling — What Verification Looks Like
With cosign, verifying an image’s provenance keylessly (Sigstore docs):
cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp '^https://github.com/slsa-framework/slsa-github-generator/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/acme/app@sha256:e3b0c4...Here --certificate-identity* and --certificate-oidc-issuer implement steps 2–3 (authenticate + trust the signer/builder), and the returned predicate is then evaluated against expectations (step 4) by a policy engine — CUE/Rego with cosign, or a purpose-built tool like slsa-verifier which bakes in the builder-id and source-repo checks:
slsa-verifier verify-artifact app.tar.gz \
--provenance-path app.intoto.jsonl \
--source-uri github.com/acme/app \
--builder-id https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml--source-uri and --builder-id are the expectations; the tool fails closed if the provenance’s recorded source or builder don’t match.
Uncertain
Verify: the exact current
slsa-verifierflag surface and the pinnedslsa-github-generatorversion/builder.idstring above. Reason: tool flags and the generator’s tag move between releases and were reconstructed from the SLSA docs’ examples plus general knowledge, not fetched from the tool’s current README during this write. To resolve: check theslsa-framework/slsa-verifierandslsa-github-generatorREADMEs at use time.#uncertain
Failure Modes and Common Misunderstandings
- Verifying the signature but not the expectations. The most common and most dangerous gap: an authentic, trusted-builder provenance for an artifact built from a forked repo or a modified workflow passes signature and builder checks but should fail the
externalParameters/source check. Skipping step 4 defeats the point. - Trusting
builder.idyou haven’t vetted. A provenance can name anybuilder.id; the value is only meaningful if it’s in your trusted map. Accepting arbitrary builder ids lets an attacker’s self-hosted runner assert L3. - Ignoring the subject digest. Provenance not bound to the artifact’s actual hash is provenance for something else.
- Treating L1 as security. L1 provenance may be unsigned and incomplete — it aids awareness but stops no motivated attacker. Signature (L2+) is where tamper-detection begins.
- Assuming provenance implies reproducibility. It doesn’t; only Reproducible Builds lets a third party independently confirm the artifact matches the inputs.
- Stale trust roots. If the map of trusted builders / their signing identities isn’t maintained, either legitimate builds get rejected or a rotated-away key stays wrongly trusted.
See Also
- in-toto Attestations — the envelope/statement/predicate format provenance rides on, and the VSA (delegated trust)
- Build Provenance and Attestations — the CI/CD producer counterpart: generating and signing provenance in a pipeline
- The SLSA Framework — the Build track levels (L0–L3) and Source track this verification enforces
- Reproducible Builds — bit-for-bit rebuilds, the strongest corroboration of provenance
- Sigstore Signing and Keyless Verification — keyless signing and the identity checks step 2–3 rely on
- Software Bill of Materials — the complementary “what’s inside” attestation
- DevSecOps and Supply Chain Security MOC — parent map (§4 Artifact Integrity and Provenance)