Supply Chain Security: Generate an SBOM and Block Critical CVEs Before the Merge, Not in Prod

2026-11-03来源 dev.to DevOpsEnglish

Most teams discover a critical CVE in a container image the same way: a scanner running against the production cluster fires an alert at 3pm on a Friday, and someone has to figure out whether the vulnerable library is actually reachable, who owns the base image, and how fast a patched version can be shipped. That workflow is expensive and stressful. The fix is unglamorous: move the detection left, into the pull request, where the cost of a change is a one-line bump in a lockfile instead of an emergency change request.

This article shows how to build that guardrail with two small, focused tools — Syft to produce a CycloneDX SBOM, and Grype to scan it — wired into GitHub Actions, with a severity policy that fails the build and an exception mechanism that is documented, time-boxed, and reviewable. We will also cover where Trivy fits, and how to keep the same policy enforced at admission time on OpenShift/Kubernetes so the CI gate is not the only line of defence.

The target reader is someone who has never generated an SBOM before. Every command below is runnable as-is.

Context: why a scanner in prod is already too late

A vulnerability scan tells you three things: what software you ship, which known CVEs affect it, and whether a fix exists. Running that scan in production answers the question after you have already taken the risk. Running it in CI answers it before the artifact exists.

There are three concrete reasons this matters:

1. You cannot patch what you cannot enumerate. When log4shell landed, the teams that answered "are we affected?" in minutes were the ones with a stored SBOM per build. Everyone else spent two days running find / -name "log4j*" across clusters.

2. The cost of a fix grows with distance from the commit. Bumping express from 4.17.1 to 4.19.2 in a PR is a 10-minute job. Doing it on a running production service means a change window, a rollback plan, and a regression test campaign.

3. Regulations now require the artefact. The EU Cyber Resilience Act, the US Executive Order 14028, and most enterprise vendor questionnaires ask for a machine-readable bill of materials. CycloneDX and SPDX are the two accepted formats. Generating one as a build side effect costs you about 15 seconds of CI time.

The guardrail we are building has a simple contract: no pull request merges if it introduces a Critical (or High, depending on your policy) vulnerability with a known fix, unless an exception is explicitly recorded in the repository.

The tools, in 90 seconds

  • Syft (Anchore, Apache-2.0) walks a filesystem, a directory, or a container image and lists every package it can identify: OS packages (apk/deb/rpm), language packages (npm, pip, go modules, Maven, Cargo...), and binaries. Output: an SBOM in CycloneDX, SPDX, or its own JSON format. Syft does not know about vulnerabilities.
  • Grype (same vendor) takes an SBOM — or an image directly — and matches each package against a vulnerability database (NVD, GitHub Security Advisories, distro trackers). It knows about fix state, which is the single most useful field for a sane policy.
  • Trivy (Aqua, Apache-2.0) does both jobs in one binary, plus IaC and secret scanning. It is an excellent alternative, and many teams pick it precisely because it is one tool instead of two.

Why split Syft and Grype? Because the SBOM becomes an artifact in its own right. You generate it once, attach it to the release, and can re-scan it six months later against a newer vulnerability DB without rebuilding anything. That decoupling is the whole point of an SBOM.

Install locally first

Always validate the pipeline on your laptop before writing YAML.

# Syft and Grype (binaries land in ./bin or /usr/local/bin) curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \ | sh -s -- -b /usr/local/bin v1.18.1 curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \ | sh -s -- -b /usr/local/bin v0.87.0 syft version grype version 

Pin the versions. An unpinned install.sh is itself a supply chain risk, and a tool upgrade that changes default severity mappings will break builds at the worst moment.

Step 1 — Generate a CycloneDX SBOM

Syft accepts several source types. The two you will use most:

# From a container image in a registry (no Docker daemon required) syft registry:quay.io/myorg/payment-api:1.4.2 \ -o cyclonedx-json=sbom.cdx.json # From the local source tree (catalogs lockfiles: package-lock.json, go.sum, poetry.lock...) syft dir:. -o cyclonedx-json=sbom-src.cdx.json 

