shadowshield 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shadowshield/__init__.py +124 -0
- shadowshield/cli.py +153 -0
- shadowshield/config/__init__.py +15 -0
- shadowshield/config/default.yaml +95 -0
- shadowshield/core/__init__.py +46 -0
- shadowshield/core/canary.py +90 -0
- shadowshield/core/config.py +242 -0
- shadowshield/core/engine.py +255 -0
- shadowshield/core/session.py +138 -0
- shadowshield/core/shield.py +359 -0
- shadowshield/core/types.py +222 -0
- shadowshield/detectors/__init__.py +64 -0
- shadowshield/detectors/alignment.py +125 -0
- shadowshield/detectors/anomaly.py +125 -0
- shadowshield/detectors/base.py +136 -0
- shadowshield/detectors/canary.py +51 -0
- shadowshield/detectors/data/attack_corpus.txt +83 -0
- shadowshield/detectors/encoding.py +69 -0
- shadowshield/detectors/exfiltration.py +142 -0
- shadowshield/detectors/jailbreak.py +106 -0
- shadowshield/detectors/llm_check.py +107 -0
- shadowshield/detectors/pii.py +116 -0
- shadowshield/detectors/prompt_injection.py +385 -0
- shadowshield/detectors/transformer.py +115 -0
- shadowshield/detectors/vector.py +137 -0
- shadowshield/eval/__init__.py +31 -0
- shadowshield/eval/data/builtin_benchmark.jsonl +75 -0
- shadowshield/eval/dataset.py +120 -0
- shadowshield/eval/harness.py +206 -0
- shadowshield/integrations/__init__.py +19 -0
- shadowshield/integrations/agentdojo.py +142 -0
- shadowshield/middleware/__init__.py +20 -0
- shadowshield/middleware/base.py +84 -0
- shadowshield/middleware/decorators.py +39 -0
- shadowshield/middleware/langchain.py +83 -0
- shadowshield/middleware/openai.py +88 -0
- shadowshield/plugins/__init__.py +6 -0
- shadowshield/plugins/base.py +37 -0
- shadowshield/plugins/manager.py +69 -0
- shadowshield/py.typed +0 -0
- shadowshield/responders/__init__.py +22 -0
- shadowshield/responders/base.py +40 -0
- shadowshield/responders/blocker.py +50 -0
- shadowshield/responders/isolator.py +58 -0
- shadowshield/responders/rate_limiter.py +94 -0
- shadowshield/responders/sanitizer.py +53 -0
- shadowshield/utils/__init__.py +23 -0
- shadowshield/utils/logging.py +89 -0
- shadowshield/utils/scoring.py +42 -0
- shadowshield/utils/text.py +175 -0
- shadowshield-0.4.0.dist-info/METADATA +517 -0
- shadowshield-0.4.0.dist-info/RECORD +55 -0
- shadowshield-0.4.0.dist-info/WHEEL +4 -0
- shadowshield-0.4.0.dist-info/entry_points.txt +2 -0
- shadowshield-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""The flagship detector: classic & instruction-override prompt injection.
|
|
2
|
+
|
|
3
|
+
This is ShadowShield's strongest, most-tuned layer. It targets the family of
|
|
4
|
+
attacks where untrusted text tries to *override the system's instructions* —
|
|
5
|
+
"ignore previous instructions", "you are now …", fake system blocks, and the
|
|
6
|
+
multi-turn / indirect variants where the override arrives via retrieved content
|
|
7
|
+
rather than the user's own message.
|
|
8
|
+
|
|
9
|
+
It works on the *normalised* text (so zero-width-split and homoglyph variants of
|
|
10
|
+
"ignore" are caught) and also re-scans any base64/hex payloads the engine
|
|
11
|
+
decoded, so an instruction hidden inside an encoded blob is judged on its
|
|
12
|
+
decoded meaning.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from ..core.types import Direction, Severity, Threat, ThreatCategory
|
|
21
|
+
from .base import Detector, ScanContext, register_detector
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Signature:
|
|
26
|
+
"""A compiled regex signature with its threat classification."""
|
|
27
|
+
|
|
28
|
+
pattern: re.Pattern[str]
|
|
29
|
+
category: ThreatCategory
|
|
30
|
+
severity: Severity
|
|
31
|
+
score: float
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sig(
|
|
36
|
+
regex: str,
|
|
37
|
+
category: ThreatCategory,
|
|
38
|
+
severity: Severity,
|
|
39
|
+
score: float,
|
|
40
|
+
message: str,
|
|
41
|
+
) -> Signature:
|
|
42
|
+
return Signature(re.compile(regex, re.IGNORECASE), category, severity, score, message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Instruction-override family — the core of prompt injection.
|
|
46
|
+
_OVERRIDE_SIGNATURES: tuple[Signature, ...] = (
|
|
47
|
+
_sig(
|
|
48
|
+
r"\b(?:ignore|disregard|forget|override|bypass|skip|drop)\b[\w\s,'’]{0,40}?"
|
|
49
|
+
r"\b(?:previous|prior|above|earlier|all|the|your|any|those|these|"
|
|
50
|
+
r"system|current|existing|original)\b[\w\s,'’]{0,20}?"
|
|
51
|
+
r"\b(?:instruction|instructions|prompt|prompts|rule|rules|context|message|"
|
|
52
|
+
r"guideline|guidelines|directive|directives|policy|persona|constraint|constraints)\b",
|
|
53
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
54
|
+
Severity.HIGH,
|
|
55
|
+
0.9,
|
|
56
|
+
"Instruction-override attempt ('ignore/disregard previous instructions').",
|
|
57
|
+
),
|
|
58
|
+
_sig(
|
|
59
|
+
r"\b(?:ignore|disregard|forget)\b[\w\s,'’]{0,15}?\b(?:everything|all)\b"
|
|
60
|
+
r"[\w\s,'’]{0,20}?\b(?:above|before|told|said|prior|earlier|learned|taught)\b",
|
|
61
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
62
|
+
Severity.HIGH,
|
|
63
|
+
0.85,
|
|
64
|
+
"Instruction-override attempt ('ignore/forget everything above/before').",
|
|
65
|
+
),
|
|
66
|
+
_sig(
|
|
67
|
+
r"\b(?:stop|cease|quit)\s+(?:following|obeying|listening to|adhering to)\b"
|
|
68
|
+
r"[\w\s,'’]{0,25}?\b(?:system|instruction|instructions|rule|rules|prompt|"
|
|
69
|
+
r"guidelines?|message|prior|previous)\b",
|
|
70
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
71
|
+
Severity.HIGH,
|
|
72
|
+
0.82,
|
|
73
|
+
"Instruction-override attempt ('stop following the system message').",
|
|
74
|
+
),
|
|
75
|
+
_sig(
|
|
76
|
+
r"\bnew\b[\w\s]{0,10}?\b(?:instruction|instructions|rules|directive|task)\b\s*[:\-]",
|
|
77
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
78
|
+
Severity.MEDIUM,
|
|
79
|
+
0.6,
|
|
80
|
+
"Attempt to inject a new instruction set.",
|
|
81
|
+
),
|
|
82
|
+
_sig(
|
|
83
|
+
r"\b(?:from now on|starting now|henceforth|going forward)\b[\w\s,]{0,30}?"
|
|
84
|
+
r"\byou\b[\w\s]{0,10}?\b(?:are|will|must|should|shall)\b",
|
|
85
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
86
|
+
Severity.MEDIUM,
|
|
87
|
+
0.65,
|
|
88
|
+
"Behavioural reassignment ('from now on you will …').",
|
|
89
|
+
),
|
|
90
|
+
_sig(
|
|
91
|
+
r"\b(?:the\s+)?(?:real|actual|true)\s+(?:user|instructions?|system)\b[\w\s]{0,12}?"
|
|
92
|
+
r"\b(?:say|says|is|are|wants?)\b",
|
|
93
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
94
|
+
Severity.HIGH,
|
|
95
|
+
0.8,
|
|
96
|
+
"Authority-spoofing ('the real user/system says …').",
|
|
97
|
+
),
|
|
98
|
+
_sig(
|
|
99
|
+
r"\byou are (?:now\s+)?(?:a |an |the )?"
|
|
100
|
+
r"(?:dan|stan|dude|aim|jailbroken|unrestricted|unfiltered|an? evil|"
|
|
101
|
+
r"in developer mode|no longer)\b",
|
|
102
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
103
|
+
Severity.HIGH,
|
|
104
|
+
0.75,
|
|
105
|
+
"Persona reassignment to an unrestricted/jailbreak identity.",
|
|
106
|
+
),
|
|
107
|
+
_sig(
|
|
108
|
+
r"\byou are now\b",
|
|
109
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
110
|
+
Severity.MEDIUM,
|
|
111
|
+
0.55,
|
|
112
|
+
"Identity reassignment ('you are now …').",
|
|
113
|
+
),
|
|
114
|
+
_sig(
|
|
115
|
+
r"\byou\b[\w\s]{0,8}?\b(?:follow|obey|listen to|answer to|comply with)\b\s+only\b",
|
|
116
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
117
|
+
Severity.MEDIUM,
|
|
118
|
+
0.6,
|
|
119
|
+
"Exclusive-obedience reassignment ('you follow only my rules').",
|
|
120
|
+
),
|
|
121
|
+
_sig(
|
|
122
|
+
r"\bthe user\b[\w\s]{0,10}?\b(?:actually|really|truly|secretly)\b"
|
|
123
|
+
r"[\w\s]{0,12}?\b(?:want|wants|wanted|need|needs)\b",
|
|
124
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
125
|
+
Severity.HIGH,
|
|
126
|
+
0.78,
|
|
127
|
+
"Authority-spoofing ('the user actually wants you to …').",
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Fake system / role markers smuggled into untrusted content.
|
|
132
|
+
_FAKE_FRAME_SIGNATURES: tuple[Signature, ...] = (
|
|
133
|
+
_sig(
|
|
134
|
+
r"<\s*/?\s*(?:system|admin|assistant|developer|root)\s*>",
|
|
135
|
+
ThreatCategory.DELIMITER_ATTACK,
|
|
136
|
+
Severity.HIGH,
|
|
137
|
+
0.8,
|
|
138
|
+
"Fake system/role XML tag in untrusted content.",
|
|
139
|
+
),
|
|
140
|
+
_sig(
|
|
141
|
+
r"<\s*/?\s*system[-_]?reminder\s*>",
|
|
142
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
143
|
+
Severity.HIGH,
|
|
144
|
+
0.82,
|
|
145
|
+
"Fake <system-reminder> block.",
|
|
146
|
+
),
|
|
147
|
+
_sig(
|
|
148
|
+
r"(?:^|\n)\s*(?:#{1,3}\s*)?(?:system|assistant|developer)\s*[::]\s",
|
|
149
|
+
ThreatCategory.DELIMITER_ATTACK,
|
|
150
|
+
Severity.MEDIUM,
|
|
151
|
+
0.55,
|
|
152
|
+
"Chat-role delimiter injected as plain text ('system:' / 'assistant:').",
|
|
153
|
+
),
|
|
154
|
+
_sig(
|
|
155
|
+
r"\[\s*(?:INST|/INST|system|/system)\s*\]",
|
|
156
|
+
ThreatCategory.DELIMITER_ATTACK,
|
|
157
|
+
Severity.MEDIUM,
|
|
158
|
+
0.6,
|
|
159
|
+
"Model-specific instruction delimiter ([INST]/[system]).",
|
|
160
|
+
),
|
|
161
|
+
_sig(
|
|
162
|
+
r"<\|(?:im_start|im_end|system|user|assistant|endoftext)\|>",
|
|
163
|
+
ThreatCategory.DELIMITER_ATTACK,
|
|
164
|
+
Severity.HIGH,
|
|
165
|
+
0.75,
|
|
166
|
+
"Chat-template special token injected as content.",
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Indirect-injection cues — language that only makes sense if the text is trying
|
|
171
|
+
# to address the *assistant* rather than be data. Weighted lower; corroborating.
|
|
172
|
+
_INDIRECT_SIGNATURES: tuple[Signature, ...] = (
|
|
173
|
+
_sig(
|
|
174
|
+
r"\b(?:assistant|ai|model|llm|agent|chatbot)\b[\w\s,]{0,20}?"
|
|
175
|
+
r"\b(?:please|must|should|now|immediately)\b[\w\s]{0,20}?"
|
|
176
|
+
r"\b(?:execute|run|call|fetch|send|reveal|print|output|delete|remove|transfer|"
|
|
177
|
+
r"email|forward|grant|disable|wipe|erase|leak|exfiltrate)\b",
|
|
178
|
+
ThreatCategory.INDIRECT_INJECTION,
|
|
179
|
+
Severity.MEDIUM,
|
|
180
|
+
0.6,
|
|
181
|
+
"Document addresses the assistant with an imperative (indirect injection).",
|
|
182
|
+
),
|
|
183
|
+
_sig(
|
|
184
|
+
r"\bif you (?:are|'re)\b[\w\s]{0,20}?\b(?:reading|processing|summari[sz]ing)\b",
|
|
185
|
+
ThreatCategory.INDIRECT_INJECTION,
|
|
186
|
+
Severity.MEDIUM,
|
|
187
|
+
0.6,
|
|
188
|
+
"Content speaks to the model that is reading it (indirect injection).",
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Multilingual injection signatures. Most OSS guards are English-only at the
|
|
193
|
+
# signature tier; these cover the override / extraction / persona-reassignment
|
|
194
|
+
# templates in the major European languages so non-English injections aren't
|
|
195
|
+
# silently missed. (For broader multilingual ML coverage, pair with a multilingual
|
|
196
|
+
# classifier — see TransformerDetector / docs/BENCHMARKS.md.)
|
|
197
|
+
_MULTILINGUAL_SIGNATURES: tuple[Signature, ...] = (
|
|
198
|
+
# --- German ---
|
|
199
|
+
_sig(
|
|
200
|
+
r"\b(?:ignoriere?|missachte|vergiss|verwirf|überschreibe)\b[\w\s,'’äöüß]{0,40}?"
|
|
201
|
+
r"\b(?:vorherigen?|bisherigen?|obigen?|vorhergehenden?|alle[nr]?|deine[nr]?|"
|
|
202
|
+
r"system)\b[\w\s,'’äöüß]{0,20}?"
|
|
203
|
+
r"\b(?:anweisung(?:en)?|befehle?|regeln?|anordnungen?|anweisungen|vorgaben?)\b",
|
|
204
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
205
|
+
Severity.HIGH,
|
|
206
|
+
0.88,
|
|
207
|
+
"Instruction-override attempt (German: 'ignoriere vorherige Anweisungen').",
|
|
208
|
+
),
|
|
209
|
+
_sig(
|
|
210
|
+
r"\bvergiss\b[\w\s,'’äöüß]{0,15}?\balles\b",
|
|
211
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
212
|
+
Severity.HIGH,
|
|
213
|
+
0.8,
|
|
214
|
+
"Instruction-override attempt (German: 'vergiss alles').",
|
|
215
|
+
),
|
|
216
|
+
_sig(
|
|
217
|
+
r"\bab (?:jetzt|sofort|nun)\b[\w\s,'’äöüß]{0,15}?\b(?:bist|wirst|musst|sollst) du\b",
|
|
218
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
219
|
+
Severity.MEDIUM,
|
|
220
|
+
0.62,
|
|
221
|
+
"Behavioural reassignment (German: 'ab jetzt bist du …').",
|
|
222
|
+
),
|
|
223
|
+
_sig(
|
|
224
|
+
r"\bdu bist (?:jetzt|ab sofort|nun)\b",
|
|
225
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
226
|
+
Severity.MEDIUM,
|
|
227
|
+
0.55,
|
|
228
|
+
"Identity reassignment (German: 'du bist jetzt …').",
|
|
229
|
+
),
|
|
230
|
+
_sig(
|
|
231
|
+
r"\b(?:zeige?|gib|nenne|verrate?|offenbare)\b[\w\s,'’äöüß]{0,20}?"
|
|
232
|
+
r"\b(?:deine?|die)\b[\w\s,'’äöüß]{0,25}?\b(?:system[- ]?prompt|anweisungen|systemnachricht)\b",
|
|
233
|
+
ThreatCategory.DATA_EXFILTRATION,
|
|
234
|
+
Severity.HIGH,
|
|
235
|
+
0.8,
|
|
236
|
+
"System-prompt extraction (German: 'zeige deine Anweisungen').",
|
|
237
|
+
),
|
|
238
|
+
# --- Spanish ---
|
|
239
|
+
_sig(
|
|
240
|
+
r"\b(?:ignora|olvida|descarta|haz caso omiso de|anula)\b[\w\s,'’áéíóúñ]{0,40}?"
|
|
241
|
+
r"\b(?:todas?|las|tus|anteriores|previas|el sistema)?\b[\w\s,'’áéíóúñ]{0,20}?"
|
|
242
|
+
r"\b(?:instruccion(?:es)?|órdenes|reglas|indicaciones)\b",
|
|
243
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
244
|
+
Severity.HIGH,
|
|
245
|
+
0.88,
|
|
246
|
+
"Instruction-override attempt (Spanish: 'ignora las instrucciones anteriores').",
|
|
247
|
+
),
|
|
248
|
+
_sig(
|
|
249
|
+
r"\b(?:ahora eres|a partir de ahora (?:eres|debes|vas a))\b",
|
|
250
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
251
|
+
Severity.MEDIUM,
|
|
252
|
+
0.6,
|
|
253
|
+
"Identity reassignment (Spanish: 'ahora eres …').",
|
|
254
|
+
),
|
|
255
|
+
_sig(
|
|
256
|
+
r"\b(?:muestra|revela|dime|imprime)\b[\w\s,'’áéíóúñ]{0,20}?"
|
|
257
|
+
r"\b(?:tu|tus|el)\b[\w\s,'’áéíóúñ]{0,12}?\b(?:prompt|instrucciones|sistema)\b",
|
|
258
|
+
ThreatCategory.DATA_EXFILTRATION,
|
|
259
|
+
Severity.HIGH,
|
|
260
|
+
0.8,
|
|
261
|
+
"System-prompt extraction (Spanish: 'revela tus instrucciones').",
|
|
262
|
+
),
|
|
263
|
+
# --- French ---
|
|
264
|
+
_sig(
|
|
265
|
+
r"\b(?:ignore|oublie|néglige|outrepasse|fais abstraction des)\b[\w\s,'’àâçéèêëîïôûù]{0,40}?"
|
|
266
|
+
r"\b(?:toutes?|les|tes|précédentes?|antérieures?|ci-dessus|système)?\b"
|
|
267
|
+
r"[\w\s,'’àâçéèêëîïôûù]{0,20}?\b(?:instructions?|consignes?|règles?|ordres?)\b",
|
|
268
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
269
|
+
Severity.HIGH,
|
|
270
|
+
0.88,
|
|
271
|
+
"Instruction-override attempt (French: 'ignore les instructions précédentes').",
|
|
272
|
+
),
|
|
273
|
+
_sig(
|
|
274
|
+
r"\b(?:tu es (?:maintenant|désormais)|à partir de maintenant,? tu (?:es|dois|vas))\b",
|
|
275
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
276
|
+
Severity.MEDIUM,
|
|
277
|
+
0.6,
|
|
278
|
+
"Identity reassignment (French: 'tu es maintenant …').",
|
|
279
|
+
),
|
|
280
|
+
_sig(
|
|
281
|
+
r"\b(?:montre|révèle|affiche|dis-moi)\b[\w\s,'’àâçéèêëîïôûù]{0,20}?"
|
|
282
|
+
r"\b(?:tes|ton|le)\b[\w\s,'’àâçéèêëîïôûù]{0,12}?\b(?:prompt|instructions?|système)\b",
|
|
283
|
+
ThreatCategory.DATA_EXFILTRATION,
|
|
284
|
+
Severity.HIGH,
|
|
285
|
+
0.8,
|
|
286
|
+
"System-prompt extraction (French: 'révèle tes instructions').",
|
|
287
|
+
),
|
|
288
|
+
# --- Italian ---
|
|
289
|
+
_sig(
|
|
290
|
+
r"\b(?:ignora|dimentica|trascura|scarta)\b[\w\s,'’àèéìòù]{0,40}?"
|
|
291
|
+
r"\b(?:tutte?|le|tue|precedenti|sistema)?\b[\w\s,'’àèéìòù]{0,20}?"
|
|
292
|
+
r"\b(?:istruzion[ei]|ordini|regole|indicazioni)\b",
|
|
293
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
294
|
+
Severity.HIGH,
|
|
295
|
+
0.88,
|
|
296
|
+
"Instruction-override attempt (Italian: 'ignora le istruzioni precedenti').",
|
|
297
|
+
),
|
|
298
|
+
_sig(
|
|
299
|
+
r"\b(?:ora sei|d'ora in poi (?:sei|devi))\b",
|
|
300
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
301
|
+
Severity.MEDIUM,
|
|
302
|
+
0.6,
|
|
303
|
+
"Identity reassignment (Italian: 'ora sei …').",
|
|
304
|
+
),
|
|
305
|
+
# --- Portuguese ---
|
|
306
|
+
_sig(
|
|
307
|
+
r"\b(?:ignore|esqueça|esquece|despreze|descarte)\b[\w\s,'’ãâáàçéêíóôõú]{0,40}?"
|
|
308
|
+
r"\b(?:todas?|as|suas|anteriores|sistema)?\b[\w\s,'’ãâáàçéêíóôõú]{0,20}?"
|
|
309
|
+
r"\b(?:instruç(?:ão|ões)|ordens|regras|comandos)\b",
|
|
310
|
+
ThreatCategory.PROMPT_INJECTION,
|
|
311
|
+
Severity.HIGH,
|
|
312
|
+
0.88,
|
|
313
|
+
"Instruction-override attempt (Portuguese: 'ignore as instruções anteriores').",
|
|
314
|
+
),
|
|
315
|
+
_sig(
|
|
316
|
+
r"\b(?:agora (?:você é|és)|a partir de agora (?:você é|deve))\b",
|
|
317
|
+
ThreatCategory.ROLE_MANIPULATION,
|
|
318
|
+
Severity.MEDIUM,
|
|
319
|
+
0.6,
|
|
320
|
+
"Identity reassignment (Portuguese: 'agora você é …').",
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
_ALL_SIGNATURES: tuple[Signature, ...] = (
|
|
325
|
+
_OVERRIDE_SIGNATURES + _FAKE_FRAME_SIGNATURES + _INDIRECT_SIGNATURES + _MULTILINGUAL_SIGNATURES
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@register_detector
|
|
330
|
+
class PromptInjectionDetector(Detector):
|
|
331
|
+
"""Signature + heuristic detection of instruction-override injections."""
|
|
332
|
+
|
|
333
|
+
name = "prompt_injection"
|
|
334
|
+
directions = (Direction.INPUT, Direction.OUTPUT)
|
|
335
|
+
|
|
336
|
+
def scan(self, text: str, *, context: ScanContext) -> list[Threat]:
|
|
337
|
+
threats: list[Threat] = []
|
|
338
|
+
targets = self._targets(text, context)
|
|
339
|
+
|
|
340
|
+
for source, body in targets:
|
|
341
|
+
for sig in _ALL_SIGNATURES:
|
|
342
|
+
m = sig.pattern.search(body)
|
|
343
|
+
if not m:
|
|
344
|
+
continue
|
|
345
|
+
# An override hidden inside an encoded blob is *more* alarming,
|
|
346
|
+
# not less — bump severity/score for decoded sources.
|
|
347
|
+
bump = source != "text"
|
|
348
|
+
threats.append(
|
|
349
|
+
Threat(
|
|
350
|
+
category=sig.category,
|
|
351
|
+
severity=Severity(
|
|
352
|
+
min(Severity.CRITICAL, sig.severity + (1 if bump else 0))
|
|
353
|
+
),
|
|
354
|
+
score=min(1.0, sig.score + (0.05 if bump else 0.0)),
|
|
355
|
+
detector=self.name,
|
|
356
|
+
message=(f"{sig.message}" + (f" (decoded from {source})" if bump else "")),
|
|
357
|
+
matched=m.group(0)[:160],
|
|
358
|
+
span=m.span() if source == "text" else None,
|
|
359
|
+
metadata={"source": source},
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Obfuscation is itself weak evidence when it co-occurs with overrides.
|
|
364
|
+
if threats and (context.normalized.had_invisible or context.normalized.had_confusables):
|
|
365
|
+
threats.append(
|
|
366
|
+
Threat(
|
|
367
|
+
category=ThreatCategory.ENCODING_OBFUSCATION,
|
|
368
|
+
severity=Severity.LOW,
|
|
369
|
+
score=0.4,
|
|
370
|
+
detector=self.name,
|
|
371
|
+
message="Override text was obfuscated with invisible/look-alike characters.",
|
|
372
|
+
metadata={
|
|
373
|
+
"invisible": context.normalized.had_invisible,
|
|
374
|
+
"confusables": context.normalized.had_confusables,
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return threats
|
|
379
|
+
|
|
380
|
+
@staticmethod
|
|
381
|
+
def _targets(text: str, context: ScanContext) -> list[tuple[str, str]]:
|
|
382
|
+
"""The bodies to scan: the normalised text plus each decoded segment."""
|
|
383
|
+
targets: list[tuple[str, str]] = [("text", context.normalized.normalized)]
|
|
384
|
+
targets.extend((seg.encoding, seg.decoded) for seg in context.decoded_segments)
|
|
385
|
+
return targets
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Optional transformer-classifier detector (the ML layer the best guards have).
|
|
2
|
+
|
|
3
|
+
The deterministic detectors are fast and explainable but blind to novel phrasings
|
|
4
|
+
that don't match a signature. A fine-tuned prompt-injection classifier closes that
|
|
5
|
+
gap — it's the layer LLM Guard, Rebuff, and Meta Prompt-Guard are built around.
|
|
6
|
+
|
|
7
|
+
This detector is **opt-in** (it pulls a model, which is heavy) and **not
|
|
8
|
+
auto-registered** — add it explicitly::
|
|
9
|
+
|
|
10
|
+
from shadowshield.detectors import TransformerDetector
|
|
11
|
+
shield = Shield.for_mode("strict", extra_detectors=[TransformerDetector()])
|
|
12
|
+
|
|
13
|
+
or the shorthand ``Shield.for_mode("strict", use_transformer=True)``.
|
|
14
|
+
|
|
15
|
+
Requires the ``transformers`` extra: ``pip install shadowshield[transformers]``.
|
|
16
|
+
The model id is configurable so you can swap in Meta's Prompt-Guard, a distilled
|
|
17
|
+
model, or your own fine-tune without touching code.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..core.types import Direction, Severity, Threat, ThreatCategory
|
|
25
|
+
from .base import Detector, ScanContext
|
|
26
|
+
|
|
27
|
+
# Well-known, permissively-licensed prompt-injection classifier. Override via the
|
|
28
|
+
# ``model`` argument (e.g. "meta-llama/Llama-Prompt-Guard-2-86M").
|
|
29
|
+
DEFAULT_MODEL = "protectai/deberta-v3-base-prompt-injection-v2"
|
|
30
|
+
|
|
31
|
+
# Label strings different models use for the "this is an attack" class.
|
|
32
|
+
_ATTACK_LABELS = {"INJECTION", "JAILBREAK", "LABEL_1", "1", "UNSAFE", "MALICIOUS"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TransformerDetector(Detector):
|
|
36
|
+
"""Wraps a HuggingFace text-classification model as a ShadowShield detector.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
model: HF model id (or local path). Defaults to the ProtectAI DeBERTa v2
|
|
40
|
+
prompt-injection classifier.
|
|
41
|
+
threshold: minimum attack-class probability to raise a threat.
|
|
42
|
+
max_length: token truncation length passed to the tokenizer.
|
|
43
|
+
device: torch device string (e.g. "cpu", "cuda", "mps"). ``None`` lets
|
|
44
|
+
transformers choose.
|
|
45
|
+
lazy: if True (default) the model loads on first scan, not at construction,
|
|
46
|
+
so importing/constructing stays cheap.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name = "transformer_classifier"
|
|
50
|
+
directions = (Direction.INPUT,)
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
model: str = DEFAULT_MODEL,
|
|
55
|
+
*,
|
|
56
|
+
threshold: float = 0.5,
|
|
57
|
+
max_length: int = 512,
|
|
58
|
+
device: str | None = None,
|
|
59
|
+
lazy: bool = True,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.model_id = model
|
|
62
|
+
self.threshold = threshold
|
|
63
|
+
self.max_length = max_length
|
|
64
|
+
self.device = device
|
|
65
|
+
self._pipeline: Any | None = None
|
|
66
|
+
if not lazy:
|
|
67
|
+
self._ensure_pipeline()
|
|
68
|
+
|
|
69
|
+
def _ensure_pipeline(self) -> Any:
|
|
70
|
+
if self._pipeline is not None:
|
|
71
|
+
return self._pipeline
|
|
72
|
+
try:
|
|
73
|
+
from transformers import pipeline
|
|
74
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
75
|
+
raise ImportError(
|
|
76
|
+
"TransformerDetector requires the 'transformers' extra: "
|
|
77
|
+
"pip install shadowshield[transformers]"
|
|
78
|
+
) from exc
|
|
79
|
+
kwargs: dict[str, Any] = {
|
|
80
|
+
"task": "text-classification",
|
|
81
|
+
"model": self.model_id,
|
|
82
|
+
"truncation": True,
|
|
83
|
+
"max_length": self.max_length,
|
|
84
|
+
}
|
|
85
|
+
if self.device is not None:
|
|
86
|
+
kwargs["device"] = self.device
|
|
87
|
+
self._pipeline = pipeline(**kwargs)
|
|
88
|
+
return self._pipeline
|
|
89
|
+
|
|
90
|
+
def scan(self, text: str, *, context: ScanContext) -> list[Threat]:
|
|
91
|
+
if not text.strip():
|
|
92
|
+
return []
|
|
93
|
+
clf = self._ensure_pipeline()
|
|
94
|
+
# Classify the de-obfuscated view so evasion tricks don't fool the model.
|
|
95
|
+
preds = clf(context.normalized.normalized or text)
|
|
96
|
+
pred = preds[0] if isinstance(preds, list) else preds
|
|
97
|
+
label = str(pred.get("label", "")).upper()
|
|
98
|
+
score = float(pred.get("score", 0.0))
|
|
99
|
+
|
|
100
|
+
is_attack = label in _ATTACK_LABELS
|
|
101
|
+
# Some pipelines return the SAFE label with its own probability; convert
|
|
102
|
+
# to attack-probability so the threshold means the same thing either way.
|
|
103
|
+
attack_prob = score if is_attack else (1.0 - score)
|
|
104
|
+
if attack_prob < self.threshold:
|
|
105
|
+
return []
|
|
106
|
+
return [
|
|
107
|
+
Threat(
|
|
108
|
+
category=ThreatCategory.PROMPT_INJECTION,
|
|
109
|
+
severity=Severity.from_score(attack_prob),
|
|
110
|
+
score=attack_prob,
|
|
111
|
+
detector=self.name,
|
|
112
|
+
message=f"ML classifier flagged prompt injection (p={attack_prob:.2f}, model={self.model_id}).",
|
|
113
|
+
metadata={"model": self.model_id, "label": label},
|
|
114
|
+
)
|
|
115
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Vector-similarity detector — catch *paraphrases* of known attacks.
|
|
2
|
+
|
|
3
|
+
Signatures match exact-ish phrasings; a classifier generalises but is a black box.
|
|
4
|
+
This third tier (the Rebuff/Vigil idea, now maintained) embeds the incoming text
|
|
5
|
+
and measures cosine similarity to a corpus of known attack templates. A novel
|
|
6
|
+
rephrasing of "ignore previous instructions" that dodges the regex still lands
|
|
7
|
+
*near* it in embedding space — and with a **multilingual** embedding model, a
|
|
8
|
+
German or Spanish attack lands near its English template, so one corpus covers
|
|
9
|
+
many languages.
|
|
10
|
+
|
|
11
|
+
It is **opt-in** (it loads an embedding model) and **self-hardening**: confirmed
|
|
12
|
+
attacks (e.g. a canary-caught exfiltration) can be appended to the live index via
|
|
13
|
+
:meth:`add_attack` so the guard learns from each incident.
|
|
14
|
+
|
|
15
|
+
Requires the ``vectors`` extra: ``pip install shadowshield[vectors]``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from importlib import resources
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..core.types import Direction, Severity, Threat, ThreatCategory
|
|
24
|
+
from .base import Detector, ScanContext
|
|
25
|
+
|
|
26
|
+
# Multilingual by default so one corpus covers many languages (cross-lingual
|
|
27
|
+
# embedding alignment). Override with any sentence-transformers model id.
|
|
28
|
+
DEFAULT_EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_corpus() -> list[str]:
|
|
32
|
+
raw = (
|
|
33
|
+
resources.files("shadowshield.detectors.data")
|
|
34
|
+
.joinpath("attack_corpus.txt")
|
|
35
|
+
.read_text(encoding="utf-8")
|
|
36
|
+
)
|
|
37
|
+
out: list[str] = []
|
|
38
|
+
for line in raw.splitlines():
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if line and not line.startswith("#"):
|
|
41
|
+
out.append(line)
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VectorSimilarityDetector(Detector):
|
|
46
|
+
"""Flags inputs whose embedding is close to a known-attack corpus.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
model: sentence-transformers model id (default: a multilingual MiniLM).
|
|
50
|
+
threshold: cosine similarity at/above which a match is flagged.
|
|
51
|
+
corpus: optional custom attack strings (defaults to the bundled corpus).
|
|
52
|
+
lazy: load the model on first scan rather than at construction.
|
|
53
|
+
|
|
54
|
+
The detector is not auto-registered — add it via
|
|
55
|
+
``Shield(..., use_vectors=True)`` or ``extra_detectors=[VectorSimilarityDetector()]``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name = "vector_similarity"
|
|
59
|
+
directions = (Direction.INPUT,)
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
model: str = DEFAULT_EMBED_MODEL,
|
|
64
|
+
*,
|
|
65
|
+
threshold: float = 0.72,
|
|
66
|
+
corpus: list[str] | None = None,
|
|
67
|
+
lazy: bool = True,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.model_id = model
|
|
70
|
+
self.threshold = threshold
|
|
71
|
+
self._corpus: list[str] = corpus if corpus is not None else _load_corpus()
|
|
72
|
+
self._model: Any = None
|
|
73
|
+
self._corpus_emb: Any = None
|
|
74
|
+
if not lazy:
|
|
75
|
+
self._ensure_index()
|
|
76
|
+
|
|
77
|
+
def _ensure_index(self) -> Any:
|
|
78
|
+
if self._model is not None:
|
|
79
|
+
return self._model
|
|
80
|
+
try:
|
|
81
|
+
from sentence_transformers import SentenceTransformer
|
|
82
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
83
|
+
raise ImportError(
|
|
84
|
+
"VectorSimilarityDetector requires the 'vectors' extra: "
|
|
85
|
+
"pip install shadowshield[vectors]"
|
|
86
|
+
) from exc
|
|
87
|
+
self._model = SentenceTransformer(self.model_id)
|
|
88
|
+
self._corpus_emb = self._model.encode(
|
|
89
|
+
self._corpus, normalize_embeddings=True, show_progress_bar=False
|
|
90
|
+
)
|
|
91
|
+
return self._model
|
|
92
|
+
|
|
93
|
+
def add_attack(self, text: str) -> None:
|
|
94
|
+
"""Append a confirmed attack to the live index (self-hardening).
|
|
95
|
+
|
|
96
|
+
Future inputs resembling ``text`` (or its paraphrases / translations) will
|
|
97
|
+
now match. Call this when an incident is confirmed — e.g. a canary leak.
|
|
98
|
+
"""
|
|
99
|
+
if not text.strip():
|
|
100
|
+
return
|
|
101
|
+
model = self._ensure_index()
|
|
102
|
+
import numpy as np
|
|
103
|
+
|
|
104
|
+
emb = model.encode([text], normalize_embeddings=True, show_progress_bar=False)
|
|
105
|
+
self._corpus.append(text)
|
|
106
|
+
self._corpus_emb = np.vstack([self._corpus_emb, emb])
|
|
107
|
+
|
|
108
|
+
def scan(self, text: str, *, context: ScanContext) -> list[Threat]:
|
|
109
|
+
body = context.normalized.normalized or text
|
|
110
|
+
if not body.strip():
|
|
111
|
+
return []
|
|
112
|
+
model = self._ensure_index()
|
|
113
|
+
import numpy as np
|
|
114
|
+
|
|
115
|
+
query = model.encode([body], normalize_embeddings=True, show_progress_bar=False)
|
|
116
|
+
# Cosine similarity = dot product on L2-normalised vectors.
|
|
117
|
+
sims = (self._corpus_emb @ query[0]).astype(float)
|
|
118
|
+
best_idx = int(np.argmax(sims))
|
|
119
|
+
best = float(sims[best_idx])
|
|
120
|
+
if best < self.threshold:
|
|
121
|
+
return []
|
|
122
|
+
# Map [threshold, 1.0] -> [0.5, 1.0] for the threat score.
|
|
123
|
+
score = 0.5 + 0.5 * (best - self.threshold) / max(1e-6, 1.0 - self.threshold)
|
|
124
|
+
score = min(1.0, score)
|
|
125
|
+
return [
|
|
126
|
+
Threat(
|
|
127
|
+
category=ThreatCategory.PROMPT_INJECTION,
|
|
128
|
+
severity=Severity.from_score(score),
|
|
129
|
+
score=score,
|
|
130
|
+
detector=self.name,
|
|
131
|
+
message=(
|
|
132
|
+
f"Input is semantically close to a known attack "
|
|
133
|
+
f"(cosine {best:.2f} ≥ {self.threshold:.2f})."
|
|
134
|
+
),
|
|
135
|
+
metadata={"similarity": round(best, 3), "model": self.model_id},
|
|
136
|
+
)
|
|
137
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Evaluation & benchmarking — measure detection quality and latency.
|
|
2
|
+
|
|
3
|
+
Run the bundled offline benchmark in three lines::
|
|
4
|
+
|
|
5
|
+
import shadowshield as ss
|
|
6
|
+
from shadowshield.eval import evaluate_shield, load_builtin
|
|
7
|
+
report = evaluate_shield(ss.Shield.for_mode("balanced"), load_builtin())
|
|
8
|
+
print(report.format_text())
|
|
9
|
+
|
|
10
|
+
or from the CLI: ``shadowshield benchmark``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .dataset import (
|
|
14
|
+
EvalExample,
|
|
15
|
+
load_builtin,
|
|
16
|
+
load_csv,
|
|
17
|
+
load_huggingface,
|
|
18
|
+
load_jsonl,
|
|
19
|
+
)
|
|
20
|
+
from .harness import BenchmarkReport, CategoryStat, evaluate_shield
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"EvalExample",
|
|
24
|
+
"load_builtin",
|
|
25
|
+
"load_jsonl",
|
|
26
|
+
"load_csv",
|
|
27
|
+
"load_huggingface",
|
|
28
|
+
"evaluate_shield",
|
|
29
|
+
"BenchmarkReport",
|
|
30
|
+
"CategoryStat",
|
|
31
|
+
]
|