Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
PipelineJune 8, 20269 min read

Securing Your Container Supply Chain: SBOM, Cosign Signing, and SLSA Provenance in One Pipeline

Generate an SBOM, sign with cosign, attach SLSA provenance, and enforce with Kyverno. One GitHub Actions pipeline.

O
O3 Security Team
Research & Engineering
Container
Security illustration
Key takeaways
  • Most container security guides stop at 'scan your image.' Supply chain attacks require four layers: SBOM generation, image signing, provenance attestation, and admission enforcement.
  • Cosign keyless signing uses ephemeral OIDC-backed certificates. No long-lived private keys to rotate, leak, or manage in CI.
  • SLSA Level 2 provenance is a signed attestation that records exactly which source commit and build system produced your image. It's automatable with slsa-github-generator in one workflow step.
  • Kyverno policies can block any unsigned or unattested image at the Kubernetes admission layer, so even a compromised registry can't sneak an image into production.
  • The 2024 Trivy-action incident (75 of 76 version tags poisoned via force-push) is exactly why tag-based pulls without signature verification are dangerous.

In April 2024, 75 of 76 version tags in the aquasecurity/trivy-action repository were force-pushed to a malicious commit. Anyone using a tag pin (rather than a commit SHA) silently pulled a tampered scanner. The attack didn't touch DockerHub. It exploited one simple fact: tags are mutable, and nobody was checking signatures.

That's the supply chain threat container teams face. Not a bug inside your app. A compromise of the build artifacts flowing into your cluster. A poisoned base image. A hijacked CI action. A registry mirror serving stale layers. CVE scanning is necessary. It's not enough. What you need is a chain of custody from source commit to running pod.

This guide builds that chain. By the end you'll have a GitHub Actions pipeline that does four things: generates a machine-readable SBOM, signs your image with cosign keyless (no long-lived secrets), attaches SLSA Level 2 provenance, and enforces all three at the Kubernetes admission layer with Kyverno. Every step uses open-source tooling. Each layer adds to the one before it.

Tip

You can adopt these layers incrementally. Each one independently reduces risk. Start with SBOM generation in CI, add signing next week, and layer in admission enforcement when you're ready. You don't need all four on day one.

Why four layers? What each one actually stops

Before writing any YAML, it's worth understanding what threat each layer addresses. They're different threats, and skipping one leaves a real gap.

LayerToolWhat it stops
SBOM generationSyft or TrivyUnknown composition: you can't triage a Log4Shell if you don't know who's shipping log4j-core in their image.
Image signingcosign (keyless)Tampered or substituted images: a signed image from your registry can be verified to come from your build, not an attacker's.
SLSA provenanceslsa-github-generatorBuild-time compromise: provenance links the image digest to the exact source commit and build job, so you can detect a poisoned pipeline.
Admission enforcementKyvernoRuntime introduction of unsigned images: blocks pods that lack a valid signature or provenance attestation from starting in the cluster.
Each supply chain security layer and the attack it stops.

What you'll need

  • A GitHub repository with Actions enabled
  • A container registry that supports OCI 1.1 artifacts (Docker Hub, GHCR, ECR, GCR, and ACR all work)
  • A Kubernetes cluster (local kind/k3d works fine for testing) with Kyverno installed
  • The cosign CLI installed locally for verification steps (`brew install cosign` / `go install github.com/sigstore/cosign/v2/cmd/cosign@latest`)

Layer 1: Generate an SBOM inside the container build

An SBOM (Software Bill of Materials) is a structured inventory of every package, library, and OS component inside your image. It's what lets you answer "are we affected by CVE-2021-44228?" in minutes rather than hours. The right place to generate it is immediately after the docker build step, before the image leaves CI.

We'll use Syft (from Anchore) to generate a CycloneDX 1.6 SBOM and push it as an OCI artifact attached to the image digest. This keeps the SBOM co-located with the image in your registry. No separate storage, no sync problem.

Note

CycloneDX 1.6 is the format of choice here: it's the same format used for CBOM, AIBOM, and HBOM, making it easy to extend your container inventory into a full BOM posture later. SPDX is a valid alternative if your toolchain already produces it.

Add this to your GitHub Actions workflow after the `docker/build-push-action` step:

  1. Install Syft: `curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin`
  2. Generate the SBOM: `syft ${IMAGE}@${DIGEST} -o cyclonedx-json > sbom.cdx.json`
  3. Attach it to the image: `cosign attach sbom --sbom sbom.cdx.json --type cyclonedx ${IMAGE}@${DIGEST}`
  4. Optionally scan the SBOM for known CVEs with Grype: `grype sbom:sbom.cdx.json --fail-on high`

A note on using the image digest rather than the tag: the digest is immutable. Once you've pinned your SBOM to a digest, you know exactly which image layers it describes. Tags can be overwritten (see the trivy-action incident). Throughout this guide we work with digests.

Layer 2: Sign the image with cosign keyless

Image signing proves that an image came from your pipeline and hasn't been tampered with. The old way: store a long-lived private key as a CI secret, rotate it, scan for leaks, repeat. It works. It's also annoying.

