Automated SBOM Generation
Permalink to "Automated SBOM Generation"This workflow is part of Supply Chain Auditing & Dependency Verification. Without automated Software Bill of Materials (SBOM) generation baked into every build, your security posture degrades silently: a transitive dependency update merges undetected, a new CVE lands in a library you ship but never audited, and the compliance evidence your auditor requests does not exist until someone scrambles to produce it manually. The problem is not awareness — it is that manual SBOM production cannot keep pace with the commit frequency of a real team.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation
Permalink to "Conceptual Foundation"An SBOM is a machine-readable inventory of every software component in a deliverable, including name, version, supplier, license, and cryptographic hash. Two formats dominate production use:
CycloneDX (OWASP) is optimised for security workflows. Components carry hashes, vulnerabilities, and externalReferences. The JSON schema is versioned and strictly typed. Generator support is broad: Syft, Trivy, and the official @cyclonedx/cyclonedx-npm package all emit valid CycloneDX JSON or XML.
SPDX (ISO/IEC 5962:2021) is the format required by US Executive Order 14028 for federal procurement. NTIA’s minimum elements specification defines the seven mandatory fields: supplier name, component name, version, other unique identifiers, dependency relationships, author of the SBOM data, and timestamp. spdx-tools and Syft both emit SPDX-2.3 tag-value or JSON.
The two formats are not mutually exclusive — most regulated pipelines generate both. CycloneDX drives runtime CVE alerting; SPDX satisfies procurement attestation.
Where SRI fits: once your SBOM artifact is generated, it is itself an asset that can be tampered with. Computing a SHA-384 digest of sbom.json and recording it alongside the artifact gives downstream consumers the same integrity guarantee that <script integrity="sha384-..."> gives browsers loading third-party scripts — evidence that the file has not changed since the build system produced it.
Step 1 — Resolve the Complete Dependency Tree
Permalink to "Step 1 — Resolve the Complete Dependency Tree"SBOM generation must run after the lockfile is honoured but before any node_modules pruning step.
# npm
npm ci
# Yarn Berry
yarn install --immutable
# pnpm
pnpm install --frozen-lockfile
Why ci/--immutable/--frozen-lockfile and not bare install: these flags abort if the lockfile does not match package.json, preventing silent dependency drift from producing an inaccurate inventory. Dependency Pinning Best Practices covers the broader strategy for keeping lockfiles trustworthy.
Expected verification signal: the install command exits 0 with no lockfile modification. If it exits non-zero, the lockfile is stale and the SBOM run must not proceed.
Step 2 — Generate the SBOM Artifact
Permalink to "Step 2 — Generate the SBOM Artifact"Option A: Syft (language-agnostic, multiple output formats)
Permalink to "Option A: Syft (language-agnostic, multiple output formats)"# Pin the version; never float to :latest in CI
docker run --rm \
-v "$(pwd)":/src \
anchore/syft:v1.4.1 \
/src \
-o cyclonedx-json=/src/sbom-cyclonedx.json \
-o spdx-json=/src/sbom-spdx.json
Expected output excerpt:
✔ Indexed file system /src
✔ Cataloged packages [312 packages]
Option B: @cyclonedx/cyclonedx-npm (npm-native, exact pnpm/Yarn/npm support)
Permalink to "Option B: @cyclonedx/cyclonedx-npm (npm-native, exact pnpm/Yarn/npm support)"npx --yes @cyclonedx/cyclonedx-npm@latest \
--include-dev=false \
--output-format JSON \
--output-file sbom-cyclonedx.json
--include-dev=false excludes build tooling from the runtime inventory. Use --include-dev=true only when your compliance requirement demands a full build-time manifest.
Option C: Trivy (vulnerability-oriented, fast)
Permalink to "Option C: Trivy (vulnerability-oriented, fast)"trivy fs . \
--format cyclonedx \
--output sbom-cyclonedx.json \
--scanners vuln,license
Expected verification signal: the output file exists, is valid JSON, and its metadata.timestamp matches the build time. A file size under 1 KB usually indicates an empty scan — verify that node_modules was present during generation.
Step 3 — Compute and Record the SRI Hash
Permalink to "Step 3 — Compute and Record the SRI Hash"After generation, hash the SBOM artifact so its integrity can be verified by downstream consumers:
SRI_HASH="sha384-$(openssl dgst -sha384 -binary sbom-cyclonedx.json | openssl base64 -A)"
echo "sbom_sri=${SRI_HASH}" >> sbom-cyclonedx.json.sri
echo "sbom_sri=${SRI_HASH}" >> "$GITHUB_ENV" # or >> "$CI_ENV_FILE" for GitLab
The .sri sidecar file travels with the SBOM artifact. Any consumer can verify integrity before processing the manifest:
EXPECTED=$(cat sbom-cyclonedx.json.sri | grep sbom_sri | cut -d= -f2)
ACTUAL="sha384-$(openssl dgst -sha384 -binary sbom-cyclonedx.json | openssl base64 -A)"
[ "$EXPECTED" = "$ACTUAL" ] && echo "SBOM integrity OK" || { echo "SBOM TAMPERED"; exit 1; }
For Provenance Verification Workflows, this hash can be embedded in a Sigstore attestation or an in-toto statement, binding the SBOM to the specific build that produced it.
Step 4 — Gate on Policy Before Promotion
Permalink to "Step 4 — Gate on Policy Before Promotion"Policy evaluation must occur before the artifact is promoted to staging or production. Two distinct checks apply:
Vulnerability threshold gate (Grype):
grype sbom-cyclonedx.json \
--fail-on critical \
--output table
Exit code 1 if any CRITICAL severity CVE is present. The build halts; no deployment proceeds.
License compatibility gate (OSV-Scanner or custom):
osv-scanner \
--sbom sbom-cyclonedx.json \
--format table
For teams with a defined license allowlist (Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause), add a custom check:
# Extract all SPDX identifiers from the CycloneDX JSON
jq -r '.components[].licenses[]?.license.id // empty' sbom-cyclonedx.json \
| sort -u \
| while read -r license; do
case "$license" in
"MIT"|"Apache-2.0"|"BSD-2-Clause"|"BSD-3-Clause"|"ISC") ;;
*) echo "BLOCKED license: $license"; exit 1 ;;
esac
done
Expected verification signal: both gates exit 0, or the pipeline halts with a human-readable message identifying the specific component and version that triggered the block.
Step 5 — Full Pipeline Configurations
Permalink to "Step 5 — Full Pipeline Configurations"GitHub Actions
Permalink to "GitHub Actions"name: SBOM Generation & Policy Gate
on:
push:
branches: [main, "release/**"]
pull_request:
jobs:
sbom:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for OIDC signing
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies (lockfile-strict)
run: npm ci
- name: Generate CycloneDX SBOM
uses: anchore/sbom-action@v0
with:
path: .
format: cyclonedx-json
output-file: sbom-cyclonedx.json
# Pin the action version so format output is deterministic
# anchore/[email protected]
- name: Compute SHA-384 SRI hash of SBOM
run: |
SRI="sha384-$(openssl dgst -sha384 -binary sbom-cyclonedx.json | openssl base64 -A)"
echo "sbom_sri=${SRI}" >> sbom-cyclonedx.json.sri
echo "SBOM_SRI=${SRI}" >> "$GITHUB_ENV"
- name: Vulnerability gate (Grype)
uses: anchore/scan-action@v4
with:
sbom: sbom-cyclonedx.json
fail-build: true
severity-cutoff: critical
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom-$
path: |
sbom-cyclonedx.json
sbom-cyclonedx.json.sri
retention-days: 90
GitLab CI
Permalink to "GitLab CI"sbom_generation:
stage: verify
image: aquasec/trivy:0.52.0 # pinned, not :latest
script:
- trivy fs .
--format cyclonedx
--output sbom-cyclonedx.json
--scanners vuln,license
- SRI="sha384-$(openssl dgst -sha384 -binary sbom-cyclonedx.json | openssl base64 -A)"
- echo "sbom_sri=${SRI}" >> sbom-cyclonedx.json.sri
- trivy sbom sbom-cyclonedx.json --severity CRITICAL --exit-code 1
artifacts:
paths:
- sbom-cyclonedx.json
- sbom-cyclonedx.json.sri
expire_in: 90 days
rules:
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_TAG'
npm script integration
Permalink to "npm script integration"{
"scripts": {
"sbom:generate": "cyclonedx-npm --include-dev=false --output-format JSON --output-file sbom-cyclonedx.json",
"sbom:hash": "openssl dgst -sha384 -binary sbom-cyclonedx.json | openssl base64 -A | sed 's/^/sha384-/' > sbom-cyclonedx.json.sri",
"sbom": "npm run sbom:generate && npm run sbom:hash"
},
"devDependencies": {
"@cyclonedx/cyclonedx-npm": "1.19.3"
}
}
Configuration Reference
Permalink to "Configuration Reference"| Option | Generator | Valid values | Security implication |
|---|---|---|---|
--include-dev |
@cyclonedx/cyclonedx-npm |
true / false |
false keeps the manifest scoped to runtime risk; true required for build-chain audits |
--format |
Syft | cyclonedx-json, spdx-json, syft-json, github |
CycloneDX for CVE tools; SPDX for EO 14028 procurement |
--severity |
Trivy / Grype | CRITICAL, HIGH, MEDIUM, LOW |
Set the threshold at which the pipeline blocks; CRITICAL is the minimum recommended gate |
--scanners |
Trivy | vuln, license, secret, misconfig |
Always include license — license violations are a legal risk separate from CVEs |
--fail-on |
Grype | negligible, low, medium, high, critical |
critical blocks the build; set to high for PCI DSS environments |
output-file |
All | Any path | Use a deterministic name tied to the commit SHA in artifact storage for auditability |
retention-days |
GitHub/GitLab | Integer | 90 days is typical for compliance evidence; EO 14028 does not specify, but NIST SSDF implies traceability across releases |
Integration with Adjacent Tooling
Permalink to "Integration with Adjacent Tooling"SBOM generation does not stand alone — it is one link in a chain:
Upstream: Lockfile Mapping & Analysis produces the resolved dependency graph that SBOM generators consume. If the lockfile is stale or partially resolved, the generated manifest will be incomplete.
Downstream — CVE tracking: the CycloneDX JSON feeds directly into Grype, OSV-Scanner, or Dependency-Track for continuous CVE alerting. New vulnerabilities disclosed after the build trigger re-evaluation of the stored SBOM without requiring a rebuild.
Downstream — CSP policy: for frontend assets, the component hashes extracted from the SBOM can seed the script-src directive of a Configuring Content-Security-Policy with SRI policy, creating a closed loop where every allowed script in the browser has a corresponding entry in the supply chain manifest.
Downstream — provenance: the SBOM artifact and its SRI hash can be embedded in a Sigstore attestation statement and attached to a container image or npm package via cosign attest. This extends the chain of trust from the source commit all the way to the deployed artifact.
For browser-specific component mapping and third-party CDN script inclusion, see Generating CycloneDX SBOMs for Frontend Assets.
Troubleshooting
Permalink to "Troubleshooting"Error: empty SBOM — 0 packages catalogued
Root cause: the generator ran before npm ci / yarn install, so node_modules was absent.
Fix: ensure the install step precedes the SBOM step in your pipeline definition; verify node_modules exists with ls node_modules | head -5 in the same shell session.
SBOM hash mismatch on downstream verify
Root cause: the artifact was re-serialised or re-compressed after the hash was recorded (e.g. a CI artifact zip step changed line endings).
Fix: hash the raw file before compression; store the .sri sidecar outside the zip; verify against the decompressed file.
License: UNKNOWN for N components
Root cause: the package’s package.json omits the license field, or uses a non-SPDX string.
Fix: for first-party packages, add the SPDX identifier. For third-party packages, check the repository README and add an override entry in your SBOM generator’s config. Do not treat UNKNOWN as compliant.
Trivy: exit code 1 on non-critical finding
Root cause: --exit-code 1 without --severity applies to all findings including LOW.
Fix: always pair --exit-code 1 with --severity CRITICAL,HIGH to scope the block threshold.
anchore/sbom-action produces different output across runs
Root cause: the action version is floating (@v0) and a minor release changed the serialisation order.
Fix: pin to a full semver tag (@v0.17.1) and lock the underlying Syft version via the action’s syft-version input.
jq: null component licenses field
Root cause: CycloneDX schema allows licenses to be absent for components with no detected license. The [] default in jq handles this: jq -r '.components[].licenses[]?.license.id // empty' — the ? and // empty guard against null.
Security Boundary
Permalink to "Security Boundary"Automated SBOM generation tells you what components were present at build time and whether they carried known CVEs or non-compliant licenses at that moment. It does not:
- Detect vulnerabilities disclosed after the SBOM was generated (use continuous monitoring with stored SBOMs and Dependency-Track)
- Verify that the running application uses only the components listed (runtime enforcement requires CSP, Trusted Types, or a service mesh policy)
- Protect against a malicious package that passes all CVE and license checks (a package with a clean record can still contain backdoors — signature verification via Sigstore addresses this separately, see Provenance Verification Workflows)
- Capture dynamically loaded CDN scripts injected at runtime by marketing or analytics tags — those require a runtime inventory mechanism or explicit SRI hash enforcement on every
<script>tag
FAQ
Permalink to "FAQ"What is the difference between CycloneDX and SPDX?
CycloneDX (OWASP) is optimised for security operations: it natively carries vulnerability identifiers, services, and component hashes. SPDX is the ISO/IEC 5962:2021 standard built for license compliance and now required by US Executive Order 14028 for federal procurement. Most regulated pipelines generate both. CycloneDX drives CVE alerting; SPDX satisfies procurement attestation requirements.
When exactly in the pipeline should generation run?
After full dependency resolution (post npm ci / yarn --immutable) but before the deployment promotion step. Running it before install produces an incomplete transitive graph; running it after deployment means the shipped artifact has no pre-release verifiable inventory.
Do SBOM generators capture CDN-hosted third-party scripts?
No. Static generators only scan files in node_modules or the source tree. CDN-hosted scripts loaded via <script src="https://..."> are invisible to them. Maintain a separate external-dependencies manifest and enforce SRI integrity attributes on every such tag so the browser validates those assets at load time.
How do I verify an SBOM has not been tampered with?
Record a SHA-384 digest of the SBOM JSON immediately after generation and store it as a .sri sidecar file. Before consuming the SBOM, recompute the digest and compare. For stronger guarantees, embed the hash in a Sigstore attestation bound to the specific build’s OIDC identity.
Which regulation or standard mandates SBOM generation?
US Executive Order 14028 requires SBOM delivery for software sold to federal agencies, using NTIA minimum elements. PCI DSS v4.0.1 requirement 6.4.3 mandates an inventory of all scripts in payment pages. NIST SSDF (SP 800-218) practice PW.4.1 calls for tracking third-party component provenance. ISO 27001 Annex A control 8.8 covers management of technical vulnerabilities.
Related
Permalink to "Related"- Supply Chain Auditing & Dependency Verification — parent overview of the full supply chain hardening workflow
- Generating CycloneDX SBOMs for Frontend Assets — browser-specific component mapping and third-party CDN script inclusion
- Lockfile Mapping & Analysis — the upstream step that produces the resolved dependency graph SBOM generators consume
- Provenance Verification Workflows — attaching Sigstore attestations to SBOM artifacts for cryptographic supply chain proof
- Dependency Pinning Best Practices — keeping lockfiles trustworthy so SBOM generation stays accurate