import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { buildRepositoryFileInventory, parseRepositoryFileInventoryPolicy, } from "./repository-file-inventory.ts"; import { findSecretMatches, secretScanRules, type SecretAllowlistEntry, type SecretFinding, } from "./secret-scan.ts"; import { parseSecretScanIncludedPaths, selectIncludedInventoryFiles, } from "./secret-scan-policy.ts"; import { supplyChainDigest } from "./supply-chain.ts"; const nonEmptyString = z.string().min(1); const sarifRuleSchema = z .object({ id: nonEmptyString, shortDescription: z.object({ text: nonEmptyString }).strict(), }) .strict(); const sarifResultSchema = z .object({ ruleId: nonEmptyString, message: z.object({ text: nonEmptyString }).strict(), partialFingerprints: z .object({ primaryLocationLineHash: nonEmptyString }) .strict() .optional(), locations: z .array( z .object({ physicalLocation: z .object({ artifactLocation: z.object({ uri: nonEmptyString }).strict(), region: z.object({ startLine: z.int().positive() }).strict(), }) .strict(), }) .strict(), ) .optional(), }) .strict(); export const secretScanSarifSchema = z .object({ version: z.literal("2.1.0"), $schema: z.literal("https://json.schemastore.org/sarif-2.1.0.json"), runs: z .array( z .object({ tool: z .object({ driver: z .object({ name: z.literal("ca-frontend-secret-scan"), rules: z.array(sarifRuleSchema), }) .strict(), }) .strict(), results: z.array(sarifResultSchema), }) .strict(), ) .length(1), }) .strict(); type AllowlistEntry = SecretAllowlistEntry & Readonly<{ owner: string; reason: string }>; export type SecretScanPolicy = Readonly<{ excludedPaths: readonly string[]; trackedRoots: readonly string[]; generatedRoots: readonly string[]; optionalRoots: readonly string[]; includedPaths: readonly string[] | null; allowlist: readonly AllowlistEntry[]; }>; export function parseSecretScanPolicy(value: unknown): SecretScanPolicy { const document = isRecord(value) ? value : {}; const inventoryPolicy = parseRepositoryFileInventoryPolicy(value); const allowlist = Array.isArray(document.allowlist) ? document.allowlist.map((rawEntry) => { const entry = isRecord(rawEntry) ? rawEntry : {}; return Object.freeze({ path: typeof entry.path === "string" ? entry.path : "", ruleId: typeof entry.ruleId === "string" ? entry.ruleId : "", owner: typeof entry.owner === "string" ? entry.owner : "", reason: typeof entry.reason === "string" ? entry.reason : "", expiresAt: typeof entry.expiresAt === "string" ? entry.expiresAt : "", }); }) : []; return Object.freeze({ excludedPaths: Object.freeze(strings(document.excludedPaths)), trackedRoots: inventoryPolicy.trackedRoots, generatedRoots: inventoryPolicy.generatedRoots, optionalRoots: inventoryPolicy.optionalRoots, includedPaths: parseSecretScanIncludedPaths(document.includedPaths), allowlist: Object.freeze(allowlist), }); } export async function evaluateRepositorySecretScan(input: Readonly<{ repositoryRoot?: string; policyPath?: string; now?: number; }>) { const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd()); const policy = parseSecretScanPolicy( JSON.parse( await readFile( path.resolve( repositoryRoot, input.policyPath ?? "config/security/secret-scan-policy.json", ), "utf8", ), ) as unknown, ); const inventory = await buildRepositoryFileInventory({ repositoryRoot, trackedRoots: policy.trackedRoots, generatedRoots: policy.generatedRoots, optionalRoots: policy.optionalRoots, }); return evaluateSecretScan({ policy, inventoryFiles: inventory.files, readText: (file) => readFile(path.join(repositoryRoot, file), "utf8"), now: input.now, }); } export async function evaluateSecretScan(input: Readonly<{ policy: SecretScanPolicy; inventoryFiles: readonly string[]; readText: (file: string) => Promise; now?: number; }>) { const now = input.now ?? Date.now(); const findings: SecretFinding[] = []; const policyFailures: string[] = []; for (const entry of input.policy.allowlist) { const expiry = Date.parse(entry.expiresAt); if ( !entry.path.startsWith("tests/") || !entry.owner.trim() || !entry.reason.trim() || !Number.isFinite(expiry) || expiry <= now ) { policyFailures.push( `invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`, ); } } const scanFiles = selectIncludedInventoryFiles( input.inventoryFiles, input.policy.includedPaths, ); const excluded = new Set( input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")), ); const scanInputs: Readonly<{ path: string; bytes: number; sha256: string }>[] = []; for (const scanFile of [...new Set(scanFiles)].sort()) { const normalized = scanFile.replaceAll("\\", "/"); if ( [...excluded].some( (entry) => normalized === entry || normalized.startsWith(`${entry}/`), ) || /\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/iu.test(normalized) ) { continue; } const content = await input.readText(scanFile); const bytes = Buffer.from(content, "utf8"); scanInputs.push(Object.freeze({ path: normalized, bytes: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex"), })); findings.push( ...findSecretMatches(normalized, content, { allowlist: input.policy.allowlist, now, }), ); } const sarif = secretScanSarifSchema.parse({ version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [ { tool: { driver: { name: "ca-frontend-secret-scan", rules: secretScanRules().map((pattern) => ({ id: pattern.id, shortDescription: { text: "Potential credential material" }, })), }, }, results: [ ...findings.map((finding) => ({ ruleId: finding.ruleId, message: { text: "Potential secret material must be removed." }, partialFingerprints: { primaryLocationLineHash: finding.fingerprint, }, locations: [ { physicalLocation: { artifactLocation: { uri: finding.file }, region: { startLine: finding.line }, }, }, ], })), ...policyFailures.map((failure) => ({ ruleId: "invalid-allowlist", message: { text: failure }, })), ], }, ], }); return Object.freeze({ findings: Object.freeze(findings), policyFailures: Object.freeze(policyFailures), scanFiles: Object.freeze([...scanFiles]), scanInputs: Object.freeze(scanInputs), scanInputSha256: supplyChainDigest(scanInputs), sarif, }); } export function verifyStoredSecretScan( evaluation: Awaited>, stored: unknown, ): string[] { const failures: string[] = []; const blockingCount = evaluation.findings.length + evaluation.policyFailures.length; if (blockingCount > 0) { failures.push( `recomputed secret scan contains ${blockingCount} blocking result(s)`, ); } const parsed = secretScanSarifSchema.safeParse(stored); if ( !parsed.success || supplyChainDigest(parsed.data) !== supplyChainDigest(evaluation.sarif) ) { failures.push("stored secret scan SARIF does not match recomputed results"); } return failures; } function strings(value: unknown): string[] { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }