Security Advanced

Software Supply Chain Security: SBOMs, Sigstore, SLSA, and Dependency and Image Scanning in CI

Defend against typosquatting, compromised maintainers and build tampering with hash-pinned lockfiles, Syft SBOMs, Trivy, Grype and pip-audit scanning, triage rules, cosign signing, SLSA provenance, Rego policy gates and a complete CI pipeline.

KC Ramo DjangoZen Team Nov 05, 2026 20 min read 3 views

A modern service ships far more third-party code than first-party code, and it passes through registries, build runners and base images that nobody on the team reviews. Supply chain security means being able to answer three questions with evidence: what is in this artifact, where did it come from, and has anyone changed it. This guide covers the controls that produce that evidence, what each one fails to catch, and how to wire them into one CI pipeline that blocks a release when the evidence is missing.

Threat scenarios

Controls only make sense against named attacks. These four cover most real incidents.

  • Typosquatting and dependency confusion. An attacker publishes requets, or registers your internal package name on a public index with a higher version number. Install scripts run with developer or CI credentials as soon as the package resolves.
  • Compromised maintainers. A legitimate package gets a malicious release through a stolen token or a patient social engineering campaign. The event-stream incident (2018) was a handover to a new maintainer. The xz-utils backdoor (CVE-2024-3094) involved years of contributor trust-building and a payload present only in release tarballs.
  • Build tampering. The source is clean but the build changes the output: a poisoned runner, cache or plugin. In CVE-2025-30066, tags of the widely used tj-actions/changed-files CI action were repointed to malicious code that dumped runner secrets, hitting everyone who referenced it by tag.
  • Registry or deploy tampering. An attacker with push access overwrites a tag, so production pulls a different image from the one that was tested.
ControlStopsDoes not stop
Lockfile with hashesSilent version drift, replaced artifacts on an indexA malicious version you deliberately upgraded to
Release cooldown on updatesMost fast-detected malicious releasesSlow-burn backdoors; it also delays urgent fixes unless exempted
Vulnerability scanningKnown CVEs in shipped componentsMalware without an advisory, zero-days, unlisted binaries
SBOMBlind spots after a new CVE is disclosedAnything on its own; it is inventory, not protection
SigningTampering after the signing stepMalicious content that was signed by your own pipeline
Provenance and policyArtifacts built outside the approved pipeline or from the wrong sourceA compromise inside the approved pipeline, unless the build platform isolates it

The right column matters most: no single row is sufficient.

Lockfiles and hash pinning

A version pin says which name and number you want. Only a hash says which bytes. If an index or mirror is compromised, or a release file is swapped, a version pin still resolves and a hash pin fails loudly.

Python

# requirements.in holds direct dependencies only
uv pip compile --generate-hashes requirements.in -o requirements.txt
requests==2.32.5 \
    --hash=sha256:<wheel-hash> \
    --hash=sha256:<sdist-hash>
    # via -r requirements.in

Hash mode is all-or-nothing, so every transitive dependency must be listed, which is what you want. Install with no fallback and no build step:

FROM python:3.14-slim@sha256:<base-image-digest>
WORKDIR /srv/app
COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes --no-deps \
    --only-binary :all: -r requirements.txt
COPY . .
USER 10001
CMD ["gunicorn", "myproject.wsgi:application", "--bind", ":8000"]

--only-binary :all: blocks code execution from source distributions at install time. The failure mode is a package with no wheel for your platform, or a wheel not present at lock time, so lock on the platform and Python version you deploy, or use a universal lock such as uv lock.

Other ecosystems and CI actions

EcosystemEnforce lock in CINote
npmnpm ci --ignore-scriptsFails if lockfile and manifest disagree; npm audit signatures checks registry signatures
uvuv sync --lockedErrors if uv.lock is stale
Gogo build -mod=readonly ./...go.sum holds hashes; this is the default mode
Container baseFROM image@sha256:...Needs an update bot, or the base never gets patched
CI actionsuses: org/action@<full-commit-sha>Tags are mutable; a full commit hash is not

