fix: harden provider and promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 16:28:24 +09:00
parent 42ffb79997
commit 30ceac23c1
29 changed files with 3961 additions and 1076 deletions
+201 -195
View File
@@ -23,6 +23,7 @@ import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.
import {
evaluatePromotionEvidence,
providerEvidenceSignaturePayload,
providerPublicKeyFingerprint,
providerVerificationArtifactSchema,
} from "../../scripts/lib/provider-evidence.ts";
import {
@@ -51,17 +52,41 @@ const dependency = {
const candidateDistSha256 = "1".repeat(64);
const lockfileSha256 = "2".repeat(64);
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
const sourceIdentity = Object.freeze({
revision: "a".repeat(40),
sourceSetSha256: "b".repeat(64),
});
const localIdentity = Object.freeze({
sourceRevision: sourceIdentity.revision,
sourceSetSha256: sourceIdentity.sourceSetSha256,
assessmentSha256: "c".repeat(64),
});
const expectedProviderContext = Object.freeze({
run: Object.freeze({ id: "fixture-run", attempt: 1 }),
source: sourceIdentity,
candidate: Object.freeze({
archiveSha256: "3".repeat(64),
bundleSha256: "4".repeat(64),
distSha256: candidateDistSha256,
lockfileSha256,
}),
vulnerabilityInvocationNonce: "5".repeat(64),
provenanceInvocationNonce: "6".repeat(64),
});
function signedProviderEvidence(
value: Record<string, unknown>,
keyId: string,
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
publicKeyFingerprint: string,
) {
return {
...value,
signature: {
algorithm: "Ed25519",
keyId,
publicKeyFingerprint,
value: sign(
null,
providerEvidenceSignaturePayload(value),
@@ -71,6 +96,53 @@ function signedProviderEvidence(
};
}
function providerPair(input: Readonly<{
vulnerabilityKeys: ReturnType<typeof generateKeyPairSync>;
provenanceKeys: ReturnType<typeof generateKeyPairSync>;
candidate?: typeof expectedProviderContext.candidate;
vulnerabilityFingerprint?: string;
provenanceFingerprint?: string;
}>) {
const candidate = input.candidate ?? expectedProviderContext.candidate;
const vulnerabilityFingerprint = input.vulnerabilityFingerprint ??
providerPublicKeyFingerprint(input.vulnerabilityKeys.publicKey);
const provenanceFingerprint = input.provenanceFingerprint ??
providerPublicKeyFingerprint(input.provenanceKeys.publicKey);
return {
vulnerabilityReport: signedProviderEvidence({
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability-provider",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.vulnerabilityInvocationNonce },
source: expectedProviderContext.source,
candidate,
findings: [],
}, "fixture-vulnerability-key", input.vulnerabilityKeys.privateKey, vulnerabilityFingerprint),
provenanceAttestation: signedProviderEvidence({
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance-provider",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.provenanceInvocationNonce },
source: expectedProviderContext.source,
candidate,
signer: "fixture-workload-identity",
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
}, "fixture-provenance-key", input.provenanceKeys.privateKey, provenanceFingerprint),
};
}
function providerTrust(
keyId: string,
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
publicKeyFingerprint = providerPublicKeyFingerprint(publicKey),
) {
return { keyId, publicKey, publicKeyFingerprint };
}
async function createMinimalCandidateTree(root: string) {
const rawLockfile = "lockfileVersion: '9.0'\n";
const rawLockfileSha256 = createHash("sha256")
@@ -100,37 +172,54 @@ async function createMinimalCandidateTree(root: string) {
async function writeProviderEnvironment(
root: string,
distDigest: string,
candidateLockfileSha256: string,
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
overrides: Readonly<{ distSha256?: string }> = {},
) {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const archiveBytes = "fixture archive\n";
const candidateIdentity = {
archiveSha256: createHash("sha256").update(archiveBytes).digest("hex"),
bundleSha256: candidate.bundleSha256,
distSha256: overrides.distSha256 ?? candidate.distSha256,
lockfileSha256: candidate.lockfileSha256,
};
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 1,
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: candidateLockfileSha256,
scannedDistSha256: distDigest,
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { id: "fixture-run", attempt: 1, invocationNonce: "5".repeat(64) },
source: sourceIdentity,
candidate: candidateIdentity,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
);
const provenanceAttestation = signedProviderEvidence(
{
schemaVersion: 1,
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { id: "fixture-run", attempt: 1, invocationNonce: "6".repeat(64) },
source: sourceIdentity,
candidate: candidateIdentity,
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
providerPublicKeyFingerprint(provenanceKeys.publicKey),
);
await mkdir(path.join(root, "provider"), { recursive: true });
await Promise.all([
writeFile(path.join(root, "provider/candidate.tar.gz"), "fixture archive\n"),
writeFile(path.join(root, "provider/candidate.tar.gz"), archiveBytes),
writeFile(
path.join(root, "provider/vulnerability.json"),
`${JSON.stringify(vulnerabilityReport)}\n`,
@@ -155,8 +244,13 @@ async function writeProviderEnvironment(
return {
CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz",
CANDIDATE_ARCHIVE_SHA256: createHash("sha256")
.update("fixture archive\n")
.update(archiveBytes)
.digest("hex"),
CI_RUN_ID: "fixture-run",
CI_RUN_ATTEMPT: "1",
EXPECTED_SOURCE_REVISION: sourceIdentity.revision,
VULNERABILITY_INVOCATION_NONCE: "5".repeat(64),
PROVENANCE_INVOCATION_NONCE: "6".repeat(64),
VULNERABILITY_REPORT_PATH: "provider/vulnerability.json",
PROVENANCE_ATTESTATION_PATH: "provider/provenance.json",
VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem",
@@ -167,32 +261,37 @@ async function writeProviderEnvironment(
}
describe("supply-chain policy", () => {
it("emits a strict role-bound v2 verification record from exact input bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v2-"));
it("emits a strict role-bound v3 verification record from exact input bytes", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v3-"));
try {
const manifest = await createMinimalCandidateTree(root);
const environment = await writeProviderEnvironment(
root,
manifest.distSha256,
manifest.lockfileSha256,
);
const environment = await writeProviderEnvironment(root, manifest);
const report = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment,
verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }),
} as Parameters<typeof verifyPromotionInputs>[0]);
verifyLocalEvidence: async () => ({
status: "PASS" as const,
identity: localIdentity,
failures: [] as const,
}),
nowEpochMs: () => NOW,
});
expect(providerVerificationArtifactSchema.parse(report)).toEqual(
expect.objectContaining({
schemaVersion: 2,
schemaVersion: 3,
artifactType: "provider-verification",
candidateArchiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
vulnerabilityReportSha256: createHash("sha256")
.update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!)))
.digest("hex"),
provenanceAttestationSha256: createHash("sha256")
.update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!)))
.digest("hex"),
candidate: expect.objectContaining({
archiveSha256: environment.CANDIDATE_ARCHIVE_SHA256,
}),
providerEvidence: expect.objectContaining({
vulnerabilityReportSha256: createHash("sha256")
.update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!)))
.digest("hex"),
provenanceAttestationSha256: createHash("sha256")
.update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!)))
.digest("hex"),
}),
}),
);
} finally {
@@ -204,13 +303,10 @@ describe("supply-chain policy", () => {
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 validEnvironment = await writeProviderEnvironment(root, manifest);
const acceptLocalEvidence = async () => ({
status: "PASS" as const,
identity: localIdentity,
failures: [] as const,
});
const valid = await verifyPromotionInputs({
@@ -218,23 +314,34 @@ describe("supply-chain policy", () => {
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const absent = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: {},
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const replayedNonce = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: {
...validEnvironment,
VULNERABILITY_INVOCATION_NONCE: "9".repeat(64),
},
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
const wrongEnvironment = await writeProviderEnvironment(root, manifest, {
distSha256: "3".repeat(64),
});
const wrongEnvironment = await writeProviderEnvironment(
root,
"3".repeat(64),
manifest.lockfileSha256,
);
const wrongDigest = await verifyPromotionInputs({
artifactType: "provider-verification",
repositoryRoot: root,
environment: wrongEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
const postAttestationMutation = await verifyPromotionInputs({
@@ -242,19 +349,23 @@ describe("supply-chain policy", () => {
repositoryRoot: root,
environment: validEnvironment,
verifyLocalEvidence: acceptLocalEvidence,
nowEpochMs: () => NOW,
});
expect({
valid: valid.status,
absent: absent.status,
wrongDigest: wrongDigest.status,
replayedNonce: replayedNonce.status,
postAttestationMutation: postAttestationMutation.status,
}).toEqual({
valid: "PASS",
absent: "FAIL_UNVERIFIED",
wrongDigest: "FAIL_UNVERIFIED",
replayedNonce: "FAIL_UNVERIFIED",
postAttestationMutation: "FAIL_UNVERIFIED",
});
expect(replayedNonce.failures).toContain("vulnerability report invocation nonce mismatch");
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -264,11 +375,7 @@ describe("supply-chain policy", () => {
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 environment = await writeProviderEnvironment(root, manifest);
const localVerificationPath = path.join(
root,
"artifacts/security/supply-chain-verification.json",
@@ -278,12 +385,13 @@ describe("supply-chain policy", () => {
artifactType: "provider-verification",
repositoryRoot: root,
environment,
nowEpochMs: () => NOW,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
expect(result.failures).toEqual(
expect.arrayContaining([
expect.stringMatching(/executable schema mismatch/u),
"local evidence assessment is missing or invalid",
"local supply-chain evidence is not PASS",
]),
);
@@ -393,16 +501,13 @@ describe("supply-chain policy", () => {
it("fails promotion when external provider evidence is absent", () => {
const result = evaluatePromotionEvidence({
candidate: {
distSha256: candidateDistSha256,
lockfileSha256,
},
currentDistSha256: candidateDistSha256,
expected: expectedProviderContext,
localStatus: "PASS",
vulnerabilityReport: null,
provenanceAttestation: null,
vulnerabilityTrust: null,
provenanceTrust: null,
nowEpochMs: () => NOW,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
@@ -411,50 +516,25 @@ describe("supply-chain policy", () => {
it("passes only signed provider evidence for the exact immutable candidate", () => {
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: 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,
);
const { vulnerabilityReport, provenanceAttestation } = providerPair({
vulnerabilityKeys,
provenanceKeys,
});
const result = evaluatePromotionEvidence({
candidate: {
distSha256: candidateDistSha256,
lockfileSha256,
},
currentDistSha256: candidateDistSha256,
expected: expectedProviderContext,
localStatus: "PASS",
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
vulnerabilityTrust: providerTrust(
"fixture-vulnerability-key",
vulnerabilityKeys.publicKey,
),
provenanceTrust: providerTrust(
"fixture-provenance-key",
provenanceKeys.publicKey,
),
nowEpochMs: () => NOW,
});
expect(result).toMatchObject({
@@ -469,54 +549,27 @@ describe("supply-chain policy", () => {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const wrongDistSha256 = "3".repeat(64);
const vulnerabilityReport = signedProviderEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: wrongDistSha256,
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: wrongDistSha256 } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
);
const { vulnerabilityReport, provenanceAttestation } = providerPair({
vulnerabilityKeys,
provenanceKeys,
candidate: { ...expectedProviderContext.candidate, distSha256: wrongDistSha256 },
});
const result = evaluatePromotionEvidence({
candidate: {
distSha256: candidateDistSha256,
lockfileSha256,
},
currentDistSha256: candidateDistSha256,
expected: expectedProviderContext,
localStatus: "PASS",
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => NOW,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
expect(result.failures).toEqual(
expect.arrayContaining([
"vulnerability report dist digest mismatch",
"provenance attestation dist digest mismatch",
"vulnerability report candidate identity mismatch",
"provenance attestation candidate identity mismatch",
]),
);
});
@@ -524,103 +577,56 @@ describe("supply-chain policy", () => {
it("rejects candidate bytes changed after provider attestation", () => {
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: 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,
);
const { vulnerabilityReport, provenanceAttestation } = providerPair({
vulnerabilityKeys,
provenanceKeys,
});
const result = evaluatePromotionEvidence({
candidate: {
distSha256: candidateDistSha256,
lockfileSha256,
expected: {
...expectedProviderContext,
candidate: { ...expectedProviderContext.candidate, distSha256: "4".repeat(64) },
},
currentDistSha256: "4".repeat(64),
localStatus: "PASS",
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => NOW,
});
expect(result.status).toBe("FAIL_UNVERIFIED");
expect(result.failures).toContain(
"candidate dist bytes changed after immutable build",
);
expect(result.failures).toContain("vulnerability report candidate identity mismatch");
});
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,
);
const fakeFingerprint = `sha256:${"7".repeat(64)}`;
const { vulnerabilityReport, provenanceAttestation } = providerPair({
vulnerabilityKeys,
provenanceKeys,
vulnerabilityFingerprint: fakeFingerprint,
provenanceFingerprint: fakeFingerprint,
});
expect(
evaluatePromotionEvidence({
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
currentDistSha256: candidateDistSha256,
expected: expectedProviderContext,
localStatus: "PASS",
vulnerabilityReport,
provenanceAttestation,
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
publicKeyFingerprint: fakeFingerprint,
},
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
publicKeyFingerprint: fakeFingerprint,
},
nowEpochMs: () => NOW,
}).status,
).toBe("FAIL_UNVERIFIED");
});