Skip to main content
RunBook Academy

Docker & ContainersXII · Supply ChainProvenance

Image provenance — SLSA, attestations, and what they prove

Advanced⏱ ~26 mindockercosign

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

Not yet marked complete on this device.

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 provenanceCovered instead by
That the source code is correct or benignCode review
That the dependencies are free of known vulnerabilitiesCVE scanning
That the build environment was not compromisedSLSA Build L3, and the platform operator
That anyone approved this artefact for releaseA separate attestation, if you make one

Generating it

Configuration changebuild with provenance
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:

Read-only / Safecheck provenance for leaked build arguments
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 provenance

Reading it

Read-only / Safethe provenance record
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.

Read-only / Safeverify a signed provenance attestation
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.

Read-only / Safecheck the provenance against your expectation
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
fi
OK: built from https://github.com/myorg/myapp

Illustrative 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

Configuration changeKyverno: verify an attestation and its contents
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

  1. Build on a platform you can name, and prefer one where the provenance generator is outside the tenant build.
  2. Generate provenance and an SBOM at build time, pushed to the registry so they are bound to the digest.
  3. Sign at publish, keylessly, so the signing identity is the pipeline rather than a key on a runner.
  4. Verify at deploy, constraining the identity exactly and reading the predicate contents, not merely its existence.
  5. Record what level you actually have, in writing, including what it does not defend against.
  6. 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

  1. Q1. SLSA v1.0 Build Level 1 requires that provenance exists. What does the specification say about its resistance to forgery?

  2. Q2. You publish with `--provenance=mode=max` and pass a registry token as `--build-arg`. What is the consequence?

  3. Q3. A provenance verification that previously passed now fails for an image you built. Which are plausible non-malicious causes? Select all that apply.

  4. 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.

  5. Q5. SLSA v1.0 defines four build levels, L1 through L4.

Passing score: 75%. Answers are checked in this browser.