EthicsComputerCourses
Join training +91 90532 10052

SBOMs, Provenance & Image Signing

Docker & DevOps · Lesson 30 of 39 Advanced 8 min read Updated

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

RecordQuestion it answersCommon formats and tools
SBOM (software bill of materials)Which packages and versions are in this image?SPDX, CycloneDX; BuildKit, Docker Scout, Trivy
ProvenanceWhich source commit, Dockerfile and builder produced it?SLSA provenance; BuildKit, GitHub artifact attestations
SignatureDid 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:

Terminal
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 }}'
Output
{"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:

Terminal
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.json

The 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:

YAML
permissions:
  contents: read
  packages: write
  id-token: write
YAML
steps:
  # ... 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:

Terminal
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.com
Output
Verification 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:

Terminal
gh attestation verify oci://ghcr.io/priya-sharma/shop-api:2.1.0 -R priya-sharma/shop-api

Verify 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:

Terminal
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=max provenance. 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=max attaches attestations at push time; imagetools inspect --format reads them from the registry.
  • Docker Scout and Trivy create SPDX or CycloneDX SBOMs; trivy sbom rescans 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
YAML
- 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}
Terminal
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.com

The 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?"

2. Why does the workflow sign image@${DIGEST} instead of image:2.1.0?

3. What does id-token: write allow in a GitHub Actions job?

4. What should cosign verify always be given?

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