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
45 changes: 45 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
56 changes: 54 additions & 2 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,18 @@ export function parseCycloneDX(obj: Record<string, unknown>): SBOM {
*/
export function parseSPDX(obj: Record<string, unknown>): SBOM {
const packages = Array.isArray(obj.packages) ? obj.packages : [];

const components: Component[] = packages.map((pkg: Record<string, unknown>) => ({
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<string, unknown>) => {
const id = typeof pkg.SPDXID === 'string' ? pkg.SPDXID : undefined;
return !(id !== undefined && rootIds.has(id));
})
.map((pkg: Record<string, unknown>) => ({
purl: extractSPDXPurl(pkg),
name: typeof pkg.name === 'string' ? pkg.name : 'unknown',
version: typeof pkg.versionInfo === 'string' ? pkg.versionInfo : undefined,
Expand Down Expand Up @@ -161,6 +171,48 @@ function extractCycloneDXTimestamp(metadata: Record<string, unknown>): 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<string, unknown>): Set<string> {
const ids = new Set<string>();

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<string, unknown>;
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, unknown>): string | undefined {
const refs = pkg.externalRefs;
if (!Array.isArray(refs)) return undefined;
Expand Down
Loading