Files
clean-architecture-frontend…/scripts/lib/secret-scan-evaluator.ts
T

284 lines
8.4 KiB
TypeScript

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<string>;
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<ReturnType<typeof evaluateSecretScan>>,
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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}