Containerization Containers

Securing the Container Supply Chain: Signing with Cosign, SBOMs, and SLSA Provenance

A registry digest tells you what you are running, not where it came from or who built it. This guide closes that gap end to end: generate an SBOM, sign keylessly with Cosign, attach SLSA build provenance, and refuse to admit anything unsigned in the cluster. Every step is a real command you can run in CI today.

1. The threat model

Before tooling, name what you are defending against. The attacks that actually matter on the container supply chain are:

Threat Example Control
Typosquatted / compromised base image node:lts pulled from a poisoned mirror Pin by digest; verify signatures on base images
Dependency injection Malicious transitive package in node_modules SBOM + vulnerability scan, gated in CI
Compromised CI runner Secret exfiltration, tampered build output Keyless signing tied to OIDC identity; SLSA L3
Registry tampering Image swapped after push, tag mutation Sign the digest; enforce signature at admission
Provenance forgery Attacker claims an image was “built by us” Verify the signing identity (issuer + subject), not just “is it signed”

The through-line: a signature is only meaningful if you verify which identity produced it. “Signed” is not a security property. “Signed by the GitHub Actions workflow release.yml on refs/tags/* in my repo, attested by Rekor” is.

2. Generate an SBOM with Syft and scan it

Build the image first, then produce a CycloneDX SBOM from the built artifact (not the source tree — you want what actually shipped).

# Build once, reference everything by digest from here on
docker build -t ghcr.io/acme/api:"$GIT_SHA" .
docker push ghcr.io/acme/api:"$GIT_SHA"

# Resolve the immutable digest — this is the only identifier we trust downstream
DIGEST=$(docker buildx imagetools inspect ghcr.io/acme/api:"$GIT_SHA" \
  --format '{{json .Manifest.Digest}}' | tr -d '"')
IMAGE="ghcr.io/acme/api@${DIGEST}"

# SBOM in CycloneDX JSON
syft "$IMAGE" -o cyclonedx-json=sbom.cdx.json

Then scan. Use the SBOM as scanner input so you scan exactly what you documented:

# Grype against the SBOM
grype sbom:sbom.cdx.json --fail-on high

# Or Trivy, scanning the image directly
trivy image --severity HIGH,CRITICAL --exit-code 1 "$IMAGE"

Scanning the SBOM rather than re-scanning the image keeps the documented bill of materials and the vulnerability verdict consistent. If they diverge, your SBOM is stale.

3. Keyless signing with Cosign, Fulcio, and Rekor

Keyless signing eliminates long-lived signing keys entirely. Cosign requests a short-lived (around 10-minute) certificate from Fulcio, the Sigstore CA, binding your OIDC identity to an ephemeral key. The signature plus certificate are logged in Rekor, the public transparency log, and stored as an artifact alongside the image in the registry.

In a CI runner with an OIDC token available (GitHub Actions, GitLab, etc.), set COSIGN_EXPERIMENTAL is no longer required on modern Cosign — keyless is the default when no key is supplied:

# GitHub Actions — the id-token permission is what makes keyless work
permissions:
  contents: read
  packages: write
  id-token: write   # required: lets the job mint an OIDC token for Fulcio
# Sign the digest (never a tag — tags are mutable)
cosign sign --yes "$IMAGE"

That single command fetches the OIDC token from the CI environment, gets a Fulcio cert, signs, and uploads the entry to Rekor. No cosign generate-key-pair, no secret in your repo, nothing to rotate.

To verify, you assert the identity — both the certificate subject (the workflow identity) and the OIDC issuer:

cosign verify \
  --certificate-identity-regexp "https://github.com/acme/api/.github/workflows/release.yml@.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

The two --certificate-* flags are not optional in practice. Without them, cosign verify will accept a signature from any Fulcio identity — including an attacker who legitimately signed their own malicious image with their own GitHub account.

4. Attach attestations: SBOM, scan, and SLSA provenance

A bare signature says “this digest is endorsed.” Attestations say why. Cosign wraps a predicate in an in-toto envelope, signs it keylessly, and stores it next to the image.

Attach the SBOM and a vulnerability-scan result as typed attestations:

# SBOM attestation (CycloneDX predicate)
cosign attest --yes \
  --predicate sbom.cdx.json \
  --type cyclonedx \
  "$IMAGE"

# Vulnerability report attestation (Trivy emits a cosign-compatible predicate)
trivy image --format cosign-vuln -o vuln.json "$IMAGE"
cosign attest --yes \
  --predicate vuln.json \
  --type vuln \
  "$IMAGE"

For SLSA provenance, do not hand-roll it. The SLSA project ships reusable, hardened GitHub Actions workflows that generate provenance outside your build job, so a compromised build cannot forge its own provenance. The container generator produces a signed provenance attestation for a pushed digest:

# Calls the SLSA reusable workflow as a separate, isolated job
jobs:
  provenance:
    needs: [build]
    permissions:
      actions: read       # read the build's workflow metadata
      id-token: write     # keyless signing of the provenance
      packages: write     # write the attestation to the registry
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
    with:
      image: ghcr.io/acme/api
      digest: ${{ needs.build.outputs.digest }}
    secrets:
      registry-username: ${{ github.actor }}
      registry-password: ${{ secrets.GITHUB_TOKEN }}

The isolation is the entire point: provenance generated inside the same job it describes is provenance an attacker controls.

5. Enforce at admission with Kyverno

Signing is worthless if the cluster admits unsigned images. Kyverno’s verifyImages rule blocks any image that does not carry a valid Cosign signature from the identity you specify. Install Kyverno, then apply a ClusterPolicy:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce   # Audit first, then flip to Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-acme-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences:
            - "ghcr.io/acme/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/acme/api/.github/workflows/release.yml@refs/tags/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev

Kyverno verifies the signature, then mutates the Pod spec to the resolved digest, so even a tag-based deployment lands on the exact verified artifact. To also require the SLSA provenance attestation, add an attestations block under the same entry:

          attestations:
            - type: https://slsa.dev/provenance/v1
              attestors:
                - entries:
                    - keyless:
                        issuer: "https://token.actions.githubusercontent.com"
                        subject: "https://github.com/slsa-framework/slsa-github-generator/*"
              conditions:
                - all:
                    - key: "{{ regex_match('^refs/tags/.*', element.predicate.buildDefinition.externalParameters.source.ref) }}"
                      operator: Equals
                      value: true

The Sigstore policy-controller is the equivalent native-Sigstore option with a ClusterImagePolicy CRD. Pick one admission controller — running both produces confusing double-denials.

6. SLSA Build Level 3 in practice

SLSA Build L3 is not a tool you install; it is a property of how you build. The reusable SLSA generator gets you there because it satisfies the L3 requirements:

Your job is to (a) pin the reusable workflow to a tagged version, (b) pin all third-party actions by commit SHA, not tag, and © restrict who can push tags that trigger releases. Skip any of these and you have L2 wearing an L3 badge.

7. Handling exceptions: break-glass and rollout

You will need escape hatches. Stage them deliberately:

# Exclude a namespace from the policy without disabling it cluster-wide
      exclude:
        any:
          - resources:
              namespaces: ["kube-system", "kyverno"]

Enterprise scenario

A fintech platform team flipped Kyverno to Enforce across 40 clusters and immediately broke every cluster-autoscaler scale-up. Their app images verified fine, but new nodes couldn’t pull the registry.k8s.io system images (pause, kube-proxy, CSI sidecars) — those are signed by Google’s Sigstore identity, not the team’s release.yml, and the catch-all imageReferences: "*" rule denied them. Worse, the verification webhook itself called out to rekor.sigstore.dev; under a node-pool churn storm, Rekor rate-limited them and Pods stalled in ImagePullBackOff because every admission did a fresh log lookup.

The fix had two parts. First, scope the strict identity rule to their own registry and add a separate attestor entry for the platform images they trust, keyed to the correct issuer/subject — never a blanket allow:

verifyImages:
  - imageReferences: ["ghcr.io/acme/*"]
    attestors: [{ entries: [{ keyless: { subject: "https://github.com/acme/api/.github/workflows/release.yml@refs/tags/*", issuer: "https://token.actions.githubusercontent.com" }}}]}
  - imageReferences: ["registry.k8s.io/*"]
    attestors: [{ entries: [{ keyless: { subject: "https://accounts.google.com", issuer: "https://accounts.google.com" }}}]}

Second, they cut the Rekor dependency out of the hot path with ctlog.ignoreSCT plus a cached TUF mirror, so admission verifies the bundled SCT offline instead of round-tripping the public log on every Pod. Lineage auditing still queries Rekor — just asynchronously, out of band. The lesson: a "*" image rule plus a hard online-log dependency turns your admission controller into a cluster-wide single point of failure the moment you scale.

Verify

Walk the chain end to end against a real digest:

# 1. Signature is present and from the expected identity
cosign verify \
  --certificate-identity-regexp "https://github.com/acme/api/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

# 2. SBOM attestation exists and is signed
cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity-regexp "https://github.com/acme/api/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

# 3. SLSA provenance attestation verifies
cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp "https://github.com/slsa-framework/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$IMAGE"

# 4. Cross-check against the public transparency log
rekor-cli search --artifact <(cosign download signature "$IMAGE" 2>/dev/null) || \
  echo "use: rekor-cli search --sha ${DIGEST#sha256:}"

# 5. Admission control actually blocks an unsigned image
kubectl run rogue --image=ghcr.io/acme/unsigned:latest
# expected: admission webhook "mutate.kyverno.svc-fail" denied the request

The slsa-verifier CLI gives a one-shot, higher-level check that the provenance matches the source repo and builder:

slsa-verifier verify-image "$IMAGE" \
  --source-uri github.com/acme/api \
  --source-tag v1.4.2

Auditing the chain via Rekor

Because every signature lands in Rekor, you can prove an image’s lineage after the fact without trusting your own registry. Search the log by the artifact digest, fetch the entry, and inspect the certificate that signed it:

# Find every Rekor entry for this digest
rekor-cli search --sha "${DIGEST#sha256:}"

# Pull a specific entry and decode the signing certificate's identity
rekor-cli get --uuid <entry-uuid> --format json | jq '.Body'

This is the auditor’s win: the transparency log is append-only and independent of your infrastructure, so “who built this and when” survives even a full compromise of your CI and registry.

Checklist

Pitfalls

Next steps: stand up a private Sigstore stack (Fulcio, Rekor, and a TUF root) for air-gapped environments, and wire slsa-verifier into your deploy gate so provenance is checked at promotion, not just at admission.

Supply-chainCosignSigstoreSBOMSLSATrivy

Comments

Keep Reading