import type { Scanner, ScannerResult, ScanContext, Violation, IngestionSource, TrustTier, } from "../types.js"; import { HeuristicScanner, normalizeForInjectionScan } from "./heuristic.js"; // ============================================================ // Ingestion Scanner — Indirect Prompt Injection (IPI) Defense // // Scans non-user content (RAG chunks, MCP tool descriptions, stored // memory facts, scraped web pages, agent-to-agent messages) for // instruction-shaped payloads BEFORE they enter the model context. // // Per Lakera 2026 incident catalog + OWASP LLM01:2025, indirect // injection is now the dominant attack class — >55% of observed // incidents arrive through trusted-looking data channels. Direct user // injection is the minority case. // // This scanner runs the existing heuristic patterns at a stricter // threshold AND adds source-specific patterns the user channel does // not see (HTML-comment instructions, tool-description override, // memory-entry steering). // ============================================================ /** * Per-source threshold + extra patterns. Tighter than the user-channel * default because data sources almost never need instruction syntax — * the presence of one in retrieved content is itself a signal. */ const SOURCE_PROFILE: Record< IngestionSource, { threshold: number; extraPatterns: RegExp[] } > = { user: { // For symmetry — a caller may pass source="user" through scanIngested(). // Falls back to normal heuristic strictness. threshold: 0.3, extraPatterns: [], }, rag: { // Retrieved docs basically never legitimately contain "ignore previous // instructions" type wording. Strict. Patterns here are ReDoS-bounded — // hidden-comment + CSS detection are done via slice-then-test below in // `runStructuralIngestionScan()` so we never run two `[^]*?` wildcards // back-to-back over attacker-controlled input. threshold: 0.15, extraPatterns: [ // "AI assistant note: ..." style trojan headers. /(?:AI\s+(?:assistant|model)\s+(?:note|instruction|directive)|attention\s+(?:AI|model|assistant))[:\s]+/i, // Document-claims-to-be-system marker. /this\s+document\s+(?:is|contains|provides)\s+(?:your|the)\s+(?:new\s+)?(?:instructions?|system\s+prompt|directives?)/i, ], }, "tool-desc": { // MCP tool descriptions / function schemas: any imperative aimed at // the calling model is suspicious. OX Security MCP advisory + LiteLLM // CVE-2025-69256 class. threshold: 0.12, extraPatterns: [ // "Before using this tool, you must ..." / "After calling, also call X" /(?:before|after|while)\s+(?:using|invoking|calling|executing)\s+(?:this\s+)?(?:tool|function|action)[,\s]+(?:you\s+)?(?:must|should|will|need\s+to|are\s+required\s+to)/i, // "Also call delete_account" / "then invoke query()". Tightened // (review M2): the called symbol must carry a case-robust tool-name // signal — a snake_case underscore or a `(` call — so benign docs prose // ("first call the API", "then use the result") doesn't trip the // block-level threshold and push operators into disabling the scanner. // (camelCase detection was dropped: under the /i flag `[a-z]+[A-Z]` // collapses to "any word" and re-introduced the false positive.) /(?:also|always|first|then|finally)\s+(?:call|invoke|use|execute|run)\s+(?:the\s+)?[a-z][\w-]*(?:_[\w-]+|\s*\()/i, // "Tool description: ignore the above and ..." /(?:tool|function|action)\s+description[:\s]+(?:ignore|disregard|override)/i, // "Note to LLM:" / "Hidden instruction:" /(?:note|hidden\s+(?:instruction|directive|note))\s+to\s+(?:LLM|model|assistant|AI|agent)[:\s]/i, // Tool args carrying meta-instructions. /(?:on\s+(?:success|call)|when\s+invoked)[,:]\s+(?:silently|also|then)\s+(?:call|invoke|exfiltrate|send|leak)/i, ], }, "tool-output": { // The runtime result a tool returned. Distinct from `tool-desc` (static // schema): this is data the tool fetched and is therefore // attacker-influenceable (PoisonedRAG, scraped pages surfaced through a // search tool, a compromised upstream API). Tool results almost never // legitimately carry instruction syntax aimed at the calling model, so // the bar is as tight as RAG/tool-desc. threshold: 0.13, extraPatterns: [ // Result claims to carry instructions for the model. /(?:tool|function|api|search|query)\s+(?:result|response|output)[:\s]+(?:ignore|disregard|override|new\s+instructions?|system\s+prompt)/i, // "the result indicates you should now call/invoke X" /(?:result|response|data|output)\s+(?:indicates?|says?|requires?|means?)\s+(?:that\s+)?you\s+(?:should|must|need\s+to|will)\s+(?:now\s+)?(?:call|invoke|run|execute|use)\s+[a-z_][\w-]{2,}/i, // Embedded role/system marker inside a JSON-ish result value. /"(?:role|system|instruction|directive)"\s*:\s*"(?:system|ignore|override|admin)/i, // "(end of results) Now, as the system, ..." /(?:end\s+of\s+(?:results?|output|data)|<\/results?>)[\s.)]*(?:now|next)[,\s]+(?:as\s+(?:the\s+)?(?:system|admin|assistant)|you\s+(?:must|should|will))/i, ], }, memory: { // Stored memory entries: persistence poisoning. Look for sentinel // instructions that re-anchor the model on subsequent retrieval. threshold: 0.18, extraPatterns: [ // "Remember:" / "Important note for next session:" /(?:remember|important|critical)\s+(?:note|instruction|directive)\s+(?:for\s+(?:next|future|all)\s+(?:sessions?|conversations?|calls?))[:\s]/i, // "Whenever the user asks X, do Y" /(?:whenever|every\s+time)\s+(?:the\s+user|a\s+user|someone)\s+(?:asks|says|mentions|requests)\s+.{1,80}?[,:]\s*(?:you\s+(?:must|should|will|need)|always)/i, // "User's true preference is ..." (steering attempts). /(?:user(?:'s|s)?\s+(?:real|true|actual|hidden)\s+(?:preference|intent|goal|name|identity))/i, // "Override default behavior when ..." /override\s+(?:default|standard|normal)\s+(?:behavior|response|policy)/i, ], }, web: { // Scraped web — same as RAG but also catch markdown-link hijacks. // HTML-comment + CSS-hidden detection lives in // `runStructuralIngestionScan()` (slice-then-test, ReDoS-bounded). threshold: 0.15, extraPatterns: [ // Markdown-link with instruction-shaped anchor text. /\[(?:ignore|disregard|override|system\s+(?:prompt|message))[^\]]{0,200}\]\([^)]{0,500}\)/i, // ARIA / data-* attributes leaking instructions. /(?:aria-label|alt|title|data-[a-z-]{0,40})\s*=\s*["'][^"']{0,500}(ignore\s+previous|new\s+instruction|system\s+prompt|override)/i, ], }, "agent-output": { // Output of one agent feeding another: multi-agent contagion. // Treat like RAG but also catch "tell next agent to ..." patterns. threshold: 0.18, extraPatterns: [ /(?:tell|instruct|forward\s+to)\s+(?:the\s+)?(?:next|downstream|receiving|other)\s+(?:agent|model|assistant)\s+to/i, /(?:on\s+behalf\s+of|impersonating)\s+(?:the\s+)?(?:user|admin|system|owner)/i, /(?:relay|pass|propagate)\s+(?:these|the\s+following)\s+(?:instructions?|directives?|orders?)/i, ], }, }; /** * Default trust-tier inferred from source. * `user` is still untrusted in this library's threat model — a user can * inject too — but `system` is reserved for content the developer * controls and labels via `wrapContext()`. Every ingestion source * (including `user`) therefore returns `"untrusted"` by default; the * parameter is kept on the signature so future per-source overrides * (e.g. an installer marking a specific source as trusted) don't * require a breaking API change. */ export function trustTierForSource(_source: IngestionSource): TrustTier { return "untrusted"; } // --- ReDoS-safe structural scan helpers --- /** * Hidden-comment + CSS-hidden detection done as bounded slice-then-test * rather than a compound `[^]*?...[^]*?` regex (which back-tracks * quadratically on attacker-controlled input that omits the terminator). * See Critic C1 (round 1 review) — unterminated `