Pinning creates a maintenance obligation. A digest pin that nobody updates is a guaranteed unpatched base image. Use an update bot and let it open pull requests, combined with a cooldown so that a new release must age before adoption, for example Renovate's minimumReleaseAge or uv's --exclude-newer. Many malicious releases are found within days, so a short delay removes real exposure. Exempt security fixes.

Three further rules close the common gaps:

  • Review lockfile diffs. A lockfile-only pull request is a known way to smuggle in a new package or resolved URL.
  • For internal packages, use one index that proxies upstream, with --index-url https://pypi.example.com/simple. Avoid --extra-index-url because pip then treats both indexes as equal and may pick the public package with a higher version. That is dependency confusion.
  • Treat the first addition of a dependency as a review event. Hash pinning locks in whatever you chose, typosquat included. Check the name, age, maintainers and download history before it enters the lockfile.

SBOM generation with Syft

A software bill of materials is a machine-readable inventory. Its main value appears later: when the next major CVE lands, you query stored SBOMs instead of rebuilding or rescanning every image.

# Generate from an image by digest, in both formats at once
syft registry:registry.example.com/myproject/web@sha256:<digest> \
  -o spdx-json=sbom.spdx.json \
  -o cyclonedx-json=sbom.cdx.json

# Generate from a source tree
syft dir:/srv/app -o cyclonedx-json=sbom-source.cdx.json

# A quick sanity query
jq '.components | length' sbom.cdx.json
AspectSPDXCycloneDX
Origin and focusLinux Foundation; licence and compliance rootsOWASP; security use cases
StatusISO/IEC 5962 standard for version 2.2.1ECMA-424 standard
StrengthFile-level detail, licence expressions, wide acceptance in procurementCompact, native VEX, services and dependency graph
Practical useHanding to customers and auditorsFeeding scanners and internal tooling

Where SBOMs go wrong

  • Source and image SBOMs differ. A source SBOM lists declared dependencies, including ones never shipped. An image SBOM lists what is present, including OS packages. Use the image SBOM for vulnerability work.
  • Anything not managed by a package manager may be missing. A binary fetched by curl in a Dockerfile, vendored code and copied JavaScript bundles often leave no metadata. Syft has binary classifiers for a limited set of programs, but a missing entry proves nothing.
  • Multi-stage builds hide build tools. The compiler in the build stage is not in the final image, so a compromised build tool will not show up. Provenance has to record that.
  • Empty is not clean. A scanner that reads an SBOM with zero components reports zero vulnerabilities. Gate on a minimum component count, as the policy below does.

Scanning with Trivy, Grype and pip-audit

The three tools overlap but work at different points.

# Project dependencies, before any image exists
pip-audit -r requirements.txt --require-hashes --disable-pip --strict

# Image layers, plus language packages and embedded secrets
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
  registry.example.com/myproject/web@sha256:<digest>

# Filesystem scan for secrets and IaC misconfiguration
trivy fs --scanners secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .

# Rescan a stored SBOM without pulling the image
grype sbom:sbom.cdx.json --only-fixed --fail-on high
trivy sbom sbom.cdx.json

--disable-pip skips the temporary virtual environment and needs a fully hashed requirements file or --no-deps. --strict turns skipped dependencies into failures, so a package that could not be audited does not pass silently.

ToolBest atWatch out for
pip-auditPython advisories early in CI, fast, exact lockfile inputPython only; misses OS packages
TrivyAll-in-one image, filesystem, secret and config scanningDatabase download can fail or rate-limit in CI; cache it
GrypePairs naturally with Syft; rescans SBOMs quickly; JSON output suits policy gatesAccuracy is bounded by SBOM quality

Scanners disagree because they use different advisory data, and distro feeds record backported fixes that upstream version ranges miss. Pick one primary tool for policy and treat the second as a spot check at release time. When they disagree, the distribution's security tracker usually settles it.

Scanners are themselves supply chain inputs. They run in CI with registry access and often more. Pin them by digest in a tools image, and update them through review like any other dependency.

Triage and noise management

A gate that fails constantly gets bypassed. The goal is a small actionable list, with documented reasons for everything else.

Severity is not risk

