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
80 changes: 76 additions & 4 deletions scripts/supply-chain/__tests__/validate-sbom.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
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',
specVersion: '1.5',
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", () => {
Expand Down Expand Up @@ -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']);
});
});
75 changes: 73 additions & 2 deletions scripts/supply-chain/validate-sbom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand All @@ -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 : '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A whitespace-only versionInfo is accepted as versioned, allowing a malformed SPDX entry to pass the fail-closed dependency check. Trim versionInfo before testing it against the empty and NOASSERTION values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/supply-chain/validate-sbom.mjs, line 50:

<comment>A whitespace-only `versionInfo` is accepted as versioned, allowing a malformed SPDX entry to pass the fail-closed dependency check. Trim `versionInfo` before testing it against the empty and `NOASSERTION` values.</comment>

<file context>
@@ -27,10 +35,42 @@ export function versionFromTag(tag) {
+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);
+  }
</file context>
Suggested change
const version = typeof p?.versionInfo === 'string' ? p.versionInfo : '';
const version = typeof p?.versionInfo === 'string' ? p.versionInfo.trim() : '';

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);
Expand All @@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When package.json has no usable name, this condition skips the required root SPDX check entirely. Reject a nameless release manifest before checking the root entry, then make the root check unconditional.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/supply-chain/validate-sbom.mjs, line 107:

<comment>When `package.json` has no usable `name`, this condition skips the required root SPDX check entirely. Reject a nameless release manifest before checking the root entry, then make the root check unconditional.</comment>

<file context>
@@ -54,11 +94,41 @@ export function validateSboms({ tag, pkg, cyclonedx, spdx }) {
+    );
+  }
+  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}`);
+  }
</file context>

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,
};
}

Expand All @@ -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`,
);
}

Expand Down
Loading