From e110a844a66ca13d7d09e27d3c3b1a81a847c6c5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 7 Aug 2026 12:26:33 -0600 Subject: [PATCH 1/2] feat: carry SBOM identity (name/version/generatedAt/format) through diff reports Closes #52 - ChangeReport gains from/to SBOMIdentity fields, populated by diff() from each SBOM's parsed identity - Text and markdown reporters render a 'Compared' section stating exactly which two artifacts the report was produced from - JSON output carries the fields naturally (already stringified wholesale) - New test asserts both renderers state the compared artifacts --- src/__tests__/cli.test.ts | 2 ++ src/__tests__/reporter.test.ts | 20 ++++++++++++++++++++ src/diff.ts | 15 ++++++++++++++- src/reporter.ts | 29 ++++++++++++++++++++++++++++- src/types.ts | 24 ++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 82e50e2..f7e51cf 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -77,6 +77,8 @@ describe('gateFailures', () => { }); const reportWith = (newCVEs: CVEEntry[]): ChangeReport => ({ + from: { format: 'cyclonedx', specVersion: '1.4' }, + to: { format: 'cyclonedx', specVersion: '1.4' }, added: [], removed: [], upgraded: [], diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index 555187a..587601a 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -3,6 +3,8 @@ import { renderReport } from '../reporter.js'; import type { ChangeReport } from '../types.js'; const sampleReport: ChangeReport = { + from: { format: 'cyclonedx', specVersion: '1.4', name: 'my-app', version: '1.2.0', generatedAt: '2026-07-01T00:00:00Z' }, + to: { format: 'cyclonedx', specVersion: '1.4', name: 'my-app', version: '1.3.0', generatedAt: '2026-08-01T00:00:00Z' }, added: [{ name: 'express', version: '4.18.2', ecosystem: 'npm' }], removed: [{ name: 'moment', version: '2.29.4' }], upgraded: [{ component: { name: 'lodash', version: '4.17.21' }, from: '4.17.20', to: '4.17.21', isMajorBump: false, isDowngrade: false }], @@ -39,12 +41,26 @@ describe('renderReport', () => { expect(out).toContain('| chalk | MIT | GPL-3.0 |'); }); + it('states which two artifacts were compared (issue #52)', () => { + const text = renderReport(sampleReport, 'text'); + expect(text).toContain('my-app v1.2.0'); + expect(text).toContain('my-app v1.3.0'); + expect(text).toContain('cyclonedx 1.4'); + expect(text).toContain('generated 2026-07-01'); + const md = renderReport(sampleReport, 'markdown'); + expect(md).toContain('## Compared'); + expect(md).toContain('| From | my-app v1.2.0'); + expect(md).toContain('| To | my-app v1.3.0'); + }); + it('throws on unsupported format', () => { expect(() => renderReport(sampleReport, 'xml' as never)).toThrow(); }); it('escapes pipes and newlines in markdown cells so the table stays well-formed', () => { const report: ChangeReport = { + from: { format: 'cyclonedx', specVersion: '1.4' }, + to: { format: 'cyclonedx', specVersion: '1.4' }, added: [{ name: 'evil | pkg', version: '1.0', ecosystem: 'npm' }], removed: [], upgraded: [], @@ -66,6 +82,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed' it('separates downgrades from upgrades in text output', () => { const report: ChangeReport = { + from: { format: 'cyclonedx', specVersion: '1.4' }, + to: { format: 'cyclonedx', specVersion: '1.4' }, added: [], removed: [], upgraded: [ @@ -88,6 +106,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed' it('renders a downgrades table in markdown output', () => { const report: ChangeReport = { + from: { format: 'cyclonedx', specVersion: '1.4' }, + to: { format: 'cyclonedx', specVersion: '1.4' }, added: [], removed: [], upgraded: [ diff --git a/src/diff.ts b/src/diff.ts index ed1478e..53aafd8 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,4 +1,4 @@ -import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange } from './types.js'; +import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange, SBOMIdentity } from './types.js'; /** * Compare two parsed SBOMs and produce a ChangeReport. @@ -69,6 +69,8 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { fixedCVEs.sort(compareCVEs); return { + from: toIdentity(a), + to: toIdentity(b), added, removed, upgraded, @@ -87,6 +89,17 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { }; } +/** Carry the parsed SBOM's identity fields forward into a diff report. */ +function toIdentity(sbom: SBOM): SBOMIdentity { + return { + format: sbom.format, + specVersion: sbom.specVersion, + name: sbom.name, + version: sbom.version, + generatedAt: sbom.generatedAt, + }; +} + function buildComponentMap(components: Component[]): Map { const map = new Map(); for (const comp of components) { diff --git a/src/reporter.ts b/src/reporter.ts index dac9ec0..9da3086 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -1,4 +1,19 @@ -import type { ChangeReport, CVEEntry, ReportFormat } from './types.js'; +import type { ChangeReport, CVEEntry, ReportFormat, SBOMIdentity } from './types.js'; + +/** + * Render one SBOM's identity as a compact single-line description, e.g. + * "my-app v1.4.2 (cyclonedx 1.4, generated 2026-08-01T00:00:00Z)". Falls back + * to the format alone when the SBOM carries no identity fields. + */ +function describeIdentity(id: SBOMIdentity): string { + const parts: string[] = []; + const name = id.name ? `${id.name}${id.version ? ` v${id.version}` : ''}` : ''; + if (name) parts.push(name); + if (id.specVersion) parts.push(`${id.format} ${id.specVersion}`); + else if (id.format !== 'unknown') parts.push(id.format); + if (id.generatedAt) parts.push(`generated ${id.generatedAt}`); + return parts.length > 0 ? parts.join(' · ') : 'unknown artifact'; +} /** * A short parenthetical noting a vulnerability's VEX analysis state, so a @@ -26,6 +41,11 @@ export function renderReport(report: ChangeReport, format: ReportFormat = 'text' function renderText(r: ChangeReport): string { const lines: string[] = ['SBOM Diff Report', '=================', '']; + lines.push(`Compared:`); + lines.push(` From: ${describeIdentity(r.from)}`); + lines.push(` To: ${describeIdentity(r.to)}`); + lines.push(''); + lines.push(`Summary:`); lines.push(` Added: ${r.summary.totalAdded}`); lines.push(` Removed: ${r.summary.totalRemoved}`); @@ -107,6 +127,13 @@ function renderMarkdown(r: ChangeReport): string { const lines: string[] = [ '# SBOM Diff Report', '', + '## Compared', + '', + `| | Artifact |`, + `|--------|----------|`, + `| From | ${escapeCell(describeIdentity(r.from))} |`, + `| To | ${escapeCell(describeIdentity(r.to))} |`, + '', '## Summary', '', '| Metric | Count |', diff --git a/src/types.ts b/src/types.ts index 12efdb4..20f4641 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,8 +85,32 @@ export interface LicenseChange { to: string; } +/** + * Minimal carried-forward identity of one of the two SBOMs in a diff. Lets the + * report state which artifacts it was produced from (issue #52). + */ +export interface SBOMIdentity { + /** Detected format (cyclonedx / spdx / unknown) */ + format: SBOMFormat; + /** SBOM spec version (e.g. "1.4" for CycloneDX, "SPDX-2.3" for SPDX) */ + specVersion?: string; + /** Name of the software described by the SBOM */ + name?: string; + /** Version of the software described by the SBOM */ + version?: string; + /** When the SBOM was generated */ + generatedAt?: string; +} + /** The full result of diffing two SBOMs */ export interface ChangeReport { + /** + * Identity of the "old" (baseline) SBOM that was diffed. Carried through so + * audit output can state exactly which two artifacts were compared. + */ + from: SBOMIdentity; + /** Identity of the "new" (current) SBOM that was diffed. */ + to: SBOMIdentity; /** Components in B but not in A */ added: Component[]; /** Components in A but not in B */ From 91416d98ef0fd424d0966c015dc6e76c8f0929b6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 7 Aug 2026 12:34:43 -0600 Subject: [PATCH 2/2] feat: detect CVE severity escalations that persist across scans Closes #46 - New SeverityEscalation type; ChangeReport gains severityEscalations and summary.totalSeverityEscalations - diff() flags a CVE present in both SBOMs whose severity rank increased or whose CVSS score rose (e.g. medium -> critical). De-escalations are not flagged. Such CVEs previously fell into neither newCVEs nor fixedCVEs and were invisible. - Text and markdown reporters render a Severity Escalations section; summary table gains the metric - Tests: escalation detection, CVSS-only rise, de-escalation not flagged --- src/__tests__/cli.test.ts | 2 ++ src/__tests__/diff.test.ts | 36 ++++++++++++++++++++++++++++++++++ src/__tests__/reporter.test.ts | 12 ++++++++---- src/diff.ts | 36 +++++++++++++++++++++++++++++++++- src/reporter.ts | 21 ++++++++++++++++++++ src/types.ts | 22 +++++++++++++++++++++ 6 files changed, 124 insertions(+), 5 deletions(-) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index f7e51cf..ebb6ee8 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -85,6 +85,7 @@ describe('gateFailures', () => { licenseChanges: [], newCVEs, fixedCVEs: [], + severityEscalations: [], summary: { totalAdded: 0, totalRemoved: 0, @@ -93,6 +94,7 @@ describe('gateFailures', () => { totalDowngraded: 0, totalNewCVEs: newCVEs.length, totalFixedCVEs: 0, + totalSeverityEscalations: 0, }, }); diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index c33a360..0d3538e 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -238,4 +238,40 @@ describe('diff ordering', () => { const empty = makesbom([]); expect(diff(empty, order1)).toEqual(diff(empty, order2)); }); + + it('detects a CVE whose severity was re-scored between scans (issue #46)', () => { + const a = makesbom([], [ + { id: 'CVE-2021-44228', affects: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', severity: 'medium', cvssScore: 6.0 }, + { id: 'CVE-2023-0001', affects: 'pkg:npm/foo@1.0.0', severity: 'high', cvssScore: 8.0 }, + ]); + const b = makesbom([], [ + { id: 'CVE-2021-44228', affects: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', severity: 'critical', cvssScore: 10.0 }, + { id: 'CVE-2023-0001', affects: 'pkg:npm/foo@1.0.0', severity: 'low', cvssScore: 3.0 }, + ]); + const report = diff(a, b); + // The escalated CVE is in neither newCVEs nor fixedCVEs. + expect(report.newCVEs).toHaveLength(0); + expect(report.fixedCVEs).toHaveLength(0); + // Only the escalation (medium → critical) is flagged; the de-escalation + // (high → low) is not. + expect(report.severityEscalations).toHaveLength(1); + expect(report.severityEscalations[0].cve.id).toBe('CVE-2021-44228'); + expect(report.severityEscalations[0].fromSeverity).toBe('medium'); + expect(report.severityEscalations[0].toSeverity).toBe('critical'); + expect(report.severityEscalations[0].fromScore).toBe(6.0); + expect(report.severityEscalations[0].toScore).toBe(10.0); + expect(report.summary.totalSeverityEscalations).toBe(1); + }); + + it('flags a CVSS score rise even when the severity label is unchanged', () => { + const a = makesbom([], [ + { id: 'CVE-2024-0001', affects: 'pkg:npm/a@1.0.0', severity: 'high', cvssScore: 7.0 }, + ]); + const b = makesbom([], [ + { id: 'CVE-2024-0001', affects: 'pkg:npm/a@1.0.0', severity: 'high', cvssScore: 9.0 }, + ]); + const report = diff(a, b); + expect(report.severityEscalations).toHaveLength(1); + expect(report.severityEscalations[0].toScore).toBe(9.0); + }); }); diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index 587601a..8d235c4 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -11,7 +11,8 @@ const sampleReport: ChangeReport = { licenseChanges: [{ component: { name: 'chalk', version: '5.3.0' }, from: 'MIT', to: 'GPL-3.0' }], newCVEs: [{ id: 'CVE-2023-1234', affects: 'pkg:npm/foo@1.0.0', severity: 'high' }], fixedCVEs: [{ id: 'CVE-2022-9999', affects: 'pkg:npm/bar@0.9.0' }], - summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalLicenseChanges: 1, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 1 }, + severityEscalations: [], + summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalLicenseChanges: 1, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 1, totalSeverityEscalations: 0 }, }; describe('renderReport', () => { @@ -67,7 +68,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed' licenseChanges: [], newCVEs: [{ id: 'CVE-2024-0001', affects: 'pkg:npm/a | b', severity: 'high', description: 'line1\nline2' }], fixedCVEs: [], - summary: { totalAdded: 1, totalRemoved: 0, totalUpgraded: 0, totalLicenseChanges: 0, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 0 }, + severityEscalations: [], + summary: { totalAdded: 1, totalRemoved: 0, totalUpgraded: 0, totalLicenseChanges: 0, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 0, totalSeverityEscalations: 0 }, }; const out = renderReport(report, 'markdown'); @@ -93,7 +95,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed' licenseChanges: [], newCVEs: [], fixedCVEs: [], - summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 2, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0 }, + severityEscalations: [], + summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0, totalSeverityEscalations: 0 }, }; const out = renderReport(report, 'text'); expect(out).toContain('Downgraded: 1'); @@ -116,7 +119,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed' licenseChanges: [], newCVEs: [], fixedCVEs: [], - summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0 }, + severityEscalations: [], + summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0, totalSeverityEscalations: 0 }, }; const out = renderReport(report, 'markdown'); expect(out).toContain('Downgraded Components'); diff --git a/src/diff.ts b/src/diff.ts index 53aafd8..2c816d7 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,4 +1,4 @@ -import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange, SBOMIdentity } from './types.js'; +import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange, SBOMIdentity, SeverityEscalation } from './types.js'; /** * Compare two parsed SBOMs and produce a ChangeReport. @@ -57,6 +57,24 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { const newCVEs = [...bVulns.values()].filter(v => !aVulns.has(v.id)); const fixedCVEs = [...aVulns.values()].filter(v => !bVulns.has(v.id)); + // Severity escalation detection: a CVE present in both SBOMs whose severity + // or CVSS score was re-scored (e.g. medium → critical). Without this bucket + // such CVEs fall into neither newCVEs nor fixedCVEs and are invisible. + const severityEscalations: SeverityEscalation[] = []; + for (const [id, bVuln] of bVulns) { + const aVuln = aVulns.get(id); + if (!aVuln) continue; // already in newCVEs + const fromSev = aVuln.severity; + const toSev = bVuln.severity; + const fromScore = aVuln.cvssScore; + const toScore = bVuln.cvssScore; + // Report the escalation when severity rank increased or CVSS score rose. + // A drop (e.g. critical → high) is a de-escalation and is not flagged. + if (severityRank(fromSev) < severityRank(toSev) || (fromScore !== undefined && toScore !== undefined && toScore > fromScore)) { + severityEscalations.push({ cve: bVuln, fromSeverity: fromSev, toSeverity: toSev, fromScore, toScore }); + } + } + // Order the report deterministically so it is reproducible regardless of the // (arbitrary) order in which the source SBOM listed its components/vulns. // Stable output matters for the headline use cases: committed audit trails and @@ -77,6 +95,7 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { licenseChanges, newCVEs, fixedCVEs, + severityEscalations, summary: { totalAdded: added.length, totalRemoved: removed.length, @@ -85,10 +104,25 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { totalDowngraded: upgraded.filter(u => u.isDowngrade).length, totalNewCVEs: newCVEs.length, totalFixedCVEs: fixedCVEs.length, + totalSeverityEscalations: severityEscalations.length, }, }; } +/** + * Map a severity label to an ordinal rank so we can compare them. + * undefined/none = 0, low = 1, medium = 2, high = 3, critical = 4. + */ +function severityRank(sev: string | undefined): number { + switch (sev) { + case 'critical': return 4; + case 'high': return 3; + case 'medium': return 2; + case 'low': return 1; + default: return 0; + } +} + /** Carry the parsed SBOM's identity fields forward into a diff report. */ function toIdentity(sbom: SBOM): SBOMIdentity { return { diff --git a/src/reporter.ts b/src/reporter.ts index 9da3086..47af4da 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -104,6 +104,18 @@ function renderText(r: ChangeReport): string { lines.push(` \u2713 ${v.id} \u2014 ${v.affects}`); } } + if (r.severityEscalations.length > 0) { + lines.push('\u26a0 Severity Escalations:'); + for (const e of r.severityEscalations) { + const from = e.fromSeverity ?? 'none'; + const to = e.toSeverity ?? 'none'; + const score = e.toScore !== undefined && e.fromScore !== undefined + ? ` (CVSS ${e.fromScore} \u2192 ${e.toScore})` + : ''; + lines.push(` \u26a0 ${e.cve.id} [${from} \u2192 ${to}${score}] \u2014 ${e.cve.affects}`); + } + lines.push(''); + } return lines.join('\n'); } @@ -145,6 +157,7 @@ function renderMarkdown(r: ChangeReport): string { `| License changes | ${r.summary.totalLicenseChanges} |`, `| New CVEs | ${r.summary.totalNewCVEs} |`, `| Fixed CVEs | ${r.summary.totalFixedCVEs} |`, + `| Severity escalations | ${r.summary.totalSeverityEscalations} |`, '', ]; @@ -205,6 +218,14 @@ lines.push('| CVE ID | Severity | CVSS | Affects |'); lines.push('|--------|---------|'); for (const v of r.fixedCVEs) lines.push(`| ${escapeCell(v.id)} | ${escapeCell(v.affects)} |`); } + if (r.severityEscalations.length > 0) { + lines.push('## \u26a0\ufe0f Severity Escalations', ''); + lines.push('| CVE ID | From | To | CVSS | Affects |'); + lines.push('|--------|------|----|------|---------|'); + for (const e of r.severityEscalations) { + lines.push(`| ${escapeCell(e.cve.id)} | ${escapeCell(e.fromSeverity ?? 'none')} | ${escapeCell(e.toSeverity ?? 'none')} | ${escapeCell(e.fromScore !== undefined && e.toScore !== undefined ? `${e.fromScore} \u2192 ${e.toScore}` : undefined)} | ${escapeCell(e.cve.affects)} |`); + } + } return lines.join('\n'); } diff --git a/src/types.ts b/src/types.ts index 20f4641..e1297e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,20 @@ export interface LicenseChange { to: string; } +/** A CVE present in both SBOMs whose severity or CVSS score was re-scored */ +export interface SeverityEscalation { + /** The CVE entry as it now appears in the new SBOM */ + cve: CVEEntry; + /** Severity in the old SBOM (undefined if it had none) */ + fromSeverity?: 'none' | 'low' | 'medium' | 'high' | 'critical'; + /** Severity in the new SBOM (undefined if it had none) */ + toSeverity?: 'none' | 'low' | 'medium' | 'high' | 'critical'; + /** CVSS score in the old SBOM (undefined if it had none) */ + fromScore?: number; + /** CVSS score in the new SBOM (undefined if it had none) */ + toScore?: number; +} + /** * Minimal carried-forward identity of one of the two SBOMs in a diff. Lets the * report state which artifacts it was produced from (issue #52). @@ -123,6 +137,12 @@ export interface ChangeReport { newCVEs: CVEEntry[]; /** Vulnerabilities in A but not in B (fixed) */ fixedCVEs: CVEEntry[]; + /** + * CVEs present in both SBOMs whose severity / CVSS score was re-scored + * between the scans (e.g. medium → critical). Absent from both the + * newCVEs and fixedCVEs buckets, so without this they'd be invisible. + */ + severityEscalations: SeverityEscalation[]; summary: { totalAdded: number; totalRemoved: number; @@ -132,6 +152,8 @@ export interface ChangeReport { totalDowngraded: number; totalNewCVEs: number; totalFixedCVEs: number; + /** Number of re-scored CVEs (issue #46) */ + totalSeverityEscalations: number; }; }