diff --git a/scripts/supply-chain/__tests__/validate-sbom.test.ts b/scripts/supply-chain/__tests__/validate-sbom.test.ts index 160b99bf..130f10be 100644 --- a/scripts/supply-chain/__tests__/validate-sbom.test.ts +++ b/scripts/supply-chain/__tests__/validate-sbom.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest'; // @ts-expect-error -- plain ESM script, no type declarations by design -import { versionFromTag, validateSboms } from '../validate-sbom.mjs'; +import { versionFromTag, validateSboms, cyclonedxComponentNames, spdxVersionedNames } from '../validate-sbom.mjs'; /** * The CycloneDX fixture mirrors the document `npm sbom --sbom-format cyclonedx --omit dev` * actually produced for @wave-av/sdk@2.1.3 on 2026-09-03 (CycloneDX 1.5, one runtime - * component: eventemitter3 — the SDK's single production dependency). + * component: eventemitter3 — the SDK's single production dependency). The SPDX fixture + * likewise carries the versionInfo `npm sbom --sbom-format spdx` emits for each package. */ const CDX = { bomFormat: 'CycloneDX', @@ -13,8 +14,13 @@ const CDX = { metadata: { component: { name: '@wave-av/sdk', version: '2.1.3' } }, components: [{ purl: 'pkg:npm/eventemitter3@5.0.4' }], }; -const SPDX = { packages: [{ name: '@wave-av/sdk' }, { name: 'eventemitter3' }] }; -const PKG = { version: '2.1.3' }; +const SPDX = { + packages: [ + { name: '@wave-av/sdk', versionInfo: '2.1.3' }, + { name: 'eventemitter3', versionInfo: '5.0.4' }, + ], +}; +const PKG = { name: '@wave-av/sdk', version: '2.1.3', dependencies: { eventemitter3: '^5.0.4' } }; describe('versionFromTag', () => { it("strips this repo's sdk-v prefix and a bare v", () => { @@ -70,4 +76,70 @@ describe('validateSboms', () => { /no components array/, ); }); + + // The install-before-scan regression: an SBOM produced from an uninstalled checkout has one + // self-describing entry and none of the declared dependencies. "packages[] is non-empty" + // accepted that; the per-name check below must not. + it('fails when a declared runtime dependency is missing from the SPDX document', () => { + const uninstalled = { packages: [{ name: '@wave-av/sdk', versionInfo: '2.1.3' }] }; + expect(() => validateSboms({ tag: 'sdk-v2.1.3', pkg: PKG, cyclonedx: CDX, spdx: uninstalled })).toThrow( + /spdx: no versioned package entry for declared runtime dependency\(ies\): eventemitter3/, + ); + }); + + it('does not count an SPDX entry whose versionInfo is missing or NOASSERTION', () => { + for (const bad of [{ name: 'eventemitter3' }, { name: 'eventemitter3', versionInfo: 'NOASSERTION' }]) { + const spdx = { packages: [{ name: '@wave-av/sdk', versionInfo: '2.1.3' }, bad] }; + expect(() => validateSboms({ tag: 'sdk-v2.1.3', pkg: PKG, cyclonedx: CDX, spdx })).toThrow( + /declared runtime dependency\(ies\): eventemitter3/, + ); + } + }); + + it('fails when a declared runtime dependency is missing from the CycloneDX components', () => { + const noDeps = { ...CDX, components: [] }; + expect(() => validateSboms({ tag: 'sdk-v2.1.3', pkg: PKG, cyclonedx: noDeps, spdx: SPDX })).toThrow( + /cyclonedx: no component for declared runtime dependency\(ies\): eventemitter3/, + ); + }); + + it('fails when the SPDX document has no versioned entry for the package itself', () => { + const noRoot = { packages: [{ name: 'eventemitter3', versionInfo: '5.0.4' }] }; + expect(() => validateSboms({ tag: 'sdk-v2.1.3', pkg: PKG, cyclonedx: CDX, spdx: noRoot })).toThrow( + /no versioned package entry for the package itself, @wave-av\/sdk@2.1.3/, + ); + }); + + it('refuses a package.json that declares no runtime dependencies — a floor of 0 validates nothing', () => { + const bare = { name: '@wave-av/sdk', version: '2.1.3' }; + expect(() => validateSboms({ tag: 'sdk-v2.1.3', pkg: bare, cyclonedx: CDX, spdx: SPDX })).toThrow( + /declares no runtime dependencies/, + ); + }); + + it('reports the declared dependency count on success', () => { + expect(validateSboms({ tag: 'sdk-v2.1.3', pkg: PKG, cyclonedx: CDX, spdx: SPDX })).toMatchObject({ + declaredDependencyCount: 1, + }); + }); +}); + +describe('name extraction helpers', () => { + it('reads CycloneDX component names from `name` or a (possibly scoped, percent-encoded) npm purl', () => { + const names = cyclonedxComponentNames({ + components: [ + { purl: 'pkg:npm/eventemitter3@5.0.4' }, + { purl: 'pkg:npm/%40wave-av/sdk@2.1.3' }, + { name: 'ws', purl: 'pkg:npm/ws@8.18.0?vcs_url=x' }, + ], + }); + expect([...names].sort()).toEqual(['@wave-av/sdk', 'eventemitter3', 'ws']); + }); + + it('only counts SPDX packages that carry a real versionInfo', () => { + const names = spdxVersionedNames({ + packages: [{ name: 'a', versionInfo: '1.0.0' }, { name: 'b' }, { name: 'c', versionInfo: 'NOASSERTION' }, { name: 'd', versionInfo: '' }], + }); + expect([...names]).toEqual(['a']); + }); }); diff --git a/scripts/supply-chain/validate-sbom.mjs b/scripts/supply-chain/validate-sbom.mjs index fbd86c5b..12ede952 100644 --- a/scripts/supply-chain/validate-sbom.mjs +++ b/scripts/supply-chain/validate-sbom.mjs @@ -11,6 +11,14 @@ * 1. package.json version equals the version encoded in the release tag. * 2. The CycloneDX document really is CycloneDX, and its root component is THIS version. * 3. The SPDX document lists at least one package. + * 4. EVERY runtime dependency package.json declares (`dependencies` keys) appears in the + * SPDX document with a real versionInfo AND as a CycloneDX component, and the SPDX + * document carries a versioned entry for the package itself. Both SBOMs are generated + * from the INSTALLED tree (`npm ci` then `npm sbom --omit dev`); a document produced + * from an uninstalled checkout has one self-describing entry and zero dependencies, + * which check 3 alone would wave through. The floor is derived from package.json, never + * hardcoded, so it tracks dependency changes on its own. A package that declares no + * runtime dependencies is refused outright (a floor of 0 validates nothing). * * Usage: TAG=sdk-v2.1.3 node scripts/supply-chain/validate-sbom.mjs * node scripts/supply-chain/validate-sbom.mjs --tag sdk-v2.1.3 --dir . @@ -27,10 +35,42 @@ export function versionFromTag(tag) { return version; } +const NO_VERSION = new Set(['', 'NOASSERTION']); + +/** Sorted names of the runtime dependencies package.json declares (never devDependencies). */ +export function declaredDependencies(pkg) { + const deps = pkg && typeof pkg.dependencies === 'object' && pkg.dependencies !== null ? pkg.dependencies : {}; + return Object.keys(deps).sort(); +} + +/** Names of SPDX packages[] entries that carry a real versionInfo (not missing/empty/NOASSERTION). */ +export function spdxVersionedNames(spdx) { + const names = new Set(); + for (const p of Array.isArray(spdx?.packages) ? spdx.packages : []) { + const version = typeof p?.versionInfo === 'string' ? p.versionInfo : ''; + if (typeof p?.name === 'string' && p.name && !NO_VERSION.has(version)) names.add(p.name); + } + return names; +} + +/** + * Names of CycloneDX components, from `name` when present or else from the purl + * (`pkg:npm/%40scope%2Fname@1.2.3` -> `@scope/name`), since `npm sbom` emits both. + */ +export function cyclonedxComponentNames(cyclonedx) { + const names = new Set(); + for (const c of Array.isArray(cyclonedx?.components) ? cyclonedx.components : []) { + if (typeof c?.name === 'string' && c.name) names.add(c.name); + const m = typeof c?.purl === 'string' ? /^pkg:npm\/(.+?)(?:@[^@]*)?(?:\?.*)?$/.exec(c.purl) : null; + if (m) names.add(decodeURIComponent(m[1])); + } + return names; +} + /** * Validate both SBOM documents against the version being released. * @param {{ tag: string, pkg: any, cyclonedx: any, spdx: any }} input - * @returns {{ version: string, componentCount: number, spdxPackageCount: number, specVersion: string }} + * @returns {{ version: string, componentCount: number, spdxPackageCount: number, specVersion: string, declaredDependencyCount: number }} */ export function validateSboms({ tag, pkg, cyclonedx, spdx }) { const version = versionFromTag(tag); @@ -54,11 +94,41 @@ export function validateSboms({ tag, pkg, cyclonedx, spdx }) { throw new Error('spdx: document lists no packages'); } + // Every declared runtime dependency must actually be enumerated -- by name, with a real + // version -- in BOTH documents. This is the check that distinguishes an SBOM generated + // from the installed tree from one generated before install. + const declared = declaredDependencies(pkg); + if (declared.length === 0) { + throw new Error( + 'package.json declares no runtime dependencies -- refusing to validate against a floor of 0', + ); + } + const spdxNames = spdxVersionedNames(spdx); + if (typeof pkg?.name === 'string' && pkg.name && !spdxNames.has(pkg.name)) { + throw new Error(`spdx: no versioned package entry for the package itself, ${pkg.name}@${version}`); + } + const missingSpdx = declared.filter((dep) => !spdxNames.has(dep)); + if (missingSpdx.length > 0) { + throw new Error( + `spdx: no versioned package entry for declared runtime dependency(ies): ${missingSpdx.join(', ')} ` + + '-- the SBOM was likely generated from an uninstalled tree', + ); + } + const cdxNames = cyclonedxComponentNames(cyclonedx); + const missingCdx = declared.filter((dep) => !cdxNames.has(dep)); + if (missingCdx.length > 0) { + throw new Error( + `cyclonedx: no component for declared runtime dependency(ies): ${missingCdx.join(', ')} ` + + '-- the SBOM was likely generated from an uninstalled tree', + ); + } + return { version, componentCount: cyclonedx.components.length, spdxPackageCount: spdx.packages.length, specVersion: cyclonedx.specVersion ?? 'unknown', + declaredDependencyCount: declared.length, }; } @@ -78,7 +148,8 @@ function main(argv) { }); process.stdout.write( `cyclonedx ${summary.specVersion}: ${summary.componentCount} runtime components; ` + - `spdx: ${summary.spdxPackageCount} packages; root ${summary.version}\n`, + `spdx: ${summary.spdxPackageCount} packages; root ${summary.version}; ` + + `all ${summary.declaredDependencyCount} declared runtime dependencies enumerated in both\n`, ); }