diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 06260aa..74df94f 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -215,6 +215,43 @@ describe('parse (CycloneDX)', () => { }); expect(sbom.components[0].license).toBe('MIT OR Apache-2.0'); }); + + it('derives the version from the purl when the version field is absent', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [ + // Valid CycloneDX: `version` is optional; the purl carries it. + { name: 'lodash', purl: 'pkg:npm/lodash@4.17.21' }, + // Scoped npm package: the `%40` is encoded, so the version `@` is the last one. + { name: 'core', purl: 'pkg:npm/%40angular/core@17.0.0' }, + // Version with qualifiers/subpath must be stripped. + { name: 'pg', purl: 'pkg:npm/pg@8.11.3?foo=bar#sub' }, + ], + }); + expect(sbom.components[0].version).toBe('4.17.21'); + expect(sbom.components[1].version).toBe('17.0.0'); + expect(sbom.components[2].version).toBe('8.11.3'); + }); + + it('prefers the explicit version field over the purl version', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.20' }], + }); + expect(sbom.components[0].version).toBe('4.17.21'); + }); + + it('leaves version undefined when neither the field nor the purl carries one', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [{ name: 'foo', purl: 'pkg:npm/foo' }, { name: 'bare' }], + }); + expect(sbom.components[0].version).toBeUndefined(); + expect(sbom.components[1].version).toBeUndefined(); + }); }); describe('parse (SPDX)', () => { @@ -242,6 +279,20 @@ describe('parse (SPDX)', () => { expect(sbom.components[1].supplier).toBe('Organization: Acme'); }); + it('derives the version from the purl externalRef when versionInfo is absent', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'my-service', + packages: [ + { + name: 'requests', + externalRefs: [{ referenceType: 'purl', referenceLocator: 'pkg:pypi/requests@2.28.0' }], + }, + ], + }); + expect(sbom.components[0].version).toBe('2.28.0'); + }); + it('does not report a spurious upgrade when the old version is NOASSERTION', () => { const old = parse({ spdxVersion: 'SPDX-2.3', @@ -321,6 +372,20 @@ describe('parse (SPDX)', () => { }); expect(sbom.components).toHaveLength(2); }); + + it('derives the version from the purl when versionInfo is absent', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'my-app', + packages: [ + { + name: 'requests', + externalRefs: [{ referenceType: 'purl', referenceLocator: 'pkg:pypi/requests@2.28.0' }], + }, + ], + }); + expect(sbom.components[0].version).toBe('2.28.0'); + }); }); describe('parse (JSON string input)', () => { diff --git a/src/parser.ts b/src/parser.ts index f6dd570..0446e63 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -33,7 +33,9 @@ export function parseCycloneDX(obj: Record): SBOM { const components: Component[] = rawComponents.map((c: Record) => ({ purl: typeof c.purl === 'string' ? c.purl : undefined, name: typeof c.name === 'string' ? c.name : 'unknown', - version: typeof c.version === 'string' ? c.version : undefined, + version: typeof c.version === 'string' + ? c.version + : extractVersionFromPurl(typeof c.purl === 'string' ? c.purl : ''), license: extractCycloneDXLicense(c), ecosystem: extractEcosystemFromPurl(typeof c.purl === 'string' ? c.purl : ''), supplier: extractCycloneDXSupplier(c), @@ -77,14 +79,17 @@ export function parseSPDX(obj: Record): SBOM { const id = typeof pkg.SPDXID === 'string' ? pkg.SPDXID : undefined; return !(id !== undefined && rootIds.has(id)); }) - .map((pkg: Record) => ({ - purl: extractSPDXPurl(pkg), - name: typeof pkg.name === 'string' ? pkg.name : 'unknown', - version: normalizeSPDXValue(pkg.versionInfo), - license: extractSPDXLicense(pkg), - ecosystem: extractEcosystemFromPurl(extractSPDXPurl(pkg) ?? ''), - supplier: normalizeSPDXValue(pkg.supplier), - })); + .map((pkg: Record) => { + const purl = extractSPDXPurl(pkg); + return { + purl, + name: typeof pkg.name === 'string' ? pkg.name : 'unknown', + version: normalizeSPDXValue(pkg.versionInfo) || extractVersionFromPurl(purl ?? ''), + license: extractSPDXLicense(pkg), + ecosystem: extractEcosystemFromPurl(purl ?? ''), + supplier: normalizeSPDXValue(pkg.supplier), + }; + }); return { format: 'spdx', @@ -146,6 +151,32 @@ function extractEcosystemFromPurl(purl: string): string | undefined { return match ? match[1] : undefined; } +/** + * Extract the version from a Package URL (purl). + * + * A purl encodes the version after an unescaped `@`, before any `?qualifiers` + * or `#subpath` (e.g. `pkg:npm/lodash@4.17.21`, `pkg:npm/%40angular/core@17.0.0`). + * Per the CycloneDX/SPDX specs a component's dedicated version field is optional, + * so when it is absent the purl is the authoritative source — otherwise reports + * render `name@unknown` and JSON consumers get `version: undefined` for a package + * whose version is right there in the purl. + * + * Namespace/name segments must percent-encode any literal `@`, so the last `@` + * is the version separator. Returns undefined when the purl carries no version. + */ +function extractVersionFromPurl(purl: string): string | undefined { + if (!purl.startsWith('pkg:')) return undefined; + const at = purl.lastIndexOf('@'); + if (at === -1) return undefined; + const raw = purl.slice(at + 1).split(/[?#]/)[0]; + if (!raw) return undefined; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + function extractCycloneDXLicense(c: Record): string | undefined { const licenses = c.licenses; if (!Array.isArray(licenses) || licenses.length === 0) return undefined; diff --git a/src/types.ts b/src/types.ts index 18e19af..fc5b2ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,7 @@ export interface Component { purl?: string; /** Component name */ name: string; - /** Component version */ + /** Component version (falls back to the version encoded in the purl when absent) */ version?: string; /** SPDX license expression or CycloneDX license */ license?: string;