Skip to content

CVE blast radius is truncated: parser keeps only the first affected component (affects[0]), hiding every other package a CVE hits #30

Description

@dmchaledev

Summary

When a CycloneDX vulnerability lists multiple affected components, @hailbytes/sbom-diff records and reports only the first one. extractCycloneDXAffects() reads affects[0].ref and discards the rest (src/parser.ts:122-126), and CVEEntry.affects is typed as a single string (src/types.ts:33). Every output format then renders that single ref, so the report understates the blast radius of a vulnerability.

For a package keyworded vulnerability-management / supply-chain-security, this is a real signal loss: the canonical example is Log4Shell (CVE-2021-44228), which in a typical CycloneDX SBOM affects both log4j-core and log4j-api (and often shaded/relocated copies). The tool will tell the analyst it affects only log4j-core, silently hiding the other impacted packages.

This is distinct from every open PR/issue. The in-flight CVE work touches other parts of the pipeline: CVSS extraction (#18), highest-severity selection (already merged), risk ordering (#25), the --fail-on gate (#5), and Markdown escaping (#23). None of them change how many affected refs are captured — they all operate on the single-string affects this issue is about.

Evidence (current main)

src/parser.ts:122-126:

function extractCycloneDXAffects(v: Record<string, unknown>): string {
  const affects = v.affects;
  if (!Array.isArray(affects) || affects.length === 0) return 'unknown';
  const ref = affects[0] as Record<string, unknown>;          // <-- only [0]
  return typeof ref.ref === 'string' ? ref.ref : 'unknown';
}

src/types.ts:33:

affects: string;   // <-- one ref, not a list

The truncated value is then surfaced verbatim in all three renderers — text (src/reporter.ts:49, :56) and markdown (src/reporter.ts:106, :113) — so the loss is uniform across text / json / markdown.

Reproduction

A single CycloneDX vulnerability affecting two components:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "components": [],
  "vulnerabilities": [
    {
      "id": "CVE-2021-44228",
      "affects": [
        { "ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" },
        { "ref": "pkg:maven/org.apache.logging.log4j/log4j-api@2.14.1" }
      ],
      "ratings": [{ "severity": "critical" }]
    }
  ]
}
import { parse } from '@hailbytes/sbom-diff';
const sbom = parse(/* the JSON above */);
console.log(sbom.vulnerabilities[0].affects);
// -> "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
//    log4j-api is gone; the report shows the CVE hitting only one package

Diffed against a clean baseline, the markdown "New CVEs" table reads:

| CVE ID         | Severity | Affects                                              |
|----------------|----------|------------------------------------------------------|
| CVE-2021-44228 | critical | pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 |

— with no indication that log4j-api is also affected.

Proposed change

Capture all affected refs and render them. Suggested shape (open to alternatives):

1. Types (src/types.ts)

Make affects a list while keeping it ergonomic:

export interface CVEEntry {
  id: string;
  affects: string[];   // all affected component refs (was: string)
  severity?: 'none' | 'low' | 'medium' | 'high' | 'critical';
  cvssScore?: number;
  description?: string;
}

(If a hard type change is undesirable, an additive affectsAll?: string[] alongside the existing first-ref affects is a non-breaking alternative — but a single string[] is cleaner.)

2. Parser (src/parser.ts)

function extractCycloneDXAffects(v: Record<string, unknown>): string[] {
  const affects = v.affects;
  if (!Array.isArray(affects)) return [];
  return affects
    .map(a => (a as Record<string, unknown>).ref)
    .filter((r): r is string => typeof r === 'string');
}

Empty/absent affects[] (the renderer can show a placeholder), rather than the current 'unknown' sentinel.

3. Reporter (src/reporter.ts)

Join the refs for display, e.g. v.affects.join(', ') (or one row per ref) in the text (:49, :56) and markdown (:106, :113) sections. json is automatic.

4. Tests (src/__tests__/)

  • A vuln with two affects refs ⇒ affects.length === 2, both preserved (parser test — extend the existing CVE-2021-44228 fixture in parser.test.ts).
  • A vuln with no affectsaffects === [] (or documented placeholder), no crash.
  • Reporter renders both refs in text and markdown (extend reporter.test.ts, whose fixtures currently use a single-string affects).

Note: reporter.test.ts:9-10 and diff.test.ts:73,82 construct CVEEntry objects with a string affects, so those fixtures need updating in lockstep with the type change. Flagging so this is sequenced cleanly relative to the in-flight reporter PRs (#23/#24/#25) to avoid churn.

Secondary, related undercount (optional, same area)

diff() keys vulnerabilities by id alone (src/diff.ts:42-46). If a CycloneDX document ever represents one CVE as two vulnerability objects with the same id (e.g. one per component), the Map<string, CVEEntry> collapses them to one before diffing. Capturing all refs per object (above) handles the common case; this id-only keying is a smaller, adjacent edge that could be folded into the same change or tracked separately.

Why this is high-leverage

  • Restores the tool's headline signal. "Detect newly introduced CVEs" is only useful if it tells you which packages are hit — truncating to one ref understates exposure precisely when a CVE is most dangerous (one vuln, many components).
  • Zero new dependencies / no new parsing infrastructure — the data is already in the input affects array; the parser just drops it on the floor today.
  • Composable with the CI gate (feat(cli): add --fail-on CI/CD gate, --help/--version, robust arg parsing #5) and risk ordering (feat(diff): order report deterministically by risk (severity-first CVEs, stable component listing) #25): an accurate, complete affected-set makes a --fail-on decision and per-CVE sorting trustworthy.
  • Orthogonal to all open PRs/issues — none capture more than the first affected ref, so this won't conflict with anything in flight beyond the in-lockstep test-fixture update noted above.

Happy to open a focused PR (parser + types + reporter + tests) once the direction (single string[] vs additive field) is confirmed and the in-flight reporter PRs land, to keep reporter.ts conflicts minimal.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions