Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
43 changes: 41 additions & 2 deletions src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
41 changes: 40 additions & 1 deletion src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Expand Down Expand Up @@ -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,
},
Expand All @@ -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;
}
34 changes: 28 additions & 6 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -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) {
Expand Down Expand Up @@ -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} |`,
'',
Expand All @@ -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 |');
Expand Down
8 changes: 6 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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;
};
Expand Down
Loading