import { createHash } from "node:crypto"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; /** @param {string} name @param {string} fallback */ function argumentValue(name, fallback) { const index = process.argv.indexOf(name); return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback; } const policyPath = argumentValue( "--policy", "config/security/secret-scan-policy.json", ); const artifactPath = argumentValue( "--artifact", "artifacts/security/scan.sarif", ); const policy = JSON.parse(await readFile(policyPath, "utf8")); const findings = /** @type {Array<{ * ruleId: string, * file: string, * line: number, * fingerprint: string * }>} */ ([]); const policyFailures = []; const patterns = [ { id: "private-key", expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, }, { id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g }, { id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g }, { id: "assigned-secret", expression: /\b(?:client_secret|password|private_key)\s*[:=]\s*["'][^"'${}]{12,}["']/gi, }, ]; /** @param {string} target @returns {Promise} */ async function filesWithin(target) { try { const metadata = await stat(target); if (metadata.isFile()) return [target]; const entries = await readdir(target, { withFileTypes: true }); const nested = /** @type {string[][]} */ (await Promise.all( entries.map((entry) => { const child = path.join(target, entry.name); return entry.isDirectory() ? filesWithin(child) : [child]; }), )); return nested.flat(); } catch { return []; } } const excluded = new Set( /** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) => entry.replaceAll("\\", "/"), ), ); const allowlist = /** @type {Array<{ * path: string, * ruleId: string, * owner: string, * reason: string, * expiresAt: string * }>} */ (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 roots = [ ...(/** @type {string[]} */ (policy.trackedRoots ?? [])), ...(/** @type {string[]} */ (policy.generatedRoots ?? [])), ]; const scanFiles = ( await Promise.all(roots.map((root) => filesWithin(root))) ).flat(); 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)$/i.test(normalized) ) { continue; } let content; try { content = await readFile(scanFile, "utf8"); } catch { continue; } for (const pattern of patterns) { pattern.expression.lastIndex = 0; for (const match of content.matchAll(pattern.expression)) { const isAllowed = allowlist.some( (entry) => entry.path === normalized && entry.ruleId === pattern.id && Date.parse(entry.expiresAt) > Date.now(), ); if (isAllowed) continue; const prefix = content.slice(0, match.index); findings.push({ ruleId: pattern.id, file: normalized, line: prefix.split(/\r?\n/).length, fingerprint: createHash("sha256") .update(`${pattern.id}:${normalized}:${String(match.index)}`) .digest("hex"), }); } } } 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`, );