import { sha256Hex } from "../crypto"; /** `anchor` is a literal the match must contain; the detector is skipped when the text lacks it. */ type Detector = { id: string; name: string; pattern: RegExp; group?: number; anchor?: string }; /** High-confidence token formats; each has a distinctive prefix or structure to keep false positives low. */ const DETECTORS: Detector[] = [ { id: "aws_access_key_id", name: "AWS access key ID", pattern: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/g }, { id: "aws_secret_access_key", name: "AWS secret access key", pattern: /aws_?secret_?access_?key["']?\s*[:=]\s*["']?([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+])/gi, group: 1, }, { id: "github_pat", name: "GitHub personal access token", pattern: /\b(gh[pousr]_[A-Za-z0-9]{36,255})\b/g }, { id: "github_fine_grained_pat", name: "GitHub fine-grained token", pattern: /\b(github_pat_[A-Za-z0-9_]{82})\b/g }, { id: "gitlab_pat", name: "GitLab personal access token", pattern: /\b(glpat-[A-Za-z0-9_-]{20})\b/g }, { id: "gitfor_pat", name: "gitfor personal access token", pattern: /\b(gitfor_pat_[A-Za-z0-9_-]{20,})\b/g }, { id: "slack_token", name: "Slack token", pattern: /\b(xox[abposr]-[A-Za-z0-9-]{10,72})\b/g }, { id: "slack_webhook", name: "Slack incoming webhook", pattern: /(https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{24})/g, }, { id: "stripe_secret_key", name: "Stripe secret key", pattern: /\b((?:sk|rk)_live_[A-Za-z0-9]{24,99})\b/g }, { id: "google_api_key", name: "Google API key", pattern: /\b(AIza[0-9A-Za-z_-]{35})(?![0-9A-Za-z_-])/g }, { id: "npm_token", name: "npm access token", pattern: /\b(npm_[A-Za-z0-9]{36})\b/g }, { id: "pypi_token", name: "PyPI API token", pattern: /\b(pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,})/g }, { id: "openai_api_key", name: "OpenAI API key", /** Both runs are bounded so each `sk-` start costs O(1) instead of scanning to the end of the file (INT-A9). */ pattern: /\b(sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,96}T3BlbkFJ[A-Za-z0-9_-]{20,96})(?![A-Za-z0-9_-])/g, anchor: "T3BlbkFJ", }, { id: "anthropic_api_key", name: "Anthropic API key", pattern: /\b(sk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{80,})/g }, { id: "sendgrid_api_key", name: "SendGrid API key", pattern: /\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b/g }, { id: "twilio_api_key", name: "Twilio API key", pattern: /\b(SK[0-9a-f]{32})\b/g }, { id: "shopify_token", name: "Shopify access token", pattern: /\b(shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32})\b/g }, { id: "private_key", name: "Private key", pattern: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----[\s\S]{32,8192}?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----)/g, }, ]; const DETECTOR_NAMES = new Map(DETECTORS.map((detector) => [detector.id, detector.name])); const MAX_FINDINGS_PER_TEXT = 50; const PREVIEW_PREFIX = 4; const PREVIEW_SUFFIX = 2; const MIN_SUFFIX_LENGTH = 24; export type Finding = { detector: string; secret: string; line: number }; export type RedactedFinding = { detector: string; fingerprint: string; preview: string; line: number }; export function detectorName(id: string): string { return DETECTOR_NAMES.get(id) ?? id; } /** Newline offsets are computed once per text and binary-searched, so findings cost O(log n) each. */ function lineLocator(text: string): (index: number) => number { let offsets: number[] | null = null; return (index) => { if (!offsets) { offsets = []; for (let position = text.indexOf("\n"); position !== -1; position = text.indexOf("\n", position + 1)) { offsets.push(position); } } let low = 0; let high = offsets.length; while (low < high) { const mid = (low + high) >> 1; if (offsets[mid] < index) low = mid + 1; else high = mid; } return low + 1; }; } /** Caps findings per detector, so decoys for one detector can never stop another from running. */ export function scanText(text: string): Finding[] { const findings: Finding[] = []; const lineAt = lineLocator(text); for (const detector of DETECTORS) { if (detector.anchor && !text.includes(detector.anchor)) continue; detector.pattern.lastIndex = 0; let count = 0; for (const match of text.matchAll(detector.pattern)) { const secret = match[detector.group ?? 1]; if (!secret) continue; findings.push({ detector: detector.id, secret, line: lineAt(match.index) }); if (++count >= MAX_FINDINGS_PER_TEXT) break; } } return findings; } /** Shows only a short prefix (and a tiny suffix for long tokens) so the preview cannot be used as a credential. */ export function redact(secret: string): string { const flat = secret.replace(/\s+/g, " "); if (flat.startsWith("-----BEGIN")) return flat.slice(0, flat.indexOf("-----", 5) + 5) + " … [redacted]"; const suffix = flat.length >= MIN_SUFFIX_LENGTH ? flat.slice(-PREVIEW_SUFFIX) : ""; return `${flat.slice(0, PREVIEW_PREFIX)}${"•".repeat(8)}${suffix}`; } export async function redactFindings(findings: Finding[]): Promise { return Promise.all( findings.map(async (finding) => ({ detector: finding.detector, fingerprint: await sha256Hex(finding.secret.trim()), preview: redact(finding.secret), line: finding.line, })), ); } const BINARY_SNIFF_BYTES = 8000; const MAX_TEXT_NUL_RATIO = 0.01; /** * Decodes text content for scanning, skipping binaries. A stray NUL does not make a file binary (that * would let one NUL hide a secret); only files whose sniffed prefix is more than 1% NUL bytes are skipped. */ export function scannableText(bytes: Uint8Array): string | null { const sniff = bytes.subarray(0, BINARY_SNIFF_BYTES); let nuls = 0; for (const byte of sniff) if (byte === 0) nuls++; if (nuls > sniff.byteLength * MAX_TEXT_NUL_RATIO) return null; const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); return nuls > 0 ? text.replaceAll("\0", " ") : text; }