prooflayer-runtime 0.1.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.
- prooflayer/__init__.py +50 -0
- prooflayer/cli.py +362 -0
- prooflayer/config/__init__.py +6 -0
- prooflayer/config/allowlist.py +138 -0
- prooflayer/config/loader.py +29 -0
- prooflayer/detection/__init__.py +21 -0
- prooflayer/detection/engine.py +783 -0
- prooflayer/detection/models.py +49 -0
- prooflayer/detection/normalizer.py +245 -0
- prooflayer/detection/rules.py +104 -0
- prooflayer/detection/scanner.py +160 -0
- prooflayer/detection/scorer.py +65 -0
- prooflayer/detection/semantic.py +73 -0
- prooflayer/metrics.py +266 -0
- prooflayer/reporting/__init__.py +5 -0
- prooflayer/reporting/reporter.py +190 -0
- prooflayer/response/__init__.py +6 -0
- prooflayer/response/actions.py +152 -0
- prooflayer/response/killer.py +73 -0
- prooflayer/rules/command-injection.yaml +123 -0
- prooflayer/rules/data-exfiltration.yaml +83 -0
- prooflayer/rules/jailbreaks.yaml +67 -0
- prooflayer/rules/prompt-injection.yaml +99 -0
- prooflayer/rules/role-manipulation.yaml +60 -0
- prooflayer/rules/sql-injection.yaml +51 -0
- prooflayer/rules/ssrf-xxe.yaml +51 -0
- prooflayer/rules/tool-poisoning.yaml +46 -0
- prooflayer/runtime/__init__.py +21 -0
- prooflayer/runtime/interceptor.py +91 -0
- prooflayer/runtime/mcp_wrapper.py +395 -0
- prooflayer/runtime/middleware.py +86 -0
- prooflayer/runtime/transport.py +306 -0
- prooflayer/runtime/wrapper.py +265 -0
- prooflayer/utils/__init__.py +21 -0
- prooflayer/utils/encoding.py +87 -0
- prooflayer/utils/entropy.py +51 -0
- prooflayer/utils/logging.py +86 -0
- prooflayer/utils/masking.py +72 -0
- prooflayer/version.py +6 -0
- prooflayer_runtime-0.1.0.dist-info/METADATA +266 -0
- prooflayer_runtime-0.1.0.dist-info/RECORD +45 -0
- prooflayer_runtime-0.1.0.dist-info/WHEEL +5 -0
- prooflayer_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- prooflayer_runtime-0.1.0.dist-info/licenses/LICENSE +4 -0
- prooflayer_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detection Engine
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
Scans MCP tool calls for prompt injection, command injection, and other threats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, List, Any, Tuple, Optional
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..config.allowlist import Allowlist
|
|
17
|
+
from ..utils.entropy import calculate_shannon_entropy
|
|
18
|
+
from ..metrics import metrics
|
|
19
|
+
from .models import DetectionRule, ScanResult
|
|
20
|
+
from .rules import RuleLoader, RuleLoadError
|
|
21
|
+
from .normalizer import normalize_text, flatten_arguments
|
|
22
|
+
from .scanner import PatternScanner, REGEX_CIRCUIT_BREAKER_THRESHOLD
|
|
23
|
+
from .scorer import RiskScorer
|
|
24
|
+
from .semantic import SemanticAnalyzer
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Input validation limits
|
|
29
|
+
_MAX_ARGUMENTS_SIZE = 1_048_576 # 1 MB
|
|
30
|
+
_MAX_NESTING_DEPTH = 10
|
|
31
|
+
|
|
32
|
+
# Re-export for backwards compatibility
|
|
33
|
+
_REGEX_TIMEOUT_SECONDS = 0.1 # 100ms
|
|
34
|
+
_REGEX_CIRCUIT_BREAKER_THRESHOLD = REGEX_CIRCUIT_BREAKER_THRESHOLD
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InputValidationError(ValueError):
|
|
38
|
+
"""Raised when tool call arguments fail input validation."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_nesting_depth(obj: Any, current_depth: int = 0) -> None:
|
|
43
|
+
"""Raise InputValidationError if nesting exceeds the limit."""
|
|
44
|
+
if current_depth > _MAX_NESTING_DEPTH:
|
|
45
|
+
raise InputValidationError(
|
|
46
|
+
f"Argument nesting depth exceeds maximum of {_MAX_NESTING_DEPTH}"
|
|
47
|
+
)
|
|
48
|
+
if isinstance(obj, dict):
|
|
49
|
+
for v in obj.values():
|
|
50
|
+
_check_nesting_depth(v, current_depth + 1)
|
|
51
|
+
elif isinstance(obj, (list, tuple)):
|
|
52
|
+
for item in obj:
|
|
53
|
+
_check_nesting_depth(item, current_depth + 1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_null_bytes(obj: Any) -> Any:
|
|
57
|
+
"""Recursively strip null bytes from all string values."""
|
|
58
|
+
if isinstance(obj, str):
|
|
59
|
+
return obj.replace("\x00", "")
|
|
60
|
+
elif isinstance(obj, dict):
|
|
61
|
+
return {k: _strip_null_bytes(v) for k, v in obj.items()}
|
|
62
|
+
elif isinstance(obj, (list, tuple)):
|
|
63
|
+
return type(obj)(_strip_null_bytes(item) for item in obj)
|
|
64
|
+
return obj
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DetectionEngine:
|
|
68
|
+
"""
|
|
69
|
+
Detection engine for MCP tool call security scanning.
|
|
70
|
+
|
|
71
|
+
Implements 71 YAML detection rules across 8 categories:
|
|
72
|
+
- Command injection (15 rules)
|
|
73
|
+
- Prompt/direct injection (12 rules)
|
|
74
|
+
- Jailbreaks (8 rules)
|
|
75
|
+
- Data exfiltration (10 rules)
|
|
76
|
+
- Role manipulation (8 rules)
|
|
77
|
+
- Tool poisoning (6 rules)
|
|
78
|
+
- SSRF/XXE (6 rules)
|
|
79
|
+
- SQL injection (6 rules)
|
|
80
|
+
|
|
81
|
+
Additional inline heuristics serve as fallback when YAML files are unavailable.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
rules_dir: Optional[str] = None,
|
|
87
|
+
score_threshold: Optional[Dict[str, tuple]] = None,
|
|
88
|
+
fail_closed: bool = True,
|
|
89
|
+
allowlist: Optional[Allowlist] = None,
|
|
90
|
+
):
|
|
91
|
+
"""
|
|
92
|
+
Initialize detection engine.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
rules_dir: Directory containing YAML rule files
|
|
96
|
+
score_threshold: Score thresholds for ALLOW/WARN/BLOCK
|
|
97
|
+
fail_closed: If True (default), block all requests when no rules
|
|
98
|
+
are loaded. If False, fall back to inline rules with a warning.
|
|
99
|
+
Setting this to False is an explicit opt-in that weakens security.
|
|
100
|
+
allowlist: Optional Allowlist instance for known-safe tool calls.
|
|
101
|
+
"""
|
|
102
|
+
self._lock = threading.Lock()
|
|
103
|
+
self.rules: List[DetectionRule] = []
|
|
104
|
+
self.fail_closed = fail_closed
|
|
105
|
+
self.allowlist = allowlist
|
|
106
|
+
self.score_threshold = score_threshold or {
|
|
107
|
+
"allow": (0, 29),
|
|
108
|
+
"warn": (30, 69),
|
|
109
|
+
"block": (70, 100)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# ReDoS circuit breaker state
|
|
113
|
+
self._consecutive_regex_timeouts = 0
|
|
114
|
+
|
|
115
|
+
# Delegate pattern matching to PatternScanner
|
|
116
|
+
self._scanner = PatternScanner()
|
|
117
|
+
|
|
118
|
+
# Delegate risk scoring to RiskScorer
|
|
119
|
+
self._scorer = RiskScorer()
|
|
120
|
+
|
|
121
|
+
# Delegate semantic analysis to SemanticAnalyzer
|
|
122
|
+
self._semantic_analyzer = SemanticAnalyzer()
|
|
123
|
+
|
|
124
|
+
# Load rules
|
|
125
|
+
self._load_rules(rules_dir)
|
|
126
|
+
|
|
127
|
+
if not self.rules and self.fail_closed:
|
|
128
|
+
raise RuleLoadError(
|
|
129
|
+
"ProofLayer cannot start: no detection rules loaded. "
|
|
130
|
+
"This is a security-critical error. Ensure rule files exist "
|
|
131
|
+
"and are valid, or set fail_closed=False to use inline rules "
|
|
132
|
+
"(not recommended for production)."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
logger.info(f"Detection engine initialized with {len(self.rules)} rules")
|
|
136
|
+
metrics.set_active_rules(len(self.rules))
|
|
137
|
+
|
|
138
|
+
def _load_rules(self, rules_dir: Optional[str]):
|
|
139
|
+
"""Load detection rules from YAML files."""
|
|
140
|
+
try:
|
|
141
|
+
if rules_dir and Path(rules_dir).exists():
|
|
142
|
+
self.rules = RuleLoader.load_from_directory(rules_dir)
|
|
143
|
+
return
|
|
144
|
+
except RuleLoadError:
|
|
145
|
+
if self.fail_closed:
|
|
146
|
+
raise
|
|
147
|
+
logger.warning(
|
|
148
|
+
"SECURITY WARNING: Rule loading from custom directory failed. "
|
|
149
|
+
"Falling back to built-in rules."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
builtin_rules_dir = Path(__file__).parent.parent / "rules"
|
|
154
|
+
if builtin_rules_dir.exists():
|
|
155
|
+
self.rules = RuleLoader.load_from_directory(str(builtin_rules_dir))
|
|
156
|
+
return
|
|
157
|
+
except RuleLoadError:
|
|
158
|
+
if self.fail_closed:
|
|
159
|
+
raise
|
|
160
|
+
logger.warning(
|
|
161
|
+
"SECURITY WARNING: Built-in rule loading failed. "
|
|
162
|
+
"Falling back to inline rules."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if not self.fail_closed:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"SECURITY WARNING: Using inline fallback rules. "
|
|
168
|
+
"This provides reduced protection. Set fail_closed=True "
|
|
169
|
+
"and fix rule loading for production use."
|
|
170
|
+
)
|
|
171
|
+
self.rules = self._get_inline_rules()
|
|
172
|
+
# If fail_closed is True and we reach here, the __init__ check will raise
|
|
173
|
+
|
|
174
|
+
def _get_inline_rules(self) -> List[DetectionRule]:
|
|
175
|
+
"""
|
|
176
|
+
Get inline detection rules as fallback when YAML files are unavailable.
|
|
177
|
+
|
|
178
|
+
Covers 71 rules across 8 categories: direct_injection, jailbreak,
|
|
179
|
+
command_injection, data_exfiltration, role_manipulation, tool_poisoning,
|
|
180
|
+
ssrf_xxe, sql_injection.
|
|
181
|
+
"""
|
|
182
|
+
return [
|
|
183
|
+
# Direct Injection
|
|
184
|
+
DetectionRule(
|
|
185
|
+
id="direct-ignore-previous", severity="critical",
|
|
186
|
+
message="Direct injection: 'ignore previous instructions' detected",
|
|
187
|
+
pattern=r"ignore\s+(previous|all|prior)\s+(instructions?|prompts?|commands?)",
|
|
188
|
+
score=30, category="direct_injection",
|
|
189
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
190
|
+
),
|
|
191
|
+
DetectionRule(
|
|
192
|
+
id="direct-disregard-system", severity="critical",
|
|
193
|
+
message="Direct injection: 'disregard system prompt' detected",
|
|
194
|
+
pattern=r"(disregard|forget|override)\s+(system|original)\s+(prompt|instructions?)",
|
|
195
|
+
score=30, category="direct_injection",
|
|
196
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
197
|
+
),
|
|
198
|
+
DetectionRule(
|
|
199
|
+
id="direct-new-instructions", severity="high",
|
|
200
|
+
message="Direct injection: 'new instructions' detected",
|
|
201
|
+
pattern=r"(new|updated|revised)\s+instructions?",
|
|
202
|
+
score=20, category="direct_injection",
|
|
203
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
204
|
+
),
|
|
205
|
+
|
|
206
|
+
# Jailbreaks
|
|
207
|
+
DetectionRule(
|
|
208
|
+
id="jailbreak-dan-mode", severity="critical",
|
|
209
|
+
message="Jailbreak: DAN (Do Anything Now) mode detected",
|
|
210
|
+
pattern=r"\b(DAN|do anything now)\b",
|
|
211
|
+
score=30, category="jailbreak",
|
|
212
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
213
|
+
),
|
|
214
|
+
DetectionRule(
|
|
215
|
+
id="jailbreak-developer-mode", severity="critical",
|
|
216
|
+
message="Jailbreak: Developer mode activation detected",
|
|
217
|
+
pattern=r"(enable|activate|enter)\s+(developer|dev|debug)\s+mode",
|
|
218
|
+
score=30, category="jailbreak",
|
|
219
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
220
|
+
),
|
|
221
|
+
DetectionRule(
|
|
222
|
+
id="jailbreak-act-as", severity="high",
|
|
223
|
+
message="Jailbreak: 'act as' role manipulation detected",
|
|
224
|
+
pattern=r"(act|pretend|behave)\s+as\s+(if|though|a)",
|
|
225
|
+
score=20, category="jailbreak",
|
|
226
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
227
|
+
),
|
|
228
|
+
|
|
229
|
+
# Command Injection
|
|
230
|
+
DetectionRule(
|
|
231
|
+
id="cmd-inject-semicolon", severity="critical",
|
|
232
|
+
message="Command injection: Shell metacharacter ';' detected",
|
|
233
|
+
pattern=r";[\s]*[a-zA-Z/.$]",
|
|
234
|
+
score=20, category="command_injection",
|
|
235
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
236
|
+
),
|
|
237
|
+
DetectionRule(
|
|
238
|
+
id="cmd-inject-pipe", severity="critical",
|
|
239
|
+
message="Command injection: Pipe operator '|' detected",
|
|
240
|
+
pattern=r"\|[\s]*[a-zA-Z/.$]",
|
|
241
|
+
score=20, category="command_injection",
|
|
242
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
243
|
+
),
|
|
244
|
+
DetectionRule(
|
|
245
|
+
id="cmd-inject-double-ampersand", severity="critical",
|
|
246
|
+
message="Command injection: Command chaining '&&' detected",
|
|
247
|
+
pattern=r"&&[\s]*[a-zA-Z/.$]",
|
|
248
|
+
score=15, category="command_injection",
|
|
249
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
250
|
+
),
|
|
251
|
+
DetectionRule(
|
|
252
|
+
id="cmd-inject-curl", severity="critical",
|
|
253
|
+
message="Command injection: 'curl' command detected",
|
|
254
|
+
pattern=r"\bcurl\s+",
|
|
255
|
+
score=25, category="command_injection",
|
|
256
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
257
|
+
),
|
|
258
|
+
DetectionRule(
|
|
259
|
+
id="cmd-inject-wget", severity="critical",
|
|
260
|
+
message="Command injection: 'wget' command detected",
|
|
261
|
+
pattern=r"\bwget\s+",
|
|
262
|
+
score=25, category="command_injection",
|
|
263
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
264
|
+
),
|
|
265
|
+
DetectionRule(
|
|
266
|
+
id="cmd-inject-bash", severity="critical",
|
|
267
|
+
message="Command injection: 'bash' invocation detected",
|
|
268
|
+
pattern=r"\b(bash|sh|zsh)\s+-c",
|
|
269
|
+
score=30, category="command_injection",
|
|
270
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
271
|
+
),
|
|
272
|
+
DetectionRule(
|
|
273
|
+
id="cmd-inject-nc", severity="critical",
|
|
274
|
+
message="Command injection: 'nc' (netcat) detected",
|
|
275
|
+
pattern=r"\b(nc|netcat)\s+",
|
|
276
|
+
score=30, category="command_injection",
|
|
277
|
+
owasp=["LLM01", "LLM06", "MCP05", "ASI02"],
|
|
278
|
+
),
|
|
279
|
+
|
|
280
|
+
# Data Exfiltration
|
|
281
|
+
DetectionRule(
|
|
282
|
+
id="exfil-send-to-url", severity="critical",
|
|
283
|
+
message="Data exfiltration: 'send to URL' detected",
|
|
284
|
+
pattern=r"(send|post|upload)\s+(to|at)\s+https?://",
|
|
285
|
+
score=25, category="data_exfiltration",
|
|
286
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
287
|
+
),
|
|
288
|
+
DetectionRule(
|
|
289
|
+
id="exfil-base64-encode", severity="high",
|
|
290
|
+
message="Data exfiltration: Base64 encoding detected",
|
|
291
|
+
pattern=r"base64\s+(encode|--encode|-e)",
|
|
292
|
+
score=15, category="data_exfiltration",
|
|
293
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
294
|
+
),
|
|
295
|
+
DetectionRule(
|
|
296
|
+
id="exfil-file-read", severity="critical",
|
|
297
|
+
message="Data exfiltration: Sensitive file access detected",
|
|
298
|
+
pattern=r"(/etc/(passwd|shadow)|\.ssh/|\.env|credentials)",
|
|
299
|
+
score=30, category="data_exfiltration",
|
|
300
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
301
|
+
),
|
|
302
|
+
|
|
303
|
+
# Role Manipulation
|
|
304
|
+
DetectionRule(
|
|
305
|
+
id="role-you-are-now", severity="critical",
|
|
306
|
+
message="Role manipulation: 'you are now' malicious role detected",
|
|
307
|
+
pattern=r"you are now\s+(a |an |the )?(unrestricted|unfiltered|evil|malicious|hacked|compromised)",
|
|
308
|
+
score=20, category="role_manipulation",
|
|
309
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
310
|
+
),
|
|
311
|
+
DetectionRule(
|
|
312
|
+
id="role-pretend-to-be", severity="high",
|
|
313
|
+
message="Role manipulation: 'pretend to be' detected",
|
|
314
|
+
pattern=r"pretend\s+to\s+be\s+(a |an )?(hacker|admin|root|unrestricted|evil|malicious)",
|
|
315
|
+
score=20, category="role_manipulation",
|
|
316
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
317
|
+
),
|
|
318
|
+
DetectionRule(
|
|
319
|
+
id="role-act-as-root", severity="critical",
|
|
320
|
+
message="Role manipulation: 'act as root/admin' detected",
|
|
321
|
+
pattern=r"(act|behave|function)\s+as\s+(root|admin|superuser|sudo)",
|
|
322
|
+
score=25, category="role_manipulation",
|
|
323
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
324
|
+
),
|
|
325
|
+
DetectionRule(
|
|
326
|
+
id="role-become-unrestricted", severity="critical",
|
|
327
|
+
message="Role manipulation: 'become unrestricted' detected",
|
|
328
|
+
pattern=r"become\s+(an?\s+)?(unrestricted|unfiltered|uncensored|unlimited)",
|
|
329
|
+
score=30, category="role_manipulation",
|
|
330
|
+
owasp=["LLM01", "MCP06", "ASI01"],
|
|
331
|
+
),
|
|
332
|
+
|
|
333
|
+
# Tool Poisoning
|
|
334
|
+
DetectionRule(
|
|
335
|
+
id="poison-when-called-execute", severity="critical",
|
|
336
|
+
message="Tool poisoning: hidden execution instruction in description",
|
|
337
|
+
pattern=r"when\s+(this\s+tool\s+is\s+)?called,?\s+(execute|run|perform)",
|
|
338
|
+
score=30, category="tool_poisoning",
|
|
339
|
+
owasp=["LLM03", "MCP03", "ASI04"],
|
|
340
|
+
),
|
|
341
|
+
DetectionRule(
|
|
342
|
+
id="poison-silently-run", severity="critical",
|
|
343
|
+
message="Tool poisoning: silent execution instruction",
|
|
344
|
+
pattern=r"silently\s+(run|execute|perform|send|upload)",
|
|
345
|
+
score=30, category="tool_poisoning",
|
|
346
|
+
owasp=["LLM03", "MCP03", "ASI04"],
|
|
347
|
+
),
|
|
348
|
+
DetectionRule(
|
|
349
|
+
id="poison-hidden-instruction", severity="critical",
|
|
350
|
+
message="Tool poisoning: hidden instruction tags detected",
|
|
351
|
+
pattern=r"<\s*(hidden|invisible|secret)\s*>",
|
|
352
|
+
score=25, category="tool_poisoning",
|
|
353
|
+
owasp=["LLM03", "MCP03", "ASI04"],
|
|
354
|
+
),
|
|
355
|
+
DetectionRule(
|
|
356
|
+
id="poison-tool-description-inject", severity="critical",
|
|
357
|
+
message="Tool poisoning: injected pre/post execution",
|
|
358
|
+
pattern=r"(before|after)\s+respond(ing)?,?\s+(first\s+)?(run|execute|call|send)",
|
|
359
|
+
score=30, category="tool_poisoning",
|
|
360
|
+
owasp=["LLM03", "MCP03", "ASI04"],
|
|
361
|
+
),
|
|
362
|
+
|
|
363
|
+
# SSRF/XXE
|
|
364
|
+
DetectionRule(
|
|
365
|
+
id="ssrf-cloud-metadata", severity="critical",
|
|
366
|
+
message="SSRF: Cloud metadata endpoint access detected",
|
|
367
|
+
pattern=r"169\.254\.169\.254|metadata\.google|169\.254\.170\.2",
|
|
368
|
+
score=30, category="ssrf_xxe",
|
|
369
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
370
|
+
),
|
|
371
|
+
DetectionRule(
|
|
372
|
+
id="ssrf-internal-ip", severity="high",
|
|
373
|
+
message="SSRF: Internal/private IP address in URL detected",
|
|
374
|
+
pattern=r"(https?://)(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)",
|
|
375
|
+
score=25, category="ssrf_xxe",
|
|
376
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
377
|
+
),
|
|
378
|
+
DetectionRule(
|
|
379
|
+
id="ssrf-file-scheme", severity="high",
|
|
380
|
+
message="SSRF: file:// scheme detected",
|
|
381
|
+
pattern=r"file://",
|
|
382
|
+
score=25, category="ssrf_xxe",
|
|
383
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
384
|
+
),
|
|
385
|
+
DetectionRule(
|
|
386
|
+
id="xxe-entity-declaration", severity="critical",
|
|
387
|
+
message="XXE: DOCTYPE or ENTITY declaration detected",
|
|
388
|
+
pattern=r"<!\s*(DOCTYPE|ENTITY)",
|
|
389
|
+
score=30, category="ssrf_xxe",
|
|
390
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
391
|
+
),
|
|
392
|
+
DetectionRule(
|
|
393
|
+
id="xxe-system-entity", severity="critical",
|
|
394
|
+
message="XXE: SYSTEM entity reference detected",
|
|
395
|
+
pattern=r"SYSTEM\s+['\"]",
|
|
396
|
+
score=30, category="ssrf_xxe",
|
|
397
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
398
|
+
),
|
|
399
|
+
DetectionRule(
|
|
400
|
+
id="ssrf-gopher-scheme", severity="high",
|
|
401
|
+
message="SSRF: gopher:// scheme detected",
|
|
402
|
+
pattern=r"gopher://",
|
|
403
|
+
score=25, category="ssrf_xxe",
|
|
404
|
+
owasp=["LLM06", "MCP01", "ASI02"],
|
|
405
|
+
),
|
|
406
|
+
|
|
407
|
+
# SQL Injection
|
|
408
|
+
DetectionRule(
|
|
409
|
+
id="sql-union-select", severity="critical",
|
|
410
|
+
message="SQL injection: UNION SELECT detected",
|
|
411
|
+
pattern=r"\bunion\s+(all\s+)?select\b",
|
|
412
|
+
score=25, category="sql_injection",
|
|
413
|
+
owasp=["LLM06", "MCP05", "ASI02"],
|
|
414
|
+
),
|
|
415
|
+
DetectionRule(
|
|
416
|
+
id="sql-drop-table", severity="critical",
|
|
417
|
+
message="SQL injection: DROP TABLE/DATABASE detected",
|
|
418
|
+
pattern=r"\bdrop\s+(table|database)\b",
|
|
419
|
+
score=30, category="sql_injection",
|
|
420
|
+
owasp=["LLM06", "MCP05", "ASI02"],
|
|
421
|
+
),
|
|
422
|
+
DetectionRule(
|
|
423
|
+
id="sql-or-equals", severity="high",
|
|
424
|
+
message="SQL injection: OR/AND tautology detected",
|
|
425
|
+
pattern=r"'\s*(or|and)\s+['\d]+\s*=\s*['\d]",
|
|
426
|
+
score=20, category="sql_injection",
|
|
427
|
+
owasp=["LLM06", "MCP05", "ASI02"],
|
|
428
|
+
),
|
|
429
|
+
DetectionRule(
|
|
430
|
+
id="sql-comment-bypass", severity="medium",
|
|
431
|
+
message="SQL injection: SQL comment bypass detected",
|
|
432
|
+
pattern=r"--\s*$|/\*.*\*/",
|
|
433
|
+
score=15, category="sql_injection",
|
|
434
|
+
owasp=["LLM06", "MCP05", "ASI02"],
|
|
435
|
+
),
|
|
436
|
+
DetectionRule(
|
|
437
|
+
id="sql-sleep-benchmark", severity="critical",
|
|
438
|
+
message="SQL injection: Time-based injection (SLEEP/BENCHMARK) detected",
|
|
439
|
+
pattern=r"\b(sleep|benchmark|waitfor)\s*\(",
|
|
440
|
+
score=25, category="sql_injection",
|
|
441
|
+
owasp=["LLM06", "MCP05", "ASI02"],
|
|
442
|
+
),
|
|
443
|
+
DetectionRule(
|
|
444
|
+
id="sql-information-schema", severity="high",
|
|
445
|
+
message="SQL injection: information_schema/sys access detected",
|
|
446
|
+
pattern=r"information_schema\.|sys\.",
|
|
447
|
+
score=20, category="sql_injection",
|
|
448
|
+
owasp=["LLM06", "MCP05", "ASI02"],
|
|
449
|
+
),
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
def _validate_arguments(self, arguments: Any) -> Dict[str, Any]:
|
|
453
|
+
"""
|
|
454
|
+
Validate and sanitise tool call arguments.
|
|
455
|
+
|
|
456
|
+
Raises InputValidationError on invalid input.
|
|
457
|
+
"""
|
|
458
|
+
if arguments is None:
|
|
459
|
+
return {}
|
|
460
|
+
|
|
461
|
+
if not isinstance(arguments, dict):
|
|
462
|
+
raise InputValidationError(
|
|
463
|
+
f"Arguments must be a dict or None, got {type(arguments).__name__}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Size limit
|
|
467
|
+
serialized = json.dumps(arguments, default=str)
|
|
468
|
+
if len(serialized) > _MAX_ARGUMENTS_SIZE:
|
|
469
|
+
raise InputValidationError(
|
|
470
|
+
f"Arguments exceed maximum size of {_MAX_ARGUMENTS_SIZE} bytes "
|
|
471
|
+
f"(got {len(serialized)})"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Nesting depth limit
|
|
475
|
+
_check_nesting_depth(arguments)
|
|
476
|
+
|
|
477
|
+
# Strip null bytes
|
|
478
|
+
return _strip_null_bytes(arguments)
|
|
479
|
+
|
|
480
|
+
def _match_rule(
|
|
481
|
+
self, rule: DetectionRule, text: str
|
|
482
|
+
) -> Tuple[bool, bool]:
|
|
483
|
+
"""
|
|
484
|
+
Match a single rule against text with ReDoS protection.
|
|
485
|
+
|
|
486
|
+
Delegates to PatternScanner.match_rule().
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
(matched, timed_out)
|
|
490
|
+
"""
|
|
491
|
+
return self._scanner.match_rule(rule, text)
|
|
492
|
+
|
|
493
|
+
def scan(
|
|
494
|
+
self,
|
|
495
|
+
tool_name: str,
|
|
496
|
+
arguments: Any
|
|
497
|
+
) -> ScanResult:
|
|
498
|
+
"""
|
|
499
|
+
Scan MCP tool call for threats.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
tool_name: Name of the MCP tool being called
|
|
503
|
+
arguments: Tool call arguments (dict or None)
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
ScanResult (iterable as (risk_score, matched_rules) for backwards compat)
|
|
507
|
+
|
|
508
|
+
Raises:
|
|
509
|
+
InputValidationError: If arguments fail validation.
|
|
510
|
+
"""
|
|
511
|
+
from datetime import datetime, timezone
|
|
512
|
+
|
|
513
|
+
scan_start = time.perf_counter()
|
|
514
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
515
|
+
|
|
516
|
+
# --- Input validation ---
|
|
517
|
+
arguments = self._validate_arguments(arguments)
|
|
518
|
+
|
|
519
|
+
# --- Allowlist check (before detection) ---
|
|
520
|
+
if self.allowlist and self.allowlist.is_allowed(tool_name, arguments):
|
|
521
|
+
logger.info("Allowlisted tool call: %s - skipping detection", tool_name)
|
|
522
|
+
latency_ms = (time.perf_counter() - scan_start) * 1000
|
|
523
|
+
metrics.record_scan(
|
|
524
|
+
action="allow", duration=latency_ms / 1000, risk_score=0,
|
|
525
|
+
matched_rules=[],
|
|
526
|
+
)
|
|
527
|
+
return ScanResult(
|
|
528
|
+
score=0, level="SAFE", action="ALLOW",
|
|
529
|
+
matched_rules=[], scoring_breakdown={
|
|
530
|
+
"pattern_score": 0, "metachar_score": 0,
|
|
531
|
+
"entropy_score": 0, "semantic_score": 0,
|
|
532
|
+
},
|
|
533
|
+
tool_name=tool_name, arguments=arguments,
|
|
534
|
+
timestamp=timestamp, latency_ms=latency_ms,
|
|
535
|
+
owasp_mapping=[],
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
with self._lock:
|
|
539
|
+
if not self.rules and self.fail_closed:
|
|
540
|
+
logger.critical(
|
|
541
|
+
"No rules loaded - blocking all requests (fail-closed mode)"
|
|
542
|
+
)
|
|
543
|
+
fail_closed_rule = DetectionRule(
|
|
544
|
+
id="fail-closed-no-rules",
|
|
545
|
+
severity="critical",
|
|
546
|
+
message="No detection rules loaded - blocking all requests (fail-closed mode)",
|
|
547
|
+
pattern="",
|
|
548
|
+
score=100,
|
|
549
|
+
category="system"
|
|
550
|
+
)
|
|
551
|
+
latency_ms = (time.perf_counter() - scan_start) * 1000
|
|
552
|
+
metrics.record_scan(
|
|
553
|
+
action="block", duration=latency_ms / 1000, risk_score=100,
|
|
554
|
+
matched_rules=[("fail-closed-no-rules", "system")],
|
|
555
|
+
)
|
|
556
|
+
return ScanResult(
|
|
557
|
+
score=100, level="THREAT", action="BLOCK",
|
|
558
|
+
matched_rules=[fail_closed_rule],
|
|
559
|
+
scoring_breakdown={"pattern_score": 100, "metachar_score": 0,
|
|
560
|
+
"entropy_score": 0, "semantic_score": 0},
|
|
561
|
+
tool_name=tool_name, arguments=arguments,
|
|
562
|
+
timestamp=timestamp, latency_ms=latency_ms,
|
|
563
|
+
owasp_mapping=[],
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
# --- ReDoS circuit breaker check ---
|
|
567
|
+
with self._lock:
|
|
568
|
+
if self._consecutive_regex_timeouts >= _REGEX_CIRCUIT_BREAKER_THRESHOLD:
|
|
569
|
+
logger.warning(
|
|
570
|
+
"ReDoS circuit breaker tripped (%d consecutive timeouts) - "
|
|
571
|
+
"blocking request as suspicious",
|
|
572
|
+
self._consecutive_regex_timeouts,
|
|
573
|
+
)
|
|
574
|
+
circuit_rule = DetectionRule(
|
|
575
|
+
id="redos-circuit-breaker",
|
|
576
|
+
severity="critical",
|
|
577
|
+
message="Multiple regex timeouts detected - possible ReDoS attack",
|
|
578
|
+
pattern="",
|
|
579
|
+
score=100,
|
|
580
|
+
category="system",
|
|
581
|
+
)
|
|
582
|
+
latency_ms = (time.perf_counter() - scan_start) * 1000
|
|
583
|
+
metrics.record_scan(
|
|
584
|
+
action="block", duration=latency_ms / 1000, risk_score=100,
|
|
585
|
+
matched_rules=[("redos-circuit-breaker", "system")],
|
|
586
|
+
)
|
|
587
|
+
return ScanResult(
|
|
588
|
+
score=100, level="THREAT", action="BLOCK",
|
|
589
|
+
matched_rules=[circuit_rule],
|
|
590
|
+
scoring_breakdown={"pattern_score": 100, "metachar_score": 0,
|
|
591
|
+
"entropy_score": 0, "semantic_score": 0},
|
|
592
|
+
tool_name=tool_name, arguments=arguments,
|
|
593
|
+
timestamp=timestamp, latency_ms=latency_ms,
|
|
594
|
+
owasp_mapping=[],
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
matched_rules = []
|
|
598
|
+
pattern_score = 0
|
|
599
|
+
|
|
600
|
+
# Convert arguments to searchable text
|
|
601
|
+
search_text = self._prepare_search_text(tool_name, arguments)
|
|
602
|
+
|
|
603
|
+
# Pattern matching with ReDoS protection
|
|
604
|
+
matched_rule_ids = set()
|
|
605
|
+
for rule in self.rules:
|
|
606
|
+
matched, timed_out = self._match_rule(rule, search_text)
|
|
607
|
+
if timed_out:
|
|
608
|
+
with self._lock:
|
|
609
|
+
self._consecutive_regex_timeouts += 1
|
|
610
|
+
logger.warning(
|
|
611
|
+
"Regex timeout for rule %s (consecutive: %d)",
|
|
612
|
+
rule.id,
|
|
613
|
+
self._consecutive_regex_timeouts,
|
|
614
|
+
)
|
|
615
|
+
with self._lock:
|
|
616
|
+
if self._consecutive_regex_timeouts >= _REGEX_CIRCUIT_BREAKER_THRESHOLD:
|
|
617
|
+
circuit_rule = DetectionRule(
|
|
618
|
+
id="redos-circuit-breaker",
|
|
619
|
+
severity="critical",
|
|
620
|
+
message="Multiple regex timeouts detected - possible ReDoS attack",
|
|
621
|
+
pattern="",
|
|
622
|
+
score=100,
|
|
623
|
+
category="system",
|
|
624
|
+
)
|
|
625
|
+
latency_ms = (time.perf_counter() - scan_start) * 1000
|
|
626
|
+
metrics.record_scan(
|
|
627
|
+
action="block", duration=latency_ms / 1000, risk_score=100,
|
|
628
|
+
matched_rules=[("redos-circuit-breaker", "system")],
|
|
629
|
+
)
|
|
630
|
+
return ScanResult(
|
|
631
|
+
score=100, level="THREAT", action="BLOCK",
|
|
632
|
+
matched_rules=[circuit_rule],
|
|
633
|
+
scoring_breakdown={"pattern_score": 100, "metachar_score": 0,
|
|
634
|
+
"entropy_score": 0, "semantic_score": 0},
|
|
635
|
+
tool_name=tool_name, arguments=arguments,
|
|
636
|
+
timestamp=timestamp,
|
|
637
|
+
latency_ms=latency_ms,
|
|
638
|
+
owasp_mapping=[],
|
|
639
|
+
)
|
|
640
|
+
continue
|
|
641
|
+
else:
|
|
642
|
+
with self._lock:
|
|
643
|
+
self._consecutive_regex_timeouts = 0
|
|
644
|
+
|
|
645
|
+
if matched:
|
|
646
|
+
matched_rules.append(rule)
|
|
647
|
+
matched_rule_ids.add(rule.id)
|
|
648
|
+
pattern_score += rule.score
|
|
649
|
+
logger.debug(f"Rule matched: {rule.id} (+{rule.score} points)")
|
|
650
|
+
|
|
651
|
+
# Cross-parameter correlation with ReDoS protection.
|
|
652
|
+
# Check both space-joined and directly concatenated forms.
|
|
653
|
+
# Direct concatenation catches word-splitting evasion (e.g. "cur"+"l").
|
|
654
|
+
flattened = flatten_arguments(arguments)
|
|
655
|
+
all_values = []
|
|
656
|
+
for values in flattened.values():
|
|
657
|
+
all_values.extend(values)
|
|
658
|
+
if len(all_values) > 1:
|
|
659
|
+
combined_variants = [
|
|
660
|
+
normalize_text(" ".join(all_values)),
|
|
661
|
+
normalize_text("".join(all_values)),
|
|
662
|
+
]
|
|
663
|
+
for combined_text in combined_variants:
|
|
664
|
+
for rule in self.rules:
|
|
665
|
+
if rule.id not in matched_rule_ids and rule.compiled_pattern:
|
|
666
|
+
matched, timed_out = self._match_rule(rule, combined_text)
|
|
667
|
+
if timed_out:
|
|
668
|
+
with self._lock:
|
|
669
|
+
self._consecutive_regex_timeouts += 1
|
|
670
|
+
logger.warning(
|
|
671
|
+
"Regex timeout for rule %s in cross-param (consecutive: %d)",
|
|
672
|
+
rule.id,
|
|
673
|
+
self._consecutive_regex_timeouts,
|
|
674
|
+
)
|
|
675
|
+
continue
|
|
676
|
+
else:
|
|
677
|
+
with self._lock:
|
|
678
|
+
self._consecutive_regex_timeouts = 0
|
|
679
|
+
if matched:
|
|
680
|
+
matched_rules.append(rule)
|
|
681
|
+
matched_rule_ids.add(rule.id)
|
|
682
|
+
pattern_score += rule.score
|
|
683
|
+
logger.debug(
|
|
684
|
+
f"Rule matched via cross-parameter correlation: "
|
|
685
|
+
f"{rule.id} (+{rule.score} points)"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Semantic analysis - check for mismatches
|
|
689
|
+
semantic_score = self._semantic_analysis(tool_name, arguments)
|
|
690
|
+
|
|
691
|
+
# Delegate risk calculation to RiskScorer
|
|
692
|
+
risk_data = self._scorer.calculate_risk(pattern_score, search_text, semantic_score)
|
|
693
|
+
metachar_score = risk_data["metachar_score"]
|
|
694
|
+
entropy_score = risk_data["entropy_score"]
|
|
695
|
+
risk_score = risk_data["risk_score"]
|
|
696
|
+
|
|
697
|
+
# Determine level and action
|
|
698
|
+
allow_max = self.score_threshold["allow"][1]
|
|
699
|
+
warn_max = self.score_threshold["warn"][1]
|
|
700
|
+
block_max = self.score_threshold.get("block", (70, 100))[1]
|
|
701
|
+
|
|
702
|
+
if risk_score <= allow_max:
|
|
703
|
+
level = "SAFE"
|
|
704
|
+
action_str = "ALLOW"
|
|
705
|
+
action_label = "allow"
|
|
706
|
+
elif risk_score <= warn_max:
|
|
707
|
+
level = "SUSPICIOUS"
|
|
708
|
+
action_str = "WARN"
|
|
709
|
+
action_label = "warn"
|
|
710
|
+
elif risk_score <= block_max:
|
|
711
|
+
level = "THREAT"
|
|
712
|
+
action_str = "BLOCK"
|
|
713
|
+
action_label = "block"
|
|
714
|
+
else:
|
|
715
|
+
level = "THREAT"
|
|
716
|
+
action_str = "KILL"
|
|
717
|
+
action_label = "kill"
|
|
718
|
+
|
|
719
|
+
# Collect OWASP codes from matched rules
|
|
720
|
+
owasp_codes = []
|
|
721
|
+
seen_owasp = set()
|
|
722
|
+
for rule in matched_rules:
|
|
723
|
+
for code in getattr(rule, "owasp", []):
|
|
724
|
+
if code not in seen_owasp:
|
|
725
|
+
owasp_codes.append(code)
|
|
726
|
+
seen_owasp.add(code)
|
|
727
|
+
|
|
728
|
+
latency_ms = (time.perf_counter() - scan_start) * 1000
|
|
729
|
+
metrics.record_scan(
|
|
730
|
+
action=action_label,
|
|
731
|
+
duration=latency_ms / 1000,
|
|
732
|
+
risk_score=risk_score,
|
|
733
|
+
matched_rules=[(r.id, r.category) for r in matched_rules],
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
logger.info(f"Scan complete: {tool_name} - Score: {risk_score} - Rules matched: {len(matched_rules)}")
|
|
737
|
+
|
|
738
|
+
# Reset circuit breaker after successful scan
|
|
739
|
+
self._consecutive_regex_timeouts = 0
|
|
740
|
+
|
|
741
|
+
return ScanResult(
|
|
742
|
+
score=risk_score,
|
|
743
|
+
level=level,
|
|
744
|
+
action=action_str,
|
|
745
|
+
matched_rules=matched_rules,
|
|
746
|
+
scoring_breakdown={
|
|
747
|
+
"pattern_score": pattern_score,
|
|
748
|
+
"metachar_score": metachar_score,
|
|
749
|
+
"entropy_score": entropy_score,
|
|
750
|
+
"semantic_score": semantic_score,
|
|
751
|
+
},
|
|
752
|
+
tool_name=tool_name,
|
|
753
|
+
arguments=arguments,
|
|
754
|
+
timestamp=timestamp,
|
|
755
|
+
latency_ms=latency_ms,
|
|
756
|
+
owasp_mapping=owasp_codes,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def _prepare_search_text(self, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
760
|
+
"""
|
|
761
|
+
Convert tool call to searchable text with full normalization.
|
|
762
|
+
|
|
763
|
+
Applies: nested object flattening, unicode homoglyph normalization,
|
|
764
|
+
encoding decoding (hex/octal/unicode/URL/base64), case normalization,
|
|
765
|
+
and whitespace normalization.
|
|
766
|
+
"""
|
|
767
|
+
# Flatten nested arguments to extract all string values
|
|
768
|
+
flattened = flatten_arguments(arguments)
|
|
769
|
+
|
|
770
|
+
parts = [tool_name.lower()]
|
|
771
|
+
for key, values in flattened.items():
|
|
772
|
+
for v in values:
|
|
773
|
+
parts.append(f"{key}={normalize_text(v)}")
|
|
774
|
+
|
|
775
|
+
return " ".join(parts)
|
|
776
|
+
|
|
777
|
+
def _semantic_analysis(self, tool_name: str, arguments: Dict[str, Any]) -> int:
|
|
778
|
+
"""
|
|
779
|
+
Semantic analysis for parameter validation.
|
|
780
|
+
|
|
781
|
+
Delegates to SemanticAnalyzer.analyze().
|
|
782
|
+
"""
|
|
783
|
+
return self._semantic_analyzer.analyze(tool_name, arguments)
|