Scan both. The source scan catches your direct and transitive application dependencies early, even before the image is built. The image scan catches everything: OS packages from the base image, binaries copied in, and anything a build stage pulled in.

Inspect what you got:

# Human-readable table syft registry:quay.io/myorg/payment-api:1.4.2 -o table | head -20 # Count components in the SBOM jq '.components | length' sbom.cdx.json # List components by package ecosystem jq -r '.components[].purl' sbom.cdx.json | cut -d: -f2 | cut -d/ -f1 | sort | uniq -c 

A CycloneDX document is just JSON with a bomFormat, a specVersion, a metadata block describing the subject, and a components array of packages with their purl (package URL) identifiers. That purl is what makes the SBOM matchable against vulnerability databases.

Cut the noise at the source

Syft catalogs everything it finds, including build-time tooling that never reaches the runtime image. If you use multi-stage builds correctly, this mostly solves itself:

FROM registry.access.redhat.com/ubi9/nodejs-20 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build && npm prune --omit=dev FROM registry.access.redhat.com/ubi9/nodejs-20-minimal WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules USER 1001 CMD ["node", "dist/server.js"] 

The final stage contains production dependencies only, so the SBOM reflects what actually runs. A -minimal or distroless base image typically removes 100+ OS packages and with them a long tail of irrelevant CVEs.

Step 2 — Scan the SBOM with Grype

# Scan the SBOM you just produced grype sbom:sbom.cdx.json -o table # Fail the process if anything Critical is found grype sbom:sbom.cdx.json --fail-on critical # Only report vulnerabilities that have an available fix grype sbom:sbom.cdx.json --only-fixed --fail-on high 

--only-fixed is the setting that turns a noisy scanner into a usable gate. A Critical CVE in a distro package where the vendor has not shipped a patch is not something your PR author can act on. Blocking the merge on it just teaches people to bypass the gate. Report it, track it, but do not fail the build on it.

Exit code is 1 when the threshold is hit, 0 otherwise — that is all CI needs.

Policy as a file, not as CLI flags

Grype reads .grype.yaml from the working directory. Commit it; it is your policy document.

# .grype.yaml # Severity threshold that breaks the build. fail-on-severity: high # Only consider vulnerabilities with a known fix available. only-fixed: true output: - table # Documented, time-boxed exceptions. ignore: # CVE-2024-21538 (cross-spawn ReDoS) - build-time only, not reachable at runtime. # Owner: platform-team Ticket: SEC-1427 Review by: 2025-09-30 - vulnerability: CVE-2024-21538 package: name: cross-spawn type: npm # GHSA-xxxx: triggers only when the parser is fed untrusted XML; our input is # internal and schema-validated. Owner: payments Ticket: SEC-1455 Review by: 2025-08-15 - vulnerability: GHSA-2c8p-7m4v-h3cp package: type: npm 

Three rules make exceptions safe rather than a loophole:

  1. Every entry carries an owner, a ticket, and a review date. No anonymous ignore lines.
  2. The file is protected by CODEOWNERS, so a security reviewer must approve additions.
  3. A scheduled job fails when a review date has passed. An exception that nobody re-validates silently becomes permanent.
# scripts/check-exceptions.sh — fail if any "Review by" date is in the past #!/usr/bin/env bash set -euo pipefail today=$(date +%F) expired=0 while read -r line; do d=$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' <<<"$line" || true) [[ -z "$d" ]] && continue if [[ "$d" < "$today" ]]; then echo "EXPIRED exception (review date $d): $line" expired=1 fi done < <(grep -i 'Review by' .grype.yaml) exit "$expired" 

Step 3 — Wire it into GitHub Actions

Here is the complete workflow. It runs on pull requests, builds the image, generates both SBOMs, scans, and uploads results.

