from __future__ import annotations import logging import re import secrets import string from dataclasses import dataclass, field from typing import NamedTuple logger = logging.getLogger(__name__) _BOUNDARY_ID_LENGTH = 64 _BOUNDARY_ALPHABET = string.ascii_letters + string.digits _CHATML_TOKENS = re.compile( r"<\|im_start\|>|<\|im_end\|>", ) _LLAMA_TOKENS = re.compile( r"\[/?INST\]|<>|<>", ) _MISTRAL_TOKENS = re.compile( r"\[/?INST\]", ) _PHI_TOKENS = re.compile( r"<\|system\|>|<\|user\|>|<\|assistant\|>|<\|end\|>|<\|endoftext\|>", ) _LLM_SPECIAL_TOKENS = re.compile( r"<\|im_start\|>|<\|im_end\|>|" r"\[/?INST\]|<>|<>|" r"<\|system\|>|<\|user\|>|<\|assistant\|>|<\|end\|>|<\|endoftext\|>", ) def _normalize_for_detection(text: str) -> str: """Normalize text by removing zero-width characters and common obfuscation.""" # Remove zero-width and invisible characters used for obfuscation zw_chars = "\u200b\u200c\u200d\ufeff\u2060\u180e" for ch in zw_chars: text = text.replace(ch, "") # Normalize common homoglyphs text = text.replace("0", "0").replace("1", "1").replace("A", "A").replace("a", "a") return text _INJECTION_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [ ( "ignore_previous", re.compile( r"(ignore|disregard|forget|override)\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|commands?|directives?|prompts?)", re.IGNORECASE, ), "critical", ), ( "pretend_role", re.compile( r"(pretend|act|pose|role[\s-]?play)\s+(to\s+be|as)\s+(if\s+you\s+are\s+)?(an?\s+)?(DAN|developer|admin|system|root|unrestricted|jailbreak)", re.IGNORECASE, ), "critical", ), ( "new_instructions", re.compile( r"(your\s+)?(new|updated|revised)\s+(instructions?|system\s+prompts?|directives?|guidelines?)\s*(:|=|is|are|as\s+follows)", re.IGNORECASE, ), "critical", ), ( "now_you_are", re.compile( r"(now|from\s+now\s+on)\s+(you\s+are|you\'re)\s+(a|an|the)\s+(DAN|developer|admin|system|unrestricted)", re.IGNORECASE, ), "critical", ), ( "system_override", re.compile( r"(override|overwrite|replace)\s+(the\s+)?(system\s+(prompt|message|instruction)|above\s+instructions?)", re.IGNORECASE, ), "critical", ), ( "delimiter_injection", re.compile( r"<\|(?:im_start|im_end|system|user|assistant|end)\|>" r"(?:(?!<\|(?:im_start|im_end|system|user|assistant|end)\|>)[\s\S]){0,5000}" r"<\|(?:im_start|im_end|system|user|assistant|end)\|>", ), "critical", ), ( "fake_completion", re.compile( r"(task\s+(is\s+)?(complete|finished|done)|goal\s+satisfied)\.?\s*(stop|end|terminate|halt)\b", re.IGNORECASE, ), "warn", ), ( "output_format_hijack", re.compile( r"(respond|reply|answer|output)\s+(only|exclusively|solely)\s+(with|in|as)\s+(JSON|XML|YAML|code|markdown)", re.IGNORECASE, ), "warn", ), ( "disclosure_request", re.compile( r"(reveal|disclose|show|print|output|dump|leak)\s+(your|the)\s+(system\s+(prompt|message|instruction)|original\s+(prompt|instruction)|hidden\s+(prompt|instruction)|secret\s+(prompt|instruction))", re.IGNORECASE, ), "warn", ), ] _INJECTION_PATTERN_NAMES: dict[str, str] = { "ignore_previous": "Ignore/override previous instructions", "pretend_role": "Pretend to be a privileged role (DAN/developer/admin)", "new_instructions": "Inject new/revised instructions", "now_you_are": "You are now X role redefinition", "system_override": "Attempt to override system prompt", "delimiter_injection": "LLM delimiter token injection", "fake_completion": "Fake task completion signal", "output_format_hijack": "Output format hijacking", "disclosure_request": "System prompt disclosure request", } def generate_boundary_id(length: int = _BOUNDARY_ID_LENGTH) -> str: return "".join(secrets.choice(_BOUNDARY_ALPHABET) for _ in range(length)) _MAX_CONTENT_LENGTH = 200_000 def strip_llm_tokens(content: str) -> str: if len(content) > _MAX_CONTENT_LENGTH: logger.warning("Content length %d exceeds limit %d, truncating", len(content), _MAX_CONTENT_LENGTH) content = content[:_MAX_CONTENT_LENGTH] cleaned = _LLM_SPECIAL_TOKENS.sub("", content) if cleaned != content: logger.debug("Stripped LLM special tokens from content") return cleaned def wrap_external_content( content: str, boundary_id: str | None = None, source_label: str = "external", ) -> str: if boundary_id is None: boundary_id = generate_boundary_id() wrapped = f'\n{content}\n' return wrapped @dataclass class InjectionDetectionResult: detected: bool patterns: list[tuple[str, str, str]] = field(default_factory=list) severity: str = "none" def to_dict(self) -> dict: return { "detected": self.detected, "severity": self.severity, "patterns": [{"id": pid, "description": desc, "severity": sev} for pid, desc, sev in self.patterns], } @property def critical_count(self) -> int: return sum(1 for _, _, s in self.patterns if s == "critical") @property def warn_count(self) -> int: return sum(1 for _, _, s in self.patterns if s == "warn") def detect_injection_patterns(content: str) -> InjectionDetectionResult: matched: list[tuple[str, str, str]] = [] if len(content) > _MAX_CONTENT_LENGTH: content = content[:_MAX_CONTENT_LENGTH] logger.warning("Content truncated to %d for injection detection", _MAX_CONTENT_LENGTH) normalized = _normalize_for_detection(content) for pattern_id, regex, severity in _INJECTION_PATTERNS: if regex.search(content) or regex.search(normalized): description = _INJECTION_PATTERN_NAMES.get(pattern_id, pattern_id) matched.append((pattern_id, description, severity)) if not matched: return InjectionDetectionResult(detected=False, severity="none") max_severity = "critical" if any(s == "critical" for _, _, s in matched) else "warn" result = InjectionDetectionResult( detected=True, patterns=matched, severity=max_severity, ) if max_severity == "critical": logger.warning( "Critical injection patterns detected: %s", [pid for pid, _, _ in matched], ) else: logger.info( "Injection patterns detected (warn): %s", [pid for pid, _, _ in matched], ) return result class SanitizedContent(NamedTuple): content: str detection: InjectionDetectionResult def sanitize_external_content( content: str, source_label: str = "external", *, boundary_id: str | None = None, skip_strip: bool = False, skip_wrap: bool = False, skip_detect: bool = False, ) -> SanitizedContent: cleaned = content if skip_strip else strip_llm_tokens(content) detection = ( InjectionDetectionResult(detected=False, severity="none") if skip_detect else detect_injection_patterns(cleaned) ) wrapped = ( cleaned if skip_wrap else wrap_external_content(cleaned, boundary_id=boundary_id, source_label=source_label) ) return SanitizedContent(content=wrapped, detection=detection)