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
2 changes: 2 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe('gateFailures', () => {
newCVEs,
fixedCVEs: [],
severityEscalations: [],
hashChanges: [],
summary: {
totalAdded: 0,
totalRemoved: 0,
Expand All @@ -95,6 +96,7 @@ describe('gateFailures', () => {
totalNewCVEs: newCVEs.length,
totalFixedCVEs: 0,
totalSeverityEscalations: 0,
totalHashChanges: 0,
},
});

Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,37 @@ describe('diff ordering', () => {
expect(report.severityEscalations).toHaveLength(1);
expect(report.severityEscalations[0].toScore).toBe(9.0);
});

it('detects a hash change on an unchanged version (issue #22)', () => {
const a = makesbom([
{ name: 'event-stream', version: '4.0.1', purl: 'pkg:npm/event-stream@4.0.1', hashes: { sha256: 'aaaa' } },
{ name: 'clean-pkg', version: '1.0.0', purl: 'pkg:npm/clean-pkg@1.0.0', hashes: { sha256: 'bbbb' } },
]);
const b = makesbom([
// Same version, different digest — the tampering signal.
{ name: 'event-stream', version: '4.0.1', purl: 'pkg:npm/event-stream@4.0.1', hashes: { sha256: 'cccc' } },
{ name: 'clean-pkg', version: '1.0.0', purl: 'pkg:npm/clean-pkg@1.0.0', hashes: { sha256: 'bbbb' } },
]);
const report = diff(a, b);
expect(report.hashChanges).toHaveLength(1);
expect(report.hashChanges[0].component.name).toBe('event-stream');
expect(report.hashChanges[0].algorithm).toBe('sha256');
expect(report.hashChanges[0].from).toBe('aaaa');
expect(report.hashChanges[0].to).toBe('cccc');
expect(report.summary.totalHashChanges).toBe(1);
// The unchanged component must not appear.
expect(report.upgraded).toHaveLength(0);
});

it('does not report a hash change when the version also changed (already an upgrade)', () => {
const a = makesbom([
{ name: 'pkg', version: '1.0.0', purl: 'pkg:npm/pkg@1.0.0', hashes: { sha256: 'aaaa' } },
]);
const b = makesbom([
{ name: 'pkg', version: '1.0.1', purl: 'pkg:npm/pkg@1.0.1', hashes: { sha256: 'cccc' } },
]);
const report = diff(a, b);
expect(report.upgraded).toHaveLength(1);
expect(report.hashChanges).toHaveLength(0);
});
});
58 changes: 58 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ describe('parse (CycloneDX)', () => {
expect(sbom.components[0].ecosystem).toBe('npm');
});

it('extracts component hashes from the CycloneDX hashes array (issue #22)', () => {
const sbom = parse({
bomFormat: 'CycloneDX',
specVersion: '1.4',
components: [
{ name: 'lodash', version: '4.17.21', hashes: [{ alg: 'SHA-256', content: 'ABCDEF' }, { alg: 'SHA-1', content: '123456' }] },
{ name: 'no-hashes', version: '1.0.0' },
],
});
// Algorithms and digests are normalized to lowercase for comparison.
expect(sbom.components[0].hashes).toEqual({ 'sha-256': 'abcdef', 'sha-1': '123456' });
expect(sbom.components[1].hashes).toBeUndefined();
});