Cosign keyless signing removes long-lived keys entirely. GitHub Actions issues a short-lived OIDC token for each workflow run. Cosign uses that token to get an ephemeral certificate from Sigstore's Fulcio CA. The signature lands in Sigstore's Rekor transparency log. To verify, you check the log. No private key to manage, rotate, or lose.

Keyless signing doesn't mean 'no cryptography.' It means 'no long-lived secret to rotate, leak, or lose.' The private key exists for milliseconds inside the GitHub Actions runner.

The GitHub Actions steps are straightforward once you understand the flow:

  1. Add `id-token: write` and `contents: read` to your workflow permissions (this is what lets Actions fetch an OIDC token)
  2. Install cosign: `uses: sigstore/cosign-installer@v3`
  3. Sign after push: `cosign sign --yes ${IMAGE}@${DIGEST}` (cosign fetches the OIDC token automatically in Actions)
  4. Verify locally: `cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/YOUR_ORG/YOUR_REPO' ${IMAGE}@${DIGEST}`

The verification command is the critical part: it checks that the certificate was issued by GitHub Actions for your specific repository. An attacker who compromises a different repository cannot produce a valid signature for yours.

Watch out

Do NOT use `cosign sign` without `--yes` in automation. It prompts for user input and will hang. Also confirm your registry supports OCI referrers (needed to attach the signature). GHCR, ECR (with OCI 1.1 enabled), and GCR all do.

Layer 3: Attach SLSA Level 2 provenance

A cosign signature proves an image is authentic. SLSA provenance proves how it was built. That distinction matters. If your build pipeline gets compromised (a malicious build step injected, a GitHub Action hijacked) the image might be legitimately signed but built from something you didn't intend.

SLSA provenance is a signed attestation. It records the source repo, the commit SHA, the build system, and the environment that produced the artifact. At Level 2, it must be signed by the build service itself (GitHub Actions), not just your key. That closes the self-attestation gap.

The SLSA GitHub Generator project makes Level 2 provenance a single reusable workflow call:

  1. In your existing workflow, output the image digest as a job output: `echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' ${IMAGE} | cut -d'@' -f2)" >> $GITHUB_OUTPUT`
  2. Add a separate job that calls the reusable workflow: `uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]`
  3. Pass `image: ${{ env.IMAGE }}` and `digest: ${{ needs.build.outputs.digest }}` as inputs
  4. The generator job runs in a hermetic, attester environment and signs the provenance with its own OIDC identity, distinct from your build job

The result is a signed provenance attestation pushed to your registry alongside the image. You can verify it with `slsa-verifier verify-image --source-uri github.com/YOUR_ORG/YOUR_REPO ${IMAGE}@${DIGEST}`.

Key takeaway

SLSA Level 3 (the maximum for containers) requires a fully isolated build environment. Level 2 (what slsa-github-generator provides) is the practical sweet spot for most teams: the provenance is signed by GitHub, not by you, which means it can't be self-issued by a compromised build job.

Layer 4: Enforce at admission with Kyverno

Signing and provenance only protect you if something actually checks them. A Kyverno ClusterPolicy running in your cluster can verify the cosign signature and SLSA attestation at admission time, before any pod is allowed to start. This closes the gap: even if someone pushes an unsigned image to your registry, it can't run in production.

Install Kyverno via Helm if you haven't already: `helm install kyverno kyverno/kyverno -n kyverno --create-namespace`. Then apply a policy that requires a valid cosign signature:

A minimal signature verification policy in Kyverno looks like this:

  1. Create a ClusterPolicy of kind `ClusterPolicy` with `validationFailureAction: Enforce`
  2. Under `rules`, add a `verifyImages` rule targeting your registry prefix (e.g. `ghcr.io/YOUR_ORG/*`)
  3. Set `attestors` to your GitHub Actions OIDC issuer and subject pattern (this is what ties the signature to your specific repository)
  4. Optionally add an `attestations` block requiring the SLSA provenance predicate type `https://slsa.dev/provenance/v1`
  5. Test by attempting to run an unsigned image. Kyverno should block it with a clear audit log entry

Start with `validationFailureAction: Audit` (logs without blocking) to understand what would be blocked before flipping to `Enforce`. This is especially important if you're rolling Kyverno into an existing cluster with a mix of signed and unsigned images.

Putting it together: the complete pipeline

Here's the structure of the complete GitHub Actions workflow. There are three jobs: `build` (build, push, generate SBOM, sign), `provenance` (SLSA attestation via the reusable workflow), and a `verify` job that runs as a gate before any deployment proceeds.

  1. `build` job: checkout → login to registry → build and push → get digest → generate SBOM with Syft → attach SBOM → sign image with cosign keyless → output digest
  2. `provenance` job: depends on `build` → calls `slsa-framework/slsa-github-generator` with the image and digest
  3. `deploy` job (optional): depends on both → runs `cosign verify` + `slsa-verifier verify-image` as a pre-deploy gate. Fail fast before any `kubectl apply`
Pro tip

Pin ALL your GitHub Actions to commit SHAs, not tags (e.g. `sigstore/cosign-installer@dd9d4d316e5d5b0acb5d9e5e3af82c82a4c5b7c9` not `@v3`). The trivy-action incident was a tag-based attack. Dependabot can keep your SHA pins current automatically.

