@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.
- package/Dockerfile +28 -0
- package/addon/context_classifier.py +375 -0
- package/addon/providers.py +483 -0
- package/addon/reporter.py +778 -0
- package/addon/requirements.txt +1 -0
- package/addon/rulemetric_addon.py +1288 -0
- package/addon/secret_redactor.py +130 -0
- package/addon/security_scanner.py +828 -0
- package/addon/session_linker.py +206 -0
- package/addon/sse_parser.py +364 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/anthropic.d.ts +8 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +135 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/base.d.ts +11 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +43 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/bedrock.d.ts +8 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +125 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/gemini.d.ts +9 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +140 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +116 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +129 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/session-linker.d.ts +6 -0
- package/dist/session-linker.d.ts.map +1 -0
- package/dist/session-linker.js +68 -0
- package/dist/session-linker.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- 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
|
+
)
|