Docker Advanced #4: SBOM and Signing — The Entry to Supply Chain Security

7 min read

#3’s security checklist looked at a single container. This post takes one step up — to the supply chain. What’s in this image, and who built it.

This post in the Docker Advanced series:

Why supply chain security suddenly matters #

Several large incidents have hit in recent years.

  • 2020 SolarWinds — a properly signed build artifact contained a backdoor
  • 2021 codecov bash uploader — a download script for a legit tool was tampered with, leaking secrets
  • 2024 xz utils backdoor — a trusted maintainer of a widely used compression tool inserted a backdoor

The common thread — the vulnerability wasn’t in the code, but in the process that built and shipped it. CVE scans alone don’t catch this. The two pillars are SBOM and signing.

QuestionTool
What exactly is inside this image?SBOM (Software Bill of Materials)
Was this image really built by that person/org?Signing (cosign / sigstore)
Through what process was this image built?attestation / SLSA

What is an SBOM #

An SBOM is a machine-readable inventory of what a piece of software is composed of. Think of it as a nutrition label for software.

Two standard formats:

FormatMaintained byNotes
SPDXLinux FoundationOldest standard, strong on license info
CycloneDXOWASPSecurity-first, rich dependency-graph representation

Most tools can output either. Pick one and stay consistent for your first use.

Generating an SBOM with Syft #

syft (Anchore) is close to a standard.

Install
brew install syft
# or
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
SBOM for an image
syft myapp:1.0 -o cyclonedx-json > sbom.cdx.json
syft myapp:1.0 -o spdx-json > sbom.spdx.json

Sample output (excerpt of CycloneDX):

sbom.cdx.json (excerpt)
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "components": [
    {
      "name": "openssl",
      "version": "3.0.13-1ubuntu0.1",
      "type": "library",
      "purl": "pkg:deb/ubuntu/openssl@3.0.13-1ubuntu0.1?arch=amd64"
    },
    {
      "name": "flask",
      "version": "3.0.3",
      "type": "library",
      "purl": "pkg:pypi/flask@3.0.3"
    }
  ]
}

The purl (Package URL) for each component is the precise identifier. pkg:pypi/flask@3.0.3 is a standard URL pointing at Flask 3.0.3 on PyPI.

Built-in via docker buildx #

You can attach the SBOM as an attestation at build time.

Attach SBOM at build
docker buildx build \
  --sbom=true \
  --provenance=mode=max \
  -t ghcr.io/me/myapp:1.0 \
  --push .
  • --sbom=true — generate an SBOM and attach it as an attestation
  • --provenance=mode=max — include build provenance (which command built what, from what input)

The pushed image carries the attestation manifest alongside the regular ones in the manifest list. Inspect with imagetools:

Inspect attestations
docker buildx imagetools inspect ghcr.io/me/myapp:1.0 \
  --format '{{json .SBOM}}'

This is the cleanest path — generate the SBOM during the build, store it in the same manifest.

What you actually use SBOMs for #

The SBOM itself is just data. Value comes from the tools you point at it.

Vulnerability scan — Grype #

Scan an SBOM
grype sbom:./sbom.cdx.json

No image download — scan from the SBOM file alone. Fast for CI caches and post-hoc audits in production.

License compliance #

Extract licenses
syft myapp:1.0 -o spdx-tag-value | grep PackageLicenseDeclared | sort -u

What OSS licenses are inside the image — common in compliance reporting.

Incident response #

When a new CVE drops (e.g., log4shell, xz), use the SBOM to query which already-running images contain that library. Reporting your blast radius within the first hour after a CVE drops is hard without an SBOM.

Signing — Cosign #

Cosign verifies that an image was actually built by a given person / organization. Cosign from the Sigstore project has become the standard.

Install
brew install cosign

Key-based signing (older approach) #

Generate keys
cosign generate-key-pair
# cosign.key, cosign.pub
Sign
cosign sign --key cosign.key ghcr.io/me/myapp:1.0

Signature is attached to the image and stored in the registry.

Verify
cosign verify --key cosign.pub ghcr.io/me/myapp:1.0

The downside is key management. A leaked key destroys trust. The next section’s keyless signing solves that.

Keyless signing — OIDC + Fulcio + Rekor #

The big idea in Sigstore: sign without a long-term key, using OIDC identity, then record the signing event in a public transparency log (Rekor).

