Blog
4 min read

SBOMs and Signed Images: Software Supply Chain Security With Syft and Cosign

How to know what's in your container images and prove they came from your CI: generating SBOMs with Syft or BuildKit attestations, scanning them for vulnerabilities, signing images keylessly with Cosign and GitHub Actions OIDC, verifying by identity, adding SLSA provenance, and enforcing it at deploy time.

Two questions every deploy should be able to answer:

  1. What's inside this image? Every OS package and library, with versions — so when the next critical CVE lands, you know within minutes whether you're affected.
  2. Did this image really come from our build pipeline? Not a laptop, not an attacker who got registry credentials.

An SBOM (software bill of materials) answers the first. A signature — plus provenance — answers the second. (npm Supply Chain Security)

SBOMs

An SBOM is a machine-readable inventory of components. Two standard formats:

  • SPDX (Linux Foundation; also an ISO standard).
  • CycloneDX (OWASP).

Regulation is pushing them from nice-to-have to expected: the EU's Cyber Resilience Act brings SBOM and vulnerability-handling obligations for products with digital elements, phasing in from 2026 to 2027.

Generate one with Syft

syft ghcr.io/acme/api@sha256:3f9a... -o spdx-json > sbom.spdx.json
syft dir:. -o cyclonedx-json > sbom.cdx.json      # from source

Or let BuildKit attach it at build time

docker buildx build \
  --sbom=true \
  --provenance=mode=max \
  -t ghcr.io/acme/api:1.4.2 --push .

This attaches the SBOM and a SLSA provenance statement to the image in the registry as attestations, stored alongside it.

Scan it

grype sbom:./sbom.spdx.json

Scanning the SBOM rather than the image means you can re-scan old releases against new vulnerability data without pulling them. Store SBOMs with each release.

Signing with Cosign (keyless)

Cosign (from the Sigstore project) signs container images and other OCI artifacts. The keyless mode avoids managing long-lived signing keys:

  1. Your CI job gets a short-lived OIDC identity token from its platform (GitHub Actions, GitLab CI…).
  2. Sigstore's certificate authority (Fulcio) issues a certificate valid for minutes, binding a temporary key to that identity — e.g. "workflow release.yml in acme/api, on refs/tags/v1.4.2".
  3. Cosign signs the image digest; the signature is recorded in the public Rekor transparency log.
  4. The signature is stored in the registry next to the image.

In GitHub Actions

The signing steps below assume a tag-triggered job has already configured Docker Buildx and authenticated to GHCR, with permission to push this image. Add those setup steps before building; packages: write grants permission but does not log Docker in.

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

steps:
  - uses: sigstore/cosign-installer@v4
  - name: Build and push
    id: build
    uses: docker/build-push-action@v7
    with:
      push: true
      tags: ghcr.io/acme/api:${{ github.ref_name }}
      sbom: true
      provenance: mode=max
  - name: Sign
    run: cosign sign --yes ghcr.io/acme/api@${{ steps.build.outputs.digest }}

Sign the digest, not the tag. Tags are mutable; a signature on :latest says nothing about what :latest points to tomorrow. (Check the current major versions of the actions you pin.)

Verify by identity

Verification checks the signature and who signed it:

cosign verify ghcr.io/acme/api@sha256:3f9a... \
  --certificate-identity 'https://github.com/acme/api/.github/workflows/release.yml@refs/tags/v1.4.2' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Without the identity constraints, "verified" only means someone with a GitHub workflow signed it — anyone can do that.

Use the exact repository, workflow path and release tag that should have produced the image. If you need a regex for several releases, escape literal dots and anchor both ends so it cannot match unintended identities. See Sigstore's verification documentation.

You can also attach and verify your own attestations (an SBOM, test results) with cosign attest and cosign verify-attestation.

Provenance and SLSA

Provenance records how an artifact was built: source repo and commit, builder, workflow, parameters. The SLSA framework defines levels of build integrity; signed, non-forgeable provenance generated by the build platform (not by a step your build can tamper with) is the key requirement at higher levels. BuildKit's --provenance=mode=max and GitHub's artifact attestations are practical starting points.

Enforce it at deploy time

Signing is only useful if something refuses unsigned images:

  • In Kubernetes, admission controllers (Sigstore's policy-controller, Kyverno, and others) can require valid signatures from specific identities.
  • In simpler setups, add cosign verify to the deploy script before pulling, and fail closed.

A sensible rollout

  1. Generate SBOMs on every build; store them with releases; scan nightly.
  2. Sign images by digest in CI with keyless Cosign.
  3. Verify by identity in the deploy step.
  4. Add provenance; tighten policies over time.

The summary

  • SBOMs (SPDX/CycloneDX) list what's in an image; generate with Syft or BuildKit, scan with Grype.
  • Cosign keyless signing ties an image digest to your CI identity, logged in Rekor.
  • Verify with --certificate-identity and the OIDC issuer, and enforce it where you deploy.

EasySpawn builds and deploys your app on your own isolated server from your GitHub repository, so the path from commit to running code stays short and auditable — and Claude Code can add SBOM and signing steps to your CI. See how it works or join the waitlist.

Related: Container Hardening With seccomp, AppArmor and User Namespaces · Secret Scanning · Dependabot Explained · AI Hallucinated Packages

Keep reading