diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..e59d9dc 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { parse, detectFormat } from '../parser.js'; +import { diff } from '../diff.js'; const cyclonedxFixture = { bomFormat: 'CycloneDX', @@ -119,6 +120,34 @@ describe('parse (SPDX)', () => { expect(sbom.components[0].version).toBe('2.28.0'); expect(sbom.components[0].license).toBe('Apache-2.0'); }); + + it('treats NOASSERTION/NONE version and supplier sentinels as undefined', () => { + const sbom = parse({ + spdxVersion: 'SPDX-2.3', + name: 'my-service', + packages: [ + { name: 'internal-lib', versionInfo: 'NOASSERTION', supplier: 'NOASSERTION' }, + { name: 'other-lib', versionInfo: 'NONE', supplier: 'Organization: Acme' }, + ], + }); + expect(sbom.components[0].version).toBeUndefined(); + expect(sbom.components[0].supplier).toBeUndefined(); + expect(sbom.components[1].version).toBeUndefined(); + expect(sbom.components[1].supplier).toBe('Organization: Acme'); + }); + + it('does not report a spurious upgrade when the old version is NOASSERTION', () => { + const old = parse({ + spdxVersion: 'SPDX-2.3', + packages: [{ name: 'internal-lib', versionInfo: 'NOASSERTION' }], + }); + const next = parse({ + spdxVersion: 'SPDX-2.3', + packages: [{ name: 'internal-lib', versionInfo: '2.0.0' }], + }); + const report = diff(old, next); + expect(report.upgraded).toHaveLength(0); + }); }); describe('parse (JSON string input)', () => { diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..d5f0be4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -62,10 +62,10 @@ export function parseSPDX(obj: Record): SBOM { const components: Component[] = packages.map((pkg: Record) => ({ purl: extractSPDXPurl(pkg), name: typeof pkg.name === 'string' ? pkg.name : 'unknown', - version: typeof pkg.versionInfo === 'string' ? pkg.versionInfo : undefined, + version: normalizeSPDXValue(pkg.versionInfo), license: typeof pkg.licenseConcluded === 'string' ? pkg.licenseConcluded : undefined, ecosystem: extractEcosystemFromPurl(extractSPDXPurl(pkg) ?? ''), - supplier: typeof pkg.supplier === 'string' ? pkg.supplier : undefined, + supplier: normalizeSPDXValue(pkg.supplier), })); return { @@ -161,6 +161,26 @@ function extractCycloneDXTimestamp(metadata: Record): string | return typeof metadata.timestamp === 'string' ? metadata.timestamp : undefined; } +/** + * Normalize an SPDX string field, treating the spec's `NOASSERTION` and `NONE` + * sentinels as "no value" (undefined) rather than real data. + * + * These sentinels are extremely common in generator output (e.g. `versionInfo: + * "NOASSERTION"` when a version can't be determined). Storing them verbatim + * corrupts the diff: a package whose version is "NOASSERTION" in the old SBOM + * and "2.0.0" in the new one is reported as an upgrade `NOASSERTION → 2.0.0`, + * which is meaningless. Collapsing the sentinel to undefined lets the diff's + * "both versions known" guard correctly skip it. + * + * (License normalization is handled separately — see PR #32.) + */ +function normalizeSPDXValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (trimmed === '' || trimmed === 'NOASSERTION' || trimmed === 'NONE') return undefined; + return trimmed; +} + function extractSPDXPurl(pkg: Record): string | undefined { const refs = pkg.externalRefs; if (!Array.isArray(refs)) return undefined;