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;