Skip to content

Dev/test dependencies inflate the diff and can trip --fail-on gates: component scope is discarded, so there is no way to diff runtime-only components #56

Description

@dmchaledev

Summary

CycloneDX components carry a scope field — required (runtime), optional (dev/test/build), or excluded — and generators like syft, cdxgen, and npm sbom populate it so consumers can tell production dependencies apart from dev/test ones. The parser silently drops it: scope is not on the Component type (src/types.ts:11-26) and parseCycloneDX never reads c.scope when mapping components (src/parser.ts:29-36). A grep -ri scope src/ returns nothing.

Because the signal is thrown away, every diff and every --fail-on gate treats a dev/test-only dependency exactly like a production one. For a package keyworded supply-chain-security whose headline use case is a CI/CD gate (README "CI/CD gate", src/cli.ts:105-112), that has two concrete costs:

  1. Gate false positives. A new CVE that only affects a devDependency (a test runner, a bundler plugin) trips --fail-on high and breaks the build for a vulnerability that never ships to production. Today there is no way to scope the gate to runtime risk.
  2. Report noise. Routine dev-dependency churn (linters, type stubs, test libs bumping) floods Added / Removed / Upgraded, burying the production changes a reviewer actually needs to see in an audit.

This is the same "declared/parsed-but-unused signal" class already recognized for license (#9), hashes (#22), and document identity (#52) — data the SBOM provides that never reaches the diff. It is distinct from all of those (they surface license/integrity/provenance; this is about dependency classification) and from the in-flight keying/upgrade PRs (which change how components are matched, not which components are in scope). No open issue or PR references component.scope, dev-dependency filtering, or a --scope/--runtime-only concept.

Reproduction (current main)

new.cdx.json introduces a critical CVE that only affects an optional-scope (dev) component:

// old.cdx.json
{ "bomFormat": "CycloneDX", "specVersion": "1.5",
  "components": [
    { "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2", "scope": "required" },
    { "name": "vitest",  "version": "1.0.0",  "purl": "pkg:npm/vitest@1.0.0",  "scope": "optional" }
  ],
  "vulnerabilities": [] }

// new.cdx.json — vitest bumped, and it now carries a critical CVE
{ "bomFormat": "CycloneDX", "specVersion": "1.5",
  "components": [
    { "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2", "scope": "required" },
    { "name": "vitest",  "version": "1.1.0",  "purl": "pkg:npm/vitest@1.1.0",  "scope": "optional" }
  ],
  "vulnerabilities": [
    { "id": "CVE-2025-0000", "affects": [{ "ref": "pkg:npm/vitest@1.1.0" }],
      "ratings": [{ "severity": "critical" }] }
  ] }
npx @hailbytes/sbom-diff old.cdx.json new.cdx.json --fail-on high
# → Gate failed (exit 3), even though the only affected component is a dev-only test runner
#   that never reaches a production artifact.

There is no flag that lets the user say "gate on production dependencies only."

Proposed change

Capture the field and let callers scope by it. Backward-compatible (default preserves today's behavior).

  1. Type (src/types.ts): add to Component
    /** CycloneDX component scope: 'required' (runtime), 'optional' (dev/test/build), 'excluded'. */
    scope?: 'required' | 'optional' | 'excluded';
  2. Parser (src/parser.ts, parseCycloneDX): read c.scope when it is one of the three valid values; leave undefined otherwise. SPDX has no direct scope equivalent, so parseSPDX leaves it undefined (worth a one-line doc note).
  3. CLI (src/cli.ts): add --scope <all|runtime> (default all).
    • all → current behavior (diff every component).
    • runtime → before diffing, keep only components whose scope is required or undefined (treat unknown/unset scope as runtime, so SBOMs without scope data are never silently emptied). This filters the input SBOMs, so it applies uniformly to added / removed / upgraded and to the CVE gate.
  4. Docs: one row in the README options table + a note that --scope runtime is the recommended setting for --fail-on gates.
  5. Tests: parser captures scope; --scope runtime excludes an optional component and its CVE from the report and the gate; unset-scope components survive the filter.

Open design questions for the maintainer

  • Flag surface: --scope all|runtime vs. a simpler boolean --runtime-only. A value flag leaves room for a future --scope dev or explicit excluded handling.
  • excluded components: CycloneDX excluded means "known present but explicitly out of the assertion." Reasonable to drop these under runtime (and arguably under all too, but that would be a behavior change — probably out of scope here).
  • Should --scope runtime also filter the report body, or only the gate? Filtering the whole diff (as proposed) keeps report and gate consistent; gate-only would be a smaller change but leaves the noise in the report.

Happy to open a focused PR once the flag name and the excluded/undefined defaults are settled — flagging the API decision first since it touches types.ts / parser.ts / cli.ts, which several in-flight PRs also modify.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions