Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 39 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,26 @@ Arguments:

Options:
--format <fmt> Output format: text (default), json, or markdown
--fail-on <sev> 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 */
Expand All @@ -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.
Expand All @@ -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];
Expand All @@ -102,14 +111,16 @@ 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 {
positional.push(arg);
}
}

return { positional, format, failOn, help: false, version: false };
return { positional, format, failOn, runtimeOnly, help: false, version: false };
}

/**
Expand Down Expand Up @@ -237,7 +248,7 @@ export async function loadSbom(path: string, label: string): Promise<SBOM> {
}

async function main(): Promise<void> {
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);
Expand All @@ -261,11 +272,17 @@ async function main(): Promise<void> {
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);
Expand All @@ -278,6 +295,19 @@ async function main(): Promise<void> {
}
}

/**
* 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) {
Expand Down
19 changes: 16 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function parseCycloneDX(obj: Record<string, unknown>): SBOM {
license: extractCycloneDXLicense(c),
ecosystem: extractEcosystemFromPurl(typeof c.purl === 'string' ? c.purl : ''),
supplier: extractCycloneDXSupplier(c),
scope: extractCycloneDXScope(c),
hashes: extractCycloneDXHashes(c),
}));

Expand Down Expand Up @@ -239,9 +240,21 @@ function extractSPDXLicense(pkg: Record<string, unknown>): string | undefined {
}

function extractCycloneDXSupplier(c: Record<string, unknown>): string | undefined {
const supplier = c.supplier as Record<string, unknown> | 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<string, unknown>).name === 'string' ? (supplier as Record<string, unknown>).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<string, unknown>): 'required' | 'optional' | 'excluded' | undefined {
const scope = c.scope;
if (scope === 'required' || scope === 'optional' || scope === 'excluded') return scope;
return undefined;
}

function extractCycloneDXAffects(v: Record<string, unknown>): string[] {
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}
Expand Down