Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,15 @@ describe('gateFailures', () => {
});

const reportWith = (newCVEs: CVEEntry[]): ChangeReport => ({
from: { format: 'cyclonedx', specVersion: '1.4' },
to: { format: 'cyclonedx', specVersion: '1.4' },
added: [],
removed: [],
upgraded: [],
licenseChanges: [],
newCVEs,
fixedCVEs: [],
severityEscalations: [],
summary: {
totalAdded: 0,
totalRemoved: 0,
Expand All @@ -91,6 +94,7 @@ describe('gateFailures', () => {
totalDowngraded: 0,
totalNewCVEs: newCVEs.length,
totalFixedCVEs: 0,
totalSeverityEscalations: 0,
},
});

Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
32 changes: 28 additions & 4 deletions src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ 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 }],
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', () => {
Expand Down Expand Up @@ -39,19 +42,34 @@ 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: [],
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');

Expand All @@ -66,6 +84,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: [
Expand All @@ -75,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');
Expand All @@ -88,6 +109,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: [
Expand All @@ -96,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');
Expand Down
49 changes: 48 additions & 1 deletion src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange } from './types.js';
import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange, SBOMIdentity, SeverityEscalation } from './types.js';

/**
* Compare two parsed SBOMs and produce a ChangeReport.
Expand Down Expand Up @@ -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
Expand All @@ -69,12 +87,15 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
fixedCVEs.sort(compareCVEs);

return {
from: toIdentity(a),
to: toIdentity(b),
added,
removed,
upgraded,
licenseChanges,
newCVEs,
fixedCVEs,
severityEscalations,
summary: {
totalAdded: added.length,
totalRemoved: removed.length,
Expand All @@ -83,10 +104,36 @@ 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 {
format: sbom.format,
specVersion: sbom.specVersion,
name: sbom.name,
version: sbom.version,
generatedAt: sbom.generatedAt,
};
}

function buildComponentMap(components: Component[]): Map<string, Component> {
const map = new Map<string, Component>();
for (const comp of components) {
Expand Down
50 changes: 49 additions & 1 deletion src/reporter.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -84,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');
}
Expand All @@ -107,6 +139,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 |',
Expand All @@ -118,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} |`,
'',
];

Expand Down Expand Up @@ -178,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');
}
46 changes: 46 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,46 @@ 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).
*/
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 */
Expand All @@ -99,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;
Expand All @@ -108,6 +152,8 @@ export interface ChangeReport {
totalDowngraded: number;
totalNewCVEs: number;
totalFixedCVEs: number;
/** Number of re-scored CVEs (issue #46) */
totalSeverityEscalations: number;
};
}

Expand Down
Loading