/** * F-3: Lightweight PII detection. * Scans text for common sensitive patterns (passwords, tokens, IDs). * Returns warnings, does not block writes. * * F-3b (2026-07-10): Deterministic secret redaction on the same rule set. * `redactSecrets` scrubs redactable matches BEFORE text reaches any external * surface (LLM prompts, embedding API, stderr logs, audit trail). Detection * and redaction share one rule table so the two can never drift apart. * Deliberately NOT redacted: phone / email (legitimate memory content, warn * only) and bare long-hex (would false-positive on git commit hashes, which * are high-value memory content; prefixed forms like `secret=` are * caught by the assignment rules). */ export type PIISeverity = "high" | "medium" | "low"; export interface PIIDetection { type: string; // "api_key" | "password" | "id_number" | "email" | "phone" | "credit_card" | ... severity: PIISeverity; match: string; // the matched text (partially masked) position: number; // char offset } export interface PIIScanResult { hasPII: boolean; detections: PIIDetection[]; summary: string; // human-readable summary } export interface RedactResult { text: string; // scrubbed text redacted: number; // number of replacements made } interface PIIRule { type: string; severity: PIISeverity; pattern: RegExp; /** Scrub matches via redactSecrets (detection-only rules leave text intact) */ redact: boolean; /** For key=value style rules: keep the key name, scrub only the value */ keepPrefix?: RegExp; /** Custom scrubber for structured credentials where preserving a safe * prefix/suffix is clearer than replacing the whole match. */ replacement?: (match: string) => string; /** Checksum gate for broad numeric patterns: redact ONLY when this returns * true (scan still warns regardless). Keeps snowflake IDs / order numbers * from being destructively rewritten. */ validate?: (match: string) => boolean; } /** GB 11643 checksum (ISO 7064 MOD 11-2) for 18-digit Chinese national IDs. */ function isValidChineseId(id: string): boolean { if (!/^\d{17}[\dXx]$/.test(id)) return false; const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const checkChars = "10X98765432"; let sum = 0; for (let i = 0; i < 17; i++) sum += Number(id[i]) * weights[i]; return checkChars[sum % 11] === id[17].toUpperCase(); } /** Luhn checksum for card-number candidates (separators stripped first). */ function isValidLuhn(candidate: string): boolean { const digits = candidate.replace(/[\s-]/g, ""); if (!/^\d{16}$/.test(digits)) return false; let sum = 0; for (let i = 0; i < 16; i++) { let d = Number(digits[15 - i]); if (i % 2 === 1) { d *= 2; if (d > 9) d -= 9; } sum += d; } return sum % 10 === 0; } const PII_RULES: PIIRule[] = [ // --- Structured credentials commonly found in shell/config transcripts --- { type: "uri_credentials", severity: "high", pattern: /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s\/:@]+:[^\s\/@]+@/gi, redact: true, replacement: (match) => { const schemeEnd = match.indexOf("://") + 3; return `${match.slice(0, schemeEnd)}[REDACTED:uri_credentials]@`; }, }, { type: "basic_auth", severity: "high", pattern: /\b(?:Authorization\s*:\s*)?Basic\s+[A-Za-z0-9+/]{8,}={0,2}/gi, redact: true, replacement: (match) => { const prefix = match.match(/^(?:Authorization\s*:\s*)?Basic\s+/i)?.[0] ?? "Basic "; return `${prefix}[REDACTED:basic_auth]`; }, }, { type: "cookie_header", severity: "high", pattern: /\b(?:Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, redact: true, replacement: (match) => `${match.slice(0, match.indexOf(":") + 1)} [REDACTED:cookie_header]`, }, { type: "sensitive_assignment", severity: "high", pattern: /["']?(?:database[_-]?url|aws[_-]?secret[_-]?access[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?(?:id|token)|cookie|private[_-]?key)["']?\s*[=:]\s*["']?[^\s"',;]{8,}/gi, redact: true, keepPrefix: /^["']?(?:database[_-]?url|aws[_-]?secret[_-]?access[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?(?:id|token)|cookie|private[_-]?key)["']?\s*[=:]\s*["']?/i, }, // --- Vendor-prefixed token literals (high confidence, full scrub) --- { type: "anthropic_key", severity: "high", // Negative lookbehind: real keys start their own token; without it the // "sk" tail of ordinary words (task-, risk-) plus a hyphenated slug // matches (e.g. "task-ant..." would scrub from "sk-ant..."). pattern: /(? d.severity === "high").length; const medium = detections.filter((d) => d.severity === "medium").length; const low = detections.filter((d) => d.severity === "low").length; const summary = detections.length === 0 ? "No PII detected" : `Found ${detections.length} potential PII item${detections.length > 1 ? "s" : ""} (${high} high, ${medium} medium, ${low} low)`; return { hasPII: detections.length > 0, detections, summary, }; } /** * Deterministically scrub redactable secrets. Pure regex, no LLM call. * Replacement is `[REDACTED:]`; assignment-style rules keep the key * name (`password=[REDACTED:password]`) so the memory stays readable. */ export function redactSecrets(text: string): RedactResult { let out = text; let redacted = 0; for (const rule of PII_RULES) { if (!rule.redact) continue; rule.pattern.lastIndex = 0; out = out.replace(rule.pattern, (match) => { if (rule.validate && !rule.validate(match)) { return match; // checksum failed → leave intact (detection-only) } redacted++; if (rule.replacement) return rule.replacement(match); if (rule.keepPrefix) { const prefixMatch = match.match(rule.keepPrefix); if (prefixMatch) { return `${prefixMatch[0]}[REDACTED:${rule.type}]`; } } return `[REDACTED:${rule.type}]`; }); } return { text: out, redacted }; } /** * Redact + truncate for log lines. Truncation happens AFTER redaction so a * secret can never straddle the cut and leak its prefix. */ export function redactForLog(text: string, max = 60): string { return redactSecrets(text).text.slice(0, max); }