diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index f81a64f..f55bd01 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -191,6 +191,30 @@ describe('parse (CycloneDX)', () => { expect(sbom.components.map((c) => c.name)).toEqual(['app', 'lodash', 'express', 'qs']); expect(sbom.components.find((c) => c.name === 'qs')?.version).toBe('6.11.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)', () => { @@ -230,6 +254,28 @@ describe('parse (SPDX)', () => { const report = diff(old, next); expect(report.upgraded).toHaveLength(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 c71c9d1..451a6cf 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -71,7 +71,7 @@ export function parseSPDX(obj: Record): SBOM { purl: extractSPDXPurl(pkg), name: typeof pkg.name === 'string' ? pkg.name : 'unknown', version: normalizeSPDXValue(pkg.versionInfo), - license: typeof pkg.licenseConcluded === 'string' ? pkg.licenseConcluded : undefined, + license: extractSPDXLicense(pkg), ecosystem: extractEcosystemFromPurl(extractSPDXPurl(pkg) ?? ''), supplier: normalizeSPDXValue(pkg.supplier), })); @@ -143,9 +143,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;