Docker & ContainersXII · Supply ChainProvenance
Image provenance — SLSA, attestations, and what they prove
What you'll learn
- State what a provenance attestation proves and what it explicitly does not
- Generate and read BuildKit provenance, and choose between mode=min and mode=max deliberately
- Describe SLSA v1.0 build levels L0 to L3 accurately, including what each does not defend against
- Enforce a provenance claim rather than merely require that one exists
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
Provenance is a statement about how an image was built: the builder, the inputs, the parameters, the environment. Combined with a signature, it lets a consumer check that an artefact came from the process they expect rather than from somewhere else.
The critical question — and the one most discussions of provenance skip — is who is making the statement, and could they have lied? That question is what the SLSA levels measure, and it is why “we generate SLSA provenance” is not, by itself, a security claim.
What provenance records
BuildKit attaches an in-toto attestation whose predicate is a SLSA provenance record. It contains:
- The subject: the image digest this statement is about.
- The builder identity: which build platform produced it.
- The build type: a URI naming the kind of build performed. For
BuildKit and SLSA v1 that is
https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md. - External parameters: the build request — frontend, context, target platform, build arguments.
- Materials / resolved dependencies: the base images and other inputs, by digest.
- Metadata: timestamps, an invocation identifier, and completeness indicators.
And what it does not contain, stated plainly:
| Not proven by provenance | Covered instead by |
|---|---|
| That the source code is correct or benign | Code review |
| That the dependencies are free of known vulnerabilities | CVE scanning |
| That the build environment was not compromised | SLSA Build L3, and the platform operator |
| That anyone approved this artefact for release | A separate attestation, if you make one |
Generating it
docker buildx build \
--provenance=mode=max \
--sbom=true \
--tag registry.example.com/myorg/myapp:1.0.0 \
--push .The long form is --attest type=provenance,mode=[min,max],version=[v0.2,v1],
which is what you need when you want to pin the predicate version.
Before publishing mode=max provenance for the first time, read what
it actually emitted:
IMG=registry.example.com/myorg/myapp:1.0.0
docker buildx imagetools inspect "$IMG" --format '{{ json .Provenance.SLSA }}' \
| grep -oiE '"[^"]*(token|password|secret|api[_-]?key)[^"]*"[[:space:]]*:[[:space:]]*"[^"]+"' \
|| echo 'OK: no credential-shaped build parameters in provenance'OK: no credential-shaped build parameters in provenanceReading it
IMG=registry.example.com/myorg/myapp:1.0.0
docker buildx imagetools inspect "$IMG" --format '{{ json .Provenance.SLSA }}' | jq '{
buildType: .buildDefinition.buildType,
builder: .runDetails.builder.id,
source: .buildDefinition.externalParameters
}'{
"buildType": "https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md",
"builder": "https://github.com/myorg/myapp/actions/runs/0000000000",
"source": {
"configSource": {
"uri": "https://github.com/myorg/myapp",
"digest": { "sha1": "0000000000000000000000000000000000000000" }
}
}
}Illustrative output
The fields to actually look at, in priority order: the builder id (who claims to have built this), the config source (which repository and commit), and the resolved dependencies (which base image digests went in).
The SLSA levels, honestly
SLSA v1.0 defines Build levels 0 through 3. There is no Level 4 in v1.0 — earlier drafts had one, and material referring to “SLSA 4” is describing a superseded version.
Build L0 — no guarantees. “No requirements — L0 represents the lack of SLSA.”
Build L1 — provenance exists. “Package has provenance showing how it was built. Can be used to prevent mistakes but is trivial to bypass or forge.”
Read that last clause carefully, because it describes the default state of most provenance in the wild. L1 protects against mistakes: it tells you which commit an artefact came from when somebody has lost track. It does not protect against anyone who wants to lie, because the provenance is produced by the same process that could be lying.
Build L2 — hosted build platform. “Forging the provenance or evading verification requires an explicit ‘attack’, though this may be easy to perform.” The addition over L1 is that the build runs on a hosted platform and the provenance is signed, so post-build tampering becomes detectable.
What L2 explicitly does not cover: tampering during the build. If build steps can influence the provenance the platform generates — and in many configurations they can — the signature is over a statement the build itself helped write.
Build L3 — hardened builds. “Forging the provenance or evading verification requires exploiting a vulnerability that is beyond the capabilities of most adversaries.” L3 requires that the build platform generates provenance the tenant cannot influence, and that builds are isolated from one another.
L3 is the level at which “this provenance says the build came from commit X” becomes a claim about the world rather than a claim the builder made about itself.
Verifying and enforcing
Generating provenance and requiring provenance are different things, and the gap between them is where most implementations stop.
DIGEST=sha256:REPLACE_ME
IMG="registry.example.com/myorg/myapp@${DIGEST}"
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity-regexp '^https://github\.com/myorg/myapp/\.github/workflows/release\.yml@refs/heads/main$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMG"That verifies the attestation is authentic. It does not check that the attestation says anything you want. Those are separate steps and the second is the one with the security value.
set -euo pipefail
IMG=registry.example.com/myorg/myapp:1.0.0
EXPECTED_URI=https://github.com/myorg/myapp
ACTUAL=$(docker buildx imagetools inspect "$IMG" \
--format '{{ json .Provenance.SLSA }}' \
| jq -r '.buildDefinition.externalParameters.configSource.uri // "unknown"')
if [ "$ACTUAL" = "$EXPECTED_URI" ]; then
echo "OK: built from $ACTUAL"
else
echo "FAIL: provenance claims source $ACTUAL, expected $EXPECTED_URI" >&2
exit 1
fiOK: built from https://github.com/myorg/myappIllustrative output
Cosign also supports evaluating a Rego or CUE policy against the
predicate with --policy, which is the right shape once the conditions
grow beyond one comparison.
Admission control
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-provenance
spec:
webhookConfiguration:
failurePolicy: Fail
timeoutSeconds: 30
background: false
rules:
- name: check-provenance
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- 'registry.example.com/myorg/*'
failureAction: Enforce
attestations:
- predicateType: https://slsa.dev/provenance/v1
attestors:
- entries:
- keyless:
subject: 'https://github.com/myorg/*'
issuer: 'https://token.actions.githubusercontent.com'
conditions:
- all:
- key: '{{ buildDefinition.externalParameters.configSource.uri }}'
operator: Equals
value: 'https://github.com/myorg/myapp'The conditions block is what turns “an attestation exists” into “the
attestation says the thing we require”. Without it, an attacker who can
produce any attestation at all satisfies the policy.
Building a chain that means something
- Build on a platform you can name, and prefer one where the provenance generator is outside the tenant build.
- Generate provenance and an SBOM at build time, pushed to the registry so they are bound to the digest.
- Sign at publish, keylessly, so the signing identity is the pipeline rather than a key on a runner.
- Verify at deploy, constraining the identity exactly and reading the predicate contents, not merely its existence.
- Record what level you actually have, in writing, including what it does not defend against.
- Re-check the recorded claim when the pipeline changes — a runner migration or a workflow rename silently invalidates every identity constraint you wrote.
Step 6 is the operational reality nobody warns about. Identity
constraints are exact strings; renaming release.yml to publish.yml
breaks every verification in the estate, and the failure arrives as a
deploy blocked at 02:00 rather than as a review comment.
Knowledge check
Knowledge check · 5 questions
Q1. SLSA v1.0 Build Level 1 requires that provenance exists. What does the specification say about its resistance to forgery?
Q2. You publish with `--provenance=mode=max` and pass a registry token as `--build-arg`. What is the consequence?
Q3. A provenance verification that previously passed now fails for an image you built. Which are plausible non-malicious causes? Select all that apply.
Q4. Checking that an attestation is authentically signed and checking what the attestation actually says are separate steps, and a policy without the second one accepts any validly signed attestation.
Q5. SLSA v1.0 defines four build levels, L1 through L4.
Passing score: 75%. Answers are checked in this browser.