fix: close immutable promotion trust gaps

This commit is contained in:
DongHyeonka
2026-08-02 06:39:41 +09:00
parent 7c5ed80407
commit 92e5cace5c
16 changed files with 1228 additions and 274 deletions
+294 -2
View File
@@ -1,4 +1,4 @@
import { generateKeyPairSync, sign } from "node:crypto";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -29,6 +29,8 @@ import {
RELEASE_CANDIDATE_EVIDENCE_PATHS,
verifyReleaseCandidate,
} from "../../scripts/lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts";
import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts";
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
const dependency = {
@@ -64,23 +66,240 @@ function signedProviderEvidence(
};
}
async function createMinimalCandidateTree(root: string) {
const rawLockfile = "lockfileVersion: '9.0'\n";
const rawLockfileSha256 = createHash("sha256")
.update(rawLockfile)
.digest("hex");
await mkdir(path.join(root, "dist"), { recursive: true });
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile);
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
const value =
file === "artifacts/release/dependency-inventory.json"
? { lockfileSha256: rawLockfileSha256 }
: file === "artifacts/security/supply-chain-verification.json"
? { localStatus: "PASS" }
: { fixture: file };
await writeFile(path.join(root, file), `${JSON.stringify(value)}\n`);
}
const manifest = await createReleaseCandidateManifest(root);
await writeFile(
path.join(root, "artifacts/release/release-candidate.json"),
`${JSON.stringify(manifest)}\n`,
);
return manifest;
}
async function writeProviderEnvironment(
root: string,
distDigest: string,
candidateLockfileSha256: string,
) {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: candidateLockfileSha256,
scannedDistSha256: distDigest,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
);
const provenanceAttestation = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
await mkdir(path.join(root, "provider"), { recursive: true });
await Promise.all([
writeFile(
path.join(root, "provider/vulnerability.json"),
`${JSON.stringify(vulnerabilityReport)}\n`,
),
writeFile(
path.join(root, "provider/provenance.json"),
`${JSON.stringify(provenanceAttestation)}\n`,
),
writeFile(
path.join(root, "provider/vulnerability.pem"),
vulnerabilityKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
writeFile(
path.join(root, "provider/provenance.pem"),
provenanceKeys.publicKey
.export({ type: "spki", format: "pem" })
.toString(),
),
]);
return {
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
VULNERABILITY_KEY_ID: "fixture-vulnerability-key",
PROVENANCE_PUBLIC_KEY_PATH: "provider/provenance.pem",
PROVENANCE_KEY_ID: "fixture-provenance-key",
} satisfies NodeJS.ProcessEnv;
}
describe("supply-chain policy", () => {
it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-"));
try {
const manifest = await createMinimalCandidateTree(root);
const validEnvironment = await writeProviderEnvironment(
root,
manifest.distSha256,
manifest.lockfileSha256,
);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
failures: [] as const,
});
const valid = await verifyPromotionInputs({
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
const absent = await verifyPromotionInputs({
repositoryRoot: root,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
});
const wrongEnvironment = await writeProviderEnvironment(
root,
"3".repeat(64),
manifest.lockfileSha256,
);
const wrongDigest = await verifyPromotionInputs({
repositoryRoot: root,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
const postAttestationMutation = await verifyPromotionInputs({
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
});
expect({
valid: valid.status,
absent: absent.status,
wrongDigest: wrongDigest.status,
postAttestationMutation: postAttestationMutation.status,
}).toEqual({
valid: "PASS",
absent: "FAIL_UNVERIFIED",
wrongDigest: "FAIL_UNVERIFIED",
postAttestationMutation: "FAIL_UNVERIFIED",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a self-consistent candidate that merely claims localStatus PASS", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-"));
try {
const manifest = await createMinimalCandidateTree(root);
const environment = await writeProviderEnvironment(
root,
manifest.distSha256,
manifest.lockfileSha256,
);
const localVerificationPath = path.join(
root,
"artifacts/security/supply-chain-verification.json",
);
const before = await readFile(localVerificationPath, "utf8");
const result = await verifyPromotionInputs({
repositoryRoot: root,
environment,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
expect(result.failures).toEqual(
expect.arrayContaining([
expect.stringMatching(/executable schema mismatch/u),
"local supply-chain evidence is not PASS",
]),
);
expect(await readFile(localVerificationPath, "utf8")).toBe(before);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("derives a stable supply-chain timestamp from the immutable build epoch", () => {
const input = {
generatedAt: "2026-08-01T00:00:00.000Z",
sourceDateEpoch: "1785542400",
};
expect(deterministicSupplyChainGeneratedAt(input)).toBe(
"2026-08-01T00:00:00.000Z",
);
expect(deterministicSupplyChainGeneratedAt(input)).toBe(
deterministicSupplyChainGeneratedAt({ ...input }),
);
expect(() =>
deterministicSupplyChainGeneratedAt({
generatedAt: "not-a-time",
sourceDateEpoch: "1785542400",
}),
).toThrow(/generatedAt/u);
expect(() =>
deterministicSupplyChainGeneratedAt({
generatedAt: "2026-08-01T00:00:00.000Z",
sourceDateEpoch: "1785542401",
}),
).toThrow(/SOURCE_DATE_EPOCH/u);
});
it("rejects release candidate dist bytes changed after manifest creation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "release-candidate-"));
try {
const rawLockfile = "lockfileVersion: '9.0'\n";
const rawLockfileSha256 = createHash("sha256")
.update(rawLockfile)
.digest("hex");
await mkdir(path.join(root, "dist/.vite"), { recursive: true });
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
await writeFile(path.join(root, "dist/.vite/metadata.json"), "{}\n");
await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile);
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
await writeFile(
path.join(root, file),
file === "artifacts/release/dependency-inventory.json"
? `${JSON.stringify({ lockfileSha256 })}\n`
? `${JSON.stringify({ lockfileSha256: rawLockfileSha256 })}\n`
: `${file}\n`,
);
}
const manifest = await createReleaseCandidateManifest(root);
expect(manifest.lockfileSha256).toBe(rawLockfileSha256);
expect(manifest.files).toContainEqual(
expect.objectContaining({
path: "pnpm-lock.yaml",
sha256: rawLockfileSha256,
}),
);
expect(await createReleaseCandidateManifest(root)).toEqual(manifest);
expect((await verifyReleaseCandidate(manifest, root)).failures).toEqual(
[],
);
@@ -100,6 +319,30 @@ describe("supply-chain policy", () => {
}
});
it("rejects a dependency inventory digest that differs from raw pnpm-lock bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "release-lockfile-"));
try {
await mkdir(path.join(root, "dist"), { recursive: true });
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
await writeFile(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
if (file === "pnpm-lock.yaml") continue;
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
await writeFile(
path.join(root, file),
file === "artifacts/release/dependency-inventory.json"
? `${JSON.stringify({ lockfileSha256 })}\n`
: `${file}\n`,
);
}
await expect(createReleaseCandidateManifest(root)).rejects.toThrow(
/raw pnpm-lock digest mismatch/u,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("fails promotion when external provider evidence is absent", () => {
const result = evaluatePromotionEvidence({
candidate: {
@@ -285,6 +528,55 @@ describe("supply-chain policy", () => {
);
});
it("rejects Ed448 keys mislabeled as Ed25519 evidence", () => {
const vulnerabilityKeys = generateKeyPairSync("ed448");
const provenanceKeys = generateKeyPairSync("ed448");
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: candidateDistSha256,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
);
const provenanceAttestation = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: {
name: "dist",
digest: { sha256: candidateDistSha256 },
},
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
expect(
evaluatePromotionEvidence({
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
currentDistSha256: candidateDistSha256,
localStatus: "PASS",
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
}).status,
).toBe("FAIL_UNVERIFIED");
});
it.each([
["empty", []],
["empty entry", [""]],