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
41 changes: 40 additions & 1 deletion src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { parseArgs, gateFailures, gateWarning } from '../cli.js';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseArgs, loadSbom, gateFailures, gateWarning } from '../cli.js';
import type { ChangeReport, CVEEntry, SBOM } from '../types.js';

describe('parseArgs', () => {
Expand Down Expand Up @@ -161,3 +164,39 @@ describe('gateWarning', () => {
);
});
});

describe('loadSbom', () => {
it('wraps a missing file with its path and role', async () => {
await expect(loadSbom('/no/such/sbom-diff-missing.json', 'old')).rejects.toThrowError(
"Failed to read old SBOM '/no/such/sbom-diff-missing.json'",
);
});

it('wraps malformed JSON with its path and role', async () => {
const dir = await mkdtemp(join(tmpdir(), 'sbom-diff-'));
const path = join(dir, 'bad.json');
await writeFile(path, '{ not valid json');
try {
await expect(loadSbom(path, 'new')).rejects.toThrowError(
`Failed to parse new SBOM '${path}'`,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('parses a valid SBOM file', async () => {
const dir = await mkdtemp(join(tmpdir(), 'sbom-diff-'));
const path = join(dir, 'good.json');
await writeFile(
path,
JSON.stringify({ bomFormat: 'CycloneDX', specVersion: '1.5', components: [] }),
);
try {
const sbom = await loadSbom(path, 'old');
expect(sbom.format).toBe('cyclonedx');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
37 changes: 32 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,35 @@ export function gateWarning(oldSBOM: SBOM, newSBOM: SBOM, failOn: FailOn): strin
'attach a scan/VEX step that emits a CycloneDX 1.4+ "vulnerabilities" list to enable CVE gating.'
);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

/**
* Read and parse an SBOM file, attaching the file path and role to any failure.
*
* The underlying `readFile`/`JSON.parse` errors (e.g. `ENOENT` or
* `Expected property name or '}' in JSON at position 2`) don't say which of the
* two inputs failed or that the SBOM-load step is where it broke. In a CI gate a
* wrong or corrupt artifact is a common misconfiguration, so surface the path and
* whether the read or the parse failed instead of a bare low-level message.
*
* @param label human-readable role of the file, e.g. `old` or `new`.
* @throws if the file cannot be read or is not valid JSON / SBOM.
*/
export async function loadSbom(path: string, label: string): Promise<SBOM> {
let raw: string;
try {
raw = await readFile(path, 'utf-8');
} catch (err) {
throw new Error(`Failed to read ${label} SBOM '${path}': ${errorMessage(err)}`);
}
try {
return parse(raw);
} catch (err) {
throw new Error(`Failed to parse ${label} SBOM '${path}': ${errorMessage(err)}`);
}
}

async function main(): Promise<void> {
const { positional, format, failOn } = parseArgs(process.argv.slice(2));
Expand All @@ -149,13 +178,11 @@ async function main(): Promise<void> {

const [oldPath, newPath] = positional;

const [oldRaw, newRaw] = await Promise.all([
readFile(oldPath, 'utf-8'),
readFile(newPath, 'utf-8'),
const [oldSBOM, newSBOM] = await Promise.all([
loadSbom(oldPath, 'old'),
loadSbom(newPath, 'new'),
]);

const oldSBOM = parse(oldRaw);
const newSBOM = parse(newRaw);
const report = diff(oldSBOM, newSBOM);

console.log(renderReport(report, format));
Expand Down
Loading