it('parses vulnerabilities', () => {
const sbom = parse(cyclonedxFixture);
expect(sbom.vulnerabilities).toHaveLength(1);
Expand Down Expand Up @@ -286,6 +300,22 @@ describe('parse (SPDX)', () => {
expect(sbom.components[0].license).toBe('Apache-2.0');
});

it('extracts package checksums from the SPDX checksums array (issue #22)', () => {
const sbom = parse({
spdxVersion: 'SPDX-2.3',
name: 'my-service',
packages: [
{
name: 'requests',
SPDXID: 'SPDXRef-requests',
versionInfo: '2.28.0',
checksums: [{ algorithm: 'SHA256', checksumValue: 'ABCDEF' }],
},
],
});
expect(sbom.components[0].hashes).toEqual({ sha256: 'abcdef' });
});

it('treats NOASSERTION/NONE version and supplier sentinels as undefined', () => {
const sbom = parse({
spdxVersion: 'SPDX-2.3',
Expand Down Expand Up @@ -433,3 +463,31 @@ describe('parse (JSON string input)', () => {
expect(sbom.components[0].name).toBe('ab');
});
});

describe('parse (input validation, issue #21)', () => {
it('throws ParseError on a non-SBOM object (e.g. a package.json)', () => {
const notAnSbom = JSON.stringify({ name: 'my-app', dependencies: { lodash: '^4.17.21' } });
expect(() => parse(notAnSbom)).toThrow(/not a recognized SBOM/);
});

it('throws ParseError on invalid JSON', () => {
expect(() => parse('{not json')).toThrow(/not valid JSON/);
});

it('throws ParseError on a JSON array', () => {
expect(() => parse('[1, 2, 3]')).toThrow(/not an SBOM document/);
});

it('throws ParseError on null input', () => {
expect(() => parse('null')).toThrow(/not an SBOM document/);
});

it('throws ParseError on an object passed directly (not a string)', () => {
expect(() => parse({ name: 'my-app', dependencies: {} })).toThrow(/not a recognized SBOM/);
});

it('still accepts valid CycloneDX and SPDX documents', () => {
expect(parse(JSON.stringify(cyclonedxFixture)).format).toBe('cyclonedx');
expect(parse({ spdxVersion: 'SPDX-2.3', packages: [] }).format).toBe('spdx');
});
});
12 changes: 8 additions & 4 deletions src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const sampleReport: ChangeReport = {
newCVEs: [{ id: 'CVE-2023-1234', affects: 'pkg:npm/foo@1.0.0', severity: 'high' }],
fixedCVEs: [{ id: 'CVE-2022-9999', affects: 'pkg:npm/bar@0.9.0' }],
severityEscalations: [],
summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalLicenseChanges: 1, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 1, totalSeverityEscalations: 0 },
hashChanges: [],
summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalLicenseChanges: 1, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 1, totalSeverityEscalations: 0, totalHashChanges: 0 },
};

describe('renderReport', () => {
Expand Down Expand Up @@ -69,7 +70,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed'
newCVEs: [{ id: 'CVE-2024-0001', affects: 'pkg:npm/a | b', severity: 'high', description: 'line1\nline2' }],
fixedCVEs: [],
severityEscalations: [],
summary: { totalAdded: 1, totalRemoved: 0, totalUpgraded: 0, totalLicenseChanges: 0, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 0, totalSeverityEscalations: 0 },
hashChanges: [],
summary: { totalAdded: 1, totalRemoved: 0, totalUpgraded: 0, totalLicenseChanges: 0, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 0, totalSeverityEscalations: 0, totalHashChanges: 0 },
};
const out = renderReport(report, 'markdown');

Expand All @@ -96,7 +98,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed'
newCVEs: [],
fixedCVEs: [],
severityEscalations: [],
summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0, totalSeverityEscalations: 0 },
hashChanges: [],
summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0, totalSeverityEscalations: 0, totalHashChanges: 0 },
};
const out = renderReport(report, 'text');
expect(out).toContain('Downgraded: 1');
Expand All @@ -120,7 +123,8 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed'
newCVEs: [],
fixedCVEs: [],
severityEscalations: [],
summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0, totalSeverityEscalations: 0 },
hashChanges: [],
summary: { totalAdded: 0, totalRemoved: 0, totalUpgraded: 1, totalLicenseChanges: 0, totalDowngraded: 1, totalNewCVEs: 0, totalFixedCVEs: 0, totalSeverityEscalations: 0, totalHashChanges: 0 },
};
const out = renderReport(report, 'markdown');
expect(out).toContain('Downgraded Components');
Expand Down
21 changes: 20 additions & 1 deletion src/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange, SBOMIdentity, SeverityEscalation } from './types.js';
import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange, SBOMIdentity, SeverityEscalation, HashChange } from './types.js';

