fix: close immutable promotion trust gaps
This commit is contained in:
@@ -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", [""]],
|
||||
|
||||
Reference in New Issue
Block a user