feat: add fail-closed signed release provenance
This commit is contained in:
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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"}`);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user