Keyless signing (in CI)
cosign sign ghcr.io/me/myapp:1.0
# Browser opens for OIDC sign-in (or automated in CI)
# Fulcio issues a short-lived certificate
# Used to sign
# Recorded in Rekor (public transparency log)
Keyless verification
cosign verify \
  --certificate-identity-regexp 'https://github.com/me/.+' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/me/myapp:1.0
  • certificate-identity-regexp — who is allowed to sign (URL pattern)
  • certificate-oidc-issuer — which OIDC issuer’s certificates to trust

Builds running on GitHub Actions get an OIDC token automatically, so signing happens without env-var or secret management. In production, keyless is almost always the safer choice.

Putting it together in GitHub Actions #

.github/workflows/build-sign.yml
name: build and sign
on: { push: { branches: [main] } }

permissions:
  contents: read
  packages: write
  id-token: write    # required for keyless signing

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/setup-buildx-action@v3

      - id: build
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          sbom: true               # SBOM attestation
          provenance: mode=max     # provenance attestation

      - uses: sigstore/cosign-installer@v3

      - name: Sign image
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          cosign sign --yes ghcr.io/${{ github.repository }}@${DIGEST}

In one workflow:

  1. Build the image, multi-arch (#2)
  2. Attach SBOM + provenance attestations
  3. Keyless sign (and record in Rekor)

The whole supply chain flow can live in a single workflow.

Attestation — bundling SBOM and signature #

A picture of all the pieces:

Supply chain metadata
   image
     ├── manifest (linux/amd64)
     ├── manifest (linux/arm64)
     ├── attestation: SBOM (CycloneDX)
     ├── attestation: provenance (SLSA build info)
     └── signature (cosign)

All of it lives in the same OCI registry as metadata for the same image. imagetools inspect peers in.

See attestations
docker buildx imagetools inspect ghcr.io/me/myapp:1.0 \
  --format '{{json .}}' | jq '.Manifest'

Verification policies vary, but a common production pattern:

  1. Does this image have an SBOM attached? — reject otherwise
  2. Was it signed by our org’s OIDC identity? — reject otherwise
  3. Does the SBOM contain critical CVEs? — reject if so

Admission control — enforced at the cluster #

In environments like Kubernetes, an admission controller enforces these policies.

ToolRole
KyvernoYAML-based image verification — verifyImages
OPA GatekeeperPolicies in Rego
Sigstore Policy Controllerdedicated cosign verification

You don’t usually need this with Docker on a single host, but it’s natural once you operate on K8s.

SLSA — supply chain maturity model #

SLSA (Supply-chain Levels for Software Artifacts) is a framework that defines levels of supply chain security.

LevelWhat’s required
L1Build process is documented
L2Build runs on a hosted service — provenance generated automatically
L3Build runs in an isolated environment, provenance is tamper-resistant
L4 (legacy)Reproducible builds, two-party verification

A GitHub Actions hosted runner + --provenance=mode=max + cosign keyless signing already satisfies most of L3. You can climb the levels gradually without going all-in.

Common pitfalls / mistakes #

  • Generating SBOMs in production but not verifying — the SBOMs are useless. Make verification a CI / admission gate.
  • Lost or leaked keys (key-based signing) — migrate to keyless.
  • Multiple builds overwriting the same tag — tags like latest break attestation traceability. Verify by digest (@sha256:...).
  • Building when Rekor is down — keyless signing fails. Wrap signing in retry logic.
  • SBOM components don’t trace to a real source — some static binaries elude syft. Build-time SBOM (buildx --sbom) is more accurate.

Wrap-up #

The picture from this post:

  • Supply chain threats come from the build process, not the code itself — CVE scans alone aren’t enough.
  • An SBOM is a machine-readable manifest of what’s in the image (CycloneDX / SPDX).
  • Generate SBOMs with Syft; attach attestations at build time with buildx --sbom=true.
  • Cosign keyless signing = OIDC identity + short-lived certs (Fulcio) + transparency log (Rekor) — no long-term key management.
  • One GHA workflow covers build → multi-arch → SBOM → provenance → keyless sign.
  • In production, make verification a gate — attaching signatures and SBOMs without enforcement is theater.
  • SLSA levels are incremental — climb them one step at a time.

In the next post (#5 Resource limits and cgroups) we shift to resource limits. cgroups v2 basics, the behavior of mem_limit / cpus, debugging OOMKilled, and other isolation knobs like ulimit / pids.

X