74 lines
1.9 KiB
TypeScript
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;
|
|
}
|