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.
Files changed (45) hide show
  1. prooflayer/__init__.py +50 -0
  2. prooflayer/cli.py +362 -0
  3. prooflayer/config/__init__.py +6 -0
  4. prooflayer/config/allowlist.py +138 -0
  5. prooflayer/config/loader.py +29 -0
  6. prooflayer/detection/__init__.py +21 -0
  7. prooflayer/detection/engine.py +783 -0
  8. prooflayer/detection/models.py +49 -0
  9. prooflayer/detection/normalizer.py +245 -0
  10. prooflayer/detection/rules.py +104 -0
  11. prooflayer/detection/scanner.py +160 -0
  12. prooflayer/detection/scorer.py +65 -0
  13. prooflayer/detection/semantic.py +73 -0
  14. prooflayer/metrics.py +266 -0
  15. prooflayer/reporting/__init__.py +5 -0
  16. prooflayer/reporting/reporter.py +190 -0
  17. prooflayer/response/__init__.py +6 -0
  18. prooflayer/response/actions.py +152 -0
  19. prooflayer/response/killer.py +73 -0
  20. prooflayer/rules/command-injection.yaml +123 -0
  21. prooflayer/rules/data-exfiltration.yaml +83 -0
  22. prooflayer/rules/jailbreaks.yaml +67 -0
  23. prooflayer/rules/prompt-injection.yaml +99 -0
  24. prooflayer/rules/role-manipulation.yaml +60 -0
  25. prooflayer/rules/sql-injection.yaml +51 -0
  26. prooflayer/rules/ssrf-xxe.yaml +51 -0
  27. prooflayer/rules/tool-poisoning.yaml +46 -0
  28. prooflayer/runtime/__init__.py +21 -0
  29. prooflayer/runtime/interceptor.py +91 -0
  30. prooflayer/runtime/mcp_wrapper.py +395 -0
  31. prooflayer/runtime/middleware.py +86 -0
  32. prooflayer/runtime/transport.py +306 -0
  33. prooflayer/runtime/wrapper.py +265 -0
  34. prooflayer/utils/__init__.py +21 -0
  35. prooflayer/utils/encoding.py +87 -0
  36. prooflayer/utils/entropy.py +51 -0
  37. prooflayer/utils/logging.py +86 -0
  38. prooflayer/utils/masking.py +72 -0
  39. prooflayer/version.py +6 -0
  40. prooflayer_runtime-0.1.0.dist-info/METADATA +266 -0
  41. prooflayer_runtime-0.1.0.dist-info/RECORD +45 -0
  42. prooflayer_runtime-0.1.0.dist-info/WHEEL +5 -0
  43. prooflayer_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  44. prooflayer_runtime-0.1.0.dist-info/licenses/LICENSE +4 -0
  45. 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)