diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f3c2a..d08f8fc 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` — `--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` - `src/parser.ts` — `parse()` / `parseCycloneDX()` / `parseSPDX()`: auto-detect and parse CycloneDX + SPDX JSON SBOMs, extracts purls, ecosystems, licenses, suppliers, CVEs diff --git a/README.md b/README.md index 6fe9bef..a6d817d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,27 @@ npx @hailbytes/sbom-diff old.json new.json --format json # Output as Markdown (great for PR comments) npx @hailbytes/sbom-diff old.json new.json --format markdown + +# Fail the build (exit code 3) if any new high or critical CVE appears +npx @hailbytes/sbom-diff old.json new.json --fail-on high +``` + +### CI/CD gate + +Use `--fail-on` to turn the diff into a pass/fail gate. When the policy is +triggered, the report is still printed and the process exits with code `3`, so +your pipeline stops on risky changes: + +| `--fail-on` | Fails when… | +|-------------|-------------| +| `none` *(default)* | never — always exits `0` | +| `any` | any new CVE is introduced | +| `low` / `medium` / `high` / `critical` | a new CVE appears at or above that severity | + +```yaml +# GitHub Actions example +- name: Gate on new high-severity CVEs + run: npx @hailbytes/sbom-diff sbom.base.json sbom.pr.json --fail-on high ``` ### Programmatic diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 3532b91..23df1e1 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { parseArgs } from '../cli.js'; +import { parseArgs, gateFailures } from '../cli.js'; +import type { ChangeReport, CVEEntry } from '../types.js'; describe('parseArgs', () => { it('defaults to text format when no flag is given', () => { - const { positional, format } = parseArgs(['old.json', 'new.json']); + const { positional, format, failOn } = parseArgs(['old.json', 'new.json']); expect(positional).toEqual(['old.json', 'new.json']); expect(format).toBe('text'); + expect(failOn).toBe('none'); }); it('parses --format=json', () => { @@ -37,4 +39,84 @@ describe('parseArgs', () => { it('throws on an unknown option', () => { expect(() => parseArgs(['old.json', 'new.json', '--bogus'])).toThrow(/Unknown option/); }); + + it('parses --fail-on high', () => { + const { failOn } = parseArgs(['old.json', 'new.json', '--fail-on', 'high']); + expect(failOn).toBe('high'); + }); + + it('parses --fail-on=any', () => { + const { failOn } = parseArgs(['old.json', 'new.json', '--fail-on=any']); + expect(failOn).toBe('any'); + }); + + it('throws on an unsupported --fail-on value', () => { + expect(() => parseArgs(['old.json', 'new.json', '--fail-on=sometimes'])).toThrow( + /Invalid --fail-on/, + ); + }); + + it('throws when --fail-on is given without a value', () => { + expect(() => parseArgs(['old.json', 'new.json', '--fail-on'])).toThrow(/Invalid --fail-on/); + }); +}); + +describe('gateFailures', () => { + const cve = (id: string, severity?: CVEEntry['severity']): CVEEntry => ({ + id, + affects: 'pkg:npm/example', + severity, + }); + + const reportWith = (newCVEs: CVEEntry[]): ChangeReport => ({ + added: [], + removed: [], + upgraded: [], + newCVEs, + fixedCVEs: [], + summary: { + totalAdded: 0, + totalRemoved: 0, + totalUpgraded: 0, + totalNewCVEs: newCVEs.length, + totalFixedCVEs: 0, + }, + }); + + it('never fails under the "none" policy, even with new CVEs', () => { + const report = reportWith([cve('CVE-1', 'critical')]); + expect(gateFailures(report, 'none')).toEqual([]); + }); + + it('fails on any new CVE under the "any" policy', () => { + const report = reportWith([cve('CVE-1', 'low'), cve('CVE-2')]); + expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-1', 'CVE-2']); + }); + + it('passes when there are no new CVEs', () => { + expect(gateFailures(reportWith([]), 'any')).toEqual([]); + expect(gateFailures(reportWith([]), 'critical')).toEqual([]); + }); + + it('fails only on new CVEs at or above the severity threshold', () => { + const report = reportWith([ + cve('CVE-low', 'low'), + cve('CVE-med', 'medium'), + cve('CVE-high', 'high'), + cve('CVE-crit', 'critical'), + ]); + expect(gateFailures(report, 'high').map(v => v.id)).toEqual(['CVE-high', 'CVE-crit']); + expect(gateFailures(report, 'medium').map(v => v.id)).toEqual([ + 'CVE-med', + 'CVE-high', + 'CVE-crit', + ]); + }); + + it('does not trip a severity threshold on unknown-severity CVEs', () => { + const report = reportWith([cve('CVE-unknown')]); + expect(gateFailures(report, 'critical')).toEqual([]); + // ...but "any" still catches them. + expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-unknown']); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 969f741..4af54ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ * @hailbytes/sbom-diff CLI * * Usage: - * npx @hailbytes/sbom-diff [--format text|json|markdown] + * npx @hailbytes/sbom-diff [--format text|json|markdown] [--fail-on ] */ import { readFile } from 'node:fs/promises'; @@ -11,27 +11,53 @@ import { pathToFileURL } from 'node:url'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; -import type { ReportFormat } from './types.js'; +import type { ChangeReport, CVEEntry, ReportFormat } from './types.js'; -const USAGE = 'Usage: sbom-diff [--format text|json|markdown]'; +const USAGE = + 'Usage: sbom-diff [--format text|json|markdown] [--fail-on none|any|low|medium|high|critical]'; const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown']; +/** + * CI/CD gate policy. Determines whether the CLI exits non-zero. + * - `none`: never fail (default; preserves prior behaviour) + * - `any`: fail if any new CVE is introduced, regardless of severity + * - a severity: fail if any new CVE meets or exceeds that severity + */ +export type FailOn = 'none' | 'any' | 'low' | 'medium' | 'high' | 'critical'; +const VALID_FAIL_ON: FailOn[] = ['none', 'any', 'low', 'medium', 'high', 'critical']; + +/** Exit code used when a `--fail-on` gate is triggered (distinct from usage/runtime errors). */ +export const GATE_FAILURE_EXIT_CODE = 3; + +/** Severity ordering, lowest to highest, for threshold comparisons. */ +const SEVERITY_RANK: Record, number> = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4, +}; + export interface ParsedArgs { positional: string[]; format: ReportFormat; + failOn: FailOn; } /** - * Parse CLI arguments into positional paths and the requested output format. + * Parse CLI arguments into positional paths, the requested output format, and + * the CI/CD gate policy. * - * Supports `--format text`, `--format=text`, and flags appearing in any - * position relative to the positional file paths. Defaults to `text`. + * Supports `--format text`, `--format=text`, `--fail-on high`, `--fail-on=high`, + * and flags appearing in any position relative to the positional file paths. + * Defaults to `text` format and a `none` gate policy. * - * @throws if an unknown flag or unsupported format value is supplied. + * @throws if an unknown flag or unsupported flag value is supplied. */ export function parseArgs(argv: string[]): ParsedArgs { const positional: string[] = []; let format: ReportFormat = 'text'; + let failOn: FailOn = 'none'; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -39,6 +65,10 @@ export function parseArgs(argv: string[]): ParsedArgs { format = assertFormat(argv[++i]); } else if (arg.startsWith('--format=')) { format = assertFormat(arg.slice('--format='.length)); + } else if (arg === '--fail-on') { + failOn = assertFailOn(argv[++i]); + } else if (arg.startsWith('--fail-on=')) { + failOn = assertFailOn(arg.slice('--fail-on='.length)); } else if (arg.startsWith('-')) { throw new Error(`Unknown option: ${arg}\n${USAGE}`); } else { @@ -46,7 +76,7 @@ export function parseArgs(argv: string[]): ParsedArgs { } } - return { positional, format }; + return { positional, format, failOn }; } function assertFormat(value: string | undefined): ReportFormat { @@ -58,8 +88,31 @@ function assertFormat(value: string | undefined): ReportFormat { ); } +function assertFailOn(value: string | undefined): FailOn { + if (value !== undefined && (VALID_FAIL_ON as string[]).includes(value)) { + return value as FailOn; + } + throw new Error( + `Invalid --fail-on value: ${value ?? '(none)'}. Expected one of: ${VALID_FAIL_ON.join(', ')}`, + ); +} + +/** + * Evaluate the CI/CD gate against a diff. Returns the new CVEs that trip the + * gate (empty when the gate passes). A new CVE with an unknown severity only + * trips the `any` gate, since it cannot be compared against a severity threshold. + */ +export function gateFailures(report: ChangeReport, failOn: FailOn): CVEEntry[] { + if (failOn === 'none') return []; + if (failOn === 'any') return report.newCVEs; + const threshold = SEVERITY_RANK[failOn]; + return report.newCVEs.filter( + v => v.severity !== undefined && SEVERITY_RANK[v.severity] >= threshold, + ); +} + async function main(): Promise { - const { positional, format } = parseArgs(process.argv.slice(2)); + const { positional, format, failOn } = parseArgs(process.argv.slice(2)); if (positional.length < 2) { console.error(USAGE); @@ -78,6 +131,15 @@ async function main(): Promise { const report = diff(oldSBOM, newSBOM); console.log(renderReport(report, format)); + + const failures = gateFailures(report, failOn); + if (failures.length > 0) { + const label = failOn === 'any' ? 'new CVE(s)' : `new CVE(s) at or above "${failOn}" severity`; + console.error( + `\nGate failed: ${failures.length} ${label}: ${failures.map(v => v.id).join(', ')}`, + ); + process.exit(GATE_FAILURE_EXIT_CODE); + } } // Only run when invoked directly (not when imported by tests).