Docker Image SBOMs and Provenance
You cannot answer "are we affected by this CVE" without knowing what is inside your images. Generate a real SPDX bill of materials at build time, read the package list back out of the registry, and see where the provenance record lives.
Security and Production Guide 38 of 46 Advanced
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Buildxv0.36.1
- Architectureamd64
- TimeAbout 14 min
- Reviewed22 August 2026
Tested on the versions above. Attestations need a BuildKit builder. Package lists and digests differ for every image - match the shape, not the strings.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
-
The question you need to be able to answer in minutes
When a vulnerability lands in a common library, the only question that matters is which of your images contain it. Without a bill of materials you answer that by rebuilding and grepping, image by image, under time pressure. An SBOM is that answer recorded at build time, when the information is free.
bash cat DockerfileFROM alpine:3.22RUN apk add --no-cache curlCMD ["curl","--version"]# two instructions - and 26 packages in the result, as the SBOM will showExpected resultA trivial Dockerfile.
Success conditionYou accept that you do not know what is in this image. Neither does anyone, until it is measured.
-
Build with attestations attached
--sbom=trueand--provenance=truemake BuildKit record what went into the image and how it was built, then push both alongside it. They are attached to the image rather than stored separately, so they travel with it through every registry copy.bash Example session docker buildx build --builder cg-builder --sbom=true --provenance=true -t localhost:5000/cg-ci:1 --push .#9 exporting attestation manifest sha256:67cb2ddf8e16dc650662c0f4a38ff8e8f41195c2d718ace2d42e5d16af86e506 0.0s done#9 exporting manifest list sha256:2dd2a150c294f6984e676418b7230fe26b7b1c3aaa7845c362c1179fe7bcfe1d 0.0s done#9 DONE 0.6sExpected resultAn attestation manifest exported alongside the image manifest.
Success conditionThe build produced an extra manifest. That is the attestation - if you do not see this line, the flags did not take effect.
-
See how they are stored
The tag now points at an index containing two manifests: your image for linux/amd64, and a second with platform
unknown/unknown. That odd platform is deliberate - it keeps older clients from trying to run it. The annotation ties the attestation to the exact image digest it describes.bash Example session docker buildx imagetools inspect localhost:5000/cg-ci:1MediaType: application/vnd.oci.image.index.v1+json Name: localhost:5000/cg-ci:1@sha256:063a3b8b5f85429a3e3430a1ebfe151fb9d9a0f15cd8989e5c5673094604bed2 Platform: linux/amd64 Name: localhost:5000/cg-ci:1@sha256:67cb2ddf8e16dc650662c0f4a38ff8e8f41195c2d718ace2d42e5d16af86e506 Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:063a3b8b5f85429a3e3430a1ebfe151fb9d9a0f15cd8989e5c5673094604bed2 vnd.docker.reference.type: attestation-manifestExpected resultTwo manifests, the second marked as an attestation-manifest.
Success conditionYou can point at where the SBOM lives.
unknown/unknownis expected, not a fault - and it is why a plaindocker pullignores it. -
Read the bill of materials
The SBOM is SPDX, a standard format that scanners and licence tools understand. You do not need any of them to get value from it - a template query lists every package and version in the image, which is exactly what you need when comparing against an advisory.
bash Example session docker buildx imagetools inspect localhost:5000/cg-ci:1 --format "{{ range .SBOM.SPDX.packages }}{{ .name }}@{{ .versionInfo }}{{ println }}{{ end }}" | head -8alpine-baselayout@3.7.0-r0alpine-baselayout-data@3.7.0-r0alpine-keys@2.5-r0alpine-release@3.22.5-r0apk-tools@2.14.10-r0brotli-libs@1.1.0-r2busybox@1.37.0-r20busybox-binsh@1.37.0-r20Expected resultA package list with versions, and a count.
Verify it worked
bash Example session docker buildx imagetools inspect localhost:5000/cg-ci:1 --format "{{ json .SBOM.SPDX.packages }}" | grep -c SPDXID26Success conditionTwenty-six packages from a two-line Dockerfile. That gap between what you wrote and what you shipped is the reason the SBOM exists.
-
The provenance record
Provenance answers a different question: not what is inside, but how it got made. It follows the SLSA schema and records the build type, the source of the Dockerfile and the parameters used. Note the nesting -
buildTypesits underbuildDefinition, and a template that guesses the path returns nothing rather than erroring.bash Example session docker buildx imagetools inspect localhost:5000/cg-ci:1 --format "{{ json .Provenance }}" | head -c 300{ "SLSA": { "buildDefinition": { "buildType": "https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md", "externalParameters": { "configSource": { "path": "Dockerfile"Expected resultA SLSA document naming the build type and the Dockerfile it came from.
Success conditionYou can see how the image was produced. In CI this records the repository, commit and workflow, which is what makes a build auditable.
-
Scanning is a separate tool, and this host has none
An SBOM lists packages; it does not tell you which are vulnerable. That comparison against a vulnerability database is a scanner's job - Docker Scout, Trivy, Grype and others all consume the SBOM you just produced. Scout is a CLI plugin and is not installed everywhere, as this host demonstrates.
bash docker scout versiondocker: unknown command: docker scout# not installed here - the SBOM above is still complete and portable# any SPDX-consuming scanner can read it, which is the advantage of a standard formatExpected resultThe plugin missing on a stock Engine install.
Success conditionYou understand the split. Generating the SBOM is a build concern and costs nothing; scanning it is a separate tool you choose.
-
Reduce what there is to scan
The cheapest supply-chain improvement is having less in the image. Every package in that list of 26 is something that can have a CVE. A multi-stage build that ships only a binary has almost nothing to report - which is a security property, not just a size one.
bash Example session docker images --filter reference=cg-multi --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"REPOSITORY:TAG SIZEcg-multi:1 112kBcg-multi:builder 253MB# the 112kB image has no shell, no package manager and no packages to enumerateExpected resultThe runtime image orders of magnitude smaller than the builder.
Success conditionYou can connect image size to attack surface. See guide 19 for how that image was produced.
Troubleshooting
The SBOM query returns nothing
Why: The image was built without
--sbom=true, or by a builder that cannot produce attestations.Fix:Rebuild with the flag on a docker-container builder, and confirm the attestation manifest line appears in the build output.
bash docker buildx imagetools inspect REF# no unknown/unknown manifest means no attestations are attachedA format template returns <no value>
Why: The path does not exist in the document. Provenance is nested -
buildTypeis under.Provenance.SLSA.buildDefinition, not directly under SLSA.Fix:Print the whole object as JSON first and read the real structure, then narrow the path.
bash docker buildx imagetools inspect REF --format "{{ json .Provenance }}" | head -30Attestations disappeared after copying the image
Why: A tool that copies only the platform-specific manifest drops the attestation manifest with it.
docker pullthendocker pushdoes exactly this.Fix:Copy the whole index with
docker buildx imagetools create, which preserves every manifest including attestations.bash docker buildx imagetools create --tag NEW_REF OLD_REFThe SBOM lists packages the application does not use
Why: It records what is present in the image, not what is loaded at runtime. A base image brings packages you never chose.
Fix:That is the correct behaviour - an unused vulnerable package is still shipped and still reachable if an attacker gets a shell. Remove it from the image rather than from the report.
bash docker buildx imagetools inspect REF --format "{{ range .SBOM.SPDX.packages }}{{ .name }}{{ println }}{{ end }}" | wc -l