diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs index e5f238245..dd53e407c 100644 --- a/scripts/release-evidence.mjs +++ b/scripts/release-evidence.mjs @@ -9,6 +9,7 @@ import { writeFileSync, } from "node:fs"; import { basename, resolve } from "node:path"; +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const EXPECTED_SBOM_NAME = "noema.cdx.json"; @@ -204,8 +205,14 @@ function run() { } let sbom; try { + if (hasDuplicateJsonObjectKeys(sbomText)) { + fail("SBOM contains duplicate decoded JSON object keys"); + } sbom = JSON.parse(sbomText); } catch (error) { + if (error instanceof Error && error.message === "SBOM contains duplicate decoded JSON object keys") { + throw error; + } fail(`SBOM is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); } const sbomSummary = validateSbom(sbom, identity.version); @@ -271,4 +278,4 @@ try { const message = error instanceof Error ? error.message : String(error); console.error(`release-evidence: FAIL: ${message.slice(0, 1000)}`); process.exitCode = 1; -} +} \ No newline at end of file diff --git a/test/release-sbom-json-integrity.test.ts b/test/release-sbom-json-integrity.test.ts new file mode 100644 index 000000000..389a2e370 --- /dev/null +++ b/test/release-sbom-json-integrity.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const repository = "ContextualWisdomLab/noema"; +const commitSha = "a".repeat(40); + +function runReleaseEvidence(temp: string, sbomText: string) { + const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); + const sbomPath = join(temp, "noema.cdx.json"); + const outputDir = join(temp, "release"); + writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sbomPath, sbomText, "utf8"); + + const result = spawnSync( + process.execPath, + [ + "scripts/release-evidence.mjs", + "--source", + sourcePath, + "--sbom", + sbomPath, + "--output-dir", + outputDir, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + GITHUB_REPOSITORY: repository, + GITHUB_SHA: commitSha, + GITHUB_REF: "refs/tags/v0.1.0", + NOEMA_RELEASE_VERSION: "0.1.0", + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T00:00:00.000Z", + }, + }, + ); + + return { result, outputDir }; +} + +describe("release SBOM JSON integrity", () => { + it("rejects duplicate decoded SBOM keys before last-key-wins parsing", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-sbom-integrity-")); + try { + const sbomText = `{ + "$schema":"http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat":"SPDX", + "bomF\\u006frmat":"CycloneDX", + "specVersion":"1.5", + "serialNumber":"urn:uuid:00000000-0000-4000-8000-000000000001", + "version":1, + "metadata":{"component":{"type":"application","name":"noema","version":"0.1.0","bom-ref":"noema@0.1.0"}}, + "components":[], + "dependencies":[{"ref":"noema@0.1.0","dependsOn":[]}] + }`; + + const { result, outputDir } = runReleaseEvidence(temp, sbomText); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("duplicate decoded JSON object keys"); + expect(() => readFileSync(join(outputDir, "release-evidence.json"))).toThrow(); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +});