diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..2f050ed 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -119,6 +119,51 @@ describe('parse (SPDX)', () => { expect(sbom.components[0].version).toBe('2.28.0'); expect(sbom.components[0].license).toBe('Apache-2.0'); }); + + it('excludes the document subject named via documentDescribes', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'my-app', + documentDescribes: ['SPDXRef-Package-my-app'], + packages: [ + { SPDXID: 'SPDXRef-Package-my-app', name: 'my-app', versionInfo: '1.0.0' }, + { SPDXID: 'SPDXRef-Package-lodash', name: 'lodash', versionInfo: '4.17.21' }, + ], + }); + expect(sbom.components).toHaveLength(1); + expect(sbom.components[0].name).toBe('lodash'); + }); + + it('excludes the subject named via a DESCRIBES relationship', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'my-app', + relationships: [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: 'SPDXRef-Package-my-app', + }, + ], + packages: [ + { SPDXID: 'SPDXRef-Package-my-app', name: 'my-app', versionInfo: '1.0.0' }, + { SPDXID: 'SPDXRef-Package-lodash', name: 'lodash', versionInfo: '4.17.21' }, + ], + }); + expect(sbom.components.map(c => c.name)).toEqual(['lodash']); + }); + + it('keeps every package when the document declares no subject', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'my-app', + packages: [ + { SPDXID: 'SPDXRef-Package-my-app', name: 'my-app', versionInfo: '1.0.0' }, + { SPDXID: 'SPDXRef-Package-lodash', name: 'lodash', versionInfo: '4.17.21' }, + ], + }); + expect(sbom.components).toHaveLength(2); + }); }); describe('parse (JSON string input)', () => { diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..a6c6327 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -58,8 +58,18 @@ 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) => ({ + const rootIds = extractSPDXRootIds(obj); + + const components: Component[] = packages + // Exclude the document's own subject package(s) — the application being + // described — so a release-to-release diff doesn't report the app itself + // as a spurious dependency change. This mirrors CycloneDX, where the + // subject (metadata.component) is already kept out of `components`. + .filter((pkg: Record) => { + 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: typeof pkg.versionInfo === 'string' ? pkg.versionInfo : undefined, @@ -161,6 +171,48 @@ function extractCycloneDXTimestamp(metadata: Record): string | return typeof metadata.timestamp === 'string' ? metadata.timestamp : undefined; } +/** + * Collect the SPDXIDs of the package(s) the document describes (its subject). + * + * SPDX marks the primary/root element two ways, and generators use either: + * - the `documentDescribes` shortcut array of SPDXIDs (SPDX 2.2+), and/or + * - a `DESCRIBES` relationship from `SPDXRef-DOCUMENT` (or its inverse, + * `DESCRIBED_BY` pointing back at the document). + * + * Returns an empty set when the document declares no subject, so packages are + * only ever excluded when the SBOM explicitly identifies them as the root. + */ +function extractSPDXRootIds(obj: Record): Set { + const ids = new Set(); + + if (Array.isArray(obj.documentDescribes)) { + for (const id of obj.documentDescribes) { + if (typeof id === 'string') ids.add(id); + } + } + + if (Array.isArray(obj.relationships)) { + for (const raw of obj.relationships) { + const rel = raw as Record; + if ( + rel.relationshipType === 'DESCRIBES' && + rel.spdxElementId === 'SPDXRef-DOCUMENT' && + typeof rel.relatedSpdxElement === 'string' + ) { + ids.add(rel.relatedSpdxElement); + } else if ( + rel.relationshipType === 'DESCRIBED_BY' && + rel.relatedSpdxElement === 'SPDXRef-DOCUMENT' && + typeof rel.spdxElementId === 'string' + ) { + ids.add(rel.spdxElementId); + } + } + } + + return ids; +} + function extractSPDXPurl(pkg: Record): string | undefined { const refs = pkg.externalRefs; if (!Array.isArray(refs)) return undefined;