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
28 changes: 28 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,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');
});
});
54 changes: 44 additions & 10 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,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