SBOM-driven vulnerability triage

The SBOM you generate isn't just a compliance artifact. It's the foundation for prioritized vulnerability triage. Once every image has a machine-readable SBOM attached as an OCI artifact, your security tooling can continuously scan for new CVEs without re-pulling images. When Log4Shell dropped in December 2021, teams with SBOMs found every affected image in minutes; teams without them spent weeks running manual grep exercises.

The workflow is: new CVE published → your scanner matches it against the indexed SBOMs → affected images surfaced immediately with exact package version and all affected tags/digests. Pair this with reachability analysis (is the vulnerable function actually called?) to prioritize which findings need emergency patching vs. which can wait for the next release cycle.

By the numbers

Studies consistently show 60–80% of SCA/container CVE alerts are for packages not reachable in the application's execution path. Reachability analysis is what separates an actionable alert from noise.

Mapping to CERT-In and SEBI requirements

If you're operating in India under CERT-In's Technical Guidelines v2.0 (July 2025) or SEBI's cybersecurity circular, this pipeline directly addresses several requirements. CERT-In mandates SBOM for all software components including containerized applications. The provenance attestation addresses the supply chain integrity requirements. Image signing satisfies the artifact authenticity controls.

Pipeline layerCERT-In v2.0 requirementSEBI relevance
SBOM generationMandatory for software components, 21 required fields including supplier, version, licenseSEBI cybersecurity circular: software transparency for regulated entities
Image signingArtifact integrity controls; audit trail of software provenanceNon-repudiation requirements for software delivery
SLSA provenanceBuild integrity; tamper-evident build pipeline evidenceApplicable to tech infrastructure of market intermediaries
Admission enforcementRuntime control; only authorized software executesRuntime security controls for systemically important entities
Pipeline layers mapped to CERT-In v2.0 and SEBI requirements.

Common pitfalls

  • **Signing the tag, not the digest.** Tags are mutable. Always sign and verify by digest (`@sha256:...`). cosign will warn you if you try to sign a tag.
  • **Forgetting `id-token: write` permissions.** Without this, GitHub won't issue an OIDC token and keyless signing fails silently with a confusing auth error.
  • **OCI referrers support.** Some private registries don't support OCI 1.1 referrers by default. Check that your registry accepts `cosign attach` before you build the pipeline.
  • **Kyverno in Audit before Enforce.** Jumping straight to Enforce in production is how you cause an outage. Always run a 48-hour Audit window first to inventory unsigned images.
  • **Not pinning the slsa-github-generator version.** Same logic as pinning any Action: use the specific release tag and ideally the commit SHA.

Frequently asked questions

What is the difference between cosign image signing and SLSA provenance?

+
Cosign signing proves a specific image digest came from your trusted registry/pipeline and wasn't tampered with. SLSA provenance goes further: it records exactly which source commit, build system, and build job produced the image. Signing verifies authenticity; provenance verifies build integrity. Both together mean you can trace any running container back to a specific line of source code.

Does cosign keyless signing require Sigstore or can I use my own PKI?

+
Cosign supports both. Keyless signing (the default for GitHub Actions) uses Sigstore's Fulcio CA and Rekor transparency log. No infrastructure to run. For air-gapped or regulated environments, cosign also supports hardware keys (YubiKey, HSM) and private PKI via KMS providers (AWS KMS, GCP KMS, HashiCorp Vault). Choose based on your compliance requirements.

What SLSA level should most teams target for container images?

+
SLSA Level 2 is the practical target for most teams. It requires the provenance to be generated and signed by the build service (GitHub Actions) rather than self-signed, which closes the self-attestation gap. Level 3 adds hermetic, isolated builds. That level is meaningful for critical infrastructure or public open-source projects, but operationally heavy for most product teams.

Can I use Trivy instead of Syft to generate the SBOM?

+
Yes. Trivy generates CycloneDX and SPDX SBOMs directly from container images: `trivy image --format cyclonedx --output sbom.cdx.json myimage:tag`. Syft is slightly more flexible for attaching SBOMs as OCI artifacts, but Trivy works well and is already in most container security pipelines. Either can be attached to the image digest via cosign.

Does Kyverno work with clusters other than Kubernetes?

+
Kyverno is Kubernetes-native (it runs as a webhook admission controller). For non-Kubernetes container runtimes (Nomad, ECS, raw Docker), you'd use Connaisseur (for Docker/containerd) or admission-controller equivalents in your platform. The core verification logic (`cosign verify` against an OIDC subject) is portable and CLI-invokable regardless of orchestrator.

How does this pipeline address the CERT-In SBOM mandate for containerized applications?

+
CERT-In's Technical Guidelines v2.0 (July 2025) require SBOMs for all software components, including containers, with 21 mandatory fields (supplier, version, component hash, license, etc.). Syft with CycloneDX 1.6 output satisfies all required fields. The OCI-attached SBOM means you can produce it on demand for any image digest, which is the audit evidence CERT-In requires.

See your full attack chain.
Code, build, runtime. One platform.