Files
tech-log-frontend/scripts/lib/secret-scan.ts

74 lines
1.9 KiB
TypeScript

import { createHash } from "node:crypto";
export type SecretFinding = Readonly<{
ruleId: string;
file: string;
line: number;
fingerprint: string;
}>;
export type SecretAllowlistEntry = Readonly<{
path: string;
ruleId: string;
expiresAt: string;
}>;
const secretPatterns: readonly Readonly<{
id: string;
expression: RegExp;
}>[] = [
{
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:
/(?<![\w])(["']?)(?:client_secret|password|private_key)\1(?![\w])\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
},
];
export function secretScanRules(): readonly Readonly<{
id: string;
expression: RegExp;
}>[] {
return secretPatterns;
}
export function findSecretMatches(
file: string,
content: string,
options: Readonly<{
allowlist?: readonly SecretAllowlistEntry[];
now?: number;
}> = {},
): SecretFinding[] {
const allowlist = options.allowlist ?? [];
const now = options.now ?? Date.now();
const findings: SecretFinding[] = [];
for (const pattern of secretPatterns) {
pattern.expression.lastIndex = 0;
for (const match of content.matchAll(pattern.expression)) {
const isAllowed = allowlist.some(
(entry) =>
entry.path === file &&
entry.ruleId === pattern.id &&
Date.parse(entry.expiresAt) > now,
);
if (isAllowed) continue;
const matchIndex = match.index ?? 0;
findings.push({
ruleId: pattern.id,
file,
line: content.slice(0, matchIndex).split(/\r?\n/u).length,
fingerprint: createHash("sha256")
.update(`${pattern.id}:${file}:${String(matchIndex)}`)
.digest("hex"),
});
}
}
return findings;
}