Producing an SBOM in CI/CD and acting on vulnerability findings
For developers wiring software supply-chain checks into a real build pipeline, this guide shows how to generate an SBOM, attach it to build artifacts, scan it, and turn findings into actionable policy. It focuses on concrete commands, CI examples, exit codes, and the edge cases that matter when you need signal instead of noisy security theater.
TL;DR — Generate the SBOM during the build from the exact artifact you ship, publish it alongside the artifact, then scan either the artifact or the SBOM with a policy that fails only on findings you actually intend to block on. The most common mistake is generating an SBOM from source dependencies only, then being surprised when the running container includes OS packages, transitive libs, or vendored binaries that were never scanned. Reading time: ~7 min
What it is and where it sits
In a build pipeline, an SBOM is not a paperwork artifact; it is a machine-readable inventory of what you are actually shipping. In practice, it sits between packaging and release, and it becomes input to vulnerability scanners, license checks, attestation/signing steps, and sometimes deployment policy.
The architecture context that matters:
- Your build system produces an artifact: a JAR, wheel, binary, container image, etc.
- An SBOM generator inspects that artifact or the build workspace and emits SPDX or CycloneDX JSON/XML.
- A scanner consumes either the artifact directly or the SBOM and maps components to vulnerability databases.
- CI policy decides whether to warn, fail, or open a ticket.
- Downstream systems may store the SBOM with the artifact in an OCI registry, artifact repository, or release bundle.
What it replaces: ad hoc dependency lists, hand-maintained third-party notices, and scanner runs that only look at lockfiles. Those are still useful, but they are not enough when the shipped image also contains distro packages, copied binaries, or transitive dependencies resolved at build time.
A typical flow looks like this:
Developer push
|
v
CI checkout -> build/test -> package artifact/container
|
+-> generate SBOM from built artifact
|
+-> scan artifact or SBOM for vulns/licenses
|
+-> policy gate (fail/warn/create ticket)
|
+-> sign + publish artifact and SBOM
|
v
deploy / admission / audit
The important placement decision is this: generate from the built thing whenever possible. For a containerized service, that usually means generating from the final image, not just from package-lock.json, pom.xml, or go.mod.
How it actually works
Walk one realistic example: a Node.js service built into a Debian-based container image in GitHub Actions, using Syft to generate a CycloneDX SBOM and Grype to scan it.
Step 1: Build the exact image you will ship
Suppose your Dockerfile installs app dependencies and also pulls OS packages:
FROM node:22-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
CMD ["node", "server.js"]
That image now contains at least three dependency layers that matter to security findings:
- npm packages from
npm ci - Debian packages from
apt-get install - whatever came preinstalled in
node:22-bookworm-slim
If you generate an SBOM only from package-lock.json, you miss two of the three.
Build it:
docker build -t ghcr.io/acme/orders-api:${GITHUB_SHA} .
Step 2: Generate the SBOM from the built image
Use Syft against the image, not the source tree:
syft packages docker:ghcr.io/acme/orders-api:${GITHUB_SHA} -o cyclonedx-json=sbom.cdx.json
Typical output shape:
✔ Loaded image
✔ Parsed image
✔ Cataloged contents 1f2a3b4c5d6e7f8g
├── ✔ Packages [238 packages]
├── ✔ File digests [412 files]
├── ✔ File metadata [412 locations]
└── ✔ Executables [19 executables]
✔ Generated SBOM
If the image tag is wrong or not present locally, you will usually see something shaped like:
[0000] ERROR could not determine source: an error occurred attempting to resolve 'docker:ghcr.io/acme/orders-api:deadbeef': no such image
That is not a scanner problem; your build/tag/pull step failed or used a different tag than the SBOM step.
Step 3: Scan with a policy you can defend
You can scan the image directly or scan the SBOM. Scanning the image often gives better package-location fidelity; scanning the SBOM is useful when the scanner runs later or in another environment.
Scan the image and fail on high/critical findings:
grype docker:ghcr.io/acme/orders-api:${GITHUB_SHA} --fail-on high
Typical output shape:
✔ Vulnerability DB [updated]
✔ Loaded image
✔ Parsed image
✔ Cataloged packages [238 packages]
✔ Scanned for vulnerabilities [187 matches]
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
libssl3 3.0.11-1~deb12 3.0.14-1~deb12 deb CVE-2025-12345 HIGH
lodash 4.17.20 4.17.21 npm GHSA-xxxx-xxxx HIGH
curl 7.88.1-10+deb12 7.88.1-10+deb12u1 deb CVE-2026-22222 MEDIUM
3 vulnerabilities found
On policy violation, the process exits non-zero. In CI that usually surfaces as exit code 2 or 1 depending on the tool/version and shell wrapper; treat any non-zero as failure unless you have pinned and tested exact semantics.
If you want to scan the SBOM file instead:
grype sbom:sbom.cdx.json --fail-on high
Step 4: Triage findings into actions, not panic
Not every finding should fail the build. The useful buckets are:
- Fix now: a direct dependency you control, with a clear upgrade path.
- Fix by base image refresh: OS package or inherited image issue; rebuild from a newer base.
- Temporarily accept: no fix available, not reachable in your runtime, or false positive.
- Investigate packaging drift: package appears in image but should not be there.
Concrete examples:
lodash 4.17.20 -> 4.17.21: updatepackage.jsonor lockfile, rebuild.libssl3from Debian: bump the base image or runapt-get upgradeduring image build if your policy allows it. Prefer a newer base image digest over ad hoc upgrades for reproducibility.- A vulnerability in a CLI binary copied into the image but never used at runtime: either remove it from the final stage or document an exception with expiry.
Step 5: Publish the SBOM with the artifact
Store the SBOM where release tooling can find it. For OCI images, attach it as an artifact or at minimum upload it as a CI artifact named with the image digest.
The key is correlation: tie the SBOM to the immutable artifact digest, not just a mutable tag like latest.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| You ship container images, binaries, mobile apps, or customer-installed software | Yes. Generate an SBOM from the built artifact and keep it with the release. |
| You need vulnerability management with fewer blind spots than lockfile scanning | Yes. Scan the final artifact or its SBOM. |
| You have compliance/customer requirements asking for SPDX/CycloneDX | Yes. Emit one of those formats in CI and archive it per release. |
| You only run internal scripts, never package artifacts, and can rebuild everything trivially | Maybe not. Direct dependency scanning may be enough. |
| Your team cannot yet patch or triage findings and will just ignore a red pipeline | Don’t gate merges yet. Start with report-only mode and a ticketing process. |
| You build hermetic, static binaries with no external packaging and very small dependency graphs | Maybe. The value is lower, though still useful for provenance and audits. |
You probably do not need full SBOM gating on day one if your current pain is simply “we have no dependency visibility.” Start by generating and storing the SBOM, then add scanner policy once you know the noise level.
Trade-offs
Benefit: better visibility into what you actually ship
Cost: more pipeline time and more artifacts to store. Image cataloging and scanning usually add seconds to a few minutes depending on image size and cache state.
Benefit: catches OS-level and transitive issues that lockfile scanners miss
Cost: more findings, including inherited base-image noise. You will need ownership rules: app team vs platform team.
Benefit: easier compliance and customer questionnaires
Cost: process overhead. Someone has to version, retain, and retrieve SBOMs for released artifacts.
Benefit: policy enforcement in CI
Cost: broken builds at inconvenient times. If your threshold is too strict, teams will route around it or blanket-ignore findings.
Benefit: decouples inventory from scanner vendor
Cost: format and fidelity differences. CycloneDX vs SPDX, source vs binary generation, and package-ecosystem matching can produce different results.
Benefit: supports downstream signing/attestation
Cost: more supply-chain plumbing. Digest pinning, provenance, and artifact attachment are operational work, not just one command.
The lock-in angle is subtle: SBOM formats are standardized, but vulnerability matching and policy workflows are not. Keep the SBOM as a first-class build output so you can change scanners later.
In practice
Example 1: GitHub Actions pipeline for image build, SBOM, and gating
name: build-and-scan
on:
push:
branches: [main]
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t ghcr.io/acme/orders-api:${{ github.sha }} .
- name: Install Syft and Grype
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- name: Generate SBOM
run: syft packages docker:ghcr.io/acme/orders-api:${{ github.sha }} -o cyclonedx-json=sbom.cdx.json
- name: Scan and fail on high
run: grype sbom:sbom.cdx.json --fail-on high
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: sbom.cdx.json
What it does: builds the image, generates a CycloneDX SBOM from the image, scans the SBOM, and fails the job on high/critical findings. Gotcha: installer scripts from curl | sh are convenient but not ideal for high-trust environments; pin versions and verify checksums if you need reproducibility.
Example 2: Jenkins shell stage with separate warn-only and blocking policies
#!/usr/bin/env bash
set -euo pipefail
IMAGE="registry.example.com/orders-api:${GIT_COMMIT}"
docker build -t "$IMAGE" .
syft packages "docker:${IMAGE}" -o spdx-json=sbom.spdx.json
# Report all findings to a file for triage
if ! grype "sbom:sbom.spdx.json" -o table > grype-report.txt; then
echo "grype returned non-zero while generating report; continuing to policy gate"
fi
# Block only on criticals with known fixes
# Exact flags vary by scanner version; test in your environment.
grype "sbom:sbom.spdx.json" --fail-on critical
What it does: emits SPDX JSON, saves a human-readable report, and uses a stricter blocking threshold than the report. Gotcha: scanner output and exit-code behavior can vary across versions; pin the scanner version in CI so policy changes do not appear “randomly” after an auto-update.
Example 3: Diagnosing registry/tag mismatch before blaming the SBOM tool
docker images | grep orders-api
syft packages docker:ghcr.io/acme/orders-api:${GITHUB_SHA} -o json | head
If the tag is missing, you will see output shaped like:
REPOSITORY TAG IMAGE ID CREATED SIZE
orders-api latest a1b2c3d4e5f6 10 seconds ago 221MB
[0000] ERROR could not determine source: an error occurred attempting to resolve 'docker:ghcr.io/acme/orders-api:9c0ffee': no such image
What it does: confirms whether CI built latest but the SBOM step expects a commit SHA tag. Gotcha: this mismatch is extremely common in multi-step pipelines and looks like a scanner failure until you inspect local tags.
⚠️ If you change your pipeline from warn-only to blocking, do it on a branch or with a temporary non-protected workflow first. A strict
--fail-on highon a mature codebase can stop all merges immediately, especially if your base images are stale.
A practical rollout sequence that works:
- Generate SBOMs for every build, archive them, no gating.
- Scan and publish reports, no gating.
- Gate only on criticals with fixes available.
- Add ownership rules for base image findings vs app dependency findings.
- Add exception handling with expiry dates in code or repo, not in someone’s memory.
The “what to do with findings” answer is mostly process, not tooling: define who fixes what, what blocks a release, how exceptions expire, and how you refresh base images. Without that, an SBOM is just a JSON file you feel virtuous about generating.
Further reading
- CycloneDX Specification
- SPDX Specification
- NIST SP 800-218 Secure Software Development Framework (SSDF)
- SLSA Specification
- OWASP Software Component Verification Standard
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI