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 from left to right: Lockfile resolution, npm run build, SBOM generation plus SRI hash embedding, Schema validation, then CDN delivery with integrity attributes. Arrows connect each stage. Lockfile resolution npm run build SBOM gen + SRI hashes (sha384) Schema validation CDN delivery integrity= attributes

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 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.
  • 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.

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"
Automated SBOM Generation Supply Chain Auditing & Depend…