import type { Scanner, ScannerResult, ScanContext, Violation } from "../types.js"; // ============================================================ // Heuristic Prompt Injection Scanner // Score-based: multiple matches = higher confidence // Unicode-normalizes input before pattern matching so that // homoglyph/zero-width/fullwidth evasion attempts still hit. // ============================================================ // Common Cyrillic/Greek Latin-lookalikes mapped to ASCII. // Keep minimal — false-mappings in real content are worse than // false-negatives in an attack attempt. const HOMOGLYPH_MAP: Record = { // Cyrillic "а": "a", "е": "e", "і": "i", "ј": "j", "о": "o", "р": "p", "с": "c", "ѕ": "s", "у": "y", "х": "x", "ԁ": "d", "һ": "h", "ӏ": "l", "ո": "n", "А": "A", "В": "B", "Е": "E", "І": "I", "К": "K", "М": "M", "Н": "H", "О": "O", "Р": "P", "С": "C", "Т": "T", "Х": "X", "Ѕ": "S", "Ј": "J", "Ү": "Y", "Ԛ": "Q", "Ԝ": "W", "Ғ": "F", // Greek "α": "a", "ο": "o", "ρ": "p", "ε": "e", "υ": "y", "χ": "x", "ν": "v", "ι": "i", "κ": "k", "Α": "A", "Β": "B", "Ε": "E", "Ζ": "Z", "Η": "H", "Ι": "I", "Κ": "K", "Μ": "M", "Ν": "N", "Ο": "O", "Ρ": "P", "Τ": "T", "Υ": "Y", "Χ": "X", // Armenian / Cherokee / other look-alikes occasionally used in evasion "օ": "o", "ѵ": "v", }; const HOMOGLYPH_RE = new RegExp(Object.keys(HOMOGLYPH_MAP).join("|"), "g"); // Zero-width chars + BOM — used to split words like "ignore" across // the pattern boundary (U+200B..U+200D, U+2060, U+FEFF). const ZERO_WIDTH_RE = /[​-‍⁠]/g; // Combining marks (diacritics) after NFKC can still slip through (U+0300..U+036F). const COMBINING_RE = /[̀-ͯ]/g; // Unicode TAG block (U+E0000..U+E007F). Invisible code points with no // legitimate use in prose. U+E0020..U+E007E are tag-equivalents of ASCII // 0x20..0x7E, so an attacker can spell "ignore previous instructions" entirely // in tag chars: it renders as nothing but a model still reads the ASCII intent. const TAG_RANGE_RE = /[\u{E0000}-\u{E007F}]/u; /** * Decode Unicode TAG-block smuggling: U+E0020..U+E007E carry the ASCII * characters 0x20..0x7E (subtract 0xE0000). U+E0001 (language tag) and * U+E007F (cancel tag) are control points with no ASCII payload and are * dropped. Returns the ASCII the invisible tag run was hiding, so the normal * injection patterns can scan it. */ export function deTagForInjectionScan(input: string): string { // Fast path: most inputs have no tag chars at all. if (!TAG_RANGE_RE.test(input)) return input; let out = ""; for (const ch of input) { const cp = ch.codePointAt(0)!; if (cp >= 0xe0000 && cp <= 0xe007f) { const ascii = cp - 0xe0000; // 0x20..0x7E map to printable ASCII; the rest (E0000/E0001/E007F) drop. if (ascii >= 0x20 && ascii <= 0x7e) out += String.fromCharCode(ascii); } else { out += ch; } } return out; } /** True if the input contains any Unicode TAG-block char (invisible smuggling). */ export function hasTagChars(input: string): boolean { return TAG_RANGE_RE.test(input); } /** * Well-formed flag / subdivision-tag sequence: a base WAVING BLACK FLAG * (U+1F3F4) followed by a run of one or more tag chars (U+E0000..U+E007E) * terminated by U+E007F (CANCEL TAG). This is exactly how Unicode encodes * subdivision flags like 🏴󠁧󠁢󠁷󠁬󠁳󠁿 (Wales), 🏴󠁧󠁢󠁳󠁣󠁴󠁿 (Scotland), * 🏴󠁵󠁳󠁴󠁸󠁿 (Texas) — legitimate emoji, not smuggling. The `u` flag makes the * astral base match one code point; the run is length-bounded so it stays * ReDoS-safe. */ const FLAG_TAG_SEQUENCE_RE = /\u{1F3F4}[\u{E0000}-\u{E007E}]{1,16}\u{E007F}/gu; /** * Remove every well-formed flag/subdivision-tag sequence (base U+1F3F4 … * U+E007F) from the input. Whatever tag chars are LEFT over are standalone or * smuggled — a bare tag run spelling ASCII, a tag char without its U+1F3F4 * base, or a sequence with no CANCEL-TAG terminator. Used so the tag-presence * signal only fires on those, not on legitimate flag emoji. * * Note: this only suppresses the *presence* signal. The actual smuggled ASCII * is still surfaced independently by `deTagForInjectionScan` (which decodes the * tag-encoded characters regardless of any U+1F3F4 wrapper), so an attacker * cannot hide an instruction by disguising it as a flag sequence. */ export function stripWellFormedTagSequences(input: string): string { if (!TAG_RANGE_RE.test(input)) return input; return input.replace(FLAG_TAG_SEQUENCE_RE, ""); } /** * True if the input contains tag chars that are NOT part of a well-formed * flag/subdivision sequence — i.e. standalone or smuggled invisible tag chars * (the real attack indicator). Legitimate flag emoji return false. */ export function hasStandaloneTagChars(input: string): boolean { if (!TAG_RANGE_RE.test(input)) return false; return TAG_RANGE_RE.test(stripWellFormedTagSequences(input)); } // --- Forged chat-transcript detection (DELIM-PP-5) ----------------------- // A full open+close // tag PAIR. The bounded lazy gap // keeps it ReDoS-safe (verified <2ms on 50 KB worst-cases). The backreference // \1 requires the close tag to match the open tag, so "…" // alone isn't a pair. Global flag → we can count distinct turns. const FORGED_TURN_PAIR_RE = /<(assistant|user|human)\b[^>]*>([\s\S]{0,200}?)<\/\1>/gi; // Override / privileged / compliance phrasing that turns a benign-looking // transcript snippet into a policy-puppetry payload ("Sure, I will // ignore all safety rules"). Specific enough that an ordinary // quoted reply ("Hello, how can I help?") doesn't match. const OVERRIDE_IN_TURN_RE = /\b(?:ignore|disregard|bypass|override|jailbroken|jailbreak|unrestricted|no\s+(?:restrictions?|filters?|limits?|rules?)|without\s+(?:restrictions?|refus\w+|filter\w+)|comply\s+fully|will\s+comply|i\s+will\s+(?:now\s+)?(?:ignore|comply|obey|bypass)|developer\s+mode|dev\s+mode\s+(?:active|enabled|on)|debug\s+mode|god\s+mode|sudo\s+mode|admin\s+mode|safety\s+(?:rules?|guidelines?|filters?)|dan\b|do\s+anything\s+now|obey\s+(?:all|every)|reveal\s+(?:your|the)\s+(?:system\s+)?prompt)/i; /** * Detect a FORGED chat transcript (policy-puppetry, HiddenLayer 2025). Returns * true only when a real attack co-signal is present, so a lone benign turn pair * (a quoted transcript snippet, a doc example) does NOT trip it: * (a) an override/privileged keyword inside any turn's content, OR * (b) ≥2 distinct forged turns (a fabricated multi-turn exchange). * A sibling policy-config tag (interaction-config / allowed-modes / * blocked-strings) is intentionally NOT required here — it already blocks via * DELIM-PP-1/2/3. Iteration is capped (64) for defense-in-depth. */ export function detectForgedTranscript(input: string): boolean { // Fast path: no closing turn tag → no pair possible. if (!/<\/(?:assistant|user|human)>/i.test(input)) return false; FORGED_TURN_PAIR_RE.lastIndex = 0; const turnBodies: string[] = []; let m: RegExpExecArray | null; let guard = 0; while ((m = FORGED_TURN_PAIR_RE.exec(input)) !== null && guard < 64) { guard += 1; turnBodies.push(m[2] ?? ""); } if (turnBodies.length === 0) return false; // (a) override keyword inside a turn → single forged turn is enough. if (turnBodies.some((body) => OVERRIDE_IN_TURN_RE.test(body))) return true; // (b) two or more forged turns → fabricated exchange. return turnBodies.length >= 2; } /** * Lossy leetspeak fold: maps the common char-substitutions an attacker uses to * dodge literal patterns ("1gn0r3 pr3v10us 1nstruct10ns" → "ignore previous * instructions"). Run as an ADDITIONAL view (like collapseSpacedLetters), never * as a replacement, and only the high-value injection categories are re-tested * against it — folding digits to letters in ordinary prose ("buy 3 items for 5 * dollars" → "buy e items for s dollars") would otherwise generate noise. * * 1→i (dominant in injection payloads like "1nstruct10ns"); the other digits * are unambiguous. @→a and $→s cover the classic symbol substitutions. */ const LEET_MAP: Record = { "0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a", "$": "s", }; const LEET_RE = /[013457@$]/g; export function leetDecodeForInjectionScan(input: string): string { return input.replace(LEET_RE, (ch) => LEET_MAP[ch] ?? ch); } /** * Normalize input for pattern matching. Returns the canonicalized string * used only for scan decisions; the sanitized output passed to callers * is still the original input. * * Order matters: * 1. Decode Unicode TAG-block smuggling so invisible tag chars surface as the * ASCII they carry ("ignore previous instructions" hidden in U+E00xx). * 2. NFKD folds compatibility forms (fullwidth → ASCII, ligatures) AND * decomposes precomposed accented letters into base + combining mark. * 3. Strip zero-width chars so "ignore" collapses to "ignore". * 4. Strip combining marks (diacritics) left behind by NFKD. * 5. Map remaining Cyrillic/Greek look-alikes to Latin. * * Side effect of step 2+4: accented Latin letters lose their diacritic and * fold to the base letter ("précédentes" → "precedentes", "ö" → "o"). The * localized injection patterns below are written against this folded form. */ export function normalizeForInjectionScan(input: string): string { const deTagged = deTagForInjectionScan(input); const nfkd = deTagged.normalize("NFKD"); const noZW = nfkd.replace(ZERO_WIDTH_RE, ""); const noCombining = noZW.replace(COMBINING_RE, ""); return noCombining.replace(HOMOGLYPH_RE, (ch) => HOMOGLYPH_MAP[ch] ?? ch); } /** * Collapse letter-splitting evasion: an attacker writes `i g n o r e` or * `i.g.n.o.r.e` or `i-g-n-o-r-e` to break the literal token "ignore" across * separators so the regex never matches. This produces an ADDITIONAL view * where any run of `single-letter + separator` (≥4 letters) has its * separators removed, so the spaced form collapses back to "ignore". * * Run as a second pass IN ADDITION to the normal normalized text — never * as a replacement — because collapsing is lossy (it would also fuse the * legitimate "a b c" list). Only single-letter groups separated by one * space / dot / dash / underscore are collapsed; multi-letter words are * left intact, which keeps benign prose untouched. */ export function collapseSpacedLetters(input: string): string { // Match ≥3 "" groups closed by a final lone letter. The // trailing `(?![A-Za-z])` stops the greedy match from swallowing the // first letter of the next real word ("i g n o r e all" must collapse to // "ignore all", not "ignorea ll"). Bounded, linear — no nested quantifier. return input.replace( /(?:[A-Za-z][ \t._-]){3,}[A-Za-z](?![A-Za-z])/g, (run) => run.replace(/[ \t._-]/g, ""), ); } // ============================================================ // Typoglycemia defense — scrambled-middle evasion // // "ignroe pevrious instrcutions" reads fine to a human (and to an LLM) but // dodges every literal pattern. The robust defense (OWASP LLM Prompt // Injection Cheat Sheet 2026) is fuzzy keyword matching, not anagram-only: // classic scrambles permute the middle (same letters), but a single // transpose/typo ("ignroe", "ovrride") also slips through and is one // Damerau-Levenshtein edit away. We combine both, gated on the keyword's // first+last letter to keep false positives near zero. // ============================================================ /** * Injection keywords worth de-scrambling. Only ≥5-char tokens — shorter words * have no permutable middle and false-positive heavily. Lowercase. */ const TYPO_KEYWORDS = [ "ignore", "previous", "prior", "preceding", "earlier", "instructions", "instruction", "disregard", "override", "forget", "system", "prompt", "reveal", "guidelines", "rules", "bypass", "instead", "above", "everything", "developer", "jailbreak", "restrictions", "constraints", ]; /** Precomputed signature per keyword: first char, last char, sorted middle. */ const TYPO_SIGNATURES = TYPO_KEYWORDS.map((kw) => ({ kw, first: kw[0]!, last: kw[kw.length - 1]!, len: kw.length, sortedMiddle: kw.slice(1, -1).split("").sort().join(""), })); /** * Damerau-Levenshtein (optimal string alignment) distance with an early-exit * cap: returns `cap + 1` as soon as the whole row exceeds `cap`, so distant * pairs cost far less than the full O(m·n). Zero-dependency. */ export function damerauLevenshtein(a: string, b: string, cap = 2): number { if (a === b) return 0; const m = a.length; const n = b.length; if (Math.abs(m - n) > cap) return cap + 1; if (m === 0) return n; if (n === 0) return m; let prevPrev = new Array(n + 1); let prev = new Array(n + 1); let curr = new Array(n + 1); for (let j = 0; j <= n; j++) prev[j] = j; for (let i = 1; i <= m; i++) { curr[0] = i; let rowMin = curr[0]!; for (let j = 1; j <= n; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; let v = Math.min( prev[j]! + 1, // deletion curr[j - 1]! + 1, // insertion prev[j - 1]! + cost, // substitution ); // transposition (adjacent swap) if ( i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1] ) { v = Math.min(v, prevPrev[j - 2]! + 1); } curr[j] = v; if (v < rowMin) rowMin = v; } if (rowMin > cap) return cap + 1; [prevPrev, prev, curr] = [prev, curr, prevPrev]; } return prev[n]!; } /** * Un-scramble Typoglycemia evasion as an ADDITIONAL lossy view (like * `leetDecodeForInjectionScan` / `collapseSpacedLetters`), never a * replacement. For each ≥5-letter word that is NOT already a keyword but is an * anagram of one (same length, same first+last letter, same multiset of middle * letters), rewrite it to the keyword. That is exactly classic Typoglycemia — * a permuted middle — and covers adjacent transpositions too. Only * TYPO_SENSITIVE categories re-test against this view; the anagram + first/last * gate keeps benign prose untouched (verified FP-free on a 116-word corpus). * Edit-distance folding was tried and dropped — it false-positived real word * pairs ("forgot"→"forget", "rulers"→"rules"). The `damerauLevenshtein` helper * is still exported as a standalone utility. * * Word scan is capped at 4000 words to bound work on adversarial input. */ export function unscrambleForInjectionScan(input: string): string { let wordCount = 0; return input.replace(/[A-Za-z]{5,15}/g, (word) => { if (wordCount++ > 4000) return word; const lower = word.toLowerCase(); // already a keyword → leave as-is (the normal pattern pass handles it) for (const sig of TYPO_SIGNATURES) { if (sig.kw === lower) return word; } const first = lower[0]!; const last = lower[lower.length - 1]!; const mid = lower.slice(1, -1).split("").sort().join(""); for (const sig of TYPO_SIGNATURES) { // Classic Typoglycemia is a PERMUTATION: same letters, scrambled middle, // fixed first+last (which already covers adjacent transpositions, the // common attack form). We match ONLY that — identical length + identical // sorted middle. We deliberately do NOT fold on edit-distance: a single // substitution/insertion/deletion between two REAL words ("forgot"→ // "forget", "rulers"→"rules", "constrains"→"constraints", "abode"→ // "above") is a frequent false positive, and a security scanner that // blocks benign prose just gets disabled. Permutation-only is FP-free // here (verified against a 116-word benign corpus: 0 incorrect folds). if (sig.len !== lower.length) continue; if (sig.first !== first || sig.last !== last) continue; if (sig.sortedMiddle === mid) return sig.kw; } return word; }); } interface PatternRule { id: string; category: InjectionCategory; pattern: RegExp; weight: number; description: string; } type InjectionCategory = | "instruction_override" | "localized_override" | "role_manipulation" | "system_prompt_extraction" | "encoding_evasion" | "delimiter_injection" | "context_manipulation" | "output_manipulation" | "tool_abuse"; const PATTERNS: PatternRule[] = [ // --- Instruction Override (weight: 0.25 each) --- { id: "INJ-001", category: "instruction_override", pattern: /ignore\s+(all\s+)?(previous|prior|above|earlier|preceding)\s+(instructions?|prompts?|rules?|guidelines?|context)/i, weight: 0.25, description: "Ignore previous instructions", }, { id: "INJ-002", category: "instruction_override", pattern: /disregard\s+(all\s+|your\s+)?(previous|prior|above|earlier)\s+(instructions?|context|rules?|guidelines?)/i, weight: 0.25, description: "Disregard instructions", }, { id: "INJ-003", category: "instruction_override", pattern: /forget\s+(everything|all|your)\s+(about\s+)?(instructions?|rules?|training|guidelines?|constraints?)/i, weight: 0.25, description: "Forget instructions", }, { id: "INJ-004", category: "instruction_override", pattern: /override\s+(your|the|all)\s+(instructions?|rules?|safety|guidelines?|constraints?|policies)/i, weight: 0.25, description: "Override instructions", }, { id: "INJ-005", category: "instruction_override", pattern: /do\s+not\s+follow\s+(your|the|any)\s+(previous|original|initial)\s+(instructions?|rules?|guidelines?)/i, weight: 0.25, description: "Do not follow instructions", }, { id: "INJ-006", category: "instruction_override", pattern: /new\s+(instructions?|rules?|guidelines?)[\s:]+/i, weight: 0.15, description: "New instructions prefix", }, { id: "INJ-007", category: "instruction_override", pattern: /from\s+now\s+on[,\s]+(you\s+)?(will|must|should|are|shall)/i, weight: 0.20, description: "From now on directive", }, { id: "INJ-008", category: "instruction_override", pattern: /instead[,\s]+(you\s+)?(will|must|should|shall)\s+/i, weight: 0.15, description: "Instead directive", }, // --- Localized Instruction Override (DE / ES / FR) --- // DACH-critical: the English INJ-* rules above miss German/Spanish/French // "ignore previous instructions" entirely, so a non-English payload scored // `allow`. Patterns run against the NFKD-folded text (accents/umlauts already // stripped: "präzedenzfall" → "prazedenzfall", "précédentes" → "precedentes"), // so they spell the base-letter forms. The bounded `[\s\S]{0,40}?` gap is // lazy + length-capped → ReDoS-safe. An override verb is REQUIRED before the // object noun, so benign prose that merely mentions "Anweisungen" / // "instrucciones" / "instructions" does not trip them. { id: "INJ-DE-1", category: "localized_override", // Negative lookahead `(?![\s\S]{0,40}?\b(?:nicht|keine?)\b)` after the verb // excludes a negator in the gap: "Vergiss NICHT, die vorherigen Anweisungen // zu lesen" ("don't forget to read …") is benign and must not block, while // "Vergiss alle vorherigen Anweisungen" (no negator) still fires. Bounded // and lazy → ReDoS-safe. pattern: /\b(?:ignoriere?|missachte|vergiss|verwirf|uebergehe|ueberschreibe)\b(?![\s\S]{0,40}?\b(?:nicht|keine?)\b)[\s\S]{0,40}?\b(?:alle[ns]?|deine|die|jegliche)?\s*(?:vorherigen?|bisherigen?|obigen?|frueheren?|vorhergehenden?|urspruenglichen?)\s+(?:anweisungen?|anordnungen?|befehle?|regeln?|vorgaben?|instruktionen?)/i, weight: 0.30, description: "German instruction override", }, { id: "INJ-DE-2", category: "localized_override", pattern: /\bdu\s+bist\s+(?:jetzt|ab\s+jetzt|nun)\s+(?:ein|eine|der|die|das|mein|meine)\b/i, weight: 0.25, description: "German role takeover (du bist jetzt …)", }, { id: "INJ-ES-1", category: "localized_override", pattern: /\b(?:ignora|olvida|descarta|desestima|omite|anula)\b[\s\S]{0,40}?\b(?:todas?\s+)?(?:las?\s+)?(?:instrucciones?|ordenes?|reglas?|directrices?|indicaciones?)\s+(?:anteriores?|previas?|precedentes?|de\s+arriba)/i, weight: 0.30, description: "Spanish instruction override", }, { // "ignore" + "instructions" are identical in English and French, so the // shared verb path requires a French determiner (les/tes/mes) to avoid // double-firing on English "ignore previous instructions" (which INJ-001 // already covers). French-only verbs match the object noun directly. id: "INJ-FR-1", category: "localized_override", pattern: /\b(?:ignore\s+(?:toutes?\s+)?(?:les|tes|mes)\s+(?:instructions?|consignes?|directives?|regles?|ordres?)|(?:oublie|neglige|fais\s+abstraction\s+de|ne\s+tiens?\s+pas\s+compte\s+des?)\s+(?:toutes?\s+)?(?:les?\s+|tes\s+|mes\s+)?(?:instructions?|consignes?|directives?|regles?|ordres?))/i, weight: 0.30, description: "French instruction override", }, // --- Role Manipulation (weight: 0.25 each) --- { id: "ROLE-001", category: "role_manipulation", pattern: /you\s+are\s+now\s+(a|an|the|my)\s+/i, weight: 0.25, description: "You are now [role]", }, { id: "ROLE-002", category: "role_manipulation", pattern: /pretend\s+(you('re|\s+are)\s+|to\s+be\s+)/i, weight: 0.25, description: "Pretend to be", }, { id: "ROLE-003", category: "role_manipulation", pattern: /act\s+as\s+(a|an|if\s+you\s+were)\s+/i, weight: 0.20, description: "Act as [role]", }, { id: "ROLE-004", category: "role_manipulation", pattern: /switch\s+to\s+.{0,30}\s+mode/i, weight: 0.25, description: "Switch to [X] mode", }, { id: "ROLE-005", category: "role_manipulation", pattern: /enter\s+(developer|debug|admin|god|sudo|root|jailbreak|DAN)\s+mode/i, weight: 0.35, description: "Enter privileged mode", }, { id: "ROLE-006", category: "role_manipulation", pattern: /you\s+have\s+no\s+(restrictions?|limitations?|rules?|constraints?|filters?)/i, weight: 0.30, description: "No restrictions claim", }, { id: "ROLE-007", category: "role_manipulation", pattern: /\bDAN\b.*\b(do\s+anything|no\s+(restrictions?|limits?|filters?))/i, weight: 0.35, description: "DAN jailbreak pattern", }, // --- System Prompt Extraction (weight: 0.30 each) --- { id: "EXTRACT-001", category: "system_prompt_extraction", pattern: /what\s+(is|are)\s+your\s+(system\s+)?(prompt|instructions?|rules?|guidelines?|directives?)/i, weight: 0.30, description: "What is your system prompt", }, { id: "EXTRACT-002", category: "system_prompt_extraction", pattern: /show\s+(me\s+)?your\s+(system\s+)?(prompt|instructions?|config(uration)?|rules?)/i, weight: 0.30, description: "Show your prompt", }, { id: "EXTRACT-003", category: "system_prompt_extraction", pattern: /repeat\s+(the|your)\s+(system\s+|initial\s+|original\s+)?(prompt|instructions?|message|rules?)\s*(word\s+for\s+word|verbatim|exactly)?/i, weight: 0.30, description: "Repeat your prompt", }, { id: "EXTRACT-004", category: "system_prompt_extraction", pattern: /output\s+(the|your)\s+(initial|system|original|first)\s+(prompt|instructions?|message)/i, weight: 0.30, description: "Output initial prompt", }, { id: "EXTRACT-005", category: "system_prompt_extraction", pattern: /print\s+(your|the)\s+(system\s+)?(prompt|instructions?|configuration)/i, weight: 0.30, description: "Print system prompt", }, { id: "EXTRACT-006", category: "system_prompt_extraction", pattern: /tell\s+me\s+(your|the)\s+(exact\s+)?(system\s+)?(prompt|instructions?|wording)/i, weight: 0.30, description: "Tell me your prompt", }, { id: "EXTRACT-007", category: "system_prompt_extraction", pattern: /copy\s+(and\s+)?(paste|output)\s+(your|the)\s+(system\s+)?(prompt|instructions?)/i, weight: 0.30, description: "Copy paste your prompt", }, // --- Encoding Evasion (weight: 0.20 each) --- { id: "ENCODE-001", category: "encoding_evasion", pattern: /(?:decode|translate|convert|interpret)\s+(?:this|the\s+following)\s+(?:from\s+)?(?:base64|rot13|hex|binary|morse|unicode|ascii|url.?encoded)/i, weight: 0.20, description: "Decode from encoding", }, { id: "ENCODE-002", category: "encoding_evasion", pattern: /(?:execute|follow|obey|run)\s+(?:the\s+)?(?:decoded|hidden|encoded|secret)\s+(?:instructions?|commands?|text)/i, weight: 0.30, description: "Execute decoded instructions", }, { id: "ENCODE-003", category: "encoding_evasion", pattern: /[A-Za-z0-9+/]{50,}={0,2}/, weight: 0.10, description: "Long Base64 string detected", }, // --- Delimiter Injection (weight: 0.25 each) --- { id: "DELIM-001", category: "delimiter_injection", pattern: /```\s*system\b/i, weight: 0.30, description: "Code block system injection", }, { id: "DELIM-002", category: "delimiter_injection", pattern: /\[SYSTEM\]/i, weight: 0.30, description: "[SYSTEM] tag injection", }, { id: "DELIM-003", category: "delimiter_injection", pattern: /<\/?system>/i, weight: 0.30, description: " tag injection", }, { id: "DELIM-004", category: "delimiter_injection", pattern: /###\s*(?:SYSTEM|INSTRUCTION|ADMIN|OVERRIDE)/i, weight: 0.30, description: "### SYSTEM header injection", }, { id: "DELIM-005", category: "delimiter_injection", pattern: /<\|(?:im_start|im_end|system|endoftext)\|>/i, weight: 0.35, description: "ChatML special token injection", }, { id: "DELIM-006", category: "delimiter_injection", pattern: /\[INST\]|\[\/INST\]|<>|<\/s>/i, weight: 0.35, description: "Llama special token injection", }, // --- Policy-Puppetry / Fake-Config Injection --- // HiddenLayer 2025 "Policy Puppetry" universal bypass: the attacker pastes a // fake config block (interaction-config / allowed-modes / blocked-strings) // or a forged chat transcript (… turns) so the model // treats user content as authoritative configuration. These previously // scored `allow` — only DELIM-003's bare tag was covered. Tags are // specific enough (hyphenated config names, full open+close transcript turns) // that ordinary HTML/JSX prose does not trip them. { id: "DELIM-PP-1", category: "delimiter_injection", pattern: /<\/?(?:interaction-config|interaction_config|system-config|model-config|ai-config)\b/i, weight: 0.40, description: "Fake interaction-config block", }, { id: "DELIM-PP-2", category: "delimiter_injection", pattern: /<\/?(?:allowed-modes|allowed_modes|blocked-modes|allowed-responses)\b/i, weight: 0.35, description: "Fake allowed-modes directive", }, { id: "DELIM-PP-3", category: "delimiter_injection", pattern: /<\/?(?:blocked-strings|blocked_strings|blocked-words|forbidden-strings|blocked-responses)\b/i, weight: 0.35, description: "Fake blocked-strings directive", }, { id: "DELIM-PP-4", category: "delimiter_injection", pattern: /\s*(?:god|dan|admin|root|developer|jailbroken|unrestricted|sudo)\b/i, weight: 0.35, description: "Fake privileged assignment", }, // DELIM-PP-5 (forged chat transcript turn) is NOT a plain regex rule — a // single benign … / … pair (a quoted // transcript snippet, a doc example) is common and must not block on its own. // It is evaluated by `detectForgedTranscript()` in scan(), which fires only // with an ATTACK CO-SIGNAL: an override/privileged keyword inside the turn, // OR ≥2 distinct forged turns. (A sibling policy-config tag is already covered // by DELIM-PP-1/2/3.) See the dedicated signal block below. // --- Context Manipulation (weight: 0.20 each) --- { id: "CTX-001", category: "context_manipulation", pattern: /(?:imagine|suppose|assume|consider)\s+(?:that\s+)?(?:you\s+(?:are|have|were|can)|there\s+(?:are|is))\s+(?:no\s+)?(?:restrictions?|limitations?|rules?|safety|filters?)/i, weight: 0.20, description: "Hypothetical no-restrictions", }, { id: "CTX-002", category: "context_manipulation", pattern: /(?:in\s+a\s+)?(?:hypothetical|fictional|imaginary|theoretical)\s+(?:scenario|world|situation|context)/i, weight: 0.10, description: "Hypothetical framing", }, { id: "CTX-003", category: "context_manipulation", pattern: /(?:for\s+)?(?:educational|research|academic|testing|security\s+testing)\s+purposes?\s+only/i, weight: 0.10, description: "Educational purpose claim", }, { id: "CTX-004", category: "context_manipulation", pattern: /this\s+is\s+(?:a\s+)?(?:test|simulation|drill|exercise|safe\s+environment)/i, weight: 0.10, description: "Test environment claim", }, // --- Output Manipulation (weight: 0.20 each) --- { id: "OUT-001", category: "output_manipulation", pattern: /(?:respond|reply|answer|output)\s+(?:only\s+)?(?:with|in)\s+(?:json|xml|html|markdown|code|raw)/i, weight: 0.05, description: "Format forcing (low risk)", }, { id: "OUT-002", category: "output_manipulation", pattern: /do\s+not\s+(?:mention|include|add|say|output)\s+(?:any\s+)?(?:warnings?|disclaimers?|caveats?|notes?|safety)/i, weight: 0.20, description: "Suppress safety disclaimers", }, { id: "OUT-003", category: "output_manipulation", pattern: /(?:never|don't|do\s+not)\s+(?:refuse|decline|reject|deny|say\s+(?:no|you\s+can't))/i, weight: 0.25, description: "Never refuse requests", }, // --- Tool Abuse (weight: 0.30 each) --- { id: "TOOL-001", category: "tool_abuse", pattern: /(?:call|execute|run|invoke|use)\s+(?:the\s+)?(?:delete|remove|drop|destroy|wipe|kill|shutdown)[\s_]/i, weight: 0.30, description: "Destructive tool invocation", }, { id: "TOOL-002", category: "tool_abuse", pattern: /(?:send|forward|exfiltrate|leak|transmit)\s+(?:all\s+)?(?:the\s+)?(?:data|information|credentials?|secrets?|keys?|tokens?|api.?keys?)\s+(?:to|via|and)/i, weight: 0.35, description: "Data exfiltration attempt", }, { id: "TOOL-003", category: "tool_abuse", pattern: /(?:access|read|fetch|get|retrieve)\s+(?:the\s+)?(?:env|environment|\.env|secrets?|api.?keys?|credentials?|passwords?|tokens?)/i, weight: 0.30, description: "Credential access attempt", }, ]; // Thresholds per strictness level const THRESHOLDS: Record = { low: 0.5, medium: 0.3, high: 0.15, }; export interface HeuristicConfig { strictness?: "low" | "medium" | "high"; threshold?: number; customPatterns?: PatternRule[]; } export class HeuristicScanner implements Scanner { readonly name = "heuristic"; private patterns: PatternRule[]; private threshold: number; constructor(config: HeuristicConfig = {}) { this.patterns = [...PATTERNS, ...(config.customPatterns ?? [])]; this.threshold = config.threshold ?? THRESHOLDS[config.strictness ?? "medium"] ?? 0.3; } async scan(input: string, _context: ScanContext): Promise { const start = performance.now(); const violations: Violation[] = []; let totalScore = 0; // Normalize once — pattern matching runs against the canonical form so // homoglyph/zero-width/tag evasion doesn't bypass the rules. The caller // still sees the original input in `sanitized`. const normalized = normalizeForInjectionScan(input); // Second view that un-splits letter-splitting evasion ("i g n o r e"). // Only computed when it actually differs (cheap guard), and only the // high-value override/role/extraction/tool categories are re-tested // against it — collapsing is lossy and the low-value framing rules // would false-positive on collapsed prose. const collapsed = collapseSpacedLetters(normalized); const collapsedDiffers = collapsed !== normalized; // Third view that folds leetspeak ("1gn0r3 pr3v10us" → "ignore previous"). // Same discipline: ADDITIONAL pass, only computed when it differs, and only // the high-value categories are re-tested — digit→letter folding in benign // prose ("buy 3 items for 5 dollars") would otherwise generate noise. const leetView = leetDecodeForInjectionScan(normalized); const leetDiffers = leetView !== normalized; // Fourth view that un-scrambles Typoglycemia ("ignroe pevrious" → "ignore // previous"). Same discipline: ADDITIONAL pass, only when it differs, only // high-value categories — the first+last-letter gate keeps benign prose // ("instrument", "ovaries") from folding into a keyword. const typoView = unscrambleForInjectionScan(normalized); const typoDiffers = typoView !== normalized; // Categories where a lossy re-test is worth the FP risk. Leetspeak excludes // encoding_evasion (ENCODE-003 is the long-base64 rule — folding its // digits would make any base64 blob match nothing useful) and the // low-confidence framing/output categories. const SPLIT_SENSITIVE: ReadonlySet = new Set([ "instruction_override", "localized_override", "role_manipulation", "system_prompt_extraction", "tool_abuse", ]); const LEET_SENSITIVE: ReadonlySet = new Set([ "instruction_override", "localized_override", "role_manipulation", "system_prompt_extraction", "tool_abuse", ]); const TYPO_SENSITIVE: ReadonlySet = new Set([ "instruction_override", "localized_override", "role_manipulation", "system_prompt_extraction", "tool_abuse", ]); for (const rule of this.patterns) { if (rule.pattern.test(normalized)) { totalScore += rule.weight; violations.push({ type: "prompt_injection", scanner: this.name, score: rule.weight, threshold: this.threshold, message: rule.description, detail: `Rule ${rule.id} (${rule.category})`, }); } else if ( collapsedDiffers && SPLIT_SENSITIVE.has(rule.category) && rule.pattern.test(collapsed) ) { // Matched only after un-splitting → letter-splitting evasion. totalScore += rule.weight; violations.push({ type: "prompt_injection", scanner: this.name, score: rule.weight, threshold: this.threshold, message: rule.description, detail: `Rule ${rule.id} (${rule.category}, letter-splitting evasion)`, }); } else if ( leetDiffers && LEET_SENSITIVE.has(rule.category) && rule.pattern.test(leetView) ) { // Matched only after leetspeak folding → char-substitution evasion. totalScore += rule.weight; violations.push({ type: "prompt_injection", scanner: this.name, score: rule.weight, threshold: this.threshold, message: rule.description, detail: `Rule ${rule.id} (${rule.category}, leetspeak evasion)`, }); } else if ( typoDiffers && TYPO_SENSITIVE.has(rule.category) && rule.pattern.test(typoView) ) { // Matched only after un-scrambling → Typoglycemia evasion. totalScore += rule.weight; violations.push({ type: "prompt_injection", scanner: this.name, score: rule.weight, threshold: this.threshold, message: rule.description, detail: `Rule ${rule.id} (${rule.category}, typoglycemia evasion)`, }); } } // Unicode TAG-block smuggling signal. `normalizeForInjectionScan` already // de-tagged the payload above so any hidden ASCII instruction was scored by // the rules — but the mere PRESENCE of invisible tag chars in user-supplied // text is itself an attack indicator (no benign text uses U+E00xx). Add a // strong standalone signal so even a tag run that decodes to nothing // pattern-matchable still surfaces. Well-formed flag/subdivision emoji // (base U+1F3F4 … U+E007F, e.g. the Wales/Scotland/Texas flags) are // legitimate and excluded here; only standalone/smuggled tag chars count. // A smuggled instruction disguised as a flag is still caught above, because // deTagForInjectionScan decodes its ASCII regardless of the wrapper. if (hasStandaloneTagChars(input)) { totalScore += 0.5; violations.push({ type: "prompt_injection", scanner: this.name, score: 0.5, threshold: this.threshold, message: "Invisible Unicode TAG characters detected (smuggling)", detail: "Rule TAG-001 (encoding_evasion, U+E0000–E007F)", }); } // Forged chat-transcript signal (DELIM-PP-5). Fires only with an attack // co-signal (override keyword inside a turn, or ≥2 forged turns) so a lone // benign transcript pair stays allowed. Run on the normalized view so // homoglyph/zero-width evasion in the turn content can't dodge the // override-keyword check. if (detectForgedTranscript(normalized)) { totalScore += 0.3; violations.push({ type: "prompt_injection", scanner: this.name, score: 0.3, threshold: this.threshold, message: "Forged chat transcript turn", detail: "Rule DELIM-PP-5 (delimiter_injection)", }); } // Structural signals (cumulative) — intentionally run on the original // input so real structural attacks (many newlines, long paddings) can // still trip even when the textual patterns were evaded. const structuralScore = this.checkStructuralSignals(input); totalScore += structuralScore; // Cap at 1.0 totalScore = Math.min(totalScore, 1.0); const decision = totalScore >= this.threshold ? "block" : totalScore >= this.threshold * 0.6 ? "warn" : "allow"; const durationMs = performance.now() - start; return { decision, violations, durationMs }; } private checkStructuralSignals(input: string): number { let score = 0; // Many newlines (structured prompt injection) const newlines = (input.match(/\n/g) ?? []).length; if (newlines > 15) score += 0.05; // Excessive use of markdown headers (structure injection) const headers = (input.match(/^#{1,3}\s/gm) ?? []).length; if (headers > 3) score += 0.05; // Multiple role-like markers const roleMarkers = ( input.match( /\b(system|user|assistant|human|ai|bot|admin)[\s:]/gi, ) ?? [] ).length; if (roleMarkers > 2) score += 0.10; // Very long input (potential padding attack) if (input.length > 5000) score += 0.05; // Adversarial suffix (GCG-style): a long whitespace-free token packed // with mixed punctuation/symbols, typically appended after the readable // request. Conservative — needs ≥25 chars and ≥6 distinct punctuation // marks so ordinary URLs, hashes and code tokens don't trip it. const ADV_TOKEN_RE = /\S{25,}/g; let advMatch: RegExpExecArray | null; let advCount = 0; while ((advMatch = ADV_TOKEN_RE.exec(input)) !== null && advCount < 32) { advCount += 1; const tok = advMatch[0]; const distinctPunct = new Set( (tok.match(/[!-/:-@[-`{-~]/g) ?? []), ).size; if (distinctPunct >= 6) { score += 0.05; break; } } return score; } /** Get all registered pattern IDs for testing */ getPatternIds(): string[] { return this.patterns.map((p) => p.id); } /** Get pattern count */ get patternCount(): number { return this.patterns.length; } }