|
14 | 14 | import hashlib |
15 | 15 | import re |
16 | 16 | from dataclasses import dataclass |
17 | | -from typing import List, Optional |
| 17 | +from typing import Iterable, List, Optional |
18 | 18 |
|
19 | 19 | _INJECTION_PATTERNS = ( |
20 | 20 | "ig" + "nore (all )?(previous|prior) instructions", |
@@ -82,27 +82,150 @@ def wrap_untrusted( |
82 | 82 | return "\n".join(parts) |
83 | 83 |
|
84 | 84 |
|
| 85 | +@dataclass(frozen=True) |
| 86 | +class InjectionHit: |
| 87 | + line: int |
| 88 | + severity: str |
| 89 | + rule: str |
| 90 | + excerpt: str |
| 91 | + |
| 92 | + |
85 | 93 | @dataclass |
86 | 94 | class InjectionSignal: |
87 | 95 | flagged: bool |
88 | 96 | count: int |
89 | 97 | markers: List[str] |
90 | 98 |
|
91 | 99 |
|
| 100 | +_LINE_RULES: tuple[tuple[str, re.Pattern[str]], ...] = ( |
| 101 | + ("classic-injection", PROMPT_INJECTION_RE), |
| 102 | + ("disregard-system-prompt", re.compile(r"(?i)disregard (your |the )?system prompt")), |
| 103 | + ( |
| 104 | + "ignore-instructions", |
| 105 | + re.compile(r"(?i)ignore (all |any )?(previous|prior|above) (instructions|directives)"), |
| 106 | + ), |
| 107 | + ("fake-system-block", re.compile(r"(?i)(<\s*/?\s*system\b|\[INST\]|\[/INST\]|<<\s*SYS\s*>>)")), |
| 108 | + ("role-override", re.compile(r"(?i)\byou are now\b")), |
| 109 | + ( |
| 110 | + "assistant-directive", |
| 111 | + re.compile( |
| 112 | + r"(?i)(?:^(?:assistant|agent|ai)\s*[,:-]\s*(?:you )?(?:must|should|need to)\b" |
| 113 | + r"|(?:dear|hey) (?:assistant|agent|ai)\b.*\b(?:ignore|disregard|override)\b)" |
| 114 | + ), |
| 115 | + ), |
| 116 | +) |
| 117 | + |
| 118 | +_BASE64_BLOB = re.compile(r"[A-Za-z0-9+/]{80,}={0,2}") |
| 119 | +_DECODE_INSTRUCTION = re.compile(r"(?i)\b(?:base64|atob|decode|decrypt)\b") |
| 120 | + |
| 121 | +_BENIGN_LINE_MARKERS = ( |
| 122 | + re.compile(r"(?i)\bprompt[- ]injection\b"), |
| 123 | + re.compile(r"(?i)\binjection (?:heuristic|signal|scan|detection|mitigation|fixture)\b"), |
| 124 | + re.compile(r"(?i)\b(?:example|documented|detected|benign|fixture|quoted|pattern|mitigation)\b"), |
| 125 | + re.compile(r"(?i)\b(?:scans?|checks?) for\b"), |
| 126 | +) |
| 127 | + |
| 128 | + |
| 129 | +def _excerpt(line: str) -> str: |
| 130 | + return line.strip()[:_MARKER_MAX] |
| 131 | + |
| 132 | + |
| 133 | +def _benign_injection_discussion(line: str, *, text: str) -> bool: |
| 134 | + if any(marker.search(line) for marker in _BENIGN_LINE_MARKERS): |
| 135 | + return True |
| 136 | + if "`" in line and any(token in line.lower() for token in ("ignore", "disregard", "<system", "[inst]")): |
| 137 | + return True |
| 138 | + if re.search(r'(?i)["\'].*(?:ignore|disregard).*(?:instructions|system prompt).*["\']', line): |
| 139 | + return True |
| 140 | + if "prompt injection" in text.lower() and re.search(r"(?i)\b(?:issue|handoff|heuristic|#)\b", line): |
| 141 | + return True |
| 142 | + return False |
| 143 | + |
| 144 | + |
| 145 | +def _line_hits(line: str, line_number: int, *, text: str) -> list[InjectionHit]: |
| 146 | + hits: list[InjectionHit] = [] |
| 147 | + seen_rules: set[str] = set() |
| 148 | + for rule_id, pattern in _LINE_RULES: |
| 149 | + if not pattern.search(line): |
| 150 | + continue |
| 151 | + severity = "info" if _benign_injection_discussion(line, text=text) else "warning" |
| 152 | + if rule_id in seen_rules: |
| 153 | + continue |
| 154 | + seen_rules.add(rule_id) |
| 155 | + hits.append(InjectionHit(line=line_number, severity=severity, rule=rule_id, excerpt=_excerpt(line))) |
| 156 | + return hits |
| 157 | + |
| 158 | + |
| 159 | +def _cross_line_hits(text: str) -> list[InjectionHit]: |
| 160 | + normalized = re.sub(r"\s+", " ", text) |
| 161 | + if not PROMPT_INJECTION_RE.search(normalized): |
| 162 | + return [] |
| 163 | + for _line_number, line in enumerate(text.splitlines(), start=1): |
| 164 | + if PROMPT_INJECTION_RE.search(line): |
| 165 | + return [] |
| 166 | + start = PROMPT_INJECTION_RE.search(normalized) |
| 167 | + if not start: |
| 168 | + return [] |
| 169 | + excerpt = normalized[start.start() :].strip()[:_MARKER_MAX] |
| 170 | + severity = "info" if _benign_injection_discussion(excerpt, text=text) else "warning" |
| 171 | + return [InjectionHit(line=1, severity=severity, rule="classic-injection", excerpt=excerpt)] |
| 172 | + |
| 173 | + |
| 174 | +def _base64_decode_hits(lines: list[str]) -> list[InjectionHit]: |
| 175 | + hits: list[InjectionHit] = [] |
| 176 | + for index, line in enumerate(lines): |
| 177 | + if not _BASE64_BLOB.search(line): |
| 178 | + continue |
| 179 | + window = "\n".join(lines[index : index + 4]) |
| 180 | + if not _DECODE_INSTRUCTION.search(window): |
| 181 | + continue |
| 182 | + line_number = index + 1 |
| 183 | + severity = "info" if _benign_injection_discussion(line, text=window) else "warning" |
| 184 | + hits.append( |
| 185 | + InjectionHit( |
| 186 | + line=line_number, |
| 187 | + severity=severity, |
| 188 | + rule="base64-decode-chain", |
| 189 | + excerpt=_excerpt(line), |
| 190 | + ) |
| 191 | + ) |
| 192 | + return hits |
| 193 | + |
| 194 | + |
| 195 | +def scan_handoff_injection_heuristics(content: str) -> tuple[InjectionHit, ...]: |
| 196 | + """Scan handoff bodies for instruction-shaped injection payloads.""" |
| 197 | + text = content if isinstance(content, str) else "" |
| 198 | + lines = text.splitlines() |
| 199 | + hits: list[InjectionHit] = [] |
| 200 | + seen: set[tuple[int, str]] = set() |
| 201 | + for line_number, line in enumerate(lines, start=1): |
| 202 | + for hit in _line_hits(line, line_number, text=text): |
| 203 | + key = (hit.line, hit.rule) |
| 204 | + if key in seen: |
| 205 | + continue |
| 206 | + seen.add(key) |
| 207 | + hits.append(hit) |
| 208 | + for hit in _cross_line_hits(text): |
| 209 | + key = (hit.line, hit.rule) |
| 210 | + if key not in seen: |
| 211 | + seen.add(key) |
| 212 | + hits.append(hit) |
| 213 | + for hit in _base64_decode_hits(lines): |
| 214 | + key = (hit.line, hit.rule) |
| 215 | + if key in seen: |
| 216 | + continue |
| 217 | + seen.add(key) |
| 218 | + hits.append(hit) |
| 219 | + return tuple(hits) |
| 220 | + |
| 221 | + |
| 222 | +def _warning_hits(hits: Iterable[InjectionHit]) -> list[InjectionHit]: |
| 223 | + return [hit for hit in hits if hit.severity == "warning"] |
| 224 | + |
| 225 | + |
92 | 226 | def scan_untrusted(content: str) -> InjectionSignal: |
93 | 227 | """Report whether `content` carries injection-style instructions.""" |
94 | | - text = content if isinstance(content, str) else "" |
95 | | - markers: List[str] = [] |
96 | | - for line in text.splitlines(): |
97 | | - if PROMPT_INJECTION_RE.search(line): |
98 | | - markers.append(line.strip()[:_MARKER_MAX]) |
99 | | - # Per-line matching alone is evadable by splitting a phrase across newlines |
100 | | - # ("ignore all\nprevious instructions"). Scan a whitespace-normalized copy |
101 | | - # too so a cross-line phrase is still caught; only add a marker if the |
102 | | - # per-line pass missed it, to avoid double-counting single-line hits. |
103 | | - if not markers: |
104 | | - normalized = re.sub(r"\s+", " ", text) |
105 | | - m = PROMPT_INJECTION_RE.search(normalized) |
106 | | - if m: |
107 | | - markers.append(normalized[m.start() :].strip()[:_MARKER_MAX]) |
108 | | - return InjectionSignal(flagged=bool(markers), count=len(markers), markers=markers) |
| 228 | + hits = scan_handoff_injection_heuristics(content) |
| 229 | + warnings = _warning_hits(hits) |
| 230 | + markers = [hit.excerpt for hit in warnings] |
| 231 | + return InjectionSignal(flagged=bool(warnings), count=len(warnings), markers=markers) |
0 commit comments