273 lines
7.9 KiB
TypeScript
273 lines
7.9 KiB
TypeScript
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("\\", "/")),
|
|
);
|
|
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;
|
|
}
|
|
findings.push(
|
|
...findSecretMatches(normalized, await input.readText(scanFile), {
|
|
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]),
|
|
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);
|
|
}
|