From 5588a9ad2bfc3b98d738e3d9da68ca83af9a4136 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Sat, 1 Aug 2026 08:18:18 +1000 Subject: [PATCH 1/2] feat(release): reconcile the attested SBOM against the staged install verify-sbom-scope gains an opt-in --reconcile that reads the frozen prod staging tree's real package manifests (never the pnpm dir-name encoding) and requires the attested SBOM's {name@version} set to match, catching a syft parsing regression or an altered SBOM that carries syntactically-valid-but-wrong versions past the syntax-only checks. The flag is off by default (live-release path byte-unchanged); publish.yml runs it as a NON-gating shadow step to collect pinned-syft-vs-derivation evidence on real staging trees before it can be promoted to a gate. The staging self-package is excluded by exact name@version, so a forged self entry is still flagged. Fail-closed throughout (exit 2 on any staging read failure or malformed manifest; empty inventory can only fail, never pass). --- .github/workflows/publish.yml | 14 ++ scripts/verify-sbom-scope.mjs | 207 ++++++++++++++++- test/build/verify-sbom-scope.test.ts | 330 ++++++++++++++++++++++++++- 3 files changed, 539 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c5b52a0..9630768 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -165,6 +165,20 @@ jobs: - name: Verify SBOM is scoped to the shipped runtime run: node scripts/verify-sbom-scope.mjs sbom.spdx.json + # SHADOW (non-gating) reconciliation. Runs the opt-in --reconcile check + # against the same frozen prod staging tree the SBOM was generated from, + # comparing the attested SBOM's {name@version} set to the tree's real + # package manifests. `|| true` keeps it NON-gating: a mismatch prints to + # the run log but never fails a live publish. Purpose: collect evidence + # that the manifest-derived inventory equals what the pinned syft (v1.42.3) + # catalogs, on real staging trees, BEFORE promoting this to a gate. + # ENABLEMENT CRITERIA (do this to make it gating): once the log shows a + # clean reconcile across a release (no "unexpected"/"missing"/"version + # mismatch" lines), fold `--reconcile "$RUNNER_TEMP/sbom-src"` into the + # gating step above and delete this shadow step. + - name: Reconcile SBOM against staged install (shadow, non-gating) + run: node scripts/verify-sbom-scope.mjs sbom.spdx.json --reconcile "$RUNNER_TEMP/sbom-src" || true + - name: Package .vsix run: pnpm exec vsce package --no-dependencies -o extension.vsix diff --git a/scripts/verify-sbom-scope.mjs b/scripts/verify-sbom-scope.mjs index 454bb27..b02a49f 100644 --- a/scripts/verify-sbom-scope.mjs +++ b/scripts/verify-sbom-scope.mjs @@ -15,10 +15,20 @@ // only the self-package — fails the release instead of shipping a misleading // attestation. Fail-closed by design. // -// Usage: node scripts/verify-sbom-scope.mjs +// Usage: node scripts/verify-sbom-scope.mjs [--reconcile ] // Exit: 0 pass, 1 scope violation, 2 usage/parse error. +// +// The optional `--reconcile ` flag additionally reconciles the +// SBOM's {name, version} set against an independent inventory read from the +// frozen prod staging tree's REAL package manifests (see deriveInstalledInventory). +// It is OPT-IN and NOT yet a gating CI check: publish.yml runs it only as a +// non-gating shadow step, collecting evidence that the manifest-derived +// inventory matches what the pinned syft (v1.42.3) catalogs on a real staging +// tree, before it is promoted to a gate. Absent the flag, behaviour is +// unchanged. -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; import { pathToFileURL } from "node:url"; // A resolved npm version must be an exact, anchored semver core. Prerelease @@ -85,10 +95,162 @@ export function checkSbomScope({ sbom, dependencies, devDependencies, requiredTr return { ok: errors.length === 0, errors, npmCount: pkgs.length }; } +// Known shipped transitive prod deps (see NOTICE — packages whose code the +// bundle contains but which are not direct dependencies). If the CodeMirror +// dep tree ever drops one, NOTICE + notice-covers-bundled-deps.test.ts go +// stale first; update all three together. +export const REQUIRED_TRANSITIVE = [ + "@marijn/find-cluster-break", + "crelt", + "style-mod", + "w3c-keyname", +]; + +// Parse the optional `--reconcile ` flag. Pure (no fs/exit) so the +// missing-value branch is unit-testable; main() maps `error` to exit 2. +export function resolveReconcileArg(argv) { + const i = argv.indexOf("--reconcile"); + if (i === -1) { + return { stagingDir: null, error: null }; + } + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + return { stagingDir: null, error: "--reconcile requires a path" }; + } + return { stagingDir: value, error: null }; +} + +// Strict reconciliation (opt-in via main()'s --reconcile): the SBOM's npm +// {name, version} set must EQUAL the inventory read from the staged install's +// real package manifests, after dropping `ignore` (exact `name@version` keys — +// the staging self-package, which syft catalogs from the root manifest but is +// never in .pnpm) from both sides. Compared per name on the SET of versions so +// a co-installed dual-major is classified correctly: a version on one side only +// is unexpected/missing; a name whose version sets are FULLY DISJOINT is +// surfaced as the legible "version mismatch". Catches syntactically-valid-but- +// wrong versions and any set drift the syntax-only checkSbomScope() passes. +export function reconcileSbomInventory({ sbom, installed, ignore }) { + const ignoreKeys = new Set(ignore ?? []); + const key = (p) => `${p.name}@${p.version}`; + const versionsByName = (pkgs) => { + const m = new Map(); + for (const p of pkgs) { + if (ignoreKeys.has(key(p))) { + continue; // drop self-package at its exact version + } + if (!m.has(p.name)) { + m.set(p.name, new Set()); + } + m.get(p.name).add(p.version); + } + return m; + }; + const inst = versionsByName(installed ?? []); + const sb = versionsByName(npmPackages(sbom)); + + const errors = []; + for (const name of new Set([...inst.keys(), ...sb.keys()])) { + const iv = inst.get(name) ?? new Set(); + const sv = sb.get(name) ?? new Set(); + const surplus = [...sv].filter((v) => !iv.has(v)); // in SBOM, not installed + const deficit = [...iv].filter((v) => !sv.has(v)); // installed, not in SBOM + if (surplus.length === 0 && deficit.length === 0) { + continue; + } + const shareAVersion = [...sv].some((v) => iv.has(v)); + if (!shareAVersion && surplus.length && deficit.length) { + errors.push( + `version mismatch for ${name}: SBOM has ${[...sv].sort().join(", ")}, ` + + `staged install has ${[...iv].sort().join(", ")}` + ); + } else { + for (const v of surplus) { + errors.push(`package in SBOM but not in staged install (unexpected): ${name}@${v}`); + } + for (const v of deficit) { + errors.push(`package in staged install but missing from SBOM: ${name}@${v}`); + } + } + } + return { ok: errors.length === 0, errors }; +} + +// The real (non-symlink) leaf package dirs inside one .pnpm//node_modules: +// pnpm hard-links a package's own files here and symlinks its deps, so the real +// leaf is the package itself. Scoped names add one "@scope" dir level. +function realLeafManifestDirs(innerNM) { + const leaves = []; + for (const e of readdirSync(innerNM, { withFileTypes: true })) { + if (e.name === ".bin" || e.isSymbolicLink() || !e.isDirectory()) { + continue; + } + if (e.name.startsWith("@")) { + const scopeDir = path.join(innerNM, e.name); + for (const g of readdirSync(scopeDir, { withFileTypes: true })) { + if (g.isSymbolicLink() || !g.isDirectory()) { + continue; + } + leaves.push(path.join(scopeDir, g.name)); + } + } else { + leaves.push(path.join(innerNM, e.name)); + } + } + return leaves; +} + +// fs adapter: derive the expected {name, version} inventory of the frozen prod +// staging tree from its installed package MANIFESTS (not the pnpm dir-name +// encoding — that is not a stable contract). This is the independent second +// source reconciled against the SBOM. Fail-closed: throws on an unreadable +// .pnpm, any .pnpm/ with no readable manifest, or a manifest missing +// name/version — so main() exits 2 rather than silently passing a partial +// inventory. +export function deriveInstalledInventory(stagingDir) { + const dotPnpm = path.join(stagingDir, "node_modules", ".pnpm"); + const inventory = []; + for (const d of readdirSync(dotPnpm, { withFileTypes: true })) { + if (!d.isDirectory() || d.name === "node_modules") { + continue; + } + const innerNM = path.join(dotPnpm, d.name, "node_modules"); + const leaves = realLeafManifestDirs(innerNM); + if (leaves.length === 0) { + throw new Error(`verify-sbom-scope: no package manifest under ${innerNM}`); + } + for (const leaf of leaves) { + // JSON.parse throws on invalid JSON (→ exit 2). A well-formed manifest + // missing name/version would otherwise yield {undefined, undefined} and + // slip through as an exit-1 reconcile diff — validate to keep the adapter + // contract fail-closed. + const manifest = JSON.parse(readFileSync(path.join(leaf, "package.json"), "utf8")); + if ( + typeof manifest.name !== "string" || + !manifest.name || + typeof manifest.version !== "string" || + !manifest.version + ) { + throw new Error(`verify-sbom-scope: manifest missing name/version at ${leaf}`); + } + inventory.push({ name: manifest.name, version: manifest.version }); + } + } + return inventory; +} + function main() { const sbomPath = process.argv[2]; if (!sbomPath) { - console.error("usage: node scripts/verify-sbom-scope.mjs "); + console.error( + "usage: node scripts/verify-sbom-scope.mjs [--reconcile ]" + ); + process.exit(2); + } + // Resolve the optional --reconcile flag up front so a missing value fails + // fast (exit 2) before any file read. + const { stagingDir, error: argError } = resolveReconcileArg(process.argv); + if (argError) { + console.error(`verify-sbom-scope: ${argError}`); process.exit(2); } let sbom; @@ -106,17 +268,11 @@ function main() { process.exit(2); } - // Known shipped transitive prod deps (see NOTICE — packages whose code the - // bundle contains but which are not direct dependencies). If the CodeMirror - // dep tree ever drops one, NOTICE + notice-covers-bundled-deps.test.ts go - // stale first; update all three together. - const requiredTransitive = ["@marijn/find-cluster-break", "crelt", "style-mod", "w3c-keyname"]; - const { ok, errors, npmCount } = checkSbomScope({ sbom, dependencies: pkg.dependencies, devDependencies: pkg.devDependencies, - requiredTransitive, + requiredTransitive: REQUIRED_TRANSITIVE, }); if (!ok) { @@ -125,6 +281,37 @@ function main() { } process.exit(1); } + + // Opt-in strict reconciliation against the staged install (runs only after + // the syntax gate passes). + if (stagingDir) { + let installed; + try { + installed = deriveInstalledInventory(stagingDir); + } catch (err) { + console.error( + `::error::verify-sbom-scope: cannot read staged install at ${stagingDir}: ${err.message}` + ); + process.exit(2); + } + // Exclude the staging self-package at its EXACT resolved version: syft + // catalogs the staging-root manifest (pkg.name@pkg.version), never present + // in .pnpm. Excluding by exact key (not bare name) keeps a forged self + // entry at any other version flagged. + const rec = reconcileSbomInventory({ + sbom, + installed, + ignore: [`${pkg.name}@${pkg.version}`], + }); + if (!rec.ok) { + for (const e of rec.errors) { + console.error(`::error::verify-sbom-scope: ${e}`); + } + process.exit(1); + } + console.log(`verify-sbom-scope: reconciled ${installed.length} staged packages against SBOM.`); + } + console.log(`verify-sbom-scope: OK — ${npmCount} npm packages, runtime-scoped, exact versions.`); process.exit(0); } diff --git a/test/build/verify-sbom-scope.test.ts b/test/build/verify-sbom-scope.test.ts index 504a224..407c0d3 100644 --- a/test/build/verify-sbom-scope.test.ts +++ b/test/build/verify-sbom-scope.test.ts @@ -2,8 +2,19 @@ // the attested SBOM scoped to the shipped runtime closure (no dev tooling, // exact resolved versions, declared + known-transitive runtime deps present). // @ts-nocheck — importing a plain .mjs with no bundled types; vitest runs it fine. -import { describe, expect, it } from "vitest"; -import { checkSbomScope } from "../../scripts/verify-sbom-scope.mjs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + checkSbomScope, + deriveInstalledInventory, + REQUIRED_TRANSITIVE, + reconcileSbomInventory, + resolveReconcileArg, +} from "../../scripts/verify-sbom-scope.mjs"; // Minimal SPDX-shaped npm package factory (purl external ref → npm package). const pkg = (name, version) => ({ @@ -166,3 +177,318 @@ describe("checkSbomScope", () => { expect(r.npmCount).toBe(4); }); }); + +// --- Shared fixtures for the --reconcile suites ----------------------------- + +// Build a fake prod staging tree: one real manifest per .pnpm/, deps as +// symlinks (which the adapter must skip). Mirrors pnpm's isolated layout. +function buildStaging(root, packages) { + const dotPnpm = join(root, "node_modules", ".pnpm"); + for (const p of packages) { + const enc = p.name.replace("/", "+"); + const innerNM = join(dotPnpm, `${enc}@${p.version}`, "node_modules"); + const leaf = join(innerNM, ...p.name.split("/")); + mkdirSync(leaf, { recursive: true }); + writeFileSync(join(leaf, "package.json"), JSON.stringify({ name: p.name, version: p.version })); + for (const dep of p.deps ?? []) { + const link = join(innerNM, ...dep.split("/")); + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(join(dotPnpm, "target", "node_modules", ...dep.split("/")), link, "dir"); + } + } +} + +describe("resolveReconcileArg", () => { + it("returns nulls when the flag is absent", () => { + expect(resolveReconcileArg(["node", "s.mjs", "sbom.json"])).toEqual({ + stagingDir: null, + error: null, + }); + }); + it("returns the staging dir when a value follows", () => { + const r = resolveReconcileArg(["node", "s.mjs", "sbom.json", "--reconcile", "/tmp/x"]); + expect(r.stagingDir).toBe("/tmp/x"); + expect(r.error).toBeNull(); + }); + it("errors when --reconcile has no value (end of argv)", () => { + const r = resolveReconcileArg(["node", "s.mjs", "sbom.json", "--reconcile"]); + expect(r.stagingDir).toBeNull(); + expect(r.error).toMatch(/reconcile/i); + }); + it("errors when --reconcile is followed by another flag", () => { + const r = resolveReconcileArg(["node", "s.mjs", "sbom.json", "--reconcile", "--other"]); + expect(r.error).toMatch(/reconcile/i); + }); +}); + +describe("reconcileSbomInventory", () => { + const installed = [ + { name: "@codemirror/state", version: "6.6.0" }, + { name: "@lezer/common", version: "1.5.2" }, + { name: "crelt", version: "1.0.6" }, + ]; + const sbomOf = (pkgs) => ({ packages: pkgs }); + + it("passes when the SBOM set + versions match the install exactly", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([ + pkg("@codemirror/state", "6.6.0"), + pkg("@lezer/common", "1.5.2"), + pkg("crelt", "1.0.6"), + ]), + installed, + }); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + }); + + it("fails on a syntactically-valid-but-WRONG version (the core regression)", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([ + pkg("@codemirror/state", "0.0.0"), + pkg("@lezer/common", "1.5.2"), + pkg("crelt", "1.0.6"), + ]), + installed, + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/version mismatch/i); + expect(r.errors.join(" ")).toContain("@codemirror/state"); + expect(r.errors.join(" ")).toContain("0.0.0"); + expect(r.errors.join(" ")).toContain("6.6.0"); + }); + + it("fails when the SBOM carries a package not in the install (unexpected)", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([ + pkg("@codemirror/state", "6.6.0"), + pkg("@lezer/common", "1.5.2"), + pkg("crelt", "1.0.6"), + pkg("evil-pkg", "9.9.9"), + ]), + installed, + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/unexpected/i); + expect(r.errors.join(" ")).toContain("evil-pkg"); + }); + + it("fails when an installed package is missing from the SBOM (degenerate scan)", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([pkg("@codemirror/state", "6.6.0"), pkg("@lezer/common", "1.5.2")]), + installed, + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/missing from SBOM/i); + expect(r.errors.join(" ")).toContain("crelt"); + }); + + it("fails closed when the derived inventory is empty (every SBOM pkg unexpected)", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([pkg("@codemirror/state", "6.6.0")]), + installed: [], + }); + expect(r.ok).toBe(false); + }); + + it("ignores the staging self-package at its exact version — syft emits the root manifest", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([ + pkg("quoll", "0.1.65"), + pkg("@codemirror/state", "6.6.0"), + pkg("@lezer/common", "1.5.2"), + pkg("crelt", "1.0.6"), + ]), + installed, + ignore: ["quoll@0.1.65"], + }); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + }); + + it("still flags a forged self-package at the WRONG version (exact-key ignore)", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([ + pkg("quoll", "9.9.9"), + pkg("@codemirror/state", "6.6.0"), + pkg("@lezer/common", "1.5.2"), + pkg("crelt", "1.0.6"), + ]), + installed, + ignore: ["quoll@0.1.65"], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/unexpected/i); + expect(r.errors.join(" ")).toContain("quoll@9.9.9"); + }); + + it("dual-major overlap: surplus SBOM version is 'unexpected', not a version mismatch", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([pkg("dep", "1.0.0"), pkg("dep", "2.0.0")]), + installed: [{ name: "dep", version: "1.0.0" }], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/unexpected/i); + expect(r.errors.join(" ")).toContain("dep@2.0.0"); + expect(r.errors.join(" ")).not.toMatch(/version mismatch/i); + }); + + it("dual-major overlap: missing install version is 'missing', not a version mismatch", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([pkg("dep", "1.0.0")]), + installed: [ + { name: "dep", version: "1.0.0" }, + { name: "dep", version: "2.0.0" }, + ], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/missing from SBOM/i); + expect(r.errors.join(" ")).toContain("dep@2.0.0"); + expect(r.errors.join(" ")).not.toMatch(/version mismatch/i); + }); + + it("fully-disjoint version sets for a name → version mismatch", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([pkg("dep", "3.0.0"), pkg("dep", "4.0.0")]), + installed: [ + { name: "dep", version: "1.0.0" }, + { name: "dep", version: "2.0.0" }, + ], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/version mismatch/i); + }); + + it("mixed boundary: a name that shares one version yet has surplus AND deficit", () => { + const r = reconcileSbomInventory({ + sbom: sbomOf([pkg("dep", "1.0.0"), pkg("dep", "2.0.0")]), + installed: [ + { name: "dep", version: "1.0.0" }, + { name: "dep", version: "3.0.0" }, + ], + }); + expect(r.ok).toBe(false); + expect(r.errors.join(" ")).toMatch(/unexpected/i); + expect(r.errors.join(" ")).toContain("dep@2.0.0"); + expect(r.errors.join(" ")).toMatch(/missing from SBOM/i); + expect(r.errors.join(" ")).toContain("dep@3.0.0"); + expect(r.errors.join(" ")).not.toMatch(/version mismatch/i); + }); +}); + +describe("deriveInstalledInventory", () => { + let root = ""; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "sbom-fix-")); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("reads real manifests and skips symlinked deps", () => { + buildStaging(root, [ + { name: "@codemirror/state", version: "6.6.0", deps: ["@marijn/find-cluster-break"] }, + { name: "crelt", version: "1.0.6" }, + ]); + const inv = deriveInstalledInventory(root).sort((a, b) => a.name.localeCompare(b.name)); + expect(inv).toEqual([ + { name: "@codemirror/state", version: "6.6.0" }, + { name: "crelt", version: "1.0.6" }, + ]); + }); + + it("throws when the staging .pnpm dir is missing (fail-closed)", () => { + expect(() => deriveInstalledInventory(join(root, "nope"))).toThrow(); + }); + + it("throws when a .pnpm entry has no readable manifest (malformed)", () => { + mkdirSync(join(root, "node_modules", ".pnpm", "broken@1.0.0", "node_modules"), { + recursive: true, + }); + expect(() => deriveInstalledInventory(root)).toThrow(); + }); + + it("throws on invalid JSON in a manifest (fail-closed, not exit 1)", () => { + const leaf = join(root, "node_modules", ".pnpm", "bad@1.0.0", "node_modules", "bad"); + mkdirSync(leaf, { recursive: true }); + writeFileSync(join(leaf, "package.json"), "{ not json"); + expect(() => deriveInstalledInventory(root)).toThrow(); + }); + + it.each([ + ["missing name", { version: "1.0.0" }], + ["missing version", { name: "bad" }], + ["empty object", {}], + ])("throws when a manifest is %s (name/version contract)", (_label, manifest) => { + const leaf = join(root, "node_modules", ".pnpm", "bad@1.0.0", "node_modules", "bad"); + mkdirSync(leaf, { recursive: true }); + writeFileSync(join(leaf, "package.json"), JSON.stringify(manifest)); + expect(() => deriveInstalledInventory(root)).toThrow(); + }); +}); + +describe("verify-sbom-scope CLI exit codes", () => { + const SCRIPT = fileURLToPath(new URL("../../scripts/verify-sbom-scope.mjs", import.meta.url)); + const PKG = JSON.parse( + readFileSync(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8") + ); + + const runCli = (args) => { + try { + const stdout = execFileSync("node", [SCRIPT, ...args], { encoding: "utf8" }); + return { code: 0, stdout }; + } catch (err) { + return { code: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } + }; + + // Synthetic but syntax-gate-valid inventory: every declared runtime dep + + // every known transitive, all at exact "1.0.0". SBOM and staging are built + // from this ONE list so they reconcile. + const INVENTORY = [...Object.keys(PKG.dependencies ?? {}), ...REQUIRED_TRANSITIVE].map( + (name) => ({ name, version: "1.0.0" }) + ); + + const writeSbom = (file, inv) => { + const packages = inv.map((p) => ({ + name: p.name, + versionInfo: p.version, + externalRefs: [{ referenceType: "purl", referenceLocator: `pkg:npm/${p.name}@${p.version}` }], + })); + writeFileSync(file, JSON.stringify({ packages })); + }; + + let root = ""; + let sbomPath = ""; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "sbom-cli-")); + sbomPath = join(root, "sbom.spdx.json"); + writeSbom(sbomPath, INVENTORY); + buildStaging(join(root, "staging"), INVENTORY); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("exit 0: default path, no flag (live-release path unchanged)", () => { + expect(runCli([sbomPath]).code).toBe(0); + }); + + it("exit 0: --reconcile against a matching staging tree", () => { + expect(runCli([sbomPath, "--reconcile", join(root, "staging")]).code).toBe(0); + }); + + it("exit 0: SBOM carrying the syft self-package entry still reconciles", () => { + writeSbom(sbomPath, [{ name: PKG.name, version: PKG.version }, ...INVENTORY]); + expect(runCli([sbomPath, "--reconcile", join(root, "staging")]).code).toBe(0); + }); + + it("exit 1: --reconcile catches a wrong-but-valid version in the SBOM", () => { + writeSbom(sbomPath, [{ name: INVENTORY[0].name, version: "0.0.0" }, ...INVENTORY.slice(1)]); + expect(runCli([sbomPath, "--reconcile", join(root, "staging")]).code).toBe(1); + }); + + it("exit 2: --reconcile with a missing value", () => { + expect(runCli([sbomPath, "--reconcile"]).code).toBe(2); + }); + + it("exit 2: --reconcile against an unreadable staging dir", () => { + expect(runCli([sbomPath, "--reconcile", join(root, "does-not-exist")]).code).toBe(2); + }); +}); From 3d486260c5e9d7029a455722189f2da5b57ce289 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Sat, 1 Aug 2026 08:29:50 +1000 Subject: [PATCH 2/2] test(release): pin the npm-purl discriminator in the reconcile suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcileSbomInventory filters SBOM packages through npmPackages() so a no-purl SPDX document-root or a non-npm (pypi) entry is not misclassified as 'unexpected'. No reconcile-side test pinned that filter — dropping it kept every test green. Add a reconcile case feeding a no-purl root + a pypi entry and asserting a clean reconcile, so removing the discriminator fails. --- test/build/verify-sbom-scope.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/build/verify-sbom-scope.test.ts b/test/build/verify-sbom-scope.test.ts index 407c0d3..9f762c6 100644 --- a/test/build/verify-sbom-scope.test.ts +++ b/test/build/verify-sbom-scope.test.ts @@ -291,6 +291,25 @@ describe("reconcileSbomInventory", () => { expect(r.ok).toBe(false); }); + it("skips non-npm SPDX packages (no-purl root + non-npm purl) via the npm discriminator", () => { + // A real SPDX doc carries a no-purl document-root package and may carry + // non-npm (e.g. pypi) entries. reconcileSbomInventory must skip both via + // npmPackages(); otherwise they'd be misclassified as "unexpected". Pins + // the discriminator: iterating sbom.packages directly makes this fail. + const r = reconcileSbomInventory({ + sbom: sbomOf([ + pkg("@codemirror/state", "6.6.0"), + pkg("@lezer/common", "1.5.2"), + pkg("crelt", "1.0.6"), + nonNpmPkg("some-pylib", ">=1.2"), // pypi purl → skipped + { name: "sbom-src", versionInfo: "NOASSERTION", externalRefs: [] }, // no purl → skipped + ]), + installed, + }); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + }); + it("ignores the staging self-package at its exact version — syft emits the root manifest", () => { const r = reconcileSbomInventory({ sbom: sbomOf([