/**
* Compare two parsed SBOMs and produce a ChangeReport.
Expand All @@ -17,6 +17,7 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
const licenseChanges: LicenseChange[] = [];

// Find added, upgraded, and relicensed
const hashChanges: HashChange[] = [];
for (const [key, bComp] of bMap) {
const aComp = aMap.get(key);
if (!aComp) {
Expand All @@ -41,6 +42,22 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
if (aComp.license && bComp.license && aComp.license !== bComp.license) {
licenseChanges.push({ component: bComp, from: aComp.license, to: bComp.license });
}

// Hash/integrity check: a component whose version is unchanged but whose
// digest changed is the supply-chain tampering signal (re-published /
// back-doored artifact under the same name@version). Only compare digests
// when the version did NOT change — a version bump legitimately changes
// hashes, and that's already reported as an upgrade.
if (aComp.version === bComp.version) {
const aHashes = aComp.hashes ?? {};
const bHashes = bComp.hashes ?? {};
for (const [alg, bHash] of Object.entries(bHashes)) {
const aHash = aHashes[alg];
if (aHash !== undefined && aHash !== bHash) {
hashChanges.push({ component: bComp, algorithm: alg, from: aHash, to: bHash });
}
}
}
}

// Find removed
Expand Down Expand Up @@ -96,6 +113,7 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
newCVEs,
fixedCVEs,
severityEscalations,
hashChanges,
summary: {
totalAdded: added.length,
totalRemoved: removed.length,
Expand All @@ -105,6 +123,7 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
totalNewCVEs: newCVEs.length,
totalFixedCVEs: fixedCVEs.length,
totalSeverityEscalations: severityEscalations.length,
totalHashChanges: hashChanges.length,
},
};
}
Expand Down
92 changes: 82 additions & 10 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),
hashes: extractCycloneDXHashes(c),
}));

const vulnerabilities: CVEEntry[] = rawVulns.map((v: Record<string, unknown>) => {
Expand Down Expand Up @@ -89,6 +90,7 @@ export function parseSPDX(obj: Record<string, unknown>): SBOM {
license: extractSPDXLicense(pkg),
ecosystem: extractEcosystemFromPurl(purl ?? ''),
supplier: normalizeSPDXValue(pkg.supplier),
hashes: extractSPDXChecksums(pkg),
};
});

Expand All @@ -104,23 +106,57 @@ export function parseSPDX(obj: Record<string, unknown>): SBOM {
};
}

/**
* Thrown when parse() is given input that is not a recognized SBOM document.
* The message explains what was expected so a wrong-format file (package.json,
* a truncated export, garbage JSON) fails loudly instead of silently passing
* a CI gate with "nothing changed".
*/
export class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'ParseError';
}
}

/**
* Parse a JSON string or object into an SBOM, auto-detecting format.
*
* Throws ParseError when the input is not a recognized CycloneDX or SPDX
* document. Silently accepting wrong-format input as an empty SBOM is a
* false-negative: a corrupt export or a package.json passed by mistake would
* sail through a CI gate as if nothing changed (issue #21).
*/
export function parse(input: string | Record<string, unknown>): SBOM {
// Strip a leading UTF-8 byte order mark (U+FEFF) before parsing. Several SBOM
// generators and Windows text tooling emit BOM-prefixed JSON, which is valid
// on disk but makes JSON.parse throw a cryptic "Unexpected token" error.
const obj: Record<string, unknown> =
typeof input === 'string' ? JSON.parse(input.replace(/^\uFEFF/, '')) : input;
const format = detectFormat(obj);
let obj: unknown;
if (typeof input === 'string') {
try {
// Strip a leading UTF-8 byte order mark (U+FEFF) before parsing. Several
// SBOM generators and Windows text tooling emit BOM-prefixed JSON, which
// is valid on disk but makes JSON.parse throw a cryptic "Unexpected
// token" error.
obj = JSON.parse(input.replace(/^\uFEFF/, ''));
} catch (e) {
throw new ParseError(`input is not valid JSON: ${(e as Error).message}`);
}
} else {
obj = input;
}

if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
throw new ParseError('input is not an SBOM document: expected a JSON object with bomFormat or spdxVersion');
}

const record = obj as Record<string, unknown>;
const format = detectFormat(record);

switch (format) {
case 'cyclonedx': return parseCycloneDX(obj);
case 'spdx': return parseSPDX(obj);
case 'cyclonedx': return parseCycloneDX(record);
case 'spdx': return parseSPDX(record);
default:
// Best-effort: treat as CycloneDX-like
return parseCycloneDX(obj);
throw new ParseError(
'input is not a recognized SBOM: missing CycloneDX "bomFormat" field and SPDX "spdxVersion" field'
);
}
}

Expand Down Expand Up @@ -321,6 +357,42 @@ function normalizeSPDXValue(value: unknown): string | undefined {
return trimmed;
}

