@rulemetric/proxy 0.2.2

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 (47) hide show
  1. package/Dockerfile +28 -0
  2. package/addon/context_classifier.py +375 -0
  3. package/addon/providers.py +483 -0
  4. package/addon/reporter.py +778 -0
  5. package/addon/requirements.txt +1 -0
  6. package/addon/rulemetric_addon.py +1288 -0
  7. package/addon/secret_redactor.py +130 -0
  8. package/addon/security_scanner.py +828 -0
  9. package/addon/session_linker.py +206 -0
  10. package/addon/sse_parser.py +364 -0
  11. package/dist/index.d.ts +10 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +8 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +135 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/base.d.ts +11 -0
  20. package/dist/providers/base.d.ts.map +1 -0
  21. package/dist/providers/base.js +43 -0
  22. package/dist/providers/base.js.map +1 -0
  23. package/dist/providers/bedrock.d.ts +8 -0
  24. package/dist/providers/bedrock.d.ts.map +1 -0
  25. package/dist/providers/bedrock.js +125 -0
  26. package/dist/providers/bedrock.js.map +1 -0
  27. package/dist/providers/gemini.d.ts +9 -0
  28. package/dist/providers/gemini.d.ts.map +1 -0
  29. package/dist/providers/gemini.js +140 -0
  30. package/dist/providers/gemini.js.map +1 -0
  31. package/dist/providers/index.d.ts +8 -0
  32. package/dist/providers/index.d.ts.map +1 -0
  33. package/dist/providers/index.js +116 -0
  34. package/dist/providers/index.js.map +1 -0
  35. package/dist/providers/openai.d.ts +9 -0
  36. package/dist/providers/openai.d.ts.map +1 -0
  37. package/dist/providers/openai.js +129 -0
  38. package/dist/providers/openai.js.map +1 -0
  39. package/dist/session-linker.d.ts +6 -0
  40. package/dist/session-linker.d.ts.map +1 -0
  41. package/dist/session-linker.js +68 -0
  42. package/dist/session-linker.js.map +1 -0
  43. package/dist/types.d.ts +41 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +33 -0
