diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 0d0e520..1ab5cd1 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -62,6 +62,11 @@ describe('parseArgs', () => { it('throws when --fail-on is given without a value', () => { expect(() => parseArgs(['old.json', 'new.json', '--fail-on'])).toThrow(/Invalid --fail-on/); }); + + it('parses --runtime-only as true (default false)', () => { + expect(parseArgs(['old.json', 'new.json']).runtimeOnly).toBe(false); + expect(parseArgs(['old.json', 'new.json', '--runtime-only']).runtimeOnly).toBe(true); + }); }); describe('gateFailures', () => { diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 1ccfe0f..a2c8b8d 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -75,6 +75,23 @@ describe('parse (CycloneDX)', () => { expect(sbom.components[1].hashes).toBeUndefined(); }); + it('extracts the component scope (dev/test vs runtime) (issue #56)', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.4', + components: [ + { name: 'runtime-pkg', version: '1.0.0', scope: 'required' }, + { name: 'dev-pkg', version: '1.0.0', scope: 'optional' }, + { name: 'excluded-pkg', version: '1.0.0', scope: 'excluded' }, + { name: 'no-scope-pkg', version: '1.0.0' }, + ], + }); + expect(sbom.components[0].scope).toBe('required'); + expect(sbom.components[1].scope).toBe('optional'); + expect(sbom.components[2].scope).toBe('excluded'); + expect(sbom.components[3].scope).toBeUndefined(); + }); + it('parses vulnerabilities', () => { const sbom = parse(cyclonedxFixture); expect(sbom.vulnerabilities).toHaveLength(1); diff --git a/src/cli.ts b/src/cli.ts index d49b17b..fdc30c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,18 +49,26 @@ Arguments: Options: --format Output format: text (default), json, or markdown + --fail-on Fail (exit 3) when a new CVE at/above this severity appears: + none (default), low, medium, high, critical, any + --runtime-only Only consider runtime components (scope=required); dev/test + (scope=optional/excluded) dependencies are filtered out of + the diff and the --fail-on gate -h, --help Show this help and exit -v, --version Print the installed version and exit Examples: sbom-diff old.json new.json sbom-diff old.json new.json --format json - sbom-diff old.json new.json --format markdown`; + sbom-diff old.json new.json --format markdown + sbom-diff old.json new.json --runtime-only --fail-on high`; export interface ParsedArgs { positional: string[]; format: ReportFormat; failOn: FailOn; + /** true when --runtime-only was requested (filter dev/test deps) */ + runtimeOnly: boolean; /** true when -h/--help was requested */ help: boolean; /** true when -v/--version was requested */ @@ -72,8 +80,8 @@ export interface ParsedArgs { * the CI/CD gate policy. * * 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. + * `--runtime-only`, and flags appearing in any position relative to the + * positional file paths. Defaults to `text` format and a `none` gate policy. * * `-h`/`--help` and `-v`/`--version` short-circuit parsing so they always * work — even alongside otherwise-invalid arguments — and never throw. @@ -82,15 +90,16 @@ export interface ParsedArgs { */ export function parseArgs(argv: string[]): ParsedArgs { if (argv.some(a => a === '-h' || a === '--help')) { - return { positional: [], format: 'text', failOn: 'none', help: true, version: false }; + return { positional: [], format: 'text', failOn: 'none', runtimeOnly: false, help: true, version: false }; } if (argv.some(a => a === '-v' || a === '-V' || a === '--version')) { - return { positional: [], format: 'text', failOn: 'none', help: false, version: true }; + return { positional: [], format: 'text', failOn: 'none', runtimeOnly: false, help: false, version: true }; } const positional: string[] = []; let format: ReportFormat = 'text'; let failOn: FailOn = 'none'; + let runtimeOnly = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -102,6 +111,8 @@ export function parseArgs(argv: string[]): ParsedArgs { failOn = assertFailOn(argv[++i]); } else if (arg.startsWith('--fail-on=')) { failOn = assertFailOn(arg.slice('--fail-on='.length)); + } else if (arg === '--runtime-only') { + runtimeOnly = true; } else if (arg.startsWith('-')) { throw new Error(`Unknown option: ${arg}\n${USAGE}`); } else { @@ -109,7 +120,7 @@ export function parseArgs(argv: string[]): ParsedArgs { } } - return { positional, format, failOn, help: false, version: false }; + return { positional, format, failOn, runtimeOnly, help: false, version: false }; } /** @@ -237,7 +248,7 @@ export async function loadSbom(path: string, label: string): Promise { } async function main(): Promise { - const { positional, format, failOn, help, version } = parseArgs(process.argv.slice(2)); + const { positional, format, failOn, runtimeOnly, help, version } = parseArgs(process.argv.slice(2)); if (help) { console.log(HELP); @@ -261,11 +272,17 @@ async function main(): Promise { loadSbom(newPath, 'new'), ]); - const report = diff(oldSBOM, newSBOM); + // With --runtime-only, drop dev/test (scope=optional/excluded) components so + // the diff and the --fail-on gate consider only production dependencies. + // A component without a scope is runtime by CycloneDX's default, so it stays. + const aFinal = runtimeOnly ? filterRuntimeOnly(oldSBOM) : oldSBOM; + const bFinal = runtimeOnly ? filterRuntimeOnly(newSBOM) : newSBOM; + + const report = diff(aFinal, bFinal); console.log(renderReport(report, format)); - const warning = gateWarning(oldSBOM, newSBOM, failOn); + const warning = gateWarning(aFinal, bFinal, failOn); if (warning) console.error(warning); const failures = gateFailures(report, failOn); @@ -278,6 +295,19 @@ async function main(): Promise { } } +/** + * Return a copy of the SBOM with only runtime components (those whose scope is + * "required" or unset). Dev/test/build dependencies (scope "optional" or + * "excluded") are filtered out. Vulnerabilities are kept as-is — they reference + * components by ref, and filtering them would misattribute blast radius. + */ +function filterRuntimeOnly(sbom: SBOM): SBOM { + return { + ...sbom, + components: sbom.components.filter(c => c.scope === undefined || c.scope === 'required'), + }; +} + // Only run when invoked directly (not when imported by tests). const invokedPath = process.argv[1]; if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { diff --git a/src/parser.ts b/src/parser.ts index 0b5accd..8c77996 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -39,6 +39,7 @@ export function parseCycloneDX(obj: Record): SBOM { license: extractCycloneDXLicense(c), ecosystem: extractEcosystemFromPurl(typeof c.purl === 'string' ? c.purl : ''), supplier: extractCycloneDXSupplier(c), + scope: extractCycloneDXScope(c), hashes: extractCycloneDXHashes(c), })); @@ -239,9 +240,21 @@ function extractSPDXLicense(pkg: Record): string | undefined { } function extractCycloneDXSupplier(c: Record): string | undefined { - const supplier = c.supplier as Record | undefined; - if (!supplier) return undefined; - return typeof supplier.name === 'string' ? supplier.name : undefined; + const supplier = c.supplier; + if (typeof supplier !== 'object' || supplier === null) return undefined; + return typeof (supplier as Record).name === 'string' ? (supplier as Record).name as string : undefined; +} + +/** + * Extract the CycloneDX component scope ("required" / "optional" / "excluded"). + * Returns undefined when absent, which is the meaning of "no scope" in CDX: + * scope defaults to "required" when omitted, but we keep it undefined so the + * reporter can show "default" rather than a misleading explicit value. + */ +function extractCycloneDXScope(c: Record): 'required' | 'optional' | 'excluded' | undefined { + const scope = c.scope; + if (scope === 'required' || scope === 'optional' || scope === 'excluded') return scope; + return undefined; } function extractCycloneDXAffects(v: Record): string[] { diff --git a/src/types.ts b/src/types.ts index 51b634f..9efec6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,12 @@ export interface Component { ecosystem?: string; /** Supplier / organization */ supplier?: string; + /** + * CycloneDX component scope: "required" (runtime), "optional" + * (dev/test/build), or "excluded". Lets gates/reports distinguish + * production dependencies from dev/test ones (issue #56). + */ + scope?: 'required' | 'optional' | 'excluded'; /** Hash values keyed by algorithm (sha256, sha1, md5) */ hashes?: Record; }