diff --git a/README.md b/README.md index 837611a..1382cf8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,27 @@ npx @hailbytes/sbom-diff old.json new.json --format json npx @hailbytes/sbom-diff old.json new.json --format markdown ``` +### CI/CD gate + +Use `--fail-on` to exit with a non-zero status when the diff matches one or +more conditions — turning the diff into a build gate. Conditions are +comma-separated: + +| Condition | Fails when… | +|-----------|-------------| +| `any` | any component is added, removed, or upgraded, or any new CVE appears | +| `added` / `removed` / `upgraded` | any component is added / removed / upgraded | +| `major` | any major version bump occurs | +| `new-cves` | any new CVE is introduced | +| `low` / `medium` / `high` / `critical` | any new CVE at or above that severity | + +```bash +# Fail the pipeline if a new critical CVE or a major version bump appears +npx @hailbytes/sbom-diff old.json new.json --fail-on critical,major +``` + +Exit codes: `0` = no gated changes, `1` = gate triggered (or invalid input). + ### Programmatic ```ts import { diff } from '@hailbytes/sbom-diff'; @@ -54,6 +75,16 @@ console.log(report.upgraded); // { from: Component, to: Component }[] console.log(report.newCVEs); // CVE[] — vulnerabilities in new packages ``` +```ts +import { parse, diff, evaluateGate } from '@hailbytes/sbom-diff'; + +const report = diff(parse(oldJSON), parse(newJSON)); +const gate = evaluateGate(report, ['critical', 'major']); +if (gate.shouldFail) { + throw new Error(`SBOM gate failed: ${gate.reasons.join('; ')}`); +} +``` + --- ## Who Is This For diff --git a/src/__tests__/gate.test.ts b/src/__tests__/gate.test.ts new file mode 100644 index 0000000..5630038 --- /dev/null +++ b/src/__tests__/gate.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateGate, parseGateConditions, ALL_GATE_CONDITIONS } from '../gate.js'; +import type { ChangeReport, CVEEntry } from '../types.js'; + +const emptyReport = (): ChangeReport => ({ + added: [], + removed: [], + upgraded: [], + newCVEs: [], + fixedCVEs: [], + summary: { + totalAdded: 0, + totalRemoved: 0, + totalUpgraded: 0, + totalNewCVEs: 0, + totalFixedCVEs: 0, + }, +}); + +const cve = (id: string, severity: CVEEntry['severity']): CVEEntry => ({ + id, + affects: 'pkg:npm/example@1.0.0', + severity, +}); + +describe('parseGateConditions', () => { + it('parses a comma-separated list', () => { + expect(parseGateConditions('critical,major')).toEqual(['critical', 'major']); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(parseGateConditions(' New-CVEs , HIGH ')).toEqual(['new-cves', 'high']); + }); + + it('ignores empty tokens', () => { + expect(parseGateConditions('any,,')).toEqual(['any']); + }); + + it('throws on unknown conditions', () => { + expect(() => parseGateConditions('bogus')).toThrow(/Unknown --fail-on condition/); + }); + + it('accepts every documented condition', () => { + expect(parseGateConditions(ALL_GATE_CONDITIONS.join(','))).toEqual(ALL_GATE_CONDITIONS); + }); +}); + +describe('evaluateGate', () => { + it('does not fail an empty report', () => { + const result = evaluateGate(emptyReport(), ['any', 'critical', 'major', 'new-cves']); + expect(result.shouldFail).toBe(false); + expect(result.reasons).toEqual([]); + }); + + it('fails on "any" when there are changes', () => { + const r = emptyReport(); + r.summary.totalAdded = 2; + const result = evaluateGate(r, ['any']); + expect(result.shouldFail).toBe(true); + expect(result.reasons[0]).toMatch(/2 change/); + }); + + it('fails on added/removed/upgraded counts', () => { + const r = emptyReport(); + r.summary.totalRemoved = 1; + expect(evaluateGate(r, ['added']).shouldFail).toBe(false); + expect(evaluateGate(r, ['removed']).shouldFail).toBe(true); + }); + + it('fails on major bumps only when a major bump exists', () => { + const r = emptyReport(); + r.upgraded = [ + { component: { name: 'a' }, from: '1.0.0', to: '1.2.0', isMajorBump: false }, + ]; + r.summary.totalUpgraded = 1; + expect(evaluateGate(r, ['major']).shouldFail).toBe(false); + expect(evaluateGate(r, ['upgraded']).shouldFail).toBe(true); + + r.upgraded.push({ component: { name: 'b' }, from: '1.0.0', to: '2.0.0', isMajorBump: true }); + r.summary.totalUpgraded = 2; + expect(evaluateGate(r, ['major']).shouldFail).toBe(true); + }); + + it('fails on new CVEs', () => { + const r = emptyReport(); + r.newCVEs = [cve('CVE-1', 'low')]; + r.summary.totalNewCVEs = 1; + expect(evaluateGate(r, ['new-cves']).shouldFail).toBe(true); + }); + + it('applies severity thresholds (at or above)', () => { + const r = emptyReport(); + r.newCVEs = [cve('CVE-low', 'low'), cve('CVE-high', 'high')]; + r.summary.totalNewCVEs = 2; + + expect(evaluateGate(r, ['critical']).shouldFail).toBe(false); + expect(evaluateGate(r, ['high']).shouldFail).toBe(true); + expect(evaluateGate(r, ['medium']).shouldFail).toBe(true); + expect(evaluateGate(r, ['low']).shouldFail).toBe(true); + }); + + it('ignores CVEs with unknown severity for severity thresholds', () => { + const r = emptyReport(); + r.newCVEs = [cve('CVE-x', undefined)]; + r.summary.totalNewCVEs = 1; + expect(evaluateGate(r, ['low']).shouldFail).toBe(false); + // ...but still counts under the generic new-cves condition + expect(evaluateGate(r, ['new-cves']).shouldFail).toBe(true); + }); + + it('reports a reason for each triggered condition', () => { + const r = emptyReport(); + r.summary.totalAdded = 1; + r.newCVEs = [cve('CVE-1', 'critical')]; + r.summary.totalNewCVEs = 1; + const result = evaluateGate(r, ['added', 'critical']); + expect(result.shouldFail).toBe(true); + expect(result.reasons).toHaveLength(2); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 986eb1a..3848504 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,28 +3,111 @@ * @hailbytes/sbom-diff CLI * * Usage: - * npx @hailbytes/sbom-diff [--format text|json|markdown] + * npx @hailbytes/sbom-diff [options] */ import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; import { parse } from './parser.js'; import { diff } from './diff.js'; import { renderReport } from './reporter.js'; +import { parseGateConditions, evaluateGate, ALL_GATE_CONDITIONS } from './gate.js'; import type { ReportFormat } from './types.js'; +const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown']; + +const HELP = `sbom-diff — diff two CycloneDX or SPDX SBOMs + +Usage: + sbom-diff [options] + +Options: + --format Output format: text | json | markdown (default: text) + --fail-on Exit non-zero when the diff matches the given, + comma-separated condition(s). Use as a CI/CD gate. + Conditions: ${ALL_GATE_CONDITIONS.join(', ')} + -h, --help Show this help and exit + -v, --version Show version and exit + +Examples: + sbom-diff old.json new.json + sbom-diff old.json new.json --format markdown + sbom-diff old.json new.json --fail-on critical,major +`; + +/** Flags that consume the following token as their value (space-separated form). */ +const VALUE_FLAGS = new Set(['--format', '--fail-on']); + +function getFlag(args: string[], name: string): string | undefined { + const eq = args.find(a => a.startsWith(`${name}=`)); + if (eq) return eq.slice(name.length + 1); + const idx = args.indexOf(name); + if (idx !== -1 && idx + 1 < args.length) return args[idx + 1]; + return undefined; +} + +/** Collect positional arguments, skipping flags and their consumed values. */ +function getPositionals(args: string[]): string[] { + const positionals: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('-')) { + // Space-separated value flag (e.g. "--format markdown") consumes next token. + if (VALUE_FLAGS.has(arg) && i + 1 < args.length) i++; + continue; + } + positionals.push(arg); + } + return positionals; +} + +async function readVersion(): Promise { + try { + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(await readFile(join(here, '..', 'package.json'), 'utf-8')); + return typeof pkg.version === 'string' ? pkg.version : 'unknown'; + } catch { + return 'unknown'; + } +} + async function main(): Promise { const args = process.argv.slice(2); - const positional = args.filter(a => !a.startsWith('--')); + if (args.includes('-h') || args.includes('--help')) { + console.log(HELP); + return; + } + if (args.includes('-v') || args.includes('--version')) { + console.log(await readVersion()); + return; + } + + const positional = getPositionals(args); if (positional.length < 2) { - console.error('Usage: sbom-diff [--format text|json|markdown]'); + console.error('Usage: sbom-diff [--format text|json|markdown] [--fail-on ]'); + console.error('Run "sbom-diff --help" for details.'); process.exit(1); } const [oldPath, newPath] = positional; - const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1] - ?? args[args.indexOf('--format') + 1]; - const format: ReportFormat = (formatArg as ReportFormat) ?? 'text'; + + const format = (getFlag(args, '--format') ?? 'text') as ReportFormat; + if (!VALID_FORMATS.includes(format)) { + console.error(`Error: unknown --format "${format}". Valid values: ${VALID_FORMATS.join(', ')}.`); + process.exit(1); + } + + const failOnRaw = getFlag(args, '--fail-on'); + let gateConditions; + try { + gateConditions = failOnRaw != null ? parseGateConditions(failOnRaw) : []; + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + return; + } const [oldRaw, newRaw] = await Promise.all([ readFile(oldPath, 'utf-8'), @@ -36,6 +119,14 @@ async function main(): Promise { const report = diff(oldSBOM, newSBOM); console.log(renderReport(report, format)); + + if (gateConditions.length > 0) { + const gate = evaluateGate(report, gateConditions); + if (gate.shouldFail) { + console.error(`\nGate failed (--fail-on ${failOnRaw}): ${gate.reasons.join('; ')}.`); + process.exit(1); + } + } } main().catch(err => { diff --git a/src/gate.ts b/src/gate.ts new file mode 100644 index 0000000..9b510e4 --- /dev/null +++ b/src/gate.ts @@ -0,0 +1,141 @@ +import type { ChangeReport, CVEEntry } from './types.js'; + +/** + * A condition that, when met by a {@link ChangeReport}, should cause the CLI + * to exit with a non-zero status — enabling use as a CI/CD gate. + * + * - `any` — any added, removed, upgraded component or new CVE + * - `added` — any added component + * - `removed` — any removed component + * - `upgraded` — any version upgrade + * - `major` — any major version bump + * - `new-cves` — any newly introduced CVE + * - `low` | `medium` | `high` | `critical` + * — any new CVE at or above the given severity + */ +export type GateCondition = + | 'any' + | 'added' + | 'removed' + | 'upgraded' + | 'major' + | 'new-cves' + | 'low' + | 'medium' + | 'high' + | 'critical'; + +const SEVERITY_CONDITIONS: GateCondition[] = ['low', 'medium', 'high', 'critical']; + +const SEVERITY_RANK: Record, number> = { + none: 0, + low: 1, + medium: 2, + high: 3, + critical: 4, +}; + +export const ALL_GATE_CONDITIONS: GateCondition[] = [ + 'any', + 'added', + 'removed', + 'upgraded', + 'major', + 'new-cves', + ...SEVERITY_CONDITIONS, +]; + +/** The result of evaluating a set of gate conditions against a report. */ +export interface GateResult { + /** Whether the CLI should exit with a non-zero status. */ + shouldFail: boolean; + /** Human-readable reasons each triggered condition fired. */ + reasons: string[]; +} + +/** + * Parse a comma-separated `--fail-on` value into validated conditions. + * + * @throws if any token is not a recognized {@link GateCondition}. + */ +export function parseGateConditions(value: string): GateCondition[] { + const tokens = value + .split(',') + .map(t => t.trim().toLowerCase()) + .filter(t => t.length > 0); + + const valid = new Set(ALL_GATE_CONDITIONS); + const conditions: GateCondition[] = []; + for (const token of tokens) { + if (!valid.has(token)) { + throw new Error( + `Unknown --fail-on condition: "${token}". ` + + `Valid values: ${ALL_GATE_CONDITIONS.join(', ')}.`, + ); + } + conditions.push(token as GateCondition); + } + return conditions; +} + +/** + * Evaluate gate conditions against a {@link ChangeReport}. + * + * Pure function: returns whether the run should fail and why, without any + * side effects. The CLI maps `shouldFail` to a process exit code. + */ +export function evaluateGate(report: ChangeReport, conditions: GateCondition[]): GateResult { + const reasons: string[] = []; + + for (const condition of conditions) { + switch (condition) { + case 'any': { + const total = + report.summary.totalAdded + + report.summary.totalRemoved + + report.summary.totalUpgraded + + report.summary.totalNewCVEs; + if (total > 0) reasons.push(`${total} change(s) detected`); + break; + } + case 'added': + if (report.summary.totalAdded > 0) { + reasons.push(`${report.summary.totalAdded} added component(s)`); + } + break; + case 'removed': + if (report.summary.totalRemoved > 0) { + reasons.push(`${report.summary.totalRemoved} removed component(s)`); + } + break; + case 'upgraded': + if (report.summary.totalUpgraded > 0) { + reasons.push(`${report.summary.totalUpgraded} upgraded component(s)`); + } + break; + case 'major': { + const majors = report.upgraded.filter(u => u.isMajorBump).length; + if (majors > 0) reasons.push(`${majors} major version bump(s)`); + break; + } + case 'new-cves': + if (report.summary.totalNewCVEs > 0) { + reasons.push(`${report.summary.totalNewCVEs} new CVE(s)`); + } + break; + default: { + // Severity threshold: fail on any new CVE at or above this level. + const threshold = SEVERITY_RANK[condition]; + const matches = report.newCVEs.filter( + v => v.severity != null && SEVERITY_RANK[v.severity] >= threshold, + ); + if (matches.length > 0) { + reasons.push(`${matches.length} new CVE(s) at or above ${condition} severity`); + } + break; + } + } + } + + return { shouldFail: reasons.length > 0, reasons }; +} diff --git a/src/index.ts b/src/index.ts index b68829e..e75a466 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,12 @@ export { parse, parseCycloneDX, parseSPDX, detectFormat } from './parser.js'; export { diff } from './diff.js'; export { renderReport } from './reporter.js'; +export { + evaluateGate, + parseGateConditions, + ALL_GATE_CONDITIONS, +} from './gate.js'; +export type { GateCondition, GateResult } from './gate.js'; export type { SBOM, Component,