diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f3c2a..df29805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- Downgrade detection: `diff()` now flags a version change as a rollback via the new + `VersionChange.isDowngrade` field and a `summary.totalDowngraded` count. The text and + Markdown reports render downgrades in a dedicated "Downgraded Components" section so a + dependency moving backwards (e.g. to a yanked or CVE-affected version) is no longer + silently reported as an "upgrade". A downgrade is never reported as a major bump. - Real devDependencies: `typescript`, `vitest`, `@vitest/coverage-v8`, `typescript-eslint`, `@types/node` - `src/types.ts` — Full domain model: `SBOM`, `Component`, `CVEEntry`, `ChangeReport`, `VersionChange`, `SBOMFormat`, `ReportFormat` - `src/parser.ts` — `parse()` / `parseCycloneDX()` / `parseSPDX()`: auto-detect and parse CycloneDX + SPDX JSON SBOMs, extracts purls, ecosystems, licenses, suppliers, CVEs diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..a569296 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -69,6 +69,32 @@ describe('diff', () => { expect(report.upgraded[0].isMajorBump).toBe(true); }); + it('flags a version rollback as a downgrade, not an upgrade', () => { + const a = makesbom([{ name: 'left-pad', version: '1.3.0' }]); + const b = makesbom([{ name: 'left-pad', version: '1.1.0' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].isDowngrade).toBe(true); + expect(report.upgraded[0].isMajorBump).toBe(false); + expect(report.summary.totalDowngraded).toBe(1); + }); + + it('does not flag a forward upgrade as a downgrade', () => { + const a = makesbom([{ name: 'lodash', version: '4.17.20' }]); + const b = makesbom([{ name: 'lodash', version: '4.17.21' }]); + const report = diff(a, b); + expect(report.upgraded[0].isDowngrade).toBe(false); + expect(report.summary.totalDowngraded).toBe(0); + }); + + it('never reports a downgrade as a major bump, even across major versions', () => { + const a = makesbom([{ name: 'react', version: '18.2.0' }]); + const b = makesbom([{ name: 'react', version: '17.0.2' }]); + const report = diff(a, b); + expect(report.upgraded[0].isDowngrade).toBe(true); + expect(report.upgraded[0].isMajorBump).toBe(false); + }); + it('detects new CVEs', () => { const cve = { id: 'CVE-2021-44228', affects: 'pkg:npm/log4j@2.14.1', severity: 'critical' as const }; const a = makesbom([]); diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index 7cb1553..861332d 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -5,10 +5,10 @@ import type { ChangeReport } from '../types.js'; const sampleReport: ChangeReport = { 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 }], + upgraded: [{ component: { name: 'lodash', version: '4.17.21' }, from: '4.17.20', to: '4.17.21', isMajorBump: false, isDowngrade: false }], 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, totalNewCVEs: 1, totalFixedCVEs: 1 }, + summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 1 }, }; describe('renderReport', () => { @@ -37,4 +37,43 @@ describe('renderReport', () => { it('throws on unsupported format', () => { expect(() => renderReport(sampleReport, 'xml' as never)).toThrow(); }); + + it('separates downgrades from upgrades in text output', () => { + const report: ChangeReport = { + added: [], + removed: [], + upgraded: [ + { component: { name: 'lodash', version: '4.17.21' }, from: '4.17.20', to: '4.17.21', isMajorBump: false, isDowngrade: false }, + { component: { name: 'left-pad', version: '1.1.0' }, from: '1.3.0', to: '1.1.0', isMajorBump: false, isDowngrade: true }, + ], + newCVEs: [], + fixedCVEs: [], + summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 2, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0 }, + }; + const out = renderReport(report, 'text'); + expect(out).toContain('Downgraded: 1'); + expect(out).toContain('Downgraded Components:'); + expect(out).toContain('left-pad: 1.3.0 → 1.1.0 [DOWNGRADE]'); + // The downgraded component must not appear in the Upgraded section. + const upgradedSection = out.slice(out.indexOf('Upgraded Components:'), out.indexOf('Downgraded Components:')); + expect(upgradedSection).not.toContain('left-pad'); + }); + + it('renders a downgrades table in markdown output', () => { + const report: ChangeReport = { + added: [], + removed: [], + upgraded: [ + { component: { name: 'left-pad', version: '1.1.0' }, from: '1.3.0', to: '1.1.0', isMajorBump: false, isDowngrade: true }, + ], + newCVEs: [], + fixedCVEs: [], + summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0 }, + }; + const out = renderReport(report, 'markdown'); + expect(out).toContain('Downgraded Components'); + expect(out).toContain('| left-pad | 1.3.0 | 1.1.0 |'); + expect(out).toContain('| Downgraded components | 1 |'); + expect(out).not.toContain('## ⬆️ Upgraded Components'); + }); }); diff --git a/src/diff.ts b/src/diff.ts index 6540b50..2faf271 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -21,11 +21,14 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { if (!aComp) { added.push(bComp); } else if (aComp.version !== bComp.version && aComp.version && bComp.version) { + const isDowngrade = compareVersions(bComp.version, aComp.version) < 0; upgraded.push({ component: bComp, from: aComp.version, to: bComp.version, - isMajorBump: isMajorVersionBump(aComp.version, bComp.version), + // A major bump only makes sense for forward moves; a rollback is never one. + isMajorBump: !isDowngrade && isMajorVersionBump(aComp.version, bComp.version), + isDowngrade, }); } } @@ -54,6 +57,7 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { totalAdded: added.length, totalRemoved: removed.length, totalUpgraded: upgraded.length, + totalDowngraded: upgraded.filter(u => u.isDowngrade).length, totalNewCVEs: newCVEs.length, totalFixedCVEs: fixedCVEs.length, }, @@ -80,3 +84,38 @@ function isMajorVersionBump(from: string, to: string): boolean { if (isNaN(fromMajor) || isNaN(toMajor)) return false; return toMajor > fromMajor; } + +/** + * Compare two version strings segment-by-segment. + * + * Returns a negative number if `a < b`, a positive number if `a > b`, and 0 if + * they compare equal. Any leading non-numeric prefix (e.g. a `v`) is stripped, + * then the version is split on `.`, `-`, and `+` and compared segment-by-segment: + * numeric segments numerically, anything else lexicographically. A missing + * segment counts as `0`, so `1.2` sorts below `1.2.1`. + * + * This is intentionally a lightweight comparator (no semver dependency) — enough + * to tell whether a dependency moved forward or backward, which is all the diff + * needs to flag a rollback. + */ +function compareVersions(a: string, b: string): number { + const segments = (v: string): string[] => v.replace(/^[^0-9]*/, '').split(/[.+-]/); + const as = segments(a); + const bs = segments(b); + const len = Math.max(as.length, bs.length); + + for (let i = 0; i < len; i++) { + const aSeg = as[i] ?? '0'; + const bSeg = bs[i] ?? '0'; + const aNum = Number(aSeg); + const bNum = Number(bSeg); + const bothNumeric = aSeg !== '' && bSeg !== '' && !isNaN(aNum) && !isNaN(bNum); + + if (bothNumeric) { + if (aNum !== bNum) return aNum - bNum; + } else if (aSeg !== bSeg) { + return aSeg < bSeg ? -1 : 1; + } + } + return 0; +} diff --git a/src/reporter.ts b/src/reporter.ts index 973b21d..a2a1235 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -20,7 +20,8 @@ function renderText(r: ChangeReport): string { lines.push(`Summary:`); lines.push(` Added: ${r.summary.totalAdded}`); lines.push(` Removed: ${r.summary.totalRemoved}`); - lines.push(` Upgraded: ${r.summary.totalUpgraded}`); + lines.push(` Upgraded: ${r.summary.totalUpgraded - r.summary.totalDowngraded}`); + lines.push(` Downgraded: ${r.summary.totalDowngraded}`); lines.push(` New CVEs: ${r.summary.totalNewCVEs}`); lines.push(` Fixed CVEs: ${r.summary.totalFixedCVEs}`); lines.push(''); @@ -35,14 +36,23 @@ function renderText(r: ChangeReport): string { for (const c of r.removed) lines.push(` - ${c.name}@${c.version ?? 'unknown'}`); lines.push(''); } - if (r.upgraded.length > 0) { + const upgrades = r.upgraded.filter(u => !u.isDowngrade); + const downgrades = r.upgraded.filter(u => u.isDowngrade); + if (upgrades.length > 0) { lines.push('\u2191 Upgraded Components:'); - for (const u of r.upgraded) { + for (const u of upgrades) { const major = u.isMajorBump ? ' [MAJOR]' : ''; lines.push(` ~ ${u.component.name}: ${u.from} \u2192 ${u.to}${major}`); } lines.push(''); } + if (downgrades.length > 0) { + lines.push('\u2193 Downgraded Components:'); + for (const u of downgrades) { + lines.push(` ~ ${u.component.name}: ${u.from} \u2192 ${u.to} [DOWNGRADE]`); + } + lines.push(''); + } if (r.newCVEs.length > 0) { lines.push('\u26a0 New CVEs:'); for (const v of r.newCVEs) { @@ -70,7 +80,8 @@ function renderMarkdown(r: ChangeReport): string { '|--------|-------|', `| Added components | ${r.summary.totalAdded} |`, `| Removed components | ${r.summary.totalRemoved} |`, - `| Upgraded components | ${r.summary.totalUpgraded} |`, + `| Upgraded components | ${r.summary.totalUpgraded - r.summary.totalDowngraded} |`, + `| Downgraded components | ${r.summary.totalDowngraded} |`, `| New CVEs | ${r.summary.totalNewCVEs} |`, `| Fixed CVEs | ${r.summary.totalFixedCVEs} |`, '', @@ -90,15 +101,26 @@ function renderMarkdown(r: ChangeReport): string { for (const c of r.removed) lines.push(`| ${c.name} | ${c.version ?? '\u2014'} |`); lines.push(''); } - if (r.upgraded.length > 0) { + const mdUpgrades = r.upgraded.filter(u => !u.isDowngrade); + const mdDowngrades = r.upgraded.filter(u => u.isDowngrade); + if (mdUpgrades.length > 0) { lines.push('## \u2b06\ufe0f Upgraded Components', ''); lines.push('| Name | From | To | Major? |'); lines.push('|------|------|----|--------|'); - for (const u of r.upgraded) { + for (const u of mdUpgrades) { lines.push(`| ${u.component.name} | ${u.from} | ${u.to} | ${u.isMajorBump ? '\u26a0\ufe0f Yes' : 'No'} |`); } lines.push(''); } + if (mdDowngrades.length > 0) { + lines.push('## \u2b07\ufe0f Downgraded Components', ''); + lines.push('| Name | From | To |'); + lines.push('|------|------|----|'); + for (const u of mdDowngrades) { + lines.push(`| ${u.component.name} | ${u.from} | ${u.to} |`); + } + lines.push(''); + } if (r.newCVEs.length > 0) { lines.push('## \ud83d\udea8 New CVEs', ''); lines.push('| CVE ID | Severity | Affects |'); diff --git a/src/types.ts b/src/types.ts index ede0ac5..11ad926 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,13 +57,15 @@ export interface SBOM { vulnerabilities?: CVEEntry[]; } -/** Version change details for an upgraded component */ +/** Version change details for a component whose version changed */ export interface VersionChange { component: Component; from: string; to: string; - /** true if semver major bumped */ + /** true if the semver major version bumped (only meaningful for upgrades) */ isMajorBump: boolean; + /** true if the new version is lower than the old one (a rollback / downgrade) */ + isDowngrade: boolean; } /** The full result of diffing two SBOMs */ @@ -82,6 +84,8 @@ export interface ChangeReport { totalAdded: number; totalRemoved: number; totalUpgraded: number; + /** Subset of `upgraded` whose version moved backwards */ + totalDowngraded: number; totalNewCVEs: number; totalFixedCVEs: number; };