From f05053351a3a01ae9b667c8e2a01baedfc569d47 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 7 Aug 2026 13:23:14 -0600 Subject: [PATCH] fix(parser): reject non-SBOM input instead of silently passing empty report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21 parse() previously accepted any JSON — a package.json passed by mistake, a truncated export, or garbage — and silently returned an empty CycloneDX SBOM. In a CI gate that read 'nothing changed' (a false negative). - parse() now throws ParseError when input is not a recognized CycloneDX or SPDX document (missing bomFormat/spdxVersion), is invalid JSON, or is not an object (array/null/primitives) - The CLI's loadSbom already wraps this in a clear 'Failed to parse' message; main() exits 1 (verified) - 6 new tests cover the rejection paths + valid-document acceptance 106 tests pass, tsc clean. --- src/__tests__/parser.test.ts | 28 +++++++++++++++++++ src/parser.ts | 54 +++++++++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 40b9d68..afbd78f 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -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'); + }); +}); diff --git a/src/parser.ts b/src/parser.ts index b0c20ef..a0ba3e7 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -104,23 +104,57 @@ export function parseSPDX(obj: Record): 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): 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 = - 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; + 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' + ); } }