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.
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.
Controls only make sense against named attacks. These four cover most real incidents.
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.tj-actions/changed-files CI action were repointed to malicious code that dumped runner secrets, hitting everyone who referenced it by tag.| Control | Stops | Does not stop |
|---|---|---|
| Lockfile with hashes | Silent version drift, replaced artifacts on an index | A malicious version you deliberately upgraded to |
| Release cooldown on updates | Most fast-detected malicious releases | Slow-burn backdoors; it also delays urgent fixes unless exempted |
| Vulnerability scanning | Known CVEs in shipped components | Malware without an advisory, zero-days, unlisted binaries |
| SBOM | Blind spots after a new CVE is disclosed | Anything on its own; it is inventory, not protection |
| Signing | Tampering after the signing step | Malicious content that was signed by your own pipeline |
| Provenance and policy | Artifacts built outside the approved pipeline or from the wrong source | A compromise inside the approved pipeline, unless the build platform isolates it |
The right column matters most: no single row is sufficient.
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.
# 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.
| Ecosystem | Enforce lock in CI | Note |
|---|---|---|
| npm | npm ci --ignore-scripts | Fails if lockfile and manifest disagree; npm audit signatures checks registry signatures |
| uv | uv sync --locked | Errors if uv.lock is stale |
| Go | go build -mod=readonly ./... | go.sum holds hashes; this is the default mode |
| Container base | FROM image@sha256:... | Needs an update bot, or the base never gets patched |
| CI actions | uses: 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:
--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.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
| Aspect | SPDX | CycloneDX |
|---|---|---|
| Origin and focus | Linux Foundation; licence and compliance roots | OWASP; security use cases |
| Status | ISO/IEC 5962 standard for version 2.2.1 | ECMA-424 standard |
| Strength | File-level detail, licence expressions, wide acceptance in procurement | Compact, native VEX, services and dependency graph |
| Practical use | Handing to customers and auditors | Feeding scanners and internal tooling |
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.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.
| Tool | Best at | Watch out for |
|---|---|---|
| pip-audit | Python advisories early in CI, fast, exact lockfile input | Python only; misses OS packages |
| Trivy | All-in-one image, filesystem, secret and config scanning | Database download can fail or rate-limit in CI; cache it |
| Grype | Pairs naturally with Syft; rescans SBOMs quickly; JSON output suits policy gates | Accuracy 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.
A gate that fails constantly gets bypassed. The goal is a small actionable list, with documented reasons for everything else.
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.
| Situation | Action | Suggested deadline |
|---|---|---|
| Known exploited, reachable, fix available | Block release, patch now | 24 to 72 hours |
| High or critical, fix available | Block release, upgrade | 7 to 14 days for existing deployments |
| High or critical, no fix | Do not block; mitigate, record, set a review date | Re-check at review date |
| Not reachable or not present in this context | Suppress with written justification | Expire in 90 days |
| Medium or low | Fix through routine dependency updates | Next regular cycle |
These deadlines are starting points. Your compliance obligations may fix different values.
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.
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.
| Mode | Advantages | Costs |
|---|---|---|
| Keyless, public Sigstore | No keys to rotate or leak; public audit trail | Identity, repository path and timing are public in the log; depends on external services during signing |
| KMS or hardware key | Works offline and in private networks; no public metadata | Rotation, access control and revocation are your problem |
Failure modes to plan for:
cosign copy.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 level | Requirement | Protects against |
|---|---|---|
| L0 | None | Nothing |
| L1 | Provenance exists, showing how the artifact was built | Mistakes; gives documentation for debugging and inventory |
| L2 | Hosted build platform that generates and signs the provenance | Tampering after the build; forgery by people without platform access |
| L3 | Hardened platform: isolated builds, and signing material inaccessible to build steps | Tampering 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.
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.
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.
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"
#!/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
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.
requirements.txt. pip install --require-hashes must stop with a hash mismatch error.npm ci or uv sync --locked must fail.conftest test --policy policy --namespace sbom on a file containing {"components": []}, then on {}. Both must exit non-zero.deploy-gate.sh with the correct image and a different commit. It must fail at the jq -e check.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.