@@ -0,0 +1,828 @@
1
+ """Security scanner integration for the RuleMetric mitmproxy addon.
2
+
3
+ Uses LLM Guard (from Protect AI) to scan LLM API traffic for:
4
+ - Prompt injection attacks
5
+ - Leaked secrets (API keys, tokens)
6
+ - Personally identifiable information (PII)
7
+
8
+ Two operating modes:
9
+ OBSERVE - Non-blocking. Scans a copy of the request in a background thread.
10
+ Results are reported asynchronously to the RuleMetric API.
11
+ Adds zero latency to the proxy pipeline.
12
+
13
+ BLOCK - Synchronous. Scans inline during the mitmproxy request() hook.
14
+ If a scanner flags the request above its configured threshold,
15
+ the request is killed and a synthetic 403 response is returned.
16
+ Adds ~50-200ms latency per request (model-dependent).
17
+
18
+ Configuration is driven by environment variables and an optional JSON config
19
+ file. Scanner failures never break the proxy -- all scanner calls are wrapped
20
+ in try/except with structured logging.
21
+
22
+ Architecture notes
23
+ ------------------
24
+ LLM Guard scanners are CPU-bound (they load small ML models via transformers).
25
+ First call per scanner triggers model download + load (~2-5s). Subsequent calls
26
+ run inference at ~20-100ms for PromptInjection, ~5-20ms for Secrets/Anonymize.
27
+
28
+ For OBSERVE mode we run scans on a dedicated ThreadPoolExecutor so the
29
+ mitmproxy event loop is never blocked. For BLOCK mode we run scans inline
30
+ in the request() hook -- mitmproxy's request hooks are synchronous, so this
31
+ is the correct place to intercept.
32
+
33
+ LLM Guard Python API (from llm_guard >= 0.3.14):
34
+ from llm_guard.input_scanners import PromptInjection, Secrets, Anonymize
35
+ scanner = PromptInjection(threshold=0.9)
36
+ sanitized_output, is_valid, risk_score = scanner.scan(prompt)
37
+ - sanitized_output: str -- the (possibly redacted) text
38
+ - is_valid: bool -- True if the text passed the scan
39
+ - risk_score: float -- 0.0 (clean) to 1.0 (dangerous)
40
+
41
+ Dependencies (add to requirements.txt when ready to activate):
42
+ llm-guard>=0.3.14
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import logging
49
+ import os
50
+ import threading
51
+ import time
52
+ from concurrent.futures import ThreadPoolExecutor
53
+ from dataclasses import dataclass, field
54
+ from typing import Any
55
+
56
+ from mitmproxy import http
57
+
58
+ logger = logging.getLogger("rulemetric.security_scanner")
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Configuration
62
+ # ---------------------------------------------------------------------------
63
+
64
+ # Env-var overrides (simple, twelve-factor friendly)
65
+ # RULEMETRIC_SCANNER_MODE = "observe" | "block" | "off"
66
+ # RULEMETRIC_SCANNER_CONFIG_PATH = path to JSON config (optional)
67
+ # RULEMETRIC_SCANNER_WORKERS = number of background threads (observe mode)
68
+
69
+ DEFAULT_CONFIG: dict[str, Any] = {
70
+ # Global mode: "observe" (async, non-blocking), "block" (sync), "off"
71
+ "mode": "observe",
72
+
73
+ # Per-scanner config. Set "enabled": false to skip a scanner entirely.
74
+ "scanners": {
75
+ "prompt_injection": {
76
+ "enabled": True,
77
+ # Threshold above which we consider the input malicious.
78
+ # LLM Guard's PromptInjection returns risk_score 0.0-1.0.
79
+ # Default model: ProtectAI/deberta-v3-base-prompt-injection-v2
80
+ "threshold": 0.90,
81
+ # Which model to use (None = LLM Guard default).
82
+ "model": None,
83
+ },
84
+ "secrets": {
85
+ "enabled": True,
86
+ # Secrets scanner uses regex + entropy analysis -- no ML model.
87
+ # The threshold here is for the aggregate confidence.
88
+ "threshold": 0.80,
89
+ # Redact detected secrets in observe-mode reports.
90
+ "redact_in_report": True,
91
+ },
92
+ "pii": {
93
+ "enabled": True,
94
+ # PII / Anonymize scanner detects emails, phone numbers, SSNs, etc.
95
+ # Uses Presidio under the hood (spaCy NER + regex).
96
+ "threshold": 0.70,
97
+ # Which entity types to detect. None = all defaults.
98
+ # Options: EMAIL_ADDRESS, PHONE_NUMBER, CREDIT_CARD, US_SSN, etc.
99
+ "entity_types": None,
100
+ "redact_in_report": True,
101
+ },
102
+ },
103
+
104
+ # Background thread pool size for observe mode.
105
+ "worker_threads": 2,
106
+
107
+ # Maximum prompt length to scan (chars). Truncate longer prompts to avoid
108
+ # excessive latency on very large context windows.
109
+ "max_scan_length": 50_000,
110
+ }
111
+
112
+
113
+ @dataclass
114
+ class ScanResult:
115
+ """Result from a single scanner run."""
116
+ scanner_name: str
117
+ is_valid: bool
118
+ risk_score: float
119
+ # Wall-clock time for this scan in milliseconds.
120
+ latency_ms: float
121
+ # Details about what was found (e.g., list of detected entity types).
122
+ details: dict[str, Any] = field(default_factory=dict)
123
+ # Error message if the scanner itself failed.
124
+ error: str | None = None
125
+
126
+
127
+ @dataclass
128
+ class ScanReport:
129
+ """Aggregated results across all scanners for a single request."""
130
+ flow_id: str
131
+ mode: str # "observe" or "block"
132
+ timestamp: str
133
+ # True if all scanners passed; False if any flagged the input.
134
+ passed: bool
135
+ results: list[ScanResult] = field(default_factory=list)
136
+ # Total scan latency across all scanners in milliseconds.
137
+ total_latency_ms: float = 0.0
138
+ # The prompt text that was scanned (truncated for storage).
139
+ prompt_preview: str = ""
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Scanner wrapper (lazy-loads llm_guard on first use)
144
+ # ---------------------------------------------------------------------------
145
+
146
+ class _ScannerPool:
147
+ """Lazy-initializing pool of LLM Guard scanners.
148
+
149
+ Scanners are heavyweight (they load ML models into memory), so we create
150
+ them once and reuse across requests. Initialization is thread-safe.
151
+ """
152
+
153
+ def __init__(self) -> None:
154
+ self._lock = threading.Lock()
155
+ self._initialized = False
156
+ self._prompt_injection = None
157
+ self._secrets = None
158
+ self._pii = None
159
+ self._available = False
160
+
161
+ def _init_scanners(self, config: dict[str, Any]) -> None:
162
+ """Import llm_guard and create scanner instances.
163
+
164
+ Called once, guarded by lock. If llm_guard is not installed, all
165
+ scanners remain None and _available stays False.
166
+ """
167
+ if self._initialized:
168
+ return
169
+
170
+ with self._lock:
171
+ if self._initialized:
172
+ return
173
+
174
+ try:
175
+ # ----------------------------------------------------------
176
+ # PromptInjection scanner
177
+ # ----------------------------------------------------------
178
+ pi_cfg = config.get("scanners", {}).get("prompt_injection", {})
179
+ if pi_cfg.get("enabled", True):
180
+ from llm_guard.input_scanners import PromptInjection
181
+
182
+ kwargs: dict[str, Any] = {
183
+ "threshold": pi_cfg.get("threshold", 0.90),
184
+ }
185
+ model_name = pi_cfg.get("model")
186
+ if model_name:
187
+ kwargs["model"] = model_name
188
+
189
+ self._prompt_injection = PromptInjection(**kwargs)
190
+ logger.info(
191
+ "PromptInjection scanner loaded (threshold=%.2f)",
192
+ kwargs["threshold"],
193
+ )
194
+
195
+ # ----------------------------------------------------------
196
+ # Secrets scanner
197
+ # ----------------------------------------------------------
198
+ sec_cfg = config.get("scanners", {}).get("secrets", {})
199
+ if sec_cfg.get("enabled", True):
200
+ from llm_guard.input_scanners import Secrets
201
+
202
+ self._secrets = Secrets(
203
+ redact=sec_cfg.get("redact_in_report", True),
204
+ )
205
+ logger.info("Secrets scanner loaded")
206
+
207
+ # ----------------------------------------------------------
208
+ # PII / Anonymize scanner
209
+ # ----------------------------------------------------------
210
+ pii_cfg = config.get("scanners", {}).get("pii", {})
211
+ if pii_cfg.get("enabled", True):
212
+ from llm_guard.input_scanners import Anonymize
213
+
214
+ kwargs_pii: dict[str, Any] = {}
215
+ entity_types = pii_cfg.get("entity_types")
216
+ if entity_types:
217
+ kwargs_pii["allowed_names"] = entity_types
218
+
219
+ self._pii = Anonymize(**kwargs_pii)
220
+ logger.info("PII/Anonymize scanner loaded")
221
+
222
+ self._available = True
223
+ logger.info("LLM Guard scanners initialized successfully")
224
+
225
+ except ImportError as exc:
226
+ logger.warning(
227
+ "llm_guard is not installed -- security scanning is disabled. "
228
+ "Install with: pip install llm-guard>=0.3.14 (%s)",
229
+ exc,
230
+ )
231
+ self._available = False
232
+ except Exception as exc:
233
+ logger.error(
234
+ "Failed to initialize LLM Guard scanners: %s", exc, exc_info=True
235
+ )
236
+ self._available = False
237
+ finally:
238
+ self._initialized = True
239
+
240
+ @property
241
+ def available(self) -> bool:
242
+ return self._available
243
+
244
+ def scan_prompt_injection(self, text: str, threshold: float) -> ScanResult:
245
+ """Run the PromptInjection scanner."""
246
+ if self._prompt_injection is None:
247
+ return ScanResult(
248
+ scanner_name="prompt_injection",
249
+ is_valid=True,
250
+ risk_score=0.0,
251
+ latency_ms=0.0,
252
+ details={"skipped": True, "reason": "scanner not loaded"},
253
+ )
254
+
255
+ t0 = time.monotonic()
256
+ try:
257
+ sanitized, is_valid, risk_score = self._prompt_injection.scan(text)
258
+ latency = (time.monotonic() - t0) * 1000
259
+ return ScanResult(
260
+ scanner_name="prompt_injection",
261
+ is_valid=is_valid,
262
+ risk_score=risk_score,
263
+ latency_ms=latency,
264
+ details={
265
+ "flagged": not is_valid,
266
+ "threshold": threshold,
267
+ },
268
+ )
269
+ except Exception as exc:
270
+ latency = (time.monotonic() - t0) * 1000
271
+ logger.error("PromptInjection scanner error: %s", exc, exc_info=True)
272
+ return ScanResult(
273
+ scanner_name="prompt_injection",
274
+ is_valid=True, # Fail open -- don't block on scanner errors
275
+ risk_score=0.0,
276
+ latency_ms=latency,
277
+ error=str(exc),
278
+ )
279
+
280
+ def scan_secrets(self, text: str, threshold: float) -> ScanResult:
281
+ """Run the Secrets scanner."""
282
+ if self._secrets is None:
283
+ return ScanResult(
284
+ scanner_name="secrets",
285
+ is_valid=True,
286
+ risk_score=0.0,
287
+ latency_ms=0.0,
288
+ details={"skipped": True, "reason": "scanner not loaded"},
289
+ )
290
+
291
+ t0 = time.monotonic()
292
+ try:
293
+ sanitized, is_valid, risk_score = self._secrets.scan(text)
294
+ latency = (time.monotonic() - t0) * 1000
295
+ return ScanResult(
296
+ scanner_name="secrets",
297
+ is_valid=is_valid,
298
+ risk_score=risk_score,
299
+ latency_ms=latency,
300
+ details={
301
+ "flagged": not is_valid,
302
+ "threshold": threshold,
303
+ # Include redacted text preview when secrets are found
304
+ "redacted_preview": sanitized[:200] if not is_valid else None,
305
+ },
306
+ )
307
+ except Exception as exc:
308
+ latency = (time.monotonic() - t0) * 1000
309
+ logger.error("Secrets scanner error: %s", exc, exc_info=True)
310
+ return ScanResult(
311
+ scanner_name="secrets",
312
+ is_valid=True,
313
+ risk_score=0.0,
314
+ latency_ms=latency,
315
+ error=str(exc),
316
+ )
317
+
318
+ def scan_pii(self, text: str, threshold: float) -> ScanResult:
319
+ """Run the PII / Anonymize scanner."""
320
+ if self._pii is None:
321
+ return ScanResult(
322
+ scanner_name="pii",
323
+ is_valid=True,
324
+ risk_score=0.0,
325
+ latency_ms=0.0,
326
+ details={"skipped": True, "reason": "scanner not loaded"},
327
+ )
328
+
329
+ t0 = time.monotonic()
330
+ try:
331
+ sanitized, is_valid, risk_score = self._pii.scan(text)
332
+ latency = (time.monotonic() - t0) * 1000
333
+ return ScanResult(
334
+ scanner_name="pii",
335
+ is_valid=True if risk_score < threshold else False,
336
+ risk_score=risk_score,
337
+ latency_ms=latency,
338
+ details={
339
+ "flagged": risk_score >= threshold,
340
+ "threshold": threshold,
341
+ "redacted_preview": sanitized[:200] if risk_score >= threshold else None,
342
+ },
343
+ )
344
+ except Exception as exc:
345
+ latency = (time.monotonic() - t0) * 1000
346
+ logger.error("PII scanner error: %s", exc, exc_info=True)
347
+ return ScanResult(
348
+ scanner_name="pii",
349
+ is_valid=True,
350
+ risk_score=0.0,
351
+ latency_ms=latency,
352
+ error=str(exc),
353
+ )
354
+
355
+
356
+ # Module-level singleton -- created once, shared across all flows.
357
+ _scanner_pool = _ScannerPool()
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Config loader
362
+ # ---------------------------------------------------------------------------
363
+
364
+ def _load_config() -> dict[str, Any]:
365
+ """Load scanner config from env vars and optional JSON file.
366
+
367
+ Priority: env var overrides > JSON file > DEFAULT_CONFIG.
368
+ """
369
+ config = dict(DEFAULT_CONFIG)
370
+
371
+ # Load JSON config file if specified
372
+ config_path = os.environ.get("RULEMETRIC_SCANNER_CONFIG_PATH")
373
+ if config_path:
374
+ try:
375
+ with open(config_path) as f:
376
+ file_config = json.load(f)
377
+ # Deep-merge scanner configs
378
+ for key in ("mode", "worker_threads", "max_scan_length"):
379
+ if key in file_config:
380
+ config[key] = file_config[key]
381
+ if "scanners" in file_config:
382
+ for scanner_name, scanner_cfg in file_config["scanners"].items():
383
+ if scanner_name in config["scanners"]:
384
+ config["scanners"][scanner_name].update(scanner_cfg)
385
+ else:
386
+ config["scanners"][scanner_name] = scanner_cfg
387
+ logger.info("Loaded scanner config from %s", config_path)
388
+ except (OSError, json.JSONDecodeError) as exc:
389
+ logger.warning("Failed to load scanner config from %s: %s", config_path, exc)
390
+
391
+ # Env var overrides
392
+ mode = os.environ.get("RULEMETRIC_SCANNER_MODE")
393
+ if mode:
394
+ config["mode"] = mode.lower()
395
+
396
+ workers = os.environ.get("RULEMETRIC_SCANNER_WORKERS")
397
+ if workers:
398
+ try:
399
+ config["worker_threads"] = int(workers)
400
+ except ValueError:
401
+ pass
402
+
403
+ return config
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Core scan logic
408
+ # ---------------------------------------------------------------------------
409
+
410
+ def _extract_scannable_text(flow: http.HTTPFlow) -> str | None:
411
+ """Extract the user-facing prompt text from an LLM API request body.
412
+
413
+ Returns a single string combining system prompt + user messages,
414
+ which is what we want to scan for injections, secrets, and PII.
415
+ Returns None if the body can't be parsed.
416
+ """
417
+ body_text = flow.request.get_text()
418
+ if not body_text:
419
+ return None
420
+
421
+ try:
422
+ body = json.loads(body_text)
423
+ except (json.JSONDecodeError, ValueError):
424
+ return None
425
+
426
+ parts: list[str] = []
427
+
428
+ # Anthropic format
429
+ system = body.get("system", "")
430
+ if isinstance(system, list):
431
+ for block in system:
432
+ if isinstance(block, dict):
433
+ parts.append(block.get("text", ""))
434
+ elif system:
435
+ parts.append(str(system))
436
+
437
+ # OpenAI / generic format -- messages array
438
+ for msg in body.get("messages", []):
439
+ content = msg.get("content", "")
440
+ if isinstance(content, str):
441
+ parts.append(content)
442
+ elif isinstance(content, list):
443
+ for block in content:
444
+ if isinstance(block, dict):
445
+ parts.append(block.get("text", ""))
446
+
447
+ # Gemini format -- systemInstruction + contents
448
+ sys_instr = body.get("systemInstruction", {})
449
+ if isinstance(sys_instr, dict):
450
+ for part in sys_instr.get("parts", []):
451
+ if isinstance(part, dict):
452
+ parts.append(part.get("text", ""))
453
+
454
+ for content_item in body.get("contents", []):
455
+ if isinstance(content_item, dict):
456
+ for part in content_item.get("parts", []):
457
+ if isinstance(part, dict) and "text" in part:
458
+ parts.append(part["text"])
459
+
460
+ combined = "\n".join(p for p in parts if p)
461
+ return combined if combined else None
462
+
463
+
464
+ def _run_all_scanners(text: str, config: dict[str, Any]) -> list[ScanResult]:
465
+ """Run all enabled scanners against the given text. Returns list of results."""
466
+ results: list[ScanResult] = []
467
+ scanners_cfg = config.get("scanners", {})
468
+
469
+ max_len = config.get("max_scan_length", 50_000)
470
+ scan_text = text[:max_len] if len(text) > max_len else text
471
+
472
+ # PromptInjection
473
+ pi_cfg = scanners_cfg.get("prompt_injection", {})
474
+ if pi_cfg.get("enabled", True):
475
+ results.append(
476
+ _scanner_pool.scan_prompt_injection(
477
+ scan_text,
478
+ threshold=pi_cfg.get("threshold", 0.90),
479
+ )
480
+ )
481
+
482
+ # Secrets
483
+ sec_cfg = scanners_cfg.get("secrets", {})
484
+ if sec_cfg.get("enabled", True):
485
+ results.append(
486
+ _scanner_pool.scan_secrets(
487
+ scan_text,
488
+ threshold=sec_cfg.get("threshold", 0.80),
489
+ )
490
+ )
491
+
492
+ # PII
493
+ pii_cfg = scanners_cfg.get("pii", {})
494
+ if pii_cfg.get("enabled", True):
495
+ results.append(
496
+ _scanner_pool.scan_pii(
497
+ scan_text,
498
+ threshold=pii_cfg.get("threshold", 0.70),
499
+ )
500
+ )
501
+
502
+ return results
503
+
504
+
505
+ def _build_report(
506
+ flow_id: str,
507
+ mode: str,
508
+ results: list[ScanResult],
509
+ prompt_text: str,
510
+ ) -> ScanReport:
511
+ """Build an aggregated scan report from individual scanner results."""
512
+ from datetime import datetime, timezone
513
+
514
+ total_latency = sum(r.latency_ms for r in results)
515
+ all_passed = all(r.is_valid for r in results)
516
+
517
+ return ScanReport(
518
+ flow_id=flow_id,
519
+ mode=mode,
520
+ timestamp=datetime.now(timezone.utc).isoformat(),
521
+ passed=all_passed,
522
+ results=results,
523
+ total_latency_ms=total_latency,
524
+ prompt_preview=prompt_text[:500],
525
+ )
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # Report delivery (async, reuses reporter's pattern)
530
+ # ---------------------------------------------------------------------------
531
+
532
+ def _report_scan_results(session_id: str | None, report: ScanReport) -> None:
533
+ """Post scan results to the RuleMetric API as a session event.
534
+
535
+ This runs in a background thread (fire-and-forget), matching the pattern
536
+ used by reporter.py for snapshots and events.
537
+ """
538
+ if not session_id:
539
+ logger.debug("No session ID -- scan report dropped")
540
+ return
541
+
542
+ try:
543
+ from reporter import report_event
544
+ except ImportError:
545
+ logger.debug("reporter module not available -- scan report dropped")
546
+ return
547
+
548
+ event = {
549
+ "eventType": "security_scan",
550
+ "timestamp": report.timestamp,
551
+ "metadata": {
552
+ "mode": report.mode,
553
+ "passed": report.passed,
554
+ "total_latency_ms": round(report.total_latency_ms, 1),
555
+ "scanners": [
556
+ {
557
+ "name": r.scanner_name,
558
+ "is_valid": r.is_valid,
559
+ "risk_score": round(r.risk_score, 4),
560
+ "latency_ms": round(r.latency_ms, 1),
561
+ "error": r.error,
562
+ "details": r.details,
563
+ }
564
+ for r in report.results
565
+ ],
566
+ },
567
+ }
568
+ report_event(session_id, event)
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # Blocking mode: synthetic error response
573
+ # ---------------------------------------------------------------------------
574
+
575
+ def _create_block_response(report: ScanReport) -> http.Response:
576
+ """Create a synthetic 403 response for a blocked request.
577
+
578
+ The response body is JSON that mimics an LLM API error format so that
579
+ downstream clients (Claude Code, etc.) can parse and display it.
580
+ """
581
+ flagged_scanners = [r for r in report.results if not r.is_valid]
582
+ scanner_names = [r.scanner_name for r in flagged_scanners]
583
+ max_risk = max((r.risk_score for r in flagged_scanners), default=0.0)
584
+
585
+ body = {
586
+ "error": {
587
+ "type": "security_scan_blocked",
588
+ "message": (
589
+ f"Request blocked by security scanner. "
590
+ f"Flagged by: {', '.join(scanner_names)}. "
591
+ f"Highest risk score: {max_risk:.2f}."
592
+ ),
593
+ "scanners": scanner_names,
594
+ "risk_score": round(max_risk, 4),
595
+ },
596
+ }
597
+
598
+ return http.Response.make(
599
+ status_code=403,
600
+ content=json.dumps(body).encode("utf-8"),
601
+ headers={
602
+ "Content-Type": "application/json",
603
+ "X-RuleMetric-Blocked": "true",
604
+ "X-RuleMetric-Scanner": ",".join(scanner_names),
605
+ },
606
+ )
607
+
608
+
609
+ # ---------------------------------------------------------------------------
610
+ # Public API -- mitmproxy addon integration
611
+ # ---------------------------------------------------------------------------
612
+
613
+ class SecurityScannerAddon:
614
+ """mitmproxy addon that scans LLM API traffic for security issues.
615
+
616
+ Designed to be composed with RuleMetricAddon. Add to the addons list
617
+ BEFORE RuleMetricAddon so it can block requests before they're captured.
618
+
619
+ Usage in rulemetric_addon.py:
620
+ from security_scanner import SecurityScannerAddon
621
+
622
+ addons = [SecurityScannerAddon(), RuleMetricAddon()]
623
+
624
+ Or to keep it optional:
625
+ addons = [RuleMetricAddon()]
626
+ try:
627
+ from security_scanner import SecurityScannerAddon
628
+ addons.insert(0, SecurityScannerAddon())
629
+ except Exception:
630
+ pass
631
+ """
632
+
633
+ def __init__(self, config: dict[str, Any] | None = None) -> None:
634
+ self._config = config or _load_config()
635
+ self._mode = self._config.get("mode", "observe")
636
+ self._executor: ThreadPoolExecutor | None = None
637
+ self._scan_count = 0
638
+ self._block_count = 0
639
+ self._stats_lock = threading.Lock()
640
+
641
+ if self._mode == "off":
642
+ logger.info("Security scanner is OFF (set RULEMETRIC_SCANNER_MODE=observe|block to enable)")
643
+ return
644
+
645
+ # Initialize scanners eagerly so model loading happens at startup,
646
+ # not on the first request (which would spike latency).
647
+ _scanner_pool._init_scanners(self._config)
648
+
649
+ if not _scanner_pool.available:
650
+ logger.warning("LLM Guard not available -- security scanner disabled")
651
+ self._mode = "off"
652
+ return
653
+
654
+ if self._mode == "observe":
655
+ worker_count = self._config.get("worker_threads", 2)
656
+ self._executor = ThreadPoolExecutor(
657
+ max_workers=worker_count,
658
+ thread_name_prefix="rulemetric-scanner",
659
+ )
660
+ logger.info(
661
+ "Security scanner initialized in OBSERVE mode (%d worker threads)",
662
+ worker_count,
663
+ )
664
+ elif self._mode == "block":
665
+ logger.info("Security scanner initialized in BLOCK mode (synchronous)")
666
+ else:
667
+ logger.warning("Unknown scanner mode '%s' -- defaulting to OFF", self._mode)
668
+ self._mode = "off"
669
+
670
+ def request(self, flow: http.HTTPFlow) -> None:
671
+ """Scan incoming LLM API requests.
672
+
673
+ In OBSERVE mode: submit scan to background thread pool, return immediately.
674
+ In BLOCK mode: scan inline, return synthetic 403 if flagged.
675
+ """
676
+ if self._mode == "off":
677
+ return
678
+
679
+ # Only scan POST requests to LLM API endpoints.
680
+ # Re-use the provider detection from providers.py to avoid scanning
681
+ # non-LLM traffic.
682
+ try:
683
+ from providers import detect_provider
684
+ except ImportError:
685
+ return
686
+
687
+ host = flow.request.pretty_host
688
+ path = flow.request.path
689
+ provider = detect_provider(host, path)
690
+ if provider is None:
691
+ return
692
+
693
+ if flow.request.method != "POST":
694
+ return
695
+
696
+ prompt_text = _extract_scannable_text(flow)
697
+ if not prompt_text:
698
+ return
699
+
700
+ # Find session ID for reporting (best-effort, non-critical)
701
+ session_id: str | None = None
702
+ try:
703
+ from session_linker import find_active_session
704
+ session_info = find_active_session(provider=provider)
705
+ if session_info:
706
+ session_id = session_info.get("rw_session_id") or session_info.get("session_id")
707
+ except Exception:
708
+ pass
709
+
710
+ with self._stats_lock:
711
+ self._scan_count += 1
712
+ scan_num = self._scan_count
713
+
714
+ if self._mode == "observe":
715
+ self._scan_observe(flow.id, prompt_text, session_id, scan_num)
716
+ elif self._mode == "block":
717
+ self._scan_block(flow, prompt_text, session_id, scan_num)
718
+
719
+ def _scan_observe(
720
+ self,
721
+ flow_id: str,
722
+ prompt_text: str,
723
+ session_id: str | None,
724
+ scan_num: int,
725
+ ) -> None:
726
+ """Submit scan to background thread pool. Non-blocking."""
727
+ if self._executor is None:
728
+ return
729
+
730
+ def _do_scan() -> None:
731
+ try:
732
+ results = _run_all_scanners(prompt_text, self._config)
733
+ report = _build_report(flow_id, "observe", results, prompt_text)
734
+
735
+ if not report.passed:
736
+ flagged = [r for r in results if not r.is_valid]
737
+ logger.warning(
738
+ "OBSERVE scan #%d flagged by %s (risk scores: %s, latency: %.0fms)",
739
+ scan_num,
740
+ [r.scanner_name for r in flagged],
741
+ [round(r.risk_score, 3) for r in flagged],
742
+ report.total_latency_ms,
743
+ )
744
+ else:
745
+ logger.debug(
746
+ "OBSERVE scan #%d passed all scanners (latency: %.0fms)",
747
+ scan_num,
748
+ report.total_latency_ms,
749
+ )
750
+
751
+ # Report results asynchronously
752
+ _report_scan_results(session_id, report)
753
+ except Exception as exc:
754
+ logger.error(
755
+ "Background scan #%d failed: %s", scan_num, exc, exc_info=True
756
+ )
757
+
758
+ self._executor.submit(_do_scan)
759
+
760
+ def _scan_block(
761
+ self,
762
+ flow: http.HTTPFlow,
763
+ prompt_text: str,
764
+ session_id: str | None,
765
+ scan_num: int,
766
+ ) -> None:
767
+ """Scan inline (synchronous). Block request if flagged."""
768
+ try:
769
+ results = _run_all_scanners(prompt_text, self._config)
770
+ report = _build_report(flow.id, "block", results, prompt_text)
771
+
772
+ if not report.passed:
773
+ flagged = [r for r in results if not r.is_valid]
774
+ logger.warning(
775
+ "BLOCK scan #%d BLOCKED by %s (risk scores: %s, latency: %.0fms)",
776
+ scan_num,
777
+ [r.scanner_name for r in flagged],
778
+ [round(r.risk_score, 3) for r in flagged],
779
+ report.total_latency_ms,
780
+ )
781
+
782
+ # Kill the flow and return a synthetic 403 response.
783
+ # mitmproxy's flow.response assignment prevents the request
784
+ # from being forwarded to the upstream server.
785
+ flow.response = _create_block_response(report)
786
+
787
+ with self._stats_lock:
788
+ self._block_count += 1
789
+
790
+ # Still report the block event
791
+ _report_scan_results(session_id, report)
792
+ else:
793
+ logger.debug(
794
+ "BLOCK scan #%d passed (latency: %.0fms)",
795
+ scan_num,
796
+ report.total_latency_ms,
797
+ )
798
+ # Optionally report clean scans too (for audit trail)
799
+ _report_scan_results(session_id, report)
800
+
801
+ except Exception as exc:
802
+ # Fail open -- if scanning crashes, let the request through.
803
+ logger.error(
804
+ "BLOCK scan #%d error (failing open): %s",
805
+ scan_num,
806
+ exc,
807
+ exc_info=True,
808
+ )
809
+
810
+ def get_stats(self) -> dict[str, Any]:
811
+ """Return scanner statistics for observability."""
812
+ with self._stats_lock:
813
+ return {
814
+ "mode": self._mode,
815
+ "scans": self._scan_count,
816
+ "blocks": self._block_count,
817
+ "scanners_available": _scanner_pool.available,
818
+ }
819
+
820
+ def done(self) -> None:
821
+ """Shutdown hook -- clean up the thread pool."""
822
+ if self._executor is not None:
823
+ self._executor.shutdown(wait=False)
824
+ logger.info(
825
+ "Security scanner shutdown (scans=%d, blocks=%d)",
826
+ self._scan_count,
827
+ self._block_count,
828
+ )