SBOMs, Provenance & Image Signing
When a serious vulnerability in a popular library makes the news, every company asks the same two questions within the hour: "Which of our images contain this library?" and "Can we trust that the image on our server is the one our pipeline built?" Teams that cannot answer spend days pulling and unpacking images by hand. Teams that keep the right records answer in minutes.
This lesson covers the three records that answer those questions: an SBOM (what is inside), provenance (how and from what it was built) and a signature (proof that a trusted pipeline produced it and nobody changed it since). Together they are called software supply chain security.
Three records, three questions
| Record | Question it answers | Common formats and tools |
|---|---|---|
| SBOM (software bill of materials) | Which packages and versions are in this image? | SPDX, CycloneDX; BuildKit, Docker Scout, Trivy |
| Provenance | Which source commit, Dockerfile and builder produced it? | SLSA provenance; BuildKit, GitHub artifact attestations |
| Signature | Did a trusted identity produce exactly this image? | Sigstore cosign, GitHub attestations, Notation |
An SBOM is like the ingredient list on a packet of biscuits. Provenance is the factory's batch record. The signature is the tamper-proof seal.
SBOM and provenance from BuildKit
BuildKit can attach both records to an image as attestations when it pushes. Minimal provenance is added by default; you ask for an SBOM and for detailed provenance explicitly:
docker buildx build --sbom=true --provenance=mode=max \
-t ghcr.io/priya-sharma/shop-api:2.1.0 --push .
docker buildx imagetools inspect ghcr.io/priya-sharma/shop-api:2.1.0 \
--format '{{ json .Provenance }}'{"SLSA":{"buildType":"https://mobyproject.org/buildkit@v1",...}}This output is traced from the format in Docker's documentation and heavily shortened; the real document also lists the source, the build arguments and every base image. --format '{{ json .SBOM }}' prints the SBOM in SPDX format, a long JSON document listing every package with its version and licence. mode=max provenance records the build arguments, the source repository and commit, and the base images used. Be careful: it also records build arguments, so never pass secrets as --build-arg; use build secrets instead.
Attestations are stored next to the image in the registry, so you can read them without pulling the image. In GitHub Actions, the same is two extra inputs on docker/build-push-action: sbom: true and provenance: mode=max.
SBOMs from scanners, and rescanning them later
You can also create an SBOM from any existing image, including ones you did not build:
docker scout sbom --format spdx --output shop-api.spdx.json ghcr.io/priya-sharma/shop-api:2.1.0
trivy image --format cyclonedx --output shop-api.cdx.json ghcr.io/priya-sharma/shop-api:2.1.0
trivy sbom shop-api.cdx.jsonThe first two commands write SBOMs in the two common standards, SPDX and CycloneDX. The third is the useful trick: Trivy scans a saved SBOM for vulnerabilities without the image. Keep an SBOM for every release in your artifact storage, and when the next big CVE appears, scanning a folder of small JSON files tells you which releases are affected.
Keyless signing with cosign
Sigstore is an open-source project for signing software, and cosign is its tool for container images. Old-style signing needs a private key that you must store and protect for years. Sigstore's keyless signing avoids that: in GitHub Actions, the workflow proves its identity with a short-lived OIDC token (OpenID Connect, a standard way for one service to vouch for another), Sigstore issues a certificate valid for a few minutes, cosign signs the image digest, and the signature is recorded in Sigstore's public transparency log.
Add this to the build job of your workflow:
permissions:
contents: read
packages: write
id-token: writesteps:
# ... checkout, buildx, metadata and login as before ...
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
sbom: true
provenance: mode=max
- uses: sigstore/cosign-installer@v4
- name: Sign the image
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes ghcr.io/priya-sharma/shop-api@${DIGEST}id-token: write lets the job request the OIDC token. The step signs the digest, not a tag, because a tag can later point at different content while a digest cannot. --yes skips the confirmation prompt about the public transparency log, which a CI job cannot answer.
Anyone can now check the signature, and must say which identity they trust:
cosign verify ghcr.io/priya-sharma/shop-api:2.1.0 \
--certificate-identity-regexp '^https://github.com/priya-sharma/shop-api/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comVerification for ghcr.io/priya-sharma/shop-api:2.1.0 -- 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
The output is traced and the JSON that follows it is left out. The identity is the workflow file in Priya's repository, and the issuer is GitHub Actions. An image signed by anyone else, or changed after signing, fails with an error and a non-zero exit code.
GitHub artifact attestations
GitHub offers a similar built-in service. The actions/attest@v4 action creates a signed provenance attestation for an image, using inputs subject-name (the image name without a tag) and subject-digest (the digest output of the build step), with the permissions id-token: write and attestations: write. You verify it with the GitHub CLI:
gh attestation verify oci://ghcr.io/priya-sharma/shop-api:2.1.0 -R priya-sharma/shop-apiVerify before you deploy
Signing is pointless unless something checks the signature. The simplest enforcement is one line at the top of the deploy script from Deploy to a VPS with a Reverse Proxy, so an unsigned or tampered image is never started:
cosign verify "ghcr.io/priya-sharma/shop-api:${new_tag}" \
--certificate-identity-regexp '^https://github.com/priya-sharma/shop-api/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com > /dev/null \
|| { echo "Signature check failed for $new_tag" >&2; exit 1; }In Kubernetes, admission controllers such as Kyverno or Sigstore's policy controller do the same check for every Pod; see the Kubernetes course for clusters.
Common mistakes
- Signing a tag instead of a digest. Tags move; always sign
image@sha256:.... - Verifying without an identity. A signature from "anyone" proves nothing. Always pass the expected identity and issuer.
- Secrets in build arguments with
mode=maxprovenance. They end up in the attestation. Use build secrets. - Generating SBOMs and never keeping them. Store one per release so you can rescan later.
- Signing but never verifying. Add verification to the deploy script or the cluster.
Key points
- An SBOM lists what is inside, provenance records how it was built, and a signature proves who built it and that it is unchanged.
--sbom=true --provenance=mode=maxattaches attestations at push time;imagetools inspect --formatreads them from the registry.- Docker Scout and Trivy create SPDX or CycloneDX SBOMs;
trivy sbomrescans saved SBOMs without the image. - cosign keyless signing in GitHub Actions needs
id-token: write, signs the digest and records it in a public log; verify with the expected identity and issuer. - GitHub artifact attestations (
actions/attest,gh attestation verify) are an alternative; Docker Content Trust is retired.
Exercise
Ananya's team in Kochi publishes ghcr.io/ananya-iyer/clinic-api from the workflow .github/workflows/release.yml. Write the two workflow steps that build and push with an SBOM and maximum provenance, then sign the pushed digest with cosign. Then write the command her VPS should run to verify version 3.2.0 before deploying.
Show solution
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
push: true
tags: ghcr.io/ananya-iyer/clinic-api:3.2.0
sbom: true
provenance: mode=max
- name: Sign
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes ghcr.io/ananya-iyer/clinic-api@${DIGEST}cosign verify ghcr.io/ananya-iyer/clinic-api:3.2.0 \
--certificate-identity-regexp '^https://github.com/ananya-iyer/clinic-api/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe job also needs id-token: write and a sigstore/cosign-installer@v4 step before signing. The identity pattern accepts only signatures made by the release workflow in her repository, at any branch or tag.
Quick Quiz
1. Which record answers "which packages and versions are in this image?"
An SBOM lists every package, version and licence.
2. Why does the workflow sign image@${DIGEST} instead of image:2.1.0?
Signing the digest binds the signature to exact content.
3. What does id-token: write allow in a GitHub Actions job?
Sigstore issues a short-lived certificate based on that token.
4. What should cosign verify always be given?
Without an expected identity, any signer would be accepted.
Learn Docker & DevOps with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
