164 lines
6.7 KiB
TypeScript
164 lines
6.7 KiB
TypeScript
import { randomBytes as cryptoRandomBytes } from "node:crypto";
|
|
|
|
import {
|
|
captureCiCandidateArchive,
|
|
withVerifiedCapturedCandidate,
|
|
type CapturedCandidateArchive,
|
|
} from "./ci-candidate-archive.ts";
|
|
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
|
import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts";
|
|
import { validateProviderUpload } from "./provider-upload-validator.ts";
|
|
|
|
export type ProviderInvocation = Readonly<{
|
|
candidateRoot: string;
|
|
environment: Readonly<Record<string, string>>;
|
|
}>;
|
|
|
|
export async function superviseProviderEvidence(input: Readonly<{
|
|
kind: "vulnerability" | "provenance";
|
|
archivePath: string;
|
|
expectedArchiveSha256: string;
|
|
expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>;
|
|
trust: ProviderTrust;
|
|
executeProvider: (invocation: ProviderInvocation) => Promise<void>;
|
|
captureReport: () => Promise<Buffer>;
|
|
}>, dependencies: Readonly<{
|
|
captureArchive?: typeof captureCiCandidateArchive;
|
|
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
|
|
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
|
|
validateUpload?: typeof validateProviderUpload;
|
|
randomBytes?: (bytes: number) => Buffer;
|
|
nowEpochMs?: () => number;
|
|
}> = {}): Promise<Readonly<{
|
|
evidence: unknown;
|
|
invocationNonce: string;
|
|
expectedContext: ExpectedPromotionContext;
|
|
}>> {
|
|
const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
|
archivePath: input.archivePath,
|
|
expectedSha256: input.expectedArchiveSha256,
|
|
});
|
|
const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32);
|
|
if (nonceBytes.byteLength !== 32) {
|
|
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
|
|
}
|
|
const invocationNonce = nonceBytes.toString("hex");
|
|
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
|
|
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
|
|
captured,
|
|
verify: async ({ extractionRoot, manifest }) => {
|
|
const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({
|
|
extractionRoot,
|
|
expectedManifest: manifest,
|
|
});
|
|
if (local.status !== "PASS" || !local.identity) {
|
|
throw new Error(
|
|
`provider candidate local assessment failed: ${local.failures.join("; ")}`,
|
|
);
|
|
}
|
|
if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) {
|
|
throw new Error("provider candidate source revision mismatch");
|
|
}
|
|
const expectedContext: ExpectedPromotionContext = Object.freeze({
|
|
run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }),
|
|
source: Object.freeze({
|
|
revision: local.identity.sourceRevision,
|
|
sourceSetSha256: local.identity.sourceSetSha256,
|
|
}),
|
|
candidate: Object.freeze({
|
|
archiveSha256: captured.archiveSha256,
|
|
bundleSha256: manifest.bundleSha256,
|
|
distSha256: manifest.distSha256,
|
|
lockfileSha256: manifest.lockfileSha256,
|
|
}),
|
|
secretScanAttestation: Object.freeze({
|
|
status: "PASS" as const,
|
|
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
|
sourceSetSha256: local.identity.sourceSetSha256,
|
|
policySha256: local.identity.secretScan.policySha256,
|
|
sarifSha256: local.identity.secretScan.sarifSha256,
|
|
scanInputSha256: local.identity.secretScan.scanInputSha256,
|
|
}),
|
|
vulnerabilityInvocationNonce:
|
|
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
|
|
provenanceInvocationNonce:
|
|
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
|
|
});
|
|
const issuedNow = nowEpochMs();
|
|
const issuedAt = new Date(issuedNow).toISOString();
|
|
const expiresAt = new Date(issuedNow + 60 * 60 * 1_000).toISOString();
|
|
await input.executeProvider({
|
|
candidateRoot: extractionRoot,
|
|
environment: providerInvocationEnvironment({
|
|
kind: input.kind,
|
|
expectedContext,
|
|
invocationNonce,
|
|
issuedAt,
|
|
expiresAt,
|
|
trust: input.trust,
|
|
}),
|
|
});
|
|
const capturedReport = await input.captureReport();
|
|
const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({
|
|
kind: input.kind,
|
|
verifiedManifest: manifest,
|
|
archiveSha256: captured.archiveSha256,
|
|
candidateRoot: extractionRoot,
|
|
capturedReport,
|
|
expectedContext,
|
|
trust: input.trust,
|
|
nowEpochMs,
|
|
});
|
|
return Object.freeze({ evidence, invocationNonce, expectedContext });
|
|
},
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function providerInvocationEnvironment(input: Readonly<{
|
|
kind: "vulnerability" | "provenance";
|
|
expectedContext: ExpectedPromotionContext;
|
|
invocationNonce: string;
|
|
issuedAt: string;
|
|
expiresAt: string;
|
|
trust: ProviderTrust;
|
|
}>): Readonly<Record<string, string>> {
|
|
return Object.freeze({
|
|
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
|
PROVIDER_EVIDENCE_TYPE:
|
|
input.kind === "vulnerability"
|
|
? "vulnerability-report"
|
|
: "provenance-attestation",
|
|
PROVIDER_ISSUED_AT: input.issuedAt,
|
|
PROVIDER_EXPIRES_AT: input.expiresAt,
|
|
PROVIDER_INVOCATION_NONCE: input.invocationNonce,
|
|
PROVIDER_KEY_ID: input.trust.keyId,
|
|
PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint,
|
|
CI_RUN_ID: input.expectedContext.run.id,
|
|
CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt),
|
|
SOURCE_REVISION: input.expectedContext.source.revision,
|
|
SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256,
|
|
CANDIDATE_ROOT: "/candidate",
|
|
CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml",
|
|
CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256,
|
|
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
|
|
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
|
|
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
|
|
SECRET_SCAN_STATUS: input.expectedContext.secretScanAttestation.status,
|
|
SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256:
|
|
input.expectedContext.secretScanAttestation.localEvidenceAssessmentSha256,
|
|
SECRET_SCAN_SOURCE_SET_SHA256:
|
|
input.expectedContext.secretScanAttestation.sourceSetSha256,
|
|
SECRET_SCAN_POLICY_SHA256:
|
|
input.expectedContext.secretScanAttestation.policySha256,
|
|
SECRET_SCAN_SARIF_SHA256:
|
|
input.expectedContext.secretScanAttestation.sarifSha256,
|
|
SECRET_SCAN_INPUT_SHA256:
|
|
input.expectedContext.secretScanAttestation.scanInputSha256,
|
|
});
|
|
}
|
|
|
|
export type CaptureArchiveDependency = (
|
|
input: Readonly<{ archivePath: string; expectedSha256: string }>,
|
|
) => Promise<CapturedCandidateArchive>;
|