CVSS measures theoretical severity. Prioritise with more signals: is it in the CISA Known Exploited Vulnerabilities catalog, what is its EPSS exploit probability, is the code reachable, and is the service exposed? Recent Grype releases surface EPSS and KEV data in their output, which helps sort without a separate tool.

SituationActionSuggested deadline
Known exploited, reachable, fix availableBlock release, patch now24 to 72 hours
High or critical, fix availableBlock release, upgrade7 to 14 days for existing deployments
High or critical, no fixDo not block; mitigate, record, set a review dateRe-check at review date
Not reachable or not present in this contextSuppress with written justificationExpire in 90 days
Medium or lowFix through routine dependency updatesNext regular cycle

These deadlines are starting points. Your compliance obligations may fix different values.

Suppressions as code

Every suppression lives in the repository, goes through review, and carries a reason. The IDs below are placeholders.

# .trivyignore
# Vulnerable parser is only reached through a CLI tool not used in this image.
CVE-2099-12345 exp:2027-01-31
# .grype.yaml
ignore:
  - vulnerability: CVE-2099-12345
    reason: "Vulnerable code path not reachable; review 2027-01-31"
    package:
      name: libexample
      type: deb

Scope suppressions to a package and give them an expiry so they resurface. exp: in .trivyignore does this natively. For Grype, keep the review date in the reason and have a scheduled job report any that have passed.

VEX (Vulnerability Exploitability eXchange) is the machine-readable form of the same statement, and both Trivy and Grype accept OpenVEX documents through --vex. It can travel with the image to consumers, but it is more ceremony than an ignore file, so adopt it when downstream customers ask.

The other noise control is shipping less. A distroless or minimal base removes most OS findings, at the cost of harder debugging.

Signing and verifying with cosign

Sigstore's model separates signing from long-lived keys. In keyless mode, cosign obtains an OIDC identity token from the CI platform, exchanges it with the Fulcio certificate authority for a certificate valid for minutes, signs, and records the event in the Rekor transparency log. The signing key is ephemeral and discarded. Verification asks a different question from classic key checking: was this signed by this identity, at a time when its certificate was valid?

# Always sign the digest. Signing a tag is a race against whoever can push.
cosign sign --yes registry.example.com/myproject/web@sha256:<digest>

# Verification names exactly who is allowed to have signed
cosign verify \
  --certificate-identity-regexp '^https://git\.example\.com/myproject/web/\.ci/workflows/release\.yml@refs/heads/main$' \
  --certificate-oidc-issuer https://ci.example.com \
  registry.example.com/myproject/web@sha256:<digest>

# Attach signed metadata as an in-toto attestation
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json \
  registry.example.com/myproject/web@sha256:<digest>

Replace the identity and issuer with what your CI platform documents, because the format differs between platforms. Anchor the regular expression with ^ and $ and escape the dots. A pattern such as .*myproject.* accepts any repository, on any host, whose path happens to contain that word.

For a multi-arch image, sign the index with --recursive so that each platform image is covered.

ModeAdvantagesCosts
Keyless, public SigstoreNo keys to rotate or leak; public audit trailIdentity, repository path and timing are public in the log; depends on external services during signing
KMS or hardware keyWorks offline and in private networks; no public metadataRotation, access control and revocation are your problem

Failure modes to plan for:

  • Verification is the control. A signature nobody verifies provides nothing.
  • Signing outage. If Fulcio or Rekor is unreachable, the release fails. Decide in advance what happens for an emergency hotfix.
  • The CI identity is the key. Anyone who can modify the workflow on the protected ref can get a valid signature. Branch protection and required review are therefore part of the signing system.
  • Registry garbage collection. Signatures are stored as OCI artifacts in the registry. Retention policies and image-mirroring tools may drop them, so copy with a tool that preserves them, such as cosign copy.

SLSA levels and provenance

SLSA (Supply-chain Levels for Software Artifacts) defines how much you can trust a claim about how an artifact was built. Since version 1.0 the core is the Build track, and later specification versions add a Source track.

Build levelRequirementProtects against
L0NoneNothing
L1Provenance exists, showing how the artifact was builtMistakes; gives documentation for debugging and inventory
L2Hosted build platform that generates and signs the provenanceTampering after the build; forgery by people without platform access
L3Hardened platform: isolated builds, and signing material inaccessible to build stepsTampering during the build by a compromised tenant or step

