Generating CycloneDX SBOMs for Frontend Assets
Permalink to "Generating CycloneDX SBOMs for Frontend Assets"Part of Automated SBOM Generation — this page covers exactly how to produce a CycloneDX bill of materials from a frontend JavaScript project, bind SRI hashes into the manifest, and validate the output before it leaves CI.
Quick Reference
Permalink to "Quick Reference"| Item | Value |
|---|---|
| Primary tool | @cyclonedx/cyclonedx-npm |
| Output format flag | --output-format=json |
| Exclude devDeps flag | --include-dev=false |
| Target spec version | CycloneDX 1.6 |
| Preferred hash algorithm | sha384 |
| Validation tool | @cyclonedx/cyclonedx-library validate |
| Lockfile formats supported | package-lock.json, yarn.lock, pnpm-lock.yaml |
Why CycloneDX Exists for Frontend Supply Chains
Permalink to "Why CycloneDX Exists for Frontend Supply Chains"JavaScript projects accumulate hundreds of transitive dependencies. Without a machine-readable inventory, a compromised package entering the tree through a nested transitive path goes undetected until an attacker exploits it. CycloneDX provides a standardised JSON or XML envelope that maps every resolved package — including its version, PURL, license, and cryptographic hash — to the exact build that produced it.
For frontend assets specifically, the problem compounds: code-splitting, lazy loading, and CDN delivery mean the final bytes served to a browser may differ from anything a static dependency graph shows. Embedding SHA-384 hashes in the SBOM binds the manifest to the post-build, post-minification artifacts rather than to source declarations. That binding is what lets a downstream tool — or a browser enforcing Subresource Integrity — verify the bytes it receives are the bytes you shipped.
The pipeline diagram below shows where SBOM generation sits relative to the build and delivery stages:
The highlighted stage is what this page covers in full.
Canonical Implementation
Permalink to "Canonical Implementation"The example below is a complete, production-ready workflow. It installs only production dependencies, builds the project, generates the SBOM, validates the schema, and uploads the artifact.
# .github/workflows/sbom.yml
name: Build & SBOM
on:
push:
branches: [main]
jobs:
build-and-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: '22.x'
cache: 'npm'
# Deterministic install — never npm install in CI
- name: Install production dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
# Generate SBOM from the resolved lockfile, production deps only
- name: Generate CycloneDX SBOM
run: |
npx --yes @cyclonedx/cyclonedx-npm \
--include-dev=false \
--output-format=json \
--output-file=sbom.json \
--spec-version=1.6
# Fail the build if the manifest is structurally invalid
- name: Validate SBOM schema
run: |
npx --yes @cyclonedx/cyclonedx-library \
validate --input-file sbom.json --fail-on-errors
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: frontend-sbom
path: sbom.json
retention-days: 90
npm ci guarantees a lockfile-driven, reproducible install. The --ignore-scripts flag prevents malicious lifecycle hooks from running. Both are mandatory in isolated CI contexts.
Variant Examples
Permalink to "Variant Examples"Variant 1: Webpack with SRI hashes injected into the SBOM
Permalink to "Variant 1: Webpack with SRI hashes injected into the SBOM"When you build with webpack-subresource-integrity, the plugin writes integrity attributes directly into the generated HTML. The post-build step below extracts those hashes and appends them to the CycloneDX manifest so every bundle chunk carries its sha384 digest inside the SBOM’s hashes array.
// webpack.config.js
const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
output: {
crossOriginLoading: 'anonymous', // required for SRI to work
filename: '[name].[contenthash].js',
},
plugins: [
new HtmlWebpackPlugin(),
new SubresourceIntegrityPlugin({
hashFuncNames: ['sha384'],
}),
],
};
After the build, compute each chunk’s sha384 digest and patch the SBOM:
#!/usr/bin/env bash
# patch-sbom-hashes.sh — append post-build SRI hashes to sbom.json
set -euo pipefail
DIST_DIR="${1:-dist}"
SBOM_FILE="${2:-sbom.json}"
for file in "$DIST_DIR"/*.js "$DIST_DIR"/*.css; do
[ -f "$file" ] || continue
HASH="sha384-$(openssl dgst -sha384 -binary "$file" | openssl base64 -A)"
FILENAME=$(basename "$file")
# Append to a separate hashes.json that can be merged by your release tooling
echo "{\"file\": \"$FILENAME\", \"alg\": \"SHA-384\", \"content\": \"$HASH\"}"
done
The hash string format follows the CycloneDX 1.6 hashes schema — alg is "SHA-384" (uppercase), content is the raw base64 digest without the sha384- prefix.
Variant 2: pnpm workspace with Yarn lockfile fallback
Permalink to "Variant 2: pnpm workspace with Yarn lockfile fallback"pnpm and Yarn use different lockfile formats, but @cyclonedx/cyclonedx-npm detects the active package manager automatically when invoked from the project root:
# pnpm workspace (pnpm-lock.yaml detected automatically)
npx @cyclonedx/cyclonedx-npm \
--include-dev=false \
--output-format=json \
--output-file=sbom.json \
--package-lock-only # read the lockfile without touching node_modules
For monorepos, run the command once per workspace package and merge the outputs, or pass the --flatten-components flag to produce a single top-level manifest:
# Merge per-workspace SBOMs (requires cyclonedx-cli installed separately)
cyclonedx merge \
--input-files packages/*/sbom.json \
--output-file sbom-merged.json \
--output-format json
Variant 3: CDN third-party scripts as external components
Permalink to "Variant 3: CDN third-party scripts as external components"Scripts loaded from a CDN — analytics, fonts, A/B testing — are not in your lockfile, so the generator will not include them. Add them manually as externalReferences components. This is particularly important when you enforce Configuring Content-Security-Policy with SRI because your CSP policy must match the hashes recorded in the SBOM.
{
"components": [
{
"type": "library",
"name": "analytics-cdn-script",
"version": "2024-11-15",
"purl": "pkg:generic/analytics-cdn-script@2024-11-15",
"hashes": [
{
"alg": "SHA-384",
"content": "oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
}
],
"externalReferences": [
{
"type": "distribution",
"url": "https://cdn.example.com/analytics/v2/analytics.min.js"
}
]
}
]
}
Compute the hash with openssl dgst -sha384 -binary analytics.min.js | openssl base64 -A and verify it matches the integrity= attribute you embed in the <script> tag.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"- Dynamic imports are invisible to lockfile scanning.
import()expressions resolved at runtime never appear inpackage-lock.json. Use@cyclonedx/webpack-pluginor enable static analysis in your bundler to capture lazy-loaded chunks; otherwise they produce an incomplete SBOM. - Hash must be computed after minification. Minification changes bytes. Computing
sha384before the optimizer runs produces a digest that will never match the bytes the browser downloads. Always run SBOM hash computation as the last post-build step. - Missing
specVersionfield breaks downstream parsers. Always pass--spec-version=1.6explicitly. The default in older versions of the tool is 1.4, which omits evidence and attestation fields and will fail strict validators. - devDependencies inflate the attack surface. Always pass
--include-dev=false. Babel, ESLint, and TypeScript binaries in the SBOM trigger unnecessary CVE alerts and obscure real production risk. - CDN scripts require manual inclusion. Third-party scripts loaded from external URLs are not captured by lockfile analysis. Omitting them leaves a gap between your declared supply chain and your actual runtime dependencies — a gap that vulnerability scanners and compliance auditors will flag.
crossOriginLoading: 'anonymous'is mandatory. Without it, Webpack will not addcrossorigin="anonymous"to generated<script>tags, and browsers will silently skip SRI enforcement for cross-origin resources even when anintegrityattribute is present. This is the single most common SRI misconfiguration in Webpack projects.
Verification Steps
Permalink to "Verification Steps"1. Confirm the SBOM is structurally valid:
npx @cyclonedx/cyclonedx-library validate \
--input-file sbom.json \
--fail-on-errors
# Expected: "BOM is valid"
2. Check the specVersion and component count:
node -e "
const s = require('./sbom.json');
console.log('specVersion:', s.specVersion);
console.log('components:', s.components.length);
"
# Expected: specVersion 1.6, components > 0
3. Verify SRI hashes are present in the manifest:
node -e "
const s = require('./sbom.json');
const missing = s.components.filter(c => !c.hashes || c.hashes.length === 0);
if (missing.length) {
console.error('Components without hashes:', missing.map(c => c.name));
process.exit(1);
}
console.log('All components have hashes');
"
4. Upload to Dependency-Track and confirm ingestion:
curl -sf -X POST "https://dependency-track.example.com/api/v1/bom" \
-H "X-Api-Key: $DT_API_KEY" \
-H "Content-Type: multipart/form-data" \
-F "projectName=my-frontend-app" \
-F "projectVersion=$(git describe --tags --always)" \
-F "autoCreate=true" \
-F "[email protected]"
# HTTP 200 with a token confirms the BOM was accepted for processing
Dependency-Track correlates the ingested components against NVD and OSV databases. Configure policy thresholds so any component with a CVSS score above your risk tolerance blocks the next deployment stage. Pair this monitoring loop with Provenance Verification Workflows to attach cryptographic attestations to the same artifact bundle.
Related
Permalink to "Related"- Automated SBOM Generation — the parent guide covering tool selection, policy gating, and multi-ecosystem patterns
- Lockfile Mapping & Analysis — how lockfiles are parsed into resolved dependency graphs before SBOM generation
- Automating Hash Generation in Webpack 5 — detailed Webpack 5 configuration for reproducible SRI hash generation