diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62d9649..a7e0eee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,10 @@ jobs: strategy: fail-fast: false matrix: - # Cover the engines.node range declared in package.json (>=18): - # the supported minimum, current LTS, and latest LTS. - node-version: ['18', '20', '22'] + # Cover the engines.node range declared in package.json (>=20.19): + # current LTS and latest LTS. Node 18 is EOL for this stack — the + # eslint 10 toolchain requires ^20.19.0 || ^22.13.0 || >=24. + node-version: ['20', '22'] name: ci (Node ${{ matrix.node-version }}) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/package-lock.json b/package-lock.json index 1614408..a082116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,12 @@ "@vitest/coverage-v8": "^4.1.6", "eslint": "^10.8.0", "globals": "^17.6.0", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vitest": "^4.1.6" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" } }, "node_modules/@babel/helper-string-parser": { @@ -899,346 +899,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", @@ -2769,38 +2429,17 @@ } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "node": ">=14.17" } }, "node_modules/typescript-eslint": { diff --git a/package.json b/package.json index ca8f0bb..e41aba1 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@types/node": "^26.1.2", "@vitest/coverage-v8": "^4.1.6", "globals": "^17.6.0", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vitest": "^4.1.6", "eslint": "^10.8.0" @@ -65,6 +65,6 @@ "provenance": true }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0" } } diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 82e50e2..ebb6ee8 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -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, @@ -91,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 555187a..8d235c4 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -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', () => { @@ -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'); @@ -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: [ @@ -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'); @@ -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: [ @@ -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'); diff --git a/src/diff.ts b/src/diff.ts index ed1478e..2c816d7 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, 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 @@ -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, @@ -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 { const map = new Map(); for (const comp of components) { diff --git a/src/reporter.ts b/src/reporter.ts index dac9ec0..47af4da 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}`); @@ -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'); } @@ -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 |', @@ -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} |`, '', ]; @@ -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'); } diff --git a/src/types.ts b/src/types.ts index 12efdb4..e1297e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 */ @@ -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; @@ -108,6 +152,8 @@ export interface ChangeReport { totalDowngraded: number; totalNewCVEs: number; totalFixedCVEs: number; + /** Number of re-scored CVEs (issue #46) */ + totalSeverityEscalations: number; }; }