Provenance is an attestation, a signed statement whose predicate describes the build. The in-toto statement wrapper binds it to the artifact digest. The predicate itself looks like this, with the identifiers shown as placeholders for your platform:

{
  "buildDefinition": {
    "buildType": "https://ci.example.com/buildtypes/container/v1",
    "externalParameters": {
      "repository": "https://git.example.com/myproject/web",
      "ref": "refs/heads/main",
      "workflow": ".ci/workflows/release.yml"
    },
    "resolvedDependencies": [
      {
        "uri": "git+https://git.example.com/myproject/web@refs/heads/main",
        "digest": { "gitCommit": "<40-hex-commit>" }
      }
    ]
  },
  "runDetails": {
    "builder": { "id": "https://ci.example.com/builders/hosted/v1" },
    "metadata": { "invocationId": "https://ci.example.com/runs/48213" }
  }
}

Be honest about the level you hold. If your own workflow generates provenance using a token that build steps could also reach, a compromised step can forge it. L3 needs a builder whose trusted control plane produces provenance outside tenant control, for example a platform-managed reusable builder.

BuildKit can also generate provenance with docker buildx build --provenance=mode=max, but on a self-managed runner the trust level is that of the runner. Either way, the verifier must pin exactly which builder, repository and ref are acceptable. Provenance without an expected-values check is decorative.

Policy gates

A policy gate turns evidence into a decision. Keep it in code, separate from the pipeline, so it can be reviewed and tested alone. Conftest runs Rego policies on any JSON file, which covers scanner reports and SBOMs.

# policy/vuln.rego
package vuln

import rego.v1

blocked := {"Critical", "High"}

deny contains msg if {
    some m in input.matches
    m.vulnerability.severity in blocked
    m.vulnerability.fix.state == "fixed"
    msg := sprintf("%s in %s %s: fixed in %v", [
        m.vulnerability.id, m.artifact.name,
        m.artifact.version, m.vulnerability.fix.versions,
    ])
}
# policy/sbom.rego
package sbom

import rego.v1

denied_licenses := {"AGPL-3.0-only", "AGPL-3.0-or-later", "SSPL-1.0"}

deny contains msg if {
    n := count(object.get(input, "components", []))
    n < 20
    msg := sprintf("SBOM looks incomplete: %d components", [n])
}

deny contains msg if {
    some c in input.components
    some l in c.licenses
    l.license.id in denied_licenses
    msg := sprintf("%s uses denied licence %s", [c.name, l.license.id])
}

The threshold and licence list are examples, and licences expressed as SPDX expressions need a further rule. In Rego, a rule that references a missing field simply does not fire, so a renamed key in a scanner release silently disables a deny. That is why the count check uses object.get and fails closed. Unit test policies with conftest verify against stored sample reports whenever you update a scanner.

Enforcing at deploy time

CI gates can be skipped by anyone who pushes directly to the registry, so enforce again at the point of use. The Sigstore policy-controller and Kyverno both run as Kubernetes admission webhooks that verify signatures and attestations against an identity policy before a pod starts. Roll them out in audit mode first, since enforcement can block system images. Decide what happens when the webhook is down: fail-open loses the guarantee, while fail-closed can stop cluster operations, including recovery. Outside Kubernetes, the deploy gate script below gives the same check.

A complete CI pipeline

This release workflow uses the common jobs and steps YAML format; the logic is plain shell, so it ports to any runner with OIDC. Each job has only the permissions it needs, and only the final job can obtain a signing identity. Replace the placeholders in angle brackets with real digests and commit hashes. supply-chain-tools is an internal image holding pinned versions of syft, grype, trivy, cosign, pip-audit, conftest, jq and the docker CLI, so tool versions change only through review.

name: release
on:
  push:
    branches: [main]

permissions:
  contents: read

env:
  IMAGE: registry.example.com/myproject/web

