Skip to content

Detect CVE severity escalations: a re-scored CVE (e.g. medium → critical) that persists in both SBOMs produces an empty, green report #46

Description

@dmchaledev

Summary

diff() sorts vulnerabilities into exactly two buckets — newCVEs (id in B but not A) and fixedCVEs (id in A but not B) — and nothing else (src/diff.ts:41-45). A CVE that is present in both SBOMs but whose severity / CVSS score was re-scored between the two scans falls into neither bucket, so it is completely invisible: the report shows all zeros.

For a package keyworded vulnerability-management / supply-chain-security and marketed as "perfect for CI/CD gates and audit trails," this is a material blind spot. Re-scoring is routine — NVD frequently updates a CVE from awaiting analysis → a concrete severity, or revises a score upward as exploitation matures (Log4Shell itself was re-rated). A dependency you already ship silently becoming critical between release N and N+1 is precisely the kind of change a security engineer diffs SBOMs to catch — and today the tool says "nothing changed."

This is distinct from every open PR/issue. The in-flight CVE work is about extracting CVSS (#18), ordering the report by risk (#25), capturing all affected refs (#30), and the --fail-on gate (#5). None of them compare a persisting CVE's severity across the two SBOMs — they all operate within the existing new/fixed classification.

Reproduction (current main)

A single CVE present in both SBOMs, re-scored mediumcritical:

import { parse, diff, renderReport } from '@hailbytes/sbom-diff';

const mk = (sev) => JSON.stringify({
  bomFormat: 'CycloneDX', specVersion: '1.5',
  components: [{ name: 'log4j-core', version: '2.14.1',
                purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1' }],
  vulnerabilities: [{
    id: 'CVE-2021-44228',
    affects: [{ ref: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1' }],
    ratings: [{ severity: sev }],
  }],
});

const report = diff(parse(mk('medium')), parse(mk('critical')));
console.log(report.summary.totalNewCVEs, report.summary.totalFixedCVEs); // -> 0 0
console.log(renderReport(report, 'text'));

Output:

SBOM Diff Report
=================

Summary:
  Added:       0
  Removed:     0
  Upgraded:    0
  New CVEs:    0
  Fixed CVEs:  0

An empty, green report — even though a known vulnerability in a shipped component just escalated to critical.

Evidence in source

  • src/diff.ts:41-42 — vulnerabilities are keyed by id only:
    const aVulns = new Map<string, CVEEntry>((a.vulnerabilities ?? []).map(v => [v.id, v]));
    const bVulns = new Map<string, CVEEntry>((b.vulnerabilities ?? []).map(v => [v.id, v]));
  • src/diff.ts:44-45 — classification is purely set-membership by id; a CVE in both maps is dropped entirely:
    const newCVEs   = [...bVulns.values()].filter(v => !aVulns.has(v.id));
    const fixedCVEs = [...aVulns.values()].filter(v => !bVulns.has(v.id));
    There is no branch for "in both, but severity/cvssScore differ."
  • src/types.ts:78-86ChangeReport has newCVEs / fixedCVEs and their summary counts, but no field for changed-severity CVEs.

Proposed change

Add a third vulnerability category — escalated (and, optionally, de-escalated) CVEs — computed from the intersection of the two id-keyed maps.

1. Types (src/types.ts)

export interface CVESeverityChange {
  id: string;
  affects: string;
  from?: CVEEntry['severity'];
  to?: CVEEntry['severity'];
  fromScore?: number;   // when cvssScore is available (see #18)
  toScore?: number;
  /** true when severity/score moved UP (the high-signal case) */
  escalated: boolean;
}

export interface ChangeReport {
  // ...existing fields...
  changedCVEs: CVESeverityChange[];
  summary: {
    // ...existing counts...
    totalChangedCVEs: number;
  };
}

2. Diff (src/diff.ts)

In the CVE section, after computing new/fixed, walk the intersection and compare severity (and cvssScore when present). Reuse the existing SEVERITY_RANK ordering already defined in src/parser.ts (export it, or mirror it) so escalated is a rank comparison rather than string equality:

const changedCVEs: CVESeverityChange[] = [];
for (const [id, bVuln] of bVulns) {
  const aVuln = aVulns.get(id);
  if (!aVuln) continue;                       // handled by newCVEs
  const sevChanged   = aVuln.severity !== bVuln.severity;
  const scoreChanged = aVuln.cvssScore !== bVuln.cvssScore;
  if (sevChanged || scoreChanged) {
    changedCVEs.push({
      id,
      affects: bVuln.affects,
      from: aVuln.severity, to: bVuln.severity,
      fromScore: aVuln.cvssScore, toScore: bVuln.cvssScore,
      escalated: rank(bVuln.severity) > rank(aVuln.severity)
              || (bVuln.cvssScore ?? -1) > (aVuln.cvssScore ?? -1),
    });
  }
}

3. Reporter (src/reporter.ts)

Add a "Severity Changes" section to text and markdown (mirroring the New CVEs section; json is automatic). Because an escalation to critical is high-signal, render it even when nothing else changed. Example markdown:

## 🔺 CVE Severity Changes

| CVE ID         | Affects       | From   | To       | Escalated |
|----------------|---------------|--------|----------|-----------|
| CVE-2021-44228 | pkg:maven/... | medium | critical | ⚠️ Yes    |

4. Tests (src/__tests__/)

  • Same CVE id in both, severity medium → critical ⇒ one changedCVEs entry with escalated: true (diff test).
  • Severity unchanged ⇒ none (no false positives).
  • De-escalation critical → low ⇒ one entry with escalated: false.
  • A genuinely new / genuinely fixed CVE still lands in newCVEs / fixedCVEs and not in changedCVEs (no double-count).
  • Reporter renders the section in text and markdown.

Why this is high-leverage

Happy to open a focused PR (types + diff + reporter + tests) once the direction (escalation-only vs. all-changes, and whether to gate on it) is confirmed and the in-flight CVSS/ordering PRs (#18, #25) land, to keep diff.ts / 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