/**
* Extract CycloneDX component hashes (the `hashes` array of {alg, content}
* objects) into a `{ algorithm: value }` map. SHA-256 keys are lowercased to
* the standard form so digests from different generators compare equal.
*/
function extractCycloneDXHashes(c: Record<string, unknown>): Record<string, string> | undefined {
if (!Array.isArray(c.hashes) || c.hashes.length === 0) return undefined;
const hashes: Record<string, string> = {};
for (const h of c.hashes) {
if (typeof h !== 'object' || h === null) continue;
const entry = h as Record<string, unknown>;
if (typeof entry.alg === 'string' && typeof entry.content === 'string') {
hashes[entry.alg.toLowerCase()] = entry.content.toLowerCase();
}
}
return Object.keys(hashes).length > 0 ? hashes : undefined;
}

/**
* Extract SPDX package checksums (the `checksums` array of {algorithm,
* checksumValue} objects) into a `{ algorithm: value }` map, same shape as the
* CycloneDX extraction so diff() can compare them uniformly.
*/
function extractSPDXChecksums(pkg: Record<string, unknown>): Record<string, string> | undefined {
if (!Array.isArray(pkg.checksums) || pkg.checksums.length === 0) return undefined;
const hashes: Record<string, string> = {};
for (const cs of pkg.checksums) {
if (typeof cs !== 'object' || cs === null) continue;
const entry = cs as Record<string, unknown>;
if (typeof entry.algorithm === 'string' && typeof entry.checksumValue === 'string') {
hashes[entry.algorithm.toLowerCase()] = entry.checksumValue.toLowerCase();
}
}
return Object.keys(hashes).length > 0 ? hashes : undefined;
}

/**
* Collect the SPDXIDs of the package(s) the document describes (its subject).
*
Expand Down
17 changes: 17 additions & 0 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function renderText(r: ChangeReport): string {
lines.push(` Licenses: ${r.summary.totalLicenseChanges}`);
lines.push(` New CVEs: ${r.summary.totalNewCVEs}`);
lines.push(` Fixed CVEs: ${r.summary.totalFixedCVEs}`);
lines.push(` Hash changes: ${r.summary.totalHashChanges}`);
lines.push('');

if (r.added.length > 0) {
Expand Down Expand Up @@ -116,6 +117,13 @@ function renderText(r: ChangeReport): string {
}
lines.push('');
}
if (r.hashChanges.length > 0) {
lines.push('\u26a0 Hash Changes (potential supply-chain tampering):');
for (const hc of r.hashChanges) {
lines.push(` \u26a0 ${hc.component.name}@${hc.component.version} [${hc.algorithm}: ${hc.from} \u2192 ${hc.to}]`);
}
lines.push('');
}

return lines.join('\n');
}
Expand Down Expand Up @@ -158,6 +166,7 @@ function renderMarkdown(r: ChangeReport): string {
`| New CVEs | ${r.summary.totalNewCVEs} |`,
`| Fixed CVEs | ${r.summary.totalFixedCVEs} |`,
`| Severity escalations | ${r.summary.totalSeverityEscalations} |`,
`| Hash changes | ${r.summary.totalHashChanges} |`,
'',
];

Expand Down Expand Up @@ -226,6 +235,14 @@ lines.push('| CVE ID | Severity | CVSS | Affects |');
lines.push(`| ${escapeCell(e.cve.id)} | ${escapeCell(e.fromSeverity ?? 'none')} | ${escapeCell(e.toSeverity ?? 'none')} | ${escapeCell(e.fromScore !== undefined && e.toScore !== undefined ? `${e.fromScore} \u2192 ${e.toScore}` : undefined)} | ${escapeCell(e.cve.affects)} |`);
}
}
if (r.hashChanges.length > 0) {
lines.push('## \u26a0\ufe0f Hash Changes (supply-chain tampering)', '');
lines.push('| Component | Version | Algorithm | From | To |');
lines.push('|-----------|---------|-----------|------|----|');
for (const hc of r.hashChanges) {
lines.push(`| ${escapeCell(hc.component.name)} | ${escapeCell(hc.component.version)} | ${escapeCell(hc.algorithm)} | \`${escapeCell(hc.from)}\` | \`${escapeCell(hc.to)}\` |`);
}
}

return lines.join('\n');
}
Loading