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 medium → critical:
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-86 — ChangeReport 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.
Summary
diff()sorts vulnerabilities into exactly two buckets —newCVEs(id in B but not A) andfixedCVEs(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-securityand 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-ongate (#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
medium→critical:Output:
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 byidonly:src/diff.ts:44-45— classification is purely set-membership by id; a CVE in both maps is dropped entirely:severity/cvssScorediffer."src/types.ts:78-86—ChangeReporthasnewCVEs/fixedCVEsand 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)2. Diff (
src/diff.ts)In the CVE section, after computing new/fixed, walk the intersection and compare severity (and
cvssScorewhen present). Reuse the existingSEVERITY_RANKordering already defined insrc/parser.ts(export it, or mirror it) soescalatedis a rank comparison rather than string equality:3. Reporter (
src/reporter.ts)Add a "Severity Changes" section to
textandmarkdown(mirroring the New CVEs section;jsonis automatic). Because an escalation tocriticalis high-signal, render it even when nothing else changed. Example markdown:4. Tests (
src/__tests__/)severitymedium → critical ⇒ onechangedCVEsentry withescalated: true(diff test).escalated: false.newCVEs/fixedCVEsand not inchangedCVEs(no double-count).textandmarkdown.Why this is high-leverage
newCVEs/fixedCVEsbehavior or default CLI output; SBOMs without embedded vulnerabilities behave exactly as today.severity, andcvssScoreonce feat(parser): extract CVSS scores and derive severity from numeric score #18 lands) is already onCVEEntry; this only wires the intersection through diff → report.changedCVEsexists,--fail-on escalation(fail when any CVE crosses up to/above a threshold) becomes a trivial, high-value gate condition — arguably more important than--fail-on new-cves.SEVERITY_RANKexport.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.tsconflicts minimal.