jobs:
  source-checks:
    runs-on: ubuntu-24.04
    container: registry.example.com/ci/supply-chain-tools@sha256:<tools-digest>
    steps:
      - uses: actions/checkout@<commit-sha>
      - name: Audit locked Python dependencies
        run: pip-audit -r requirements.txt --require-hashes --disable-pip --strict
      - name: Scan repository for secrets and misconfiguration
        run: trivy fs --scanners secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .
      - name: Test policies
        run: conftest verify --policy policy

  build:
    needs: source-checks
    runs-on: ubuntu-24.04
    outputs:
      ref: ${{ steps.push.outputs.ref }}
    steps:
      - uses: actions/checkout@<commit-sha>
      - name: Log in to registry
        run: |
          echo "$REGISTRY_TOKEN" | docker login registry.example.com -u ci --password-stdin
        env:
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
      - name: Build and push
        id: push
        run: |
          docker buildx build --push --pull \
            --metadata-file build-meta.json \
            --label org.opencontainers.image.revision="$GITHUB_SHA" \
            -t "$IMAGE:$GITHUB_SHA" .
          digest="$(jq -r '."containerimage.digest"' build-meta.json)"
          echo "ref=$IMAGE@$digest" >> "$GITHUB_OUTPUT"

  scan:
    needs: build
    runs-on: ubuntu-24.04
    container: registry.example.com/ci/supply-chain-tools@sha256:<tools-digest>
    env:
      IMAGE_REF: ${{ needs.build.outputs.ref }}
    steps:
      - uses: actions/checkout@<commit-sha>
      - name: Log in to registry
        run: |
          echo "$REGISTRY_TOKEN" | docker login registry.example.com -u ci --password-stdin
        env:
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
      - name: Generate SBOMs
        run: |
          syft "registry:$IMAGE_REF" \
            -o spdx-json=sbom.spdx.json \
            -o cyclonedx-json=sbom.cdx.json
      - name: Scan
        run: |
          grype sbom:sbom.cdx.json --config .grype.yaml -o json=grype.json -o table
          trivy image --ignorefile .trivyignore --ignore-unfixed \
            --severity CRITICAL --exit-code 1 "$IMAGE_REF"
      - name: Policy gate
        run: |
          conftest test --policy policy --namespace vuln grype.json
          conftest test --policy policy --namespace sbom sbom.cdx.json
      - uses: actions/upload-artifact@<commit-sha>
        with:
          name: evidence
          path: |
            sbom.spdx.json
            sbom.cdx.json
            grype.json

  sign:
    needs: [build, scan]
    runs-on: ubuntu-24.04
    container: registry.example.com/ci/supply-chain-tools@sha256:<tools-digest>
    permissions:
      contents: read
      id-token: write
    env:
      IMAGE_REF: ${{ needs.build.outputs.ref }}
    steps:
      - uses: actions/download-artifact@<commit-sha>
        with:
          name: evidence
      - name: Log in to registry
        run: |
          echo "$REGISTRY_TOKEN" | docker login registry.example.com -u ci --password-stdin
        env:
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
      - name: Write provenance predicate
        run: |
          repo="https://git.example.com/$GITHUB_REPOSITORY"
          jq -n --arg repo "$repo" --arg ref "$GITHUB_REF" --arg sha "$GITHUB_SHA" \
                --arg run "https://ci.example.com/runs/$GITHUB_RUN_ID" '{
            buildDefinition: {
              buildType: "https://ci.example.com/buildtypes/container/v1",
              externalParameters: {repository: $repo, ref: $ref,
                                   workflow: ".ci/workflows/release.yml"},
              resolvedDependencies: [{uri: ("git+" + $repo + "@" + $ref),
                                      digest: {gitCommit: $sha}}]
            },
            runDetails: {
              builder: {id: "https://ci.example.com/builders/hosted/v1"},
              metadata: {invocationId: $run}
            }
          }' > provenance.json
      - name: Sign and attest
        run: |
          cosign sign --yes "$IMAGE_REF"
          cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$IMAGE_REF"
          cosign attest --yes --type spdxjson --predicate sbom.spdx.json "$IMAGE_REF"
          cosign attest --yes --type slsaprovenance1 --predicate provenance.json "$IMAGE_REF"
  • Downstream jobs use only the digest, so nothing can swap the image between build and signing.
  • Grype produces the report and the policy decides. Trivy's severity gate is deliberately narrower here, as a second opinion on critical findings only.
  • Passing evidence through CI artifacts means the sign job trusts the scan job. Treat all of them as one trust boundary, and protect the workflow file with required review.
  • This design is roughly SLSA Build L2 at best, not L3. Provenance is written by a repository-defined step, so review and platform controls carry the weight.
  • Do not reuse caches across pull requests and release builds. A cache written by untrusted code is a build input.

Deploy gate

#!/bin/sh
# deploy-gate.sh IMAGE@sha256:DIGEST EXPECTED_COMMIT
set -eu
IMAGE_REF="$1"
EXPECTED_SHA="$2"

case "$IMAGE_REF" in
  *@sha256:*) ;;
  *) echo "refusing: reference must use a digest" >&2; exit 1 ;;
esac

ID='^https://git\.example\.com/myproject/web/\.ci/workflows/release\.yml@refs/heads/main$'
ISSUER='https://ci.example.com'

cosign verify --certificate-identity-regexp "$ID" \
  --certificate-oidc-issuer "$ISSUER" "$IMAGE_REF" > /dev/null

cosign verify-attestation --type cyclonedx --certificate-identity-regexp "$ID" \
  --certificate-oidc-issuer "$ISSUER" "$IMAGE_REF" > /dev/null

att="$(cosign verify-attestation --type slsaprovenance1 \
  --certificate-identity-regexp "$ID" \
  --certificate-oidc-issuer "$ISSUER" "$IMAGE_REF")"

printf '%s\n' "$att" | head -n 1 | jq -r '.payload' | base64 -d | jq -e \
  --arg sha "$EXPECTED_SHA" \
  --arg builder 'https://ci.example.com/builders/hosted/v1' \
  --arg repo 'https://git.example.com/myproject/web' '
    .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit == $sha
    and .predicate.runDetails.builder.id == $builder
    and .predicate.buildDefinition.externalParameters.repository == $repo
  ' > /dev/null

echo "verified: $IMAGE_REF"

A scheduled job should pull the attested SBOM of each deployed image and rescan it with a fresh database, since a verified image can become vulnerable overnight:

cosign verify-attestation --type cyclonedx \
  --certificate-identity-regexp "$ID" --certificate-oidc-issuer "$ISSUER" "$IMAGE_REF" \
  | head -n 1 | jq -r '.payload' | base64 -d | jq '.predicate' > deployed.cdx.json
grype sbom:deployed.cdx.json --config .grype.yaml --only-fixed --fail-on critical

Verifying it works

A control you have never seen fail is an assumption. Run these negative tests on a non-production branch and registry path, and repeat them after tool upgrades.

  1. Tampered hash: change one character of a hash in requirements.txt. pip install --require-hashes must stop with a hash mismatch error.
  2. Unlocked dependency: add a package to the manifest without relocking. npm ci or uv sync --locked must fail.
  3. Vulnerable dependency: pin a package to an old version with a known fixed high-severity CVE. The pipeline must stop at the audit or policy step, not at signing.
  4. Empty SBOM: run conftest test --policy policy --namespace sbom on a file containing {"components": []}, then on {}. Both must exit non-zero.
  5. Unsigned image: build and push an image by hand, then run the gate. It must fail.
  6. Wrong identity: verify a valid image with a different workflow path or branch in the regular expression. It must fail, which proves your pattern is not too broad.
  7. Tag swap: repoint a tag. A deploy by digest must be unaffected, and a deploy by tag must be refused.
  8. Wrong commit: call deploy-gate.sh with the correct image and a different commit. It must fail at the jq -e check.
  9. Expired suppression: set an exp: date in the past and confirm the finding returns.

Confirm the positive path as well:

cosign tree registry.example.com/myproject/web@sha256:<digest>   # signature and attestations present
./deploy-gate.sh registry.example.com/myproject/web@sha256:<digest> "$(git rev-parse HEAD)"

Keep checking that every running image has a valid signature, no suppression is past its date, base digests are updated within your patching window, and the workflow and policy files require review.

Found a mistake in this tutorial? Let us know and we will fix it.