Skip to content
Open
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
46 changes: 46 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand All @@ -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)', () => {
Expand Down
16 changes: 15 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function parseSPDX(obj: Record<string, unknown>): 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,
}));
Expand Down Expand Up @@ -110,9 +110,23 @@ function extractCycloneDXLicense(c: Record<string, unknown>): string | undefined
const license = first.license as Record<string, unknown> | 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, unknown>): 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, unknown>): string | undefined {
const supplier = c.supplier as Record<string, unknown> | undefined;
if (!supplier) return undefined;
Expand Down
Loading