diff --git a/.github/workflows/v2.1-artifact-qualification.yml b/.github/workflows/v2.1-artifact-qualification.yml index f5f41c6..877b7a0 100644 --- a/.github/workflows/v2.1-artifact-qualification.yml +++ b/.github/workflows/v2.1-artifact-qualification.yml @@ -2,10 +2,11 @@ name: v2.1 Artifact Qualification on: pull_request: - branches: [develop] + branches: [develop, main] paths: - .github/workflows/v2.1-artifact-qualification.yml - scripts/prepare-v2.1-npm-artifacts.mjs + - scripts/npm-provenance-identity.mjs workflow_dispatch: inputs: source_commit: diff --git a/scripts/stable-publication-recovery.mjs b/scripts/stable-publication-recovery.mjs new file mode 100644 index 0000000..4bdfb18 --- /dev/null +++ b/scripts/stable-publication-recovery.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +// Offline, content-addressed verification of the additive Registry correction. +// Historical releases without a correction retain their original checks. +export function verifyPublicationRecovery(record, qualification, readBytes) { + if (record.version !== "2.1.0" && record.provenanceRecovery === undefined) return; + assert.equal(record.version, "2.1.0", "Unsupported provenance recovery version"); + const reference = record.provenanceRecovery; + assert.ok(reference, "v2.1.0 publication requires its additive provenance recovery"); + const expectedPath = "release/stable/v2.1.0/v2.1.0-provenance-recovery.json"; + assert.equal(reference.path, expectedPath, "Recovery reference must use the versioned repository path"); + assert.match(reference.sha256 ?? "", /^[a-f0-9]{64}$/); + const bytes = readBytes(expectedPath); + assert.equal(crypto.createHash("sha256").update(bytes).digest("hex"), reference.sha256, "Recovery file SHA-256 changed"); + const recovery = JSON.parse(bytes.toString("utf8")); + assert.equal(recovery.schemaVersion, "scanly-v2.1-provenance-recovery-1"); + assert.equal(recovery.version, record.version); + assert.equal(recovery.package, "@scanly/url-safety"); + assert.equal(recovery.sourceCommit, qualification.identity.productSourceCommit); + assert.deepEqual(recovery.qualificationManifest, record.qualificationManifest); + assert.ok(Number.isSafeInteger(reference.runId) && reference.runId > 0, "Recovery run ID must be a positive integer"); + assert.equal(recovery.runId, reference.runId, "Recovery run ID changed"); + assert.ok(Number.isSafeInteger(recovery.runAttempt) && recovery.runAttempt > 0); + const publication = record.npm.find((entry) => entry.name === recovery.package); + assert.equal(publication?.provenanceIdentity?.invocationId, `https://github.com/Yangjunjie-Lin/Scanly/actions/runs/${reference.runId}/attempts/${recovery.runAttempt}`, "Published provenance does not identify the recovery run"); + assert.equal(recovery.npmCredentialsUsedByCI, false); + // The original proof is outside the replacement evidence directory and is + // deliberately not replaced. Preserve both sides of the additive audit trail. + for (const [key, expected] of [ + ["originalProvenance", "release/stable/v2.1.0/artifacts/provenance/scanly-url-safety-2.1.0.tgz.sigstore.json"], + ["replacementProvenance", "release/stable/v2.1.0/publication-recovery/provenance-A/scanly-url-safety-2.1.0.tgz.sigstore.json"], + ]) { + assert.equal(recovery[key]?.path, expected, `${key} must retain its exact versioned path`); + assert.match(recovery[key].sha256 ?? "", /^[a-f0-9]{64}$/); + assert.equal(crypto.createHash("sha256").update(readBytes(expected)).digest("hex"), recovery[key].sha256, `${key} SHA-256 changed`); + } + assert.equal(recovery.originalProvenance.registryAccepted, false); + assert.equal(recovery.artifact?.path, "release/stable/v2.1.0/artifacts/npm/scanly-url-safety-2.1.0.tgz"); + const artifactBytes = readBytes(recovery.artifact.path); + assert.equal(crypto.createHash("sha256").update(artifactBytes).digest("hex"), recovery.artifact.sha256, "Recovered artifact SHA-256 changed"); + assert.equal(crypto.createHash("sha512").update(artifactBytes).digest("hex"), recovery.artifact.sha512, "Recovered artifact SHA-512 changed"); + assert.equal(publication.integrity, `sha512-${Buffer.from(recovery.artifact.sha512, "hex").toString("base64")}`, "Recovered artifact does not match the published integrity"); + assert.ok(Array.isArray(recovery.evidence) && recovery.evidence.length === 26, "Recovery must retain both build reports, 22 proofs, workflow evidence and reproducibility report"); + const seen = new Set(); + for (const evidence of recovery.evidence) { + assert.ok(typeof evidence.path === "string" && evidence.path.startsWith("release/stable/v2.1.0/publication-recovery/") && !evidence.path.includes("..") && !evidence.path.includes("\\"), "Unsafe recovery evidence path"); + assert.ok(!seen.has(evidence.path), "Duplicate recovery evidence"); seen.add(evidence.path); + assert.match(evidence.sha256 ?? "", /^[a-f0-9]{64}$/); + assert.equal(crypto.createHash("sha256").update(readBytes(evidence.path)).digest("hex"), evidence.sha256, `Recovery evidence changed: ${evidence.path}`); + } + assert.ok(recovery.evidence.some((entry) => entry.path === recovery.replacementProvenance?.path && entry.sha256 === recovery.replacementProvenance.sha256), "Replacement provenance must be covered by the verified evidence set"); +} diff --git a/scripts/verify-stable-publication-record.mjs b/scripts/verify-stable-publication-record.mjs index ea43b34..141d4a5 100644 --- a/scripts/verify-stable-publication-record.mjs +++ b/scripts/verify-stable-publication-record.mjs @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { verifyPublicationRecovery } from "./stable-publication-recovery.mjs"; const root = path.resolve(import.meta.dirname, ".."); const stableRoot = path.join(root, "release", "stable"); @@ -43,6 +44,7 @@ for (const recordPath of recordPaths) { if (sha256(qualificationPath) !== record.qualificationManifest.sha256) fail(`${version}: qualification manifest hash changed.`); if (qualification.version !== version || qualification.stable !== "V2_STABLE_RELEASE_GO") fail(`${version}: qualification identity is not the frozen Stable GO record.`); + verifyPublicationRecovery(record, qualification, (relative) => fs.readFileSync(path.join(root, relative))); const tagObject = git("rev-parse", record.gitTag.name); const tagTarget = git("rev-parse", `${record.gitTag.name}^{}`); diff --git a/tests/unit/stable-publication-recovery.test.ts b/tests/unit/stable-publication-recovery.test.ts new file mode 100644 index 0000000..90017c5 --- /dev/null +++ b/tests/unit/stable-publication-recovery.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import crypto from "node:crypto"; +// @ts-expect-error release-only MJS has no SDK declaration +import { verifyPublicationRecovery } from "../../scripts/stable-publication-recovery.mjs"; + +const root = "release/stable/v2.1.0"; +const publication = () => JSON.parse(fs.readFileSync(`${root}/v2.1.0-publication-record.json`, "utf8")); +const qualification = JSON.parse(fs.readFileSync(`${root}/v2.1.0-manifest.json`, "utf8")); +const readBytes = (file: string) => fs.readFileSync(file); + +describe("immutable publication provenance recovery reference", () => { + it("verifies the actual additive record without rewriting it", () => { + expect(() => verifyPublicationRecovery(publication(), qualification, readBytes)).not.toThrow(); + }); + it.each(["2.0.0", "2.0.1"])("retains historical %s verification without requiring a new record", (version) => { + expect(() => verifyPublicationRecovery({ version }, {}, () => { throw new Error("unexpected read"); })).not.toThrow(); + }); + it("fails if the recovery reference or file is missing", () => { + const record = publication(); delete record.provenanceRecovery; + expect(() => verifyPublicationRecovery(record, qualification, readBytes)).toThrow(); + expect(() => verifyPublicationRecovery(publication(), qualification, () => { throw new Error("ENOENT"); })).toThrow(); + }); + it.each(["path", "sha256", "runId"])("rejects a substituted reference %s", (field) => { + const record = publication(); record.provenanceRecovery[field] = field === "runId" ? 1 : "substituted"; + expect(() => verifyPublicationRecovery(record, qualification, readBytes)).toThrow(); + }); + it("rejects changed bytes even if JSON remains valid", () => { + expect(() => verifyPublicationRecovery(publication(), qualification, (file: string) => Buffer.concat([readBytes(file), Buffer.from(" ")]))).toThrow(/SHA-256/); + }); + it.each(["schemaVersion", "version", "package", "sourceCommit", "runId", "runAttempt"])("validates recovery %s independently of its digest", (field) => { + const record = publication(); const recovery = JSON.parse(readBytes(record.provenanceRecovery.path).toString()); + recovery[field] = field.startsWith("run") ? -1 : "substituted"; + const bytes = Buffer.from(JSON.stringify(recovery)); record.provenanceRecovery.sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + expect(() => verifyPublicationRecovery(record, qualification, () => bytes)).toThrow(); + }); + it("binds the correction to the published URL Safety attestation", () => { + const record = publication(); record.npm.find((entry: { name: string }) => entry.name === "@scanly/url-safety").provenanceIdentity.invocationId = "substituted"; + expect(() => verifyPublicationRecovery(record, qualification, readBytes)).toThrow(); + }); + it("rejects changes or deletion of any referenced proof", () => { + expect(() => verifyPublicationRecovery(publication(), qualification, (file: string) => file.includes("provenance-B/") ? Buffer.from("changed") : readBytes(file))).toThrow(/evidence changed/); + expect(() => verifyPublicationRecovery(publication(), qualification, (file: string) => { if (file.includes("provenance-A/")) throw new Error("ENOENT"); return readBytes(file); })).toThrow(); + }); + it.each(["changed", "missing"])("rejects a %s original proof outside the replacement directory", (mode) => { + const originalPath = `${root}/artifacts/provenance/scanly-url-safety-2.1.0.tgz.sigstore.json`; + expect(() => verifyPublicationRecovery(publication(), qualification, (file: string) => { + if (file !== originalPath) return readBytes(file); + if (mode === "missing") throw new Error("ENOENT"); + return Buffer.from("changed"); + })).toThrow(); + }); + it.each(["originalProvenance", "replacementProvenance", "artifact"])("rejects substituted %s paths even with an updated record digest", (key) => { + const record = publication(); const recovery = JSON.parse(readBytes(record.provenanceRecovery.path).toString()); + recovery[key].path = "substituted"; + const bytes = Buffer.from(JSON.stringify(recovery)); record.provenanceRecovery.sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + expect(() => verifyPublicationRecovery(record, qualification, (file: string) => file === record.provenanceRecovery.path ? bytes : readBytes(file))).toThrow(); + }); + it("binds the recovery tarball to the published Registry integrity", () => { + const record = publication(); record.npm.find((entry: { name: string }) => entry.name === "@scanly/url-safety").integrity = "sha512-substituted"; + expect(() => verifyPublicationRecovery(record, qualification, readBytes)).toThrow(/published integrity/); + }); + it("rejects an evidence reference outside the versioned recovery directory", () => { + const record = publication(); const recovery = JSON.parse(readBytes(record.provenanceRecovery.path).toString()); + recovery.evidence[0].path = "release/stable/v2.1.0/publication-recovery/../../outside.json"; + const bytes = Buffer.from(JSON.stringify(recovery)); record.provenanceRecovery.sha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + expect(() => verifyPublicationRecovery(record, qualification, (file: string) => file === record.provenanceRecovery.path ? bytes : readBytes(file))).toThrow(/Unsafe/); + }); + it("triggers real artifact qualification for helper-only changes on both maintained branches", () => { + const workflow = fs.readFileSync(".github/workflows/v2.1-artifact-qualification.yml", "utf8"); + const pullRequest = workflow.split(" pull_request:")[1].split(" workflow_dispatch:")[0]; + expect(pullRequest).toContain("branches: [develop, main]"); + expect(pullRequest).toContain("- scripts/npm-provenance-identity.mjs"); + }); +});