Files
clean-architecture-frontend…/scripts/security-scan.ts
T

185 lines
5.5 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import {
findSecretMatches,
secretScanRules,
type SecretFinding,
} from "./lib/secret-scan.ts";
type AllowlistEntry = Readonly<{
path: string;
ruleId: string;
owner: string;
reason: string;
expiresAt: string;
}>;
type SecretPolicy = Readonly<{
excludedPaths: readonly string[];
trackedRoots: readonly string[];
generatedRoots: readonly string[];
optionalRoots: readonly string[];
includedPaths: readonly string[];
allowlist: readonly AllowlistEntry[];
}>;
function argumentValue(name: string, fallback: string): string {
const index = process.argv.indexOf(name);
return index >= 0 && process.argv[index + 1]
? process.argv[index + 1]
: fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function strings(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function parsePolicy(value: unknown): SecretPolicy {
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 {
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: Object.freeze(strings(document.includedPaths)),
allowlist: Object.freeze(allowlist),
});
}
const policyPath = argumentValue(
"--policy",
"config/security/secret-scan-policy.json",
);
const artifactPath = argumentValue(
"--artifact",
"artifacts/security/scan.sarif",
);
const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8"));
const policy = parsePolicy(rawPolicy);
const findings: SecretFinding[] = [];
const policyFailures: string[] = [];
const patterns = secretScanRules();
const excluded = new Set(
policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
);
const included = policy.includedPaths.map((entry) =>
entry.replaceAll("\\", "/"),
);
const allowlist = policy.allowlist;
for (const entry of allowlist) {
const expiry = Date.parse(entry.expiresAt);
if (
!entry.path.startsWith("tests/") ||
!entry.owner.trim() ||
!entry.reason.trim() ||
!Number.isFinite(expiry) ||
expiry <= Date.now()
) {
policyFailures.push(
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
);
}
}
const inventory = await buildRepositoryFileInventory({
trackedRoots: policy.trackedRoots,
generatedRoots: policy.generatedRoots,
optionalRoots: policy.optionalRoots,
});
const scanFiles = inventory.files;
for (const scanFile of [...new Set(scanFiles)].sort()) {
const normalized = scanFile.replaceAll("\\", "/");
if (
(included.length > 0 &&
!included.some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
)) ||
[...excluded].some(
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
) ||
/\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/i.test(normalized)
) {
continue;
}
const content = await readFile(scanFile, "utf8");
findings.push(
...findSecretMatches(normalized, content, { allowlist }),
);
}
const sarif = {
version: "2.1.0",
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
driver: {
name: "ca-frontend-secret-scan",
rules: patterns.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 },
})),
],
},
],
};
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
if (findings.length > 0 || policyFailures.length > 0) {
process.stderr.write(
`Security scan found ${findings.length + policyFailures.length} blocking result(s).\n`,
);
process.exit(1);
}
process.stdout.write(
`Tracked source, config, built asset and artifact secret scan: PASS (${scanFiles.length} files)\n`,
);