Practical DevSecOps: Hardening the Supply Chain in the Age of SBOMs
Update (15 Oct 2026): rewritten for Cosign v3 and
ImageValidatingPolicy. Kyverno 1.17 (February 2026) published thepolicies.kyverno.io/v1API and announced the retirement ofClusterPolicy; according to the policy types table,ImageValidatingPolicyis stable since 1.18,ClusterPolicywas deprecated in 1.19 and will be removed in 1.20 (expected in November 2026).
When Log4Shell was disclosed (CVE-2021-44228, 10 December 2021), the urgent question was which images shipped a vulnerable log4j-core. Without a per-image inventory, answering it means pulling and analyzing every artifact you have deployed.
The pipeline below builds that inventory from the published digest, stops on high-severity vulnerabilities that have a fix, signs the image, its SBOM and its provenance with a verifiable identity, and has the cluster reject at admission anything that does not meet those conditions:
| Stage | What runs | Blocks if… |
|---|---|---|
| Build and publish | docker/build-push-action | the build or the push fails |
| Inventory | Syft on image@sha256:… | it cannot read the image |
| Gate | Grype on the SBOM | there is a high or critical vulnerability with a fix |
| Signing and attestation | Cosign keyless and attest-build-provenance, in a separate job | the gate failed |
| Admission | Kyverno | the signature or attestation is missing, or the image is not from registry.example.com/app |
Requirements
- The workflow at
.github/workflows/release.yml; that path is part of the signing identity. - A registry that supports OCI 1.1. Cosign v3 stores the signature and attestations as artifacts that reference the image; the image itself does not change.
- Kyverno 1.18 or later, where
ImageValidatingPolicyis stable. - Versions pinned in the workflow (the latest as of 15 October 2026): Syft v1.54.1, Grype v0.120.1 and Cosign v3.1.3.
The full workflow
name: release-image
on:
push:
branches: [main]
permissions:
contents: read
env:
IMAGE: registry.example.com/app
SYFT_VERSION: v1.54.1
GRYPE_VERSION: v0.120.1
COSIGN_VERSION: v3.1.3
jobs:
build-scan:
runs-on: ubuntu-24.04
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: ${{ env.COSIGN_VERSION }}
- name: Install Syft and Grype from verified releases
run: |
set -euo pipefail
bin="${RUNNER_TEMP}/bin"
mkdir -p "${bin}"
cd "${RUNNER_TEMP}"
for tool in "syft ${SYFT_VERSION}" "grype ${GRYPE_VERSION}"; do
read -r name tag <<< "${tool}"
pkg="${name}_${tag#v}"
url="https://github.com/anchore/${name}/releases/download/${tag}"
for f in checksums.txt checksums.txt.sigstore.json linux_amd64.tar.gz; do
curl -sSfLO "${url}/${pkg}_${f}"
done
cosign verify-blob --bundle "${pkg}_checksums.txt.sigstore.json" \
--certificate-identity "https://github.com/anchore/${name}/.github/workflows/release.yaml@refs/heads/main" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"${pkg}_checksums.txt"
grep " ${pkg}_linux_amd64.tar.gz\$" "${pkg}_checksums.txt" | sha256sum -c -
tar -xzf "${pkg}_linux_amd64.tar.gz" -C "${bin}" "${name}"
done
echo "${bin}" >> "${GITHUB_PATH}"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
provenance: mode=max
- name: SBOM and vulnerability gate
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
sbom="sbom-${DIGEST#sha256:}.spdx.json"
syft scan "registry:${IMAGE}@${DIGEST}" -o "spdx-json=${sbom}"
grype "sbom:./${sbom}" --fail-on high --only-fixed
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: sbom
path: sbom-*.spdx.json
if-no-files-found: error
sign:
needs: build-scan
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
attestations: write
env:
DIGEST: ${{ needs.build-scan.outputs.digest }}
IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/heads/main
ISSUER: https://token.actions.githubusercontent.com
steps:
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: ${{ env.COSIGN_VERSION }}
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: registry.example.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: sbom
- name: Sign, attest and verify by digest
run: |
set -euo pipefail
ref="${IMAGE}@${DIGEST}"
sbom="sbom-${DIGEST#sha256:}.spdx.json"
cosign sign --yes "${ref}"
cosign attest --yes --type spdxjson --predicate "${sbom}" "${ref}"
cosign verify --certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer "${ISSUER}" "${ref}" > /dev/null
cosign verify-attestation --type spdxjson --certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer "${ISSUER}" "${ref}" > /dev/null
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.DIGEST }}
push-to-registry: true
create-storage-record: false
Build and publish: everything hinges on the digest
A sha256:… digest identifies one specific manifest, and the cosign sign reference tells you to sign by digest, not by tag. docker/build-push-action returns it in steps.build.outputs.digest, and every later stage works on registry.example.com/app@sha256:….
The Actions are pinned to the commit SHA of their release because a tag can be moved to another commit, as happened with tj-actions/changed-files in March 2025 (CVE-2025-30066; the full case is in the pipeline hijacking post). Syft and Grype are installed without running remote scripts: cosign verify-blob checks that the checksums file was signed by Anchore’s release workflow on main, and sha256sum -c checks the .tar.gz against that list. A checksum comparison like this one exposed the Codecov compromise in 2021.
Both jobs log in with a username and password stored as secrets. With ECR, GAR or ACR, replace that login with OIDC (on AWS, aws-actions/configure-aws-credentials and aws-actions/amazon-ecr-login, pinned by SHA), as in the pipeline hijacking post; build-scan then gets id-token: write too, and any of its steps could request a certificate with the release.yml identity. Otherwise, use a push-only token scoped to app, stored as an environment secret.
SBOM with Syft
An SBOM is an inventory: which packages and versions the image contains, each with its purl. It does not tell you whether they are vulnerable or who built the image; that comes from the vulnerability report, the signature and the provenance, which are separate documents.
syft scan "registry:…" reads the image straight from the registry without going through a container runtime (Syft CLI), so it inventories exactly what was published. The format is SPDX, whose version 2.2.1 is the ISO/IEC 5962:2021 standard. CycloneDX (ECMA-424) works too: use -o cyclonedx-json in Syft, --type cyclonedx in Cosign and the https://cyclonedx.org/bom type in Kyverno.
In the EU, the SBOM requirement comes from the Cyber Resilience Act (Regulation (EU) 2024/2847), not NIS2: Annex I, Part II, point 1 requires manufacturers to draw up a machine-readable SBOM covering at least the top-level dependencies, from 11 December 2027.
The limitation: Syft only inventories what its catalogers recognize. A binary copied into the image without package metadata may not show up, and no scan of the SBOM will see it.
Vulnerability gate with Grype
grype sbom:… --fail-on high --only-fixed exits with code 2 when it finds a high or critical vulnerability that already has a fix; --only-fixed ignores the ones that do not (Grype CLI). Any other error, such as failing to download the vulnerability database, also exits non-zero and, with set -euo pipefail, stops the job: no report, no signature.
Scanning the SBOM is no more accurate than scanning the image, but it lets you rescan stored SBOMs when a new CVE comes out. Because sign depends on build-scan, an image that fails the gate stays in the registry unsigned, and admission is what keeps it from being deployed.
Findings with no fix do not block the build, because bumping a version will not resolve them: they go to triage, where the CISA KEV catalog shows whether a CVE is already exploited and EPSS estimates the probability of exploitation (script in common DevSecOps mistakes). A high severity rating does not mean the vulnerable code is reachable either. Grype applies OpenVEX not_affected statements with --vex, but each one needs evidence that the code does not run or cannot be exploited in your context.
Keyless signing and attestation with Cosign
Only the sign job has id-token: write, so the steps that run your Dockerfile cannot request an OIDC token from GitHub. cosign sign --yes exchanges that token with Fulcio for a short-lived certificate whose identity is the workflow URL plus its ref, and which carries the repository and the commit as extensions (Fulcio OIDs). The signature is recorded in Rekor, a transparency log that anyone can audit to check that it is append-only.
cosign attest --type spdxjson wraps the SBOM in an in-toto statement with predicateType: https://spdx.dev/Document and the digest as its subject, signed with the same identity. It replaces cosign attach sbom, which is deprecated and uploads the SBOM unsigned. The two final verify commands repeat what the cluster will check.
attest-build-provenance generates SLSA provenance of the run (repository, workflow, commit) for the digest, signs it with the same OIDC token, stores it in GitHub’s attestations API (attestations: write) and, with push-to-registry: true, also in the registry; create-storage-record: false avoids requesting artifact-metadata: write. It requires a public repository or, for private and internal ones, GitHub Enterprise Cloud. You verify it with gh attestation verify (the pipeline hijacking post).
Admission-time verification with Kyverno
Two Kyverno policies act at admission, when the Kubernetes API receives the Pod and before it reaches a node:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: restrict-image-registries
spec:
validationActions: [Deny]
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE', 'UPDATE']
resources: ['pods', 'pods/ephemeralcontainers']
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: [kube-system, kyverno]
variables:
- name: allContainers
expression: >-
object.spec.containers + object.spec.?initContainers.orValue([]) +
object.spec.?ephemeralContainers.orValue([])
validations:
- expression: >-
variables.allContainers.all(c, c.image.startsWith('registry.example.com/app'))
message: images must come from registry.example.com/app
---
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: require-signed-images
spec:
validationActions: [Deny]
failurePolicy: Fail
webhookConfiguration:
timeoutSeconds: 15
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE', 'UPDATE']
resources: ['pods']
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: [kube-system, kyverno]
matchImageReferences:
- glob: 'registry.example.com/app*'
credentials:
secrets:
- registry-credentials
attestors:
- name: release
cosign:
keyless:
identities:
- issuer: https://token.actions.githubusercontent.com
subject: https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main
ctlog:
url: https://rekor.sigstore.dev
attestations:
- name: sbom
intoto:
type: https://spdx.dev/Document
validations:
- expression: >-
(images.containers + images.initContainers).map(image,
verifyImageSignatures(image, [attestors.release])).all(e, e > 0)
message: image is not signed by the org/repo release workflow on main
- expression: >-
(images.containers + images.initContainers).map(image,
verifyAttestationSignatures(image, attestations.sbom, [attestors.release])).all(e, e > 0)
message: image has no signed SPDX SBOM attestation from that workflow
require-signed-images requires a signature and an SPDX attestation from the release.yml of org/repo on main, and it only verifies images that match matchImageReferences. The exact subject binds the app path to one repository: with a regular expression covering all of org, a compromised repository could sign another team’s images. Each new image gets its own ImageValidatingPolicy with its glob and subject, and its prefix in restrict-image-registries, which only admits those prefixes, so no image gets in unverified.
Both use failurePolicy: Fail: if Kyverno does not respond or the evaluation fails, the Pod is rejected. mutateDigest, verifyDigest and required default to true, so Kyverno replaces the tag with the verified digest and the node pulls exactly what was checked.
Kyverno reads signatures and attestations using the credentials in credentials.secrets, and those Secrets must live in Kyverno’s namespace: kubectl create secret generic registry-credentials -n kyverno --type=kubernetes.io/dockerconfigjson --from-file=.dockerconfigjson=config.json.
This is admission, not runtime: it does not see what a container does after it starts, and it does not stop Pods that were already running. I cover runtime detection in Defensive strategies in Kubernetes. If you use OPA Gatekeeper, verifying signatures requires External Data and a provider such as Ratify.
Check that it blocks
First push an unsigned image tagged unsigned-test to the registry. If that tag does not exist, Kyverno rejects the Pod because it cannot resolve the digest, and the test proves nothing.
#!/usr/bin/env bash
# Expects DIGEST=sha256:... of an image signed by the release workflow
set -euo pipefail
ns=admission-check
kubectl create namespace "${ns}"
trap 'kubectl delete namespace "${ns}" --wait=false' EXIT
expect_denied() {
if kubectl run "$1" -n "${ns}" --restart=Never --image="$2"; then
echo "FAIL: $2 was admitted" >&2
exit 1
fi
echo "ok: $2 was denied"
}
expect_denied foreign docker.io/library/nginx:1.27
expect_denied unsigned registry.example.com/app:unsigned-test
kubectl run signed -n "${ns}" --restart=Never \
--image="registry.example.com/app@${DIGEST:?set DIGEST to a signed digest}"
echo "ok: signed digest admitted"
Answering the next Log4Shell with stored SBOMs
The workflow saves each SBOM with the digest in its file name. Actions artifacts expire (after 90 days by default), so copy them to storage you control or to Dependency-Track. With the files in ./sboms, this script answers the question from the opening:
#!/usr/bin/env bash
# Usage: ./find-cve.sh CVE-2021-44228 (SBOMs in ./sboms, one per digest)
set -euo pipefail
cve="${1:?usage: $0 CVE-ID}"
shopt -s nullglob
sboms=(sboms/sbom-*.spdx.json)
(( ${#sboms[@]} > 0 )) || { echo "no SBOMs found in ./sboms" >&2; exit 1; }
for sbom in "${sboms[@]}"; do
grype "sbom:./${sbom}" --by-cve -o json \
| jq -r --arg cve "${cve}" --arg sbom "${sbom}" \
'.matches[] | select(.vulnerability.id == $cve)
| "\($sbom)\t\(.artifact.name)@\(.artifact.version)"'
done
By default Grype shows the identifier from the vulnerability source, which can be a GHSA; --by-cve replaces it with the CVE. Because Kyverno rewrites images from your registry to their digest, kubectl get pods -A -o jsonpath='{.items[*].spec.containers[*].image}' lists the digests that are running.
Limits: what this chain does not prove
- A valid signature proves that the
release.ymloforg/repoonmainsigned that digest, not that the build was clean. SUNSPOT, the implant in the SolarWinds attack disclosed in December 2020, swapped an Orion source file whileMsBuild.exewas compiling it, inside the legitimate build process. In December 2024, the first malicious ultralytics releases were published from the project’s legitimate workflow after the attacker poisoned its GitHub Actions cache through apull_request_targetworkflow open to branch-name injection (technical analysis). - Anyone who can push to
maininorg/repoor edit itsrelease.ymlcan produce valid signatures. Protect the branch and the.github/workflows/directory, as I explain in the pipeline hijacking post. - Anyone who can create Pods in
kube-systemorkyvernobypasses the policies, andrequire-signed-imagesdoes not see the ephemeral containers thatkubectl debugadds (pods/ephemeralcontainers); restrict those RBAC permissions. provenance: mode=maxattaches to the index a record of how the image was built that includes build-arg values: do not pass secrets through them. BuildKit generates it inside the job, not the build platform, and without a signature of its own, so it does not meet SLSA Build L2. According to GitHub, the artifact attestations fromattest-build-provenanceprovide Build L2 on their own and Build L3 with a reusable workflow; the alternative is slsa-github-generator (Build L3), which Kyverno verifies with a sample policy.- The gate only detects what is already in its database: a malicious package that nobody has cataloged yet gets through.