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: syft for broad multi-ecosystem and container coverage, cdxgen for polyglot repos, or the ecosystem-native @cyclonedx/cyclonedx-npm and cyclonedx-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.

SBOM Generation, Enrichment, Signing, and Query Lifecycle A flow diagram beginning with a boundary Build Pipeline that generates a CycloneDX SBOM, which is enriched with VEX statements, then signed and attested, then stored in a trusted artifact store, and finally queried on a schedule against a vulnerability database that raises alerts on new findings. SBOM Lifecycle Generate once at build; query continuously thereafter Build Pipeline syft · cdxgen CycloneDX SBOM Full component graph Enrich with VEX affected / not_affected Sign & Attest cosign · attestation Artifact Store Immutable · per release Scheduled Query grype · osv-scanner Vulnerability DB New CVE → alert A CVE disclosed after ship still raises an alert against the stored SBOM — no rebuild required

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