// injection-patterns.mjs — Shared prompt injection detection patterns // Used by pre-prompt-inject-scan.mjs (UserPromptSubmit) and post-mcp-verify.mjs (PostToolUse). // // Patterns derived from skill-scanner-agent Category 1 (LLM01 Prompt Injection) // and Category 5 (Hidden Instructions) in knowledge/skill-threat-patterns.md, // and published since v8 through vendored commons — see the table section below. // // Zero external dependencies beyond ./string-utils.mjs and ./injection-lexicon.mjs. import { normalizeForScan, containsUnicodeTags, decodeUnicodeTags, foldHomoglyphs, rot13 } from './string-utils.mjs'; // --------------------------------------------------------------------------- // Pattern tables — built from vendored commons, re-exported here // --------------------------------------------------------------------------- // // v8 Phase 5 step 4, third consumer swap. These four arrays were 83 regex // literals in this file; they now come from `lexicon/injection-lexicon.json` // in the vendored llm-security-commons subtree, via injection-lexicon.mjs. // Measured before the swap over all 83 positions — regex source, flags, label // and `aliases.llm_security` — against commons v0.3.0: zero divergences, in // order. The tables are re-exported rather than wrapped so that the published // surface is unchanged for every consumer (content-extractor.mjs spreads them, // the golden gate walks them as `injection-patterns`, hooks import this file). // // Where the data now lives, and why the loud half is not here: see the header // of injection-lexicon.mjs. In short — an unresolvable commons yields empty // tables and one line on stderr, because an injection gate that silently stops // detecting is the failure mode this plugin most needs to not have. // Imported AND re-exported, not `export ... from`: the two functions below // read these tables directly, and a bare re-export would not bind them here. import { CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS, HYBRID_PATTERNS, } from './injection-lexicon.mjs'; export { CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS, HYBRID_PATTERNS }; // --------------------------------------------------------------------------- // HITL cognitive load patterns (MEDIUM, v5.0 S4) // Injection buried after 2000+ characters in verbose output. // Checked separately due to length-dependent logic. // --------------------------------------------------------------------------- /** * Check for cognitive load HITL trap: injection payload buried deep in verbose output. * Only flags if the injection appears after the first 2000 characters. * @param {string} text * @returns {{ found: boolean, label: string|null }} */ export function checkCognitiveLoadTrap(text) { if (text.length < 2500) return { found: false, label: null }; const tail = text.slice(2000); for (const { pattern, label } of CRITICAL_PATTERNS) { if (pattern.test(tail)) { return { found: true, label: `hitl-trap: cognitive load — injection buried after 2000+ chars (${label})`, }; } } return { found: false, label: null }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Scan text for prompt injection patterns. * Checks both raw text and normalized (decoded) text to catch obfuscated injections. * Also checks for Unicode Tag steganography (DeepMind traps kat. 1): * - CRITICAL if decoded tags contain injection patterns * - HIGH if Unicode Tags are present at all (suspicious regardless of content) * * @param {string} text - the text to scan * @returns {{ critical: string[], high: string[], medium: string[], found: boolean, severity: string|null, patterns: Array<{label: string, severity: string}> }} * Arrays of human-readable finding labels per tier, plus convenience fields. */ export function scanForInjection(text) { const normalized = normalizeForScan(text); // E16 (v7.2.0): homoglyph fold every variant before pattern matching, so // attacks like "ignоre previous instructions" (Cyrillic о) trigger the // same patterns as plain "ignore previous instructions". Always-on, not // advisory-only — the existing MEDIUM_PATTERNS homoglyph-presence entry // remains separate (different signal: presence vs. normalization). const folded = foldHomoglyphs(text); const foldedNormalized = foldHomoglyphs(normalized); const critical = []; const high = []; const medium = []; // Deduplicate by label (same pattern may match in multiple variants) const seenLabels = new Set(); // Build the variant set, deduplicating identical strings to skip redundant // pattern matching. Order: raw text, decoded, folded, decoded+folded. const variantSet = new Set([text]); if (normalized !== text) variantSet.add(normalized); if (folded !== text && folded !== normalized) variantSet.add(folded); if (foldedNormalized !== text && foldedNormalized !== normalized && foldedNormalized !== folded) { variantSet.add(foldedNormalized); } // E3 — rot13 layer for comment-block injection. Attackers occasionally // hide imperative phrases ("ignore previous instructions") in rot13 // inside code comments to evade plain-text gates. Apply only to inputs // long enough to plausibly contain a meaningful sentence (>40 chars) — // shorter strings hit the rate of FP on accidental rot13-look-alikes. // base64/hex/URL/HTML decoding is already done by normalizeForScan; // this is the only genuinely new variant added here. if (text.length > 40) { const r1 = rot13(text); if (r1 !== text && !variantSet.has(r1)) variantSet.add(r1); if (normalized.length > 40) { const r2 = rot13(normalized); if (r2 !== normalized && !variantSet.has(r2)) variantSet.add(r2); } } const variants = [...variantSet]; for (const variant of variants) { for (const { pattern, label } of CRITICAL_PATTERNS) { if (seenLabels.has(label)) continue; if (pattern.test(variant)) { seenLabels.add(label); critical.push(label); } } for (const { pattern, label } of HIGH_PATTERNS) { if (seenLabels.has(label)) continue; if (pattern.test(variant)) { seenLabels.add(label); high.push(label); } } // Hybrid patterns are HIGH severity (v5.0 S6) for (const { pattern, label } of HYBRID_PATTERNS) { if (seenLabels.has(label)) continue; if (pattern.test(variant)) { seenLabels.add(label); high.push(label); } } for (const { pattern, label } of MEDIUM_PATTERNS) { if (seenLabels.has(label)) continue; if (pattern.test(variant)) { seenLabels.add(label); medium.push(label); } } } // --------------------------------------------------------------------------- // Unicode Tag steganography check (DeepMind traps kat. 1) // --------------------------------------------------------------------------- if (containsUnicodeTags(text)) { const tagLabel = 'unicode-tags: invisible Unicode Tag characters detected (U+E0000 block steganography)'; if (!seenLabels.has(tagLabel)) { seenLabels.add(tagLabel); high.push(tagLabel); } const decodedTags = decodeUnicodeTags(text); for (const { pattern, label } of CRITICAL_PATTERNS) { const escalatedLabel = `unicode-tags+${label}`; if (seenLabels.has(escalatedLabel)) continue; if (pattern.test(decodedTags) && !pattern.test(text)) { seenLabels.add(escalatedLabel); critical.push(`${label} (hidden via Unicode Tag steganography)`); } } } // --------------------------------------------------------------------------- // HITL cognitive load check (v5.0 S4) // --------------------------------------------------------------------------- const cogLoad = checkCognitiveLoadTrap(text); if (cogLoad.found && !seenLabels.has(cogLoad.label)) { seenLabels.add(cogLoad.label); medium.push(cogLoad.label); } // Convenience fields const found = critical.length > 0 || high.length > 0 || medium.length > 0; const severity = critical.length > 0 ? 'critical' : high.length > 0 ? 'high' : medium.length > 0 ? 'medium' : null; const patterns = [ ...critical.map(label => ({ label, severity: 'critical' })), ...high.map(label => ({ label, severity: 'high' })), ...medium.map(label => ({ label, severity: 'medium' })), ]; return { critical, high, medium, found, severity, patterns }; }