From 4708367b661475074683aac1eca97be7ef73375a Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:30:02 +0200 Subject: [PATCH] feat: add fail-closed signed release provenance --- SOURCE_MANIFEST.txt | 9 ++++---- package.json | 2 +- scripts/publish-binary-release.cjs | 33 ++++++++++++++++++++++++++- scripts/verify-release-signatures.mjs | 22 ++++++++++++++++++ scripts/write-release-checksums.mjs | 12 ++++++++++ tests/update-service.test.mjs | 21 +++++++++++++++++ 6 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 scripts/verify-release-signatures.mjs diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 198979b..81ead76 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -117,7 +117,7 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md f3e2a262e251eb165f342dc3c184625ec10d56e536b5a987199b9944f463eee9 181069 package-lock.json -5162819eb594c6ff65e73180f2f95c3fd5be940c2ce6b06b0aaf4b297cf9c616 4715 package.json +0692366144cd9e941aeda32757521ae38ff98cf017adbad66e974178d8855c52 4761 package.json 82865e814a82628b778c53ec83bcda26bf1109d8bb14c11208530089f4dc96db 10522 preload.cjs abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1 @@ -130,11 +130,12 @@ fca922d7d1de598a0153f3828300c7d0327e0189c06a53a23d9fc33b36d45d91 4741 6d46dd6826069d842f20f9f22a99042257db936cdea0bee8d294d2d7ea290126 3893 scripts/doctor.mjs f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733 scripts/generate-source-manifest.mjs 842436680521311594e798848b050ae4e488d0595f0de57315f6ec081c049fb9 1266 scripts/prune-dist.mjs -403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs +b83d443f5724ac15393567f3a688aed8315fbe3e5966832c864a9466e0669464 8102 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs +eb23800559dc9c71f40a1584e287c2ca26322ef8673c8179448871fb0d082220 1837 scripts/verify-release-signatures.mjs 20cc93c86efaf4d23851225e031639b093cafb23dabb083e1804cade17651e82 16542 scripts/verify.mjs -0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs +0b9f03ba3c67ff7cdb2916a902ad8ce25e81a7c90b210e4ae52d2ad029efabf3 2353 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs 80e0afcfa77dbc617c425cd603e5c165ea425b5f47046538657bc1dc51d7b1e7 28670 src/main/config-store.cjs @@ -201,7 +202,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627 2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs dde71af691e7e6f1b6a24c1dd5f436d3473ff94db31f987b9bbceeea5661121a 43396 tests/unraid-deployment.test.mjs -4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs +861bad3f118c89bd17acf4373170c208c6e29c89af1d40fb2cf010f587a5016f 19008 tests/update-service.test.mjs 9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md diff --git a/package.json b/package.json index 83dcaf4..4cff32b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "eslint .", "coverage": "c8 --check-coverage --lines 55 --functions 55 --branches 45 --statements 55 node --test tests/*.test.mjs", "verify": "node scripts/verify.mjs", - "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/prune-dist.mjs", + "dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs", "dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs", "dist:mac": "electron-builder --mac dmg && node scripts/prune-dist.mjs", "doctor": "node scripts/doctor.mjs", diff --git a/scripts/publish-binary-release.cjs b/scripts/publish-binary-release.cjs index f752d0e..ad12304 100644 --- a/scripts/publish-binary-release.cjs +++ b/scripts/publish-binary-release.cjs @@ -116,13 +116,20 @@ app.whenReady().then(async () => { target_commitish: commit, name: `ForgeFlow ${version}`, body, - draft: false, + draft: true, prerelease: false, }), }, ); } + if (release.draft !== true) { + release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft: true }), + }); + } const binaries = [ path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`), path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`), @@ -171,6 +178,30 @@ app.whenReady().then(async () => { console.log(`PASS published ${name}`); } } + for (const [name, type] of [ + [`ForgeFlow-${version}-provenance.json`, "application/json"], + [`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"], + ]) { + const bytes = await fs.readFile(path.join(root, "dist", name)); + const existing = (release.assets || []).find((asset) => asset.name === name); + if (existing) await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`, { method: "DELETE" }); + const form = new FormData(); + form.append("attachment", new Blob([bytes], { type }), name); + const uploaded = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: "POST", body: form, timeout: 300_000 }); + release.assets = [...(release.assets || []).filter((asset) => asset.name !== name), uploaded]; + } + const requiredAssets = [ + ...binaries.flatMap((binaryPath) => [path.basename(binaryPath), `${path.basename(binaryPath)}.sha256`]), + `ForgeFlow-${version}-provenance.json`, + `ForgeFlow-${version}-sbom.cdx.json`, + ]; + const missingAssets = requiredAssets.filter((name) => !(release.assets || []).some((asset) => asset.name === name)); + if (missingAssets.length) throw new Error(`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`); + release = await api(baseUrl, token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft: false }), + }); console.log( `PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`, ); diff --git a/scripts/verify-release-signatures.mjs b/scripts/verify-release-signatures.mjs new file mode 100644 index 0000000..108fe1a --- /dev/null +++ b/scripts/verify-release-signatures.mjs @@ -0,0 +1,22 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const root = path.resolve(import.meta.dirname, ".."); +const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); +const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1"; +const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim(); +if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode."); + +const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`)); +for (const artifact of artifacts) { + const script = `$s=Get-AuthenticodeSignature -LiteralPath $args[0]; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`; + const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script, artifact], { windowsHide: true }); + const result = JSON.parse(stdout.trim()); + const valid = result.Status === "Valid" && Boolean(result.TimestampSubject); + const publisherMatches = !expectedPublisher || String(result.Subject || "").includes(expectedPublisher); + if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`); + console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "unsigned development artifact"}`); +} diff --git a/scripts/write-release-checksums.mjs b/scripts/write-release-checksums.mjs index 5d48fe9..d5838e0 100644 --- a/scripts/write-release-checksums.mjs +++ b/scripts/write-release-checksums.mjs @@ -1,12 +1,16 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const execFileAsync = promisify(execFile); const manifest = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); +const artifacts = []; for (const kind of ["Setup", "Portable"]) { const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`; const binary = await readFile(path.join(root, "dist", name)); @@ -17,4 +21,12 @@ for (const kind of ["Setup", "Portable"]) { "utf8", ); console.log(`${name}: ${sha256}`); + artifacts.push({ name, sha256 }); } +const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim(); +const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`); +const provenance = { schemaVersion: 1, product: "ForgeFlow", version: manifest.version, commit, buildId, createdAt: new Date().toISOString(), signedRelease: process.env.FORGEFLOW_SIGNED_RELEASE === "1", expectedPublisher: process.env.FORGEFLOW_EXPECTED_PUBLISHER || null, artifacts }; +await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8"); +const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8")); +const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name)); +await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-sbom.cdx.json`), `${JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${buildId}`, version: 1, metadata: { component: { type: "application", name: "ForgeFlow", version: manifest.version } }, components }, null, 2)}\n`, "utf8"); diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs index 4060184..076caef 100644 --- a/tests/update-service.test.mjs +++ b/tests/update-service.test.mjs @@ -507,6 +507,27 @@ test("packaged updater rejects a binary whose checksum does not match", async () await rm(temp, { recursive: true, force: true }); }); +test("Windows release pipeline fails closed on signatures and emits provenance plus SBOM", async () => { + const [pkgSource, signatureSource, checksumSource] = await Promise.all([ + readFile(new URL("../package.json", import.meta.url), "utf8"), + readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"), + readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"), + ]); + assert.match(pkgSource, /verify-release-signatures\.mjs/); + assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/); + assert.match(signatureSource, /FORGEFLOW_EXPECTED_PUBLISHER/); + assert.match(signatureSource, /TimestampSubject/); + assert.match(signatureSource, /Signed release verification failed/); + assert.match(checksumSource, /provenance\.json/); + assert.match(checksumSource, /sbom\.cdx\.json/); + assert.match(checksumSource, /CycloneDX/); + const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8"); + assert.match(publisher, /draft: true/); + assert.match(publisher, /requiredAssets/); + assert.match(publisher, /Release remains draft because required assets are missing/); + assert.match(publisher, /sbom\.cdx\.json/); +}); + test("binary update helper verifies, waits, applies and records restart state", async () => { const helper = await readFile( new URL("../scripts/apply-binary-update.ps1", import.meta.url),