@cyberstamp/cdx-npm-enrich
v0.1.6
Published
Enrich CycloneDX npm SBOMs with dev/prod scope and missing licenses
Maintainers
Readme
@cyberstamp/cdx-npm-enrich
A post-processing utility that enriches CycloneDX SBOMs produced by Node.js SBOM generators. It is not an SBOM generator — it takes an existing SBOM as input, cross-references it against package.json dependency declarations and node_modules contents (not the lockfile), and improves scope classification, license coverage, and hash placement.
NOTE: In an ideal world this utility shouldn't exist. Hopefully, SBOM generators for Node.js will align on a common and standard approach to manifest prod and non-prod dependencies soon.
Why
CycloneDX SBOM generators for Node.js produce varying levels of scope and license coverage. Some omit scope entirely, others use lockfile heuristics or AST analysis that can misclassify dependencies. License metadata is often incomplete.
This tool post-processes a generator's output to ensure:
- Scope classification: dev-only dependencies are marked
scope: "excluded"(not reachable at runtime per the CycloneDX spec). Production dependencies reachable only throughoptionalDependenciesedges retainscope: "optional". All other production dependencies have their scope cleared (implied"required") - Complete licenses: components missing license metadata are enriched from
node_modules/*/package.json - Hashes: if a component's
externalReferences[type=distribution]entry has no hashes, a SHA-512 checksum from the lockfile (yarn.lock,pnpm-lock.yaml, orpackage-lock.json) is added. Hashes are placed on the distribution external reference per the CycloneDX spec (the hash is of the registry tarball, not the component content itself). Existing distribution hashes are preserved - Evidence: production components receive CycloneDX
evidence.identityconfirming their PURL was verified viamanifest-analysis(readingpackage.jsonfromnode_modules). Confidence is set to 0.6 — the top of the CycloneDX-recommended range for manifest analysis. This is more conservative than cdxgen's 1.0: a lockfile orpackage.jsonconfirms a package was declared and installed, but does not verify that its contents match a known-good artifact (e.g., via content hash). Existingevidence.identityfrom the upstream SBOM generator is preserved
Production vs dev classification builds the dependency graph reachable from workspace dependencies (following dependencies, peerDependencies, and optionalDependencies — never devDependencies), then classifies each package based on the edge types through which it was reached. Peer dependencies are gated by the consumer workspace's declaration — see Peer dependency classification below.
Generators tested (August 2026)
| Generator | Scope | Licenses | Hashes | Evidence | Issues addressed |
|-----------|-------|----------|--------|----------|-----------------|
| cdxgen 12.x | Most components marked optional; some excluded for type-only imports. --required-only can strip non-prod components. Neither mode marks dev deps as excluded. | Can resolve licenses by querying public registries (FETCH_LICENSE=true); disabled by default due to performance. Does not read from node_modules/*/package.json. | SHA-512 from lockfile on component.hashes (spec-incorrect — tarball hash placed at top level) | manifest-analysis / lockfile name, confidence 1.0 | Scope reclassified; missing licenses enriched; hashes relocated from component.hashes to externalReferences[type=distribution] |
| @cyclonedx/yarn-plugin-cyclonedx 3.3 | No scope set. --prod can strip dev deps. No option to keep all and mark dev deps as excluded. | Resolved from package.json in node_modules (same approach as this tool). | Not produced | Not produced | Scope added; hashes enriched from lockfile; evidence added |
| pnpm sbom 11.x | Dev deps marked excluded with cdx:npm:package:development property; prod deps have no scope set (implied required). --prod strips dev deps, --no-optional strips optional deps. | Resolved from package.json in node_modules (same approach as this tool). | SHA-512 on externalReferences[type=distribution] (spec-correct placement) | Not produced | Evidence added (minimal value-add — pnpm sbom already handles scope, licenses, and hashes correctly) |
Install
npm install -g @cyberstamp/cdx-npm-enrichOr run directly with npx:
npx @cyberstamp/cdx-npm-enrich bom.cdx.jsonUsage
cdx-npm-enrich [options] <bom.cdx.json>The SBOM file is modified in-place.
Options
| Option | Description |
|--------|-------------|
| --project-dir <dir> | Project root containing package.json and node_modules. Defaults to the current working directory. |
| -o, --output <file> | Write to a new file instead of modifying the input in-place. |
| --prod-only | Strip dev-only components and their dependency entries instead of marking them excluded. |
Examples
Enrich an SBOM (mark dev deps as excluded, keep everything):
cdx-npm-enrich bom.cdx.jsonEnrich an SBOM for a project in a different directory:
cdx-npm-enrich --project-dir /path/to/project bom.cdx.jsonStrip dev dependencies entirely:
cdx-npm-enrich --prod-only bom.cdx.jsonWorkspace support
The tool auto-detects the workspace configuration:
- npm/yarn: reads the
workspacesfield frompackage.json - pnpm: reads
pnpm-workspace.yaml
Dependencies are resolved from per-workspace node_modules directories, including pnpm's content-addressable .pnpm store.
How it works
- Workspace discovery: finds all workspace packages from
package.json(workspacesfield) orpnpm-workspace.yaml - Graph construction: collects direct production dependencies (
dependencies, notdevDependencies) from each workspace, skippingworkspace:protocol references. Transitively walks reachable packages throughnode_modules, followingdependencies,peerDependencies, andoptionalDependenciesat each level —devDependenciesare never followed. Symlinks are resolved so that pnpm's.pnpmstore siblings are reachable. For pnpm virtual packages (store entries with_in the directory name), sibling entries are scanned to discover implicit peer bindings - Prod/dev classification: propagates production status through the graph. Regular dependencies and optional dependencies are always production. Packages reachable exclusively through
optionalDependenciesedges (including their transitive children) are markedoptional. A package reachable through both a required and an optional path is classified as required. Peer dependencies are classified based on how the consuming workspace declared them (see below) - Lockfile parsing: reads SHA-512 checksums from
yarn.lock,pnpm-lock.yaml, orpackage-lock.json - Enrichment: adds missing license data, hashes (on
externalReferences[type=distribution]), andevidence.identityto production components. Removescomponent.hashesentries that duplicate distribution tarball hashes - Output: required production components have their scope cleared (implied
"required"per CycloneDX spec). Optional-only production components getscope: "optional". Dev-only components getscope: "excluded"(or are removed in--prod-onlymode)
Peer dependency classification
A peerDependencies declaration is neutral — it says "the host must provide this" but doesn't indicate whether the package is needed at runtime or only during development. The consumer's declaration determines the classification:
If a workspace has package A in
dependenciesand A declares a peer dep on B:- B in the workspace's
dependencies→ production (explicitly declared as runtime) - B in the workspace's
devDependencies(not independencies) → dev-only (consumer signaled it's not needed at runtime) - B not declared by the workspace at all → production (safe default — the consumer didn't signal dev-only, and A needs B at runtime)
- B in the workspace's
If A is a transitive dependency (not directly in any workspace's
dependencies), no workspace is the direct consumer of A's peer dep. In this case the peer dep is always followed — the workspace's owndevDependenciesare unrelated to A's runtime needsA package reached as dev-only through a peer dep edge can still be production if it's reachable through a regular
dependenciesedge from another production package. Classification never downgrades from prod to dev
Limitations
- The tool classifies dependencies by walking
package.jsonfields, not by analyzing actual code usage. A declared production dependency that is never imported at runtime will still be classified as production - Peer dependencies and optional dependencies that are not installed are silently skipped — the package manager already warns about missing required peers during install
- For pnpm, implicit peer bindings are discovered by scanning sibling entries in virtual store directories. This relies on pnpm's internal directory naming convention (
_separator for peer variants)
