From c6d3a6d9277af8e305e083ff3e93c4c7b85e4833 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:12:01 +0000 Subject: [PATCH] fix(parser): recover licenses from CycloneDX expressions and SPDX declared field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two license data-loss bugs in the public parse() API: - CycloneDX components licensed via an SPDX license *expression* (e.g. `{ "expression": "MIT OR Apache-2.0" }`) — a spec-valid and common shape for dual/expression-licensed packages — yielded `license: undefined` because only `license.id` / `license.name` were read. - SPDX packages whose `licenseConcluded` is the sentinel "NOASSERTION" stored that meaningless string verbatim and ignored `licenseDeclared`, discarding the real license. Now prefer a concrete concluded license, fall back to the declared license, and drop the NOASSERTION sentinel. Both feed the license/compliance use case the README advertises. Adds tests for the expression path and the SPDX declared-license fallback. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Qqw3TvZsp8exiuHLLLkxTA --- src/__tests__/parser.test.ts | 46 ++++++++++++++++++++++++++++++++++++ src/parser.ts | 16 ++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..efc6df0 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -108,6 +108,30 @@ describe('parse (CycloneDX)', () => { expect(sbom.name).toBe('my-app'); expect(sbom.version).toBe('1.0.0'); }); + + it('extracts a license from a license id or name object', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [ + { name: 'a', version: '1.0.0', licenses: [{ license: { id: 'MIT' } }] }, + { name: 'b', version: '1.0.0', licenses: [{ license: { name: 'Custom EULA' } }] }, + ], + }); + expect(sbom.components[0].license).toBe('MIT'); + expect(sbom.components[1].license).toBe('Custom EULA'); + }); + + it('extracts a license from an SPDX license expression', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [ + { name: 'dual', version: '1.0.0', licenses: [{ expression: 'MIT OR Apache-2.0' }] }, + ], + }); + expect(sbom.components[0].license).toBe('MIT OR Apache-2.0'); + }); }); describe('parse (SPDX)', () => { @@ -119,6 +143,28 @@ describe('parse (SPDX)', () => { expect(sbom.components[0].version).toBe('2.28.0'); expect(sbom.components[0].license).toBe('Apache-2.0'); }); + + it('falls back to licenseDeclared when licenseConcluded is NOASSERTION', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'app', + packages: [ + { name: 'foo', versionInfo: '1.0.0', licenseConcluded: 'NOASSERTION', licenseDeclared: 'BSD-3-Clause' }, + ], + }); + expect(sbom.components[0].license).toBe('BSD-3-Clause'); + }); + + it('leaves license undefined when both concluded and declared are NOASSERTION', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'app', + packages: [ + { name: 'foo', versionInfo: '1.0.0', licenseConcluded: 'NOASSERTION', licenseDeclared: 'NOASSERTION' }, + ], + }); + expect(sbom.components[0].license).toBeUndefined(); + }); }); describe('parse (JSON string input)', () => { diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..3cb4572 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -63,7 +63,7 @@ export function parseSPDX(obj: Record): SBOM { purl: extractSPDXPurl(pkg), name: typeof pkg.name === 'string' ? pkg.name : 'unknown', version: typeof pkg.versionInfo === 'string' ? pkg.versionInfo : undefined, - license: typeof pkg.licenseConcluded === 'string' ? pkg.licenseConcluded : undefined, + license: extractSPDXLicense(pkg), ecosystem: extractEcosystemFromPurl(extractSPDXPurl(pkg) ?? ''), supplier: typeof pkg.supplier === 'string' ? pkg.supplier : undefined, })); @@ -110,9 +110,23 @@ function extractCycloneDXLicense(c: Record): string | undefined const license = first.license as Record | undefined; if (license && typeof license.id === 'string') return license.id; if (license && typeof license.name === 'string') return license.name; + // CycloneDX also allows an SPDX license *expression* in place of a license + // object, e.g. { "expression": "MIT OR Apache-2.0" }. Dual/expression-licensed + // components are common, so without this branch their license is silently lost. + if (typeof first.expression === 'string') return first.expression; return undefined; } +function extractSPDXLicense(pkg: Record): string | undefined { + // Prefer a concrete concluded license, but many generators leave it as the SPDX + // sentinel "NOASSERTION" while the real license sits in licenseDeclared. Fall + // back to the declared license and drop the sentinel so a package with a known + // license isn't reported as having the meaningless value "NOASSERTION". + const meaningful = (v: unknown): string | undefined => + typeof v === 'string' && v !== 'NOASSERTION' ? v : undefined; + return meaningful(pkg.licenseConcluded) ?? meaningful(pkg.licenseDeclared); +} + function extractCycloneDXSupplier(c: Record): string | undefined { const supplier = c.supplier as Record | undefined; if (!supplier) return undefined;