Software Bill of Materials (SBOM): Generation, Formats & Management
A Software Bill of Materials is the ingredient list for a build — a machine-readable record of every component, version, and license present in an artifact, including the transitive dependencies nobody explicitly chose. Without one, a newly disclosed CVE in a widely used library leaves you unable to answer the only question that matters during an incident: do we ship this, and where? This guide is part of Supply Chain & Dependency Security. It covers the two dominant SBOM formats and when to pick each, the generation tooling for JavaScript and Python ecosystems, VEX enrichment to suppress unexploitable findings, and the storage, signing, and query patterns that turn a static file into an operational asset. Where dependency scanning CI gates enforce policy at build time, the SBOM is the durable inventory those gates query — and for a concrete end-to-end walkthrough, see generating a CycloneDX SBOM in GitHub Actions.
Threat Anatomy
The threat an SBOM addresses is not an active exploit but a state of ignorance: an incomplete or absent component inventory blinds you to both vulnerability and license risk. When Log4Shell (CVE-2021-44228) landed, the organizations that patched fastest were the ones who could grep an SBOM to enumerate every affected build within minutes; everyone else spent days manually auditing repositories, and some never achieved full coverage because the vulnerable log4j-core was pulled transitively through a framework they did not associate with logging at all.
Attacker perspective. An adversary benefits directly from your lack of inventory. If you cannot see that a compromised or vulnerable package sits three levels deep in your graph, you cannot prioritize patching it, and the window during which the exploit is viable stays open. Supply chain intrusions are patient — a poisoned package version may sit dormant, and the defender’s only early-warning system is the ability to correlate a fresh advisory against a precise record of what is deployed. The absence of that record is the vulnerability.
MITRE ATT&CK coverage. This maps to T1195 (Supply Chain Compromise), and specifically T1195.001 (Compromise Software Dependencies and Development Tools) when the injected code arrives through a dependency. An SBOM does not prevent the compromise, but it collapses the detection-to-remediation time that determines the blast radius, and it provides the evidence trail an incident responder needs to scope the exposure.
License and compliance risk. Beyond security, an unknown component graph carries legal exposure. A copyleft license like GPL-3.0 appearing transitively in a proprietary product can create redistribution obligations the business never agreed to. The SBOM surfaces this at build time, when it is cheap to swap the dependency, rather than during due diligence for an acquisition, when it is expensive.
SBOM Format & Concept Reference
| Concept | CycloneDX | SPDX | Primary Use |
|---|---|---|---|
| Steward | OWASP | Linux Foundation | Governance and tooling ecosystem |
| Strongest at | Vulnerability & VEX correlation | License compliance & interchange | Choosing a default format |
| Component identity | purl + CPE |
purl + SPDX ID |
Cross-tool package matching |
| Vulnerability model | Native vulnerabilities array |
External (via VEX / advisories) | Embedding scan results |
| Serialization | JSON, XML, Protobuf | Tag-value, JSON, RDF | Pipeline integration |
| VEX support | First-class | Via separate VEX documents | Suppressing unreachable findings |
Prerequisites & Scope
Before generating and operationalizing SBOMs, confirm the following are in place:
- A resolved, committed lockfile for each ecosystem (
package-lock.json,poetry.lock,go.sum) so the generator sees the full transitive graph, not just top-level manifest entries. - A generation tool appropriate to your stack:
syftfor broad multi-ecosystem and container coverage,cdxgenfor polyglot repos, or the ecosystem-native@cyclonedx/cyclonedx-npmandcyclonedx-py. - An artifact store with retention — a container registry with referrers API support, or an object store — where SBOMs and their signatures persist per release, immutably.
- A vulnerability scanner (
grype,osv-scanner, or Trivy) that consumes CycloneDX or SPDX input for offline, repeatable correlation. - A defined component-ownership map so that when a finding lands, the SBOM’s component metadata routes it to the right team — the same inventory that attack surface mapping techniques rely on to enumerate entry points.
Mitigation Architecture
The operational goal is a pipeline that produces a signed, stored SBOM, enriches it with exploitability context, and continuously queries it against a vulnerability database. The SBOM is generated once at build time and then re-queried indefinitely — decoupling “what did we ship” from “what do we now know about it.” The diagram below traces that lifecycle.
Format Selection: Side-by-Side
| Decision factor | Prefer CycloneDX | Prefer SPDX |
|---|---|---|
| Primary driver is security scanning | Yes — native vulnerability + VEX model | Workable, but VEX is external |
| Primary driver is license compliance | Fully capable | Yes — richest license expression |
| Partner or government mandate names a format | Follow the mandate | Follow the mandate |
| Container and OS-package coverage needed | syft emits either |
syft emits either |
| You want one format and convert as needed | Generate CycloneDX, convert out | Generate SPDX, convert out |
Step-by-Step Implementation
Step 1 — Generate a Complete SBOM from the Resolved Graph (ASVS V14.2)
Generate from the build, after dependencies resolve, so transitive packages are captured. For a Node.js project, the CycloneDX npm plugin reads the lockfile; for containers and mixed repos, syft scans the filesystem or image.
# Node.js: emit CycloneDX JSON from package-lock.json
npx @cyclonedx/cyclonedx-npm --output-format JSON --output-file sbom.cdx.json
# Python: emit CycloneDX from the active environment / poetry.lock
cyclonedx-py environment --output-format json --outfile sbom.cdx.json
# Any image or directory: syft covers OS packages + app deps
syft packages dir:. -o cyclonedx-json=sbom.cdx.json
Validate the output before trusting it. An empty or truncated SBOM is worse than none because it creates false confidence.
# Component count sanity check — a real app has dozens to hundreds
jq '.components | length' sbom.cdx.json
# Confirm every component carries a package URL for reliable matching
jq '[.components[] | select(.purl == null)] | length' sbom.cdx.json
Step 2 — Enrich with VEX to Suppress Unexploitable Findings (NIST SSDF PW.4)
A raw scan reports every CVE in every component, including code paths you never call. VEX records the exploitability decision so the finding stops re-appearing on every scan. CycloneDX embeds VEX as analysis on a vulnerability entry.
{
"vulnerabilities": [
{
"id": "CVE-2024-0001",
"source": { "name": "NVD" },
"affects": [{ "ref": "pkg:npm/[email protected]" }],
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "The vulnerable XXE parser path is never invoked; XML input is disabled."
}
}
]
}
The state: not_affected with a machine-readable justification is what a downstream gate reads to skip the finding. Record the reasoning in detail so the decision survives an audit and can be re-reviewed when the code changes.
Step 3 — Sign and Attest the SBOM (NIST SSDF PS.3, SOC 2 CC7.1)
An unsigned SBOM can be swapped by anyone with write access to the store. Bind it to the artifact so consumers verify both together. Sigstore’s keyless signing avoids managing long-lived keys.
# Sign the SBOM as an attestation bound to the container image digest
cosign attest --yes \
--predicate sbom.cdx.json \
--type cyclonedx \
registry.example.com/app@sha256:<digest>
# A consumer verifies before trusting the inventory
cosign verify-attestation \
--type cyclonedx \
--certificate-identity-regexp '.*@example\.com' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.example.com/app@sha256:<digest>
Step 4 — Store and Query Against a Vulnerability Database (PCI-DSS 6.3.2, EO 14028)
Persist the signed SBOM per release and rescan on a schedule. The scanner reads the stored SBOM offline, so results are reproducible and a new advisory raises an alert without a rebuild.
# Correlate the stored SBOM against current advisory data
grype sbom:sbom.cdx.json --fail-on high -o json > scan.json
# Extract only actionable (non-VEX-suppressed) findings for triage
jq '[.matches[] | select(.vulnerability.severity | ascii_downcase
| IN("high","critical"))] | length' scan.json
Edge Cases & Bypass Patterns
Incomplete Transitive Capture from Manifest-Only Generation
Generating an SBOM by parsing package.json or requirements.txt instead of the resolved lockfile silently omits the transitive graph — exactly where most vulnerable code hides. Always generate from the lockfile or the installed environment, and assert a minimum component count in CI so a manifest-only regression fails loudly.
Vendored and Bundled Dependencies
Bundlers and minifiers can inline third-party code so it no longer appears as a discrete package, and vendored directories check dependency source straight into the repo. Neither shows up in a lockfile scan. Run a filesystem-level scanner like syft in addition to the ecosystem-native tool to catch code that arrived outside the package manager.
Version Range Drift Between SBOM and Deploy
If the SBOM is generated from a different resolution than the one that ships — for example, a fresh npm install in a separate job — the inventory describes an artifact you did not deploy. Generate the SBOM in the same job that produces the artifact, from the same node_modules, and bind it to the artifact digest so drift is detectable.
Stale VEX After Code Changes
A not_affected VEX justification of code_not_reachable becomes false the moment someone wires up the vulnerable path. Treat VEX statements as code: review them when the surrounding module changes, and expire justifications on a cadence so they are re-validated rather than trusted indefinitely.
Automated Testing & CI Validation
Test: SBOM Completeness Assertion
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
describe("SBOM integrity", () => {
const sbom = JSON.parse(readFileSync("sbom.cdx.json", "utf8"));
it("captures the full transitive graph, not just direct deps", () => {
expect(sbom.components.length).toBeGreaterThan(50);
});
it("assigns a package URL to every component", () => {
const missing = sbom.components.filter((c) => !c.purl);
expect(missing).toHaveLength(0);
});
});
CI/CD Gate: Generate, Validate, Scan
name: SBOM Gate
on: [push, pull_request]
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Generate CycloneDX SBOM
run: npx @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json
- name: Assert SBOM completeness
run: |
COUNT=$(jq '.components | length' sbom.cdx.json)
if [ "$COUNT" -lt 50 ]; then
echo "::error::SBOM has only $COUNT components — likely manifest-only"
exit 1
fi
- name: Scan SBOM for high/critical findings
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype sbom:sbom.cdx.json --fail-on high
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| SOC 2 | CC7.1 | Scheduled rescans of the stored SBOM detecting newly disclosed vulnerabilities |
| OWASP ASVS | V14.2 | Complete dependency inventory with pinned versions and package URLs |
| NIST SSDF | PS.3 | Archived, signed SBOM retained per release as provenance |
| NIST SSDF | PW.4 | VEX-annotated inventory of reused third-party components |
| PCI-DSS v4.0 | Req 6.3.2 | Machine-generated component inventory for bespoke and third-party software |
| Executive Order 14028 | SBOM mandate | CycloneDX SBOM produced and distributed with each release |
Common Pitfalls Checklist
Frequently Asked Questions
Should I use CycloneDX or SPDX for my SBOM?
Choose CycloneDX when the primary goal is feeding application security tooling — it has a first-class vulnerability and VEX model that scanners consume directly. Choose SPDX when license compliance and broad interchange dominate, as its license expression is the richest and it is the format most legal and procurement partners expect. In practice both are widely supported; a common approach is to generate CycloneDX for internal scanning and convert to SPDX only when a specific partner or contract requires it. The syft tool can emit either from the same scan, so the choice is not locking.
What is VEX and why does an SBOM need it?
VEX (Vulnerability Exploitability eXchange) is a statement about whether a known vulnerability in a listed component is actually exploitable in your product. An SBOM without VEX, when scanned, reports every CVE present in every dependency — including code paths you never invoke — which produces overwhelming false-positive noise and trains engineers to ignore the scanner. VEX lets you record, in machine-readable form, that a finding is not_affected because the vulnerable path is unreachable, so the finding is suppressed on future scans while the reasoning stays auditable.
When should an SBOM be generated in the pipeline?
Generate at build time, after dependency resolution but before packaging, from the same environment that produces the artifact. Generating from source manifests alone misses transitive resolution and produces a dangerously incomplete inventory; generating post-deploy loses the binding to the specific build. The SBOM should describe exactly the bytes you ship, which means it must come from the same resolved node_modules or virtual environment as the artifact and be bound to that artifact’s digest.
Related
- Dependency Scanning CI Gates — the build-time gates that query the SBOM and fail on high-severity findings
- Automated Dependency Updates — keeping the inventoried graph fresh under a policy-as-code cadence
- Generating a CycloneDX SBOM in GitHub Actions — a concrete end-to-end walkthrough with attestation
- Supply Chain & Dependency Security — the parent guide covering the full supply chain control set