# .github/workflows/supply-chain.yml name: supply-chain on: pull_request: branches: [main] push: branches: [main] schedule: # Re-scan main nightly: new CVEs appear without any code change. - cron: "0 3 * * *" permissions: contents: read security-events: write # required to upload SARIF pull-requests: write jobs: sbom-and-scan: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Verify exception review dates run: bash scripts/check-exceptions.sh - name: Build image (local, not pushed) run: | docker build -t app:${{ github.sha }} . - name: Generate CycloneDX SBOM (image) uses: anchore/sbom-action@v0 with: image: app:${{ github.sha }} format: cyclonedx-json output-file: sbom.cdx.json artifact-name: sbom-${{ github.sha }}.cdx.json - name: Generate CycloneDX SBOM (source tree) uses: anchore/sbom-action@v0 with: path: . format: cyclonedx-json output-file: sbom-src.cdx.json artifact-name: sbom-src-${{ github.sha }}.cdx.json - name: Scan SBOM with Grype id: grype uses: anchore/scan-action@v6 with: sbom: sbom.cdx.json fail-build: true severity-cutoff: high only-fixed: true output-format: sarif - name: Upload SARIF to code scanning if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: ${{ steps.grype.outputs.sarif }} - name: Keep SBOM for 90 days if: always() uses: actions/upload-artifact@v4 with: name: sbom-${{ github.sha }} path: | sbom.cdx.json sbom-src.cdx.json retention-days: 90 

Two details that matter more than they look:

The nightly schedule. A repository whose code has not changed in three months can still become vulnerable overnight, because the vulnerability database changed, not your code. Scanning only on PR gives you a false sense of safety. The scheduled run catches drift.

SARIF upload. Results land in the GitHub Security tab with per-finding deduplication and history, rather than being buried in a job log that nobody opens.

Finally, make the job a required status check in branch protection — otherwise it is advisory and will be ignored under deadline pressure.

The same thing with Trivy

If you prefer a single binary, Trivy covers both steps:

# CycloneDX SBOM trivy image --format cyclonedx --output sbom.cdx.json quay.io/myorg/payment-api:1.4.2 # Scan the SBOM, fail on HIGH/CRITICAL with a fix available trivy sbom --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 sbom.cdx.json 

Exceptions live in a .trivyignore file (one CVE ID per line, comments with #) or, for richer rules, in trivyignore.yaml with per-entry statement and expired_at fields — the expiry date being built in is a genuinely nice touch.

Pick one and standardise. Running Syft/Grype and Trivy on the same artifact produces slightly different result sets and endless "which one is right?" discussions.

Step 4 — Enforce at admission, not just in CI

A CI gate stops your pipeline from shipping a vulnerable image. It does nothing about an image someone deploys by hand, or a third-party chart pulling some/image:latest. On OpenShift/Kubernetes, close that gap with a cluster-side policy.

Minimal example with Kyverno — only allow images from trusted registries:

apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: restrict-image-registries spec: validationFailureAction: Enforce background: true rules: - name: allowed-registries match: any: - resources: kinds: [Pod] namespaces: ["prod-*"] validate: message: >- Images must come from quay.io/myorg/ or registry.access.redhat.com/ (scanned in CI). pattern: spec: =(initContainers): - image: "quay.io/myorg/* | registry.access.redhat.com/*" containers: - image: "quay.io/myorg/* | registry.access.redhat.com/*" 

And a CronJob that re-scans what is actually running, independently of CI:

apiVersion: batch/v1 kind: CronJob metadata: name: grype-runtime-rescan namespace: security-scanning spec: schedule: "0 2 * * *" concurrencyPolicy: Forbid jobTemplate: spec: backoffLimit: 2 template: spec: restartPolicy: OnFailure serviceAccountName: image-scanner containers: - name: grype image: anchore/grype:v0.87.0 args: - "registry:quay.io/myorg/payment-api:1.4.2" - "--only-fixed" - "-o" - "json" - "--file" - "/reports/payment-api.json" env: - name: GRYPE_DB_CACHE_DIR value: /tmp/grype-db - name: GRYPE_CHECK_FOR_APP_UPDATE value: "false" securityContext: allowPrivilegeEscalation: false runAsNonRoot: true capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault resources: requests: cpu: 200m memory: 512Mi limits: memory: 1Gi volumeMounts: - name: reports mountPath: /reports - name: tmp mountPath: /tmp volumes: - name: reports persistentVolumeClaim: claimName: scan-reports - name: tmp emptyDir: {} 

If the registry is private, mount a pull secret and point Grype at it:

oc -n security-scanning create secret docker-registry quay-pull \ --docker-server=quay.io \ --docker-username='myorg+scanner' \ --docker-password="$(cat ~/.quay-robot-token)" oc -n security-scanning secrets link image-scanner quay-pull --for=pull 

Never inline credentials in a manifest. Use a Secret, an External Secrets Operator reference, or OpenShift's built-in service account pull secrets.

Security best practices

Pin every tool version. install.sh ... v0.87.0, anchore/grype:v0.87.0, actions/checkout@v4 — ideally pinned to a commit SHA for third-party actions. A supply chain gate built on floating tags is self-defeating.

Restrict GitHub Actions permissions. Declare permissions: explicitly at workflow or job level. The default token is far broader than a scan job needs.

Never scan with pull_request_target on untrusted forks. That trigger runs with repository secrets available to code the PR author controls. Use pull_request and accept that forks cannot access secrets.

Store the SBOM as a durable artifact. 90-day retention in CI is a minimum; for released versions, push the SBOM to your registry as an OCI artifact or attach it to the GitHub release. The value of an SBOM is being able to answer "which of our 200 services ship library X version Y?" in under a minute.

Sign images and SBOMs. An unsigned SBOM proves nothing about provenance. cosign attest --predicate sbom.cdx.json --type cyclonedx binds the SBOM to the image digest, and Kyverno or Sigstore Policy Controller can verify that attestation at admission.

Always reference images by digest in production manifests. quay.io/myorg/payment-api@sha256:... is the only way to guarantee that what you scanned is what runs. Tags are mutable.

Set the threshold where the team can actually operate. Start with fail-on-severity: critical plus only-fixed: true, clear the backlog, then tighten to high. A gate that fails 80% of builds on day one gets disabled on day two.

Separate "block" from "report". Vulnerabilities without a fix, and Medium/Low findings, go to the Security tab and the nightly report — not to a red X on a PR.

Keep the vulnerability database fresh. Grype downloads it on each run by default; in air-gapped environments, mirror it and set GRYPE_DB_UPDATE_URL. A stale DB turns a green build into a lie.

Treat .grype.yaml as a security-reviewed file. CODEOWNERS entry, mandatory ticket reference, mandatory review date, and a job that fails when a date expires.

Conclusion

The gap between "we have a scanner" and "we have a guardrail" is mostly process, not tooling. The technical part takes an afternoon:

  • Syft produces a CycloneDX SBOM from your source tree and from your image. That file is the inventory you will need the next time a log4shell-class event happens — generate it on every build and keep it.
  • Grype scans the SBOM and returns a non-zero exit code against a policy you commit to the repository. only-fixed: true plus a severity threshold the team can realistically meet is the difference between a useful gate and an ignored one.
  • GitHub Actions runs it on every pull request, uploads SARIF to the Security tab, stores the SBOM as an artifact, and re-runs nightly to catch CVEs published after the merge. The job must be a required status check.
  • Exceptions are explicit, owned, ticketed, and expiring. An exception mechanism that nobody can audit is just a disabled scanner with extra steps.
  • CI is not the last line of defence. Admission policies on OpenShift/Kubernetes and a scheduled re-scan of running images cover the paths that bypass your pipeline.

Start small: generate an SBOM on your most critical service this week, in report-only mode. Look at the numbers, fix the trivially fixable, document the rest with review dates, then flip fail-build to true. Six weeks later, nobody on the team will remember what it was like to find out about a critical CVE from a production alert.

原文:https://www.a8q9.cn/en/article/supply-chain-security-generate-an-sbom-and-block-critical-cves-before-the-merge--7b359c.html

相关文章