feat: add fail-closed signed release provenance

This commit is contained in:
NuklearRabbit
2026-07-29 16:30:02 +02:00
parent 8e580a0b13
commit 4708367b66
6 changed files with 93 additions and 6 deletions
+32 -1
View File
@@ -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)}`,
);
+22
View File
@@ -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"}`);
}
+12
View File
@@ -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");