diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..ab00421 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -108,6 +108,43 @@ describe('parse (CycloneDX)', () => { expect(sbom.name).toBe('my-app'); expect(sbom.version).toBe('1.0.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)', () => { @@ -119,6 +156,20 @@ describe('parse (SPDX)', () => { expect(sbom.components[0].version).toBe('2.28.0'); expect(sbom.components[0].license).toBe('Apache-2.0'); }); + + 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'); + }); }); describe('parse (JSON string input)', () => { diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..ff4d951 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -29,7 +29,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), @@ -59,14 +61,17 @@ export function parseCycloneDX(obj: Record): SBOM { export function parseSPDX(obj: Record): SBOM { const packages = Array.isArray(obj.packages) ? obj.packages : []; - const components: Component[] = packages.map((pkg: Record) => ({ - purl: extractSPDXPurl(pkg), + const components: Component[] = packages.map((pkg: Record) => { + const purl = extractSPDXPurl(pkg); + return { + purl, name: typeof pkg.name === 'string' ? pkg.name : 'unknown', - version: typeof pkg.versionInfo === 'string' ? pkg.versionInfo : undefined, + version: typeof pkg.versionInfo === 'string' ? pkg.versionInfo : extractVersionFromPurl(purl ?? ''), license: typeof pkg.licenseConcluded === 'string' ? pkg.licenseConcluded : undefined, - ecosystem: extractEcosystemFromPurl(extractSPDXPurl(pkg) ?? ''), + ecosystem: extractEcosystemFromPurl(purl ?? ''), supplier: typeof pkg.supplier === 'string' ? pkg.supplier : undefined, - })); + }; + }); return { format: 'spdx', @@ -103,6 +108,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 ede0ac5..5bdd5fa 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;