diff --git a/CHANGELOG.md b/CHANGELOG.md index d08f8fc..0f13e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- `src/cli.ts` — `gateWarning()`: when `--fail-on` is armed but neither compared SBOM contains vulnerability data, the CLI now prints a warning to `stderr` explaining the CVE gate has nothing to evaluate and will pass. Guards against a silent fail-open (SPDX 2.x has no vulnerability field; default CycloneDX output omits one), so a green gate is never mistaken for "no new CVEs". - `src/cli.ts` — `--fail-on none|any|low|medium|high|critical` flag: turns the diff into a CI/CD gate that exits with code `3` when new CVEs meet the chosen severity policy (default `none` preserves prior always-exit-`0` behaviour) - Real devDependencies: `typescript`, `vitest`, `@vitest/coverage-v8`, `typescript-eslint`, `@types/node` - `src/types.ts` — Full domain model: `SBOM`, `Component`, `CVEEntry`, `ChangeReport`, `VersionChange`, `SBOMFormat`, `ReportFormat` diff --git a/README.md b/README.md index a6d817d..7fc5f91 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,15 @@ your pipeline stops on risky changes: run: npx @hailbytes/sbom-diff sbom.base.json sbom.pr.json --fail-on high ``` +> **Note:** the gate can only see vulnerabilities that are **embedded in the +> SBOMs** you compare. SPDX 2.x has no vulnerability field, and the default +> output of common CycloneDX generators omits one, so `--fail-on` has nothing to +> evaluate for those inputs and will pass. When a gate is armed but neither SBOM +> carries vulnerability data, the CLI prints a warning to `stderr` so a green +> result is never mistaken for "no new CVEs". To enable CVE gating, feed SBOMs +> that include a CycloneDX 1.4+ `vulnerabilities` list (e.g. from a scan/VEX +> step). + ### Programmatic ```ts import { readFile } from 'node:fs/promises'; diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 23df1e1..7bbd677 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { parseArgs, gateFailures } from '../cli.js'; -import type { ChangeReport, CVEEntry } from '../types.js'; +import { parseArgs, gateFailures, gateWarning } from '../cli.js'; +import type { ChangeReport, CVEEntry, SBOM } from '../types.js'; describe('parseArgs', () => { it('defaults to text format when no flag is given', () => { @@ -120,3 +120,41 @@ describe('gateFailures', () => { expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-unknown']); }); }); + +describe('gateWarning', () => { + const sbom = (vulnerabilities: SBOM['vulnerabilities'] = []): SBOM => ({ + format: 'cyclonedx', + components: [], + vulnerabilities, + }); + const withCve: SBOM = sbom([{ id: 'CVE-1', affects: 'pkg:npm/example', severity: 'high' }]); + + it('returns null when the gate is off, even without vulnerability data', () => { + expect(gateWarning(sbom(), sbom(), 'none')).toBeNull(); + }); + + it('warns when a gate is armed but neither SBOM carries vulnerability data', () => { + const warning = gateWarning(sbom(), sbom(), 'high'); + expect(warning).toMatch(/--fail-on "high"/); + expect(warning).toMatch(/neither SBOM contains vulnerability data/); + }); + + it('warns for the "any" policy too', () => { + expect(gateWarning(sbom(), sbom(), 'any')).toMatch(/neither SBOM contains vulnerability data/); + }); + + it('stays silent when the old SBOM carries vulnerability data', () => { + expect(gateWarning(withCve, sbom(), 'critical')).toBeNull(); + }); + + it('stays silent when the new SBOM carries vulnerability data', () => { + expect(gateWarning(sbom(), withCve, 'critical')).toBeNull(); + }); + + it('treats a missing vulnerabilities field as no data', () => { + const noField: SBOM = { format: 'spdx', components: [] }; + expect(gateWarning(noField, noField, 'medium')).toMatch( + /neither SBOM contains vulnerability data/, + ); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 4af54ba..8f1f27a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { pathToFileURL } from 'node:url'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; -import type { ChangeReport, CVEEntry, ReportFormat } from './types.js'; +import type { ChangeReport, CVEEntry, ReportFormat, SBOM } from './types.js'; const USAGE = 'Usage: sbom-diff [--format text|json|markdown] [--fail-on none|any|low|medium|high|critical]'; @@ -111,6 +111,34 @@ export function gateFailures(report: ChangeReport, failOn: FailOn): CVEEntry[] { ); } +/** + * Guard against a silently fail-open CVE gate. + * + * `--fail-on` can only trip on vulnerabilities that are *embedded in the SBOMs* + * being compared (`diff()` derives `newCVEs` from each SBOM's `vulnerabilities` + * list). Most SBOMs carry no such data: SPDX 2.x has no vulnerability field at + * all, and the default output of common CycloneDX generators omits it — + * vulnerabilities are usually attached by a separate scan/VEX step. When a gate + * is armed but neither input carries vulnerability data, the gate has nothing to + * evaluate and always passes, which in CI reads as "no new CVEs" when the truth + * is "CVEs were never checked". + * + * Returns a human-readable warning for that fail-open case, or `null` when the + * gate is off (`none`) or at least one SBOM actually carries vulnerability data. + */ +export function gateWarning(oldSBOM: SBOM, newSBOM: SBOM, failOn: FailOn): string | null { + if (failOn === 'none') return null; + const hasVulnData = + (oldSBOM.vulnerabilities?.length ?? 0) > 0 || (newSBOM.vulnerabilities?.length ?? 0) > 0; + if (hasVulnData) return null; + return ( + `Warning: --fail-on "${failOn}" is set, but neither SBOM contains vulnerability data, ` + + 'so the CVE gate has nothing to evaluate and will pass. Most SBOMs do not embed ' + + 'vulnerabilities (all SPDX 2.x, and the default output of common CycloneDX generators); ' + + 'attach a scan/VEX step that emits a CycloneDX 1.4+ "vulnerabilities" list to enable CVE gating.' + ); +} + async function main(): Promise { const { positional, format, failOn } = parseArgs(process.argv.slice(2)); @@ -132,6 +160,9 @@ async function main(): Promise { console.log(renderReport(report, format)); + const warning = gateWarning(oldSBOM, newSBOM, failOn); + if (warning) console.error(warning); + const failures = gateFailures(report, failOn); if (failures.length > 0) { const label = failOn === 'any' ? 'new CVE(s)' : `new CVE(s) at or above "${failOn}" severity`;