From 6c51b9b2b5d5307d5fa3b58ea6919aacf969bd52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 04:19:54 +0000 Subject: [PATCH] fix(parser): exclude the SPDX document subject from components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPDX documents list their own root/subject package (the application being described) inside `packages[]`. `parseSPDX` mapped every package into `components`, so a release-to-release diff of the same app reported the app itself as a spurious component change — typically a "[MAJOR] upgrade" whose version bumps on every release, drowning out the real dependency deltas. CycloneDX already keeps its subject (`metadata.component`) out of `components`; this brings SPDX into line. The document's subject is identified via the `documentDescribes` shortcut and/or a `DESCRIBES` relationship from `SPDXRef-DOCUMENT` (and its `DESCRIBED_BY` inverse). Packages are only excluded when the SBOM explicitly marks them as the subject, so documents that declare no root are unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RrdcuDGmzaQ6s8JqCbGTs8 --- src/__tests__/parser.test.ts | 45 +++++++++++++++++++++++++++++ src/parser.ts | 56 ++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 2 deletions(-) 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;