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:

CycloneDX SBOM generation pipeline Five stages run left to right: lockfile resolution, npm run build, a highlighted SBOM generation stage that embeds sha384 SRI hashes, schema validation, and finally CDN delivery with integrity attributes on the served tags. Lockfile resolution npm run build SBOM gen + SRI hashes (sha384) Schema validation CDN delivery integrity= attributes resolve and build bind hashes to bytes browser verifies

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. This is the single most common transcription bug in hand-rolled patch scripts: the value that goes into an HTML integrity attribute and the value that goes into a CycloneDX hashes[].content field are the same 64 base64 characters, but the SBOM field carries no algorithm prefix and names the algorithm separately, with a hyphen and uppercase letters. If you emit the sidecar file from CI rather than patching the manifest in place, the same digest table can feed both consumers — see Generating an SRI Manifest in GitHub Actions for the manifest layout that survives both.

Anatomy of an SRI value versus a CycloneDX hash entry The integrity attribute value splits into an algorithm prefix and a base64 digest. The prefix becomes the uppercase hyphenated alg field SHA-384 in the CycloneDX hashes entry, while the base64 digest is copied verbatim into the content field with no prefix. integrity attribute value in the HTML tag sha384 - oqVuAfXRKap7fdgcCY5uykM6 ... 4JwY8wC algorithm prefix base64 digest of the built file bytes uppercased copied verbatim "alg": "SHA-384" "content": "oqVuAfXRKap7fdgcCY5uykM6 ... 4JwY8wC" CycloneDX 1.6 hashes entry: no sha384- prefix is stored the digest characters are identical on both sides

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. Because a manually declared component is pinned by digest rather than by a version a resolver can re-check, it goes stale the moment the vendor ships a new build behind the same URL. Pair the manual entry with Detecting Changes in Third-Party Scripts so a silent edge redeploy shows up as a monitored diff rather than as a blocked script in production.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Dynamic imports are invisible to lockfile scanning. import() expressions resolved at runtime never appear in package-lock.json. Use @cyclonedx/webpack-plugin or enable static analysis in your bundler to capture lazy-loaded chunks; otherwise they produce an incomplete SBOM. The same blind spot exists on the enforcement side, which is why SRI for Lazy-Loaded Chunks has to solve chunk hashing at the loader level rather than in the HTML.
  • Hash must be computed after minification. Minification changes bytes. Computing sha384 before 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 specVersion field breaks downstream parsers. Always pass --spec-version=1.6 explicitly. 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 add crossorigin="anonymous" to generated <script> tags, and browsers will silently skip SRI enforcement for cross-origin resources even when an integrity attribute is present. This is the single most common SRI misconfiguration in Webpack projects.

Taken together, these gaps mean no single mechanism produces a complete frontend manifest. The lockfile reader knows about packages but never sees a build output; the bundler plugin knows about emitted files but never sees a URL you did not compile; only a hand-written component covers a script that arrives from someone else’s edge. The matrix below shows which capture mechanism you have to reach for per asset class:

SBOM coverage by asset class and capture mechanism A five-row matrix comparing three capture mechanisms. The cyclonedx-npm lockfile reader captures production dependencies and excludes devDependencies by flag, but does not list emitted bundle chunks, runtime import chunks or CDN scripts. A bundler plugin hashes emitted and runtime chunks but still misses CDN scripts, which require a manual component entry. asset class cyclonedx-npm bundler plugin manual entry production lockfile deps captured captured not needed devDependencies excluded by flag not emitted not needed emitted bundle chunks not listed hashed not needed runtime import() chunks missed hashed fallback only CDN third-party scripts missed missed required

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.


Permalink to "Related"

Related Articles

SPDX vs CycloneDX for Frontend SBOMs
Automated SBOM Generation Supply Chain Auditing & Depend…