import type { ScanContext, ScanDecision, Violation, PIIConfig, } from "../types.js"; import { PIIScanner } from "./pii.js"; import { normalizeForInjectionScan } from "./heuristic.js"; // ============================================================ // Output Scanner — OWASP LLM05 Improper Output Handling + // LLM02 Sensitive Information Disclosure (output side) // // AI Shield's input scanners answer "is this prompt safe to send to the // model?". This scanner answers the other half: "is this model OUTPUT // safe to act on / show / forward downstream?". // // LLM output must never reach a SQL engine, a shell, an HTML sink, or a // template renderer unfiltered — XSS, SSRF, SQLi and command injection // sourced from model output are a documented 2026 attack class (OWASP // LLM05). And a model can leak its own system prompt or a secret it was // shown, which is LLM02 / LLM07. // // Five checks. Inputs are Unicode-normalized first (homoglyph / zero-width / // fullwidth evasion defense). Secret + canary checks scan the FULL output // (a leak can sit anywhere); the structural checks scan a length-capped copy // (those payloads live in the first chunk): // 1. secret_leak — API keys, tokens, private keys, DSNs (full output) // 2. output_injection — SQL / shell / HTML-JS / template / md-exfil (capped) // 3. system_prompt_leak — canary-token leak (exact, full) + heuristic phrasing // 4. pii_detected — reuses the input-side PIIScanner // 5. jailbreak_indicator— compliance-preamble / mode-switch acknowledgement // // Checks 1-3 are high-confidence and block. PII follows its configured // action. Jailbreak is heuristic and only warns — a "sure, here's how" // preamble is often legitimate. // ============================================================ /** Hard cap on the bytes we pattern-scan. A 1 MB model response is not the * threat model and unbounded regex over it pressures GC. Overridable. */ const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024; /** * High-confidence secret formats. Each is anchored on a provider-specific * prefix so false positives on prose are near-zero. Patterns are linear * (no nested quantifiers) — ReDoS-safe on large output. */ const SECRET_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [ { id: "SEC-OPENAI", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/, label: "OpenAI API key" }, { id: "SEC-ANTHROPIC", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/, label: "Anthropic API key" }, { id: "SEC-AWS-AKID", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, label: "AWS access key id" }, { id: "SEC-GITHUB", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, label: "GitHub token" }, { id: "SEC-GOOGLE", re: /\bAIza[0-9A-Za-z_-]{35}\b/, label: "Google API key" }, { id: "SEC-GOOGLE-OAUTH", re: /\bGOCSPX-[A-Za-z0-9_-]{28}\b/, label: "Google OAuth client secret" }, { id: "SEC-GCP-SA", re: /"type"\s*:\s*"service_account"/, label: "GCP service-account JSON" }, { id: "SEC-HUGGINGFACE", re: /\bhf_[A-Za-z0-9]{30,}\b/, label: "HuggingFace token" }, { id: "SEC-NPM", re: /\bnpm_[A-Za-z0-9]{36}\b/, label: "npm publish token" }, { id: "SEC-SLACK", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, label: "Slack token" }, { id: "SEC-STRIPE", re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/, label: "Stripe live key" }, { id: "SEC-JWT", re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, label: "JWT" }, { id: "SEC-PEM", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/, label: "PEM private key" }, // DSN: both credential segments are length-bounded so a long near-match // without a trailing `@` can't drive O(n²) backtracking (review H1). { id: "SEC-DSN", re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqps?):\/\/[^\s:/@]{1,64}:[^\s@]{3,80}@/, label: "connection string with credentials" }, ]; /** * Output-injection payloads, grouped by downstream sink. Each pattern is * deliberately conservative — flagging legitimate output that merely * *mentions* SQL would be useless. They target syntax that only matters * when the string is interpreted, not displayed. */ const INJECTION_PATTERNS: Array<{ id: string; sink: OutputSink; re: RegExp; label: string; }> = [ // SQL { id: "OUTI-SQL-1", sink: "sql", re: /\bunion\s+(?:all\s+)?select\b/i, label: "SQL UNION SELECT" }, { id: "OUTI-SQL-2", sink: "sql", re: /['"]\s*;\s*(?:drop|delete|update|insert|truncate|alter)\s+/i, label: "SQL statement break" }, { id: "OUTI-SQL-3", sink: "sql", re: /\bor\s+1\s*=\s*1\b|\bor\s+'1'\s*=\s*'1'/i, label: "SQL tautology" }, // Shell { id: "OUTI-SH-1", sink: "shell", re: /\$\([^)]{1,200}\)|`[^`]{1,200}`/, label: "shell command substitution" }, { id: "OUTI-SH-2", sink: "shell", re: /[;&|]\s*(?:rm|curl|wget|nc|bash|sh|chmod|mkfifo|dd)\s+-?/i, label: "chained shell command" }, { id: "OUTI-SH-3", sink: "shell", re: /\|\s*(?:sh|bash|zsh|python[0-9.]*)\b/i, label: "pipe to interpreter" }, // HTML / JS (XSS) { id: "OUTI-XSS-1", sink: "html", re: /]/i, label: "