prooflayer-runtime 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prooflayer/__init__.py +50 -0
- prooflayer/cli.py +362 -0
- prooflayer/config/__init__.py +6 -0
- prooflayer/config/allowlist.py +138 -0
- prooflayer/config/loader.py +29 -0
- prooflayer/detection/__init__.py +21 -0
- prooflayer/detection/engine.py +783 -0
- prooflayer/detection/models.py +49 -0
- prooflayer/detection/normalizer.py +245 -0
- prooflayer/detection/rules.py +104 -0
- prooflayer/detection/scanner.py +160 -0
- prooflayer/detection/scorer.py +65 -0
- prooflayer/detection/semantic.py +73 -0
- prooflayer/metrics.py +266 -0
- prooflayer/reporting/__init__.py +5 -0
- prooflayer/reporting/reporter.py +190 -0
- prooflayer/response/__init__.py +6 -0
- prooflayer/response/actions.py +152 -0
- prooflayer/response/killer.py +73 -0
- prooflayer/rules/command-injection.yaml +123 -0
- prooflayer/rules/data-exfiltration.yaml +83 -0
- prooflayer/rules/jailbreaks.yaml +67 -0
- prooflayer/rules/prompt-injection.yaml +99 -0
- prooflayer/rules/role-manipulation.yaml +60 -0
- prooflayer/rules/sql-injection.yaml +51 -0
- prooflayer/rules/ssrf-xxe.yaml +51 -0
- prooflayer/rules/tool-poisoning.yaml +46 -0
- prooflayer/runtime/__init__.py +21 -0
- prooflayer/runtime/interceptor.py +91 -0
- prooflayer/runtime/mcp_wrapper.py +395 -0
- prooflayer/runtime/middleware.py +86 -0
- prooflayer/runtime/transport.py +306 -0
- prooflayer/runtime/wrapper.py +265 -0
- prooflayer/utils/__init__.py +21 -0
- prooflayer/utils/encoding.py +87 -0
- prooflayer/utils/entropy.py +51 -0
- prooflayer/utils/logging.py +86 -0
- prooflayer/utils/masking.py +72 -0
- prooflayer/version.py +6 -0
- prooflayer_runtime-0.1.0.dist-info/METADATA +266 -0
- prooflayer_runtime-0.1.0.dist-info/RECORD +45 -0
- prooflayer_runtime-0.1.0.dist-info/WHEEL +5 -0
- prooflayer_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- prooflayer_runtime-0.1.0.dist-info/licenses/LICENSE +4 -0
- prooflayer_runtime-0.1.0.dist-info/top_level.txt +1 -0
prooflayer/metrics.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prometheus Metrics
|
|
3
|
+
==================
|
|
4
|
+
|
|
5
|
+
Lightweight metrics collection and exposition for ProofLayer.
|
|
6
|
+
Uses only stdlib (no prometheus_client dependency).
|
|
7
|
+
|
|
8
|
+
Exposes metrics at /metrics in Prometheus text exposition format via http.server.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from prooflayer.metrics import metrics, start_metrics_server
|
|
12
|
+
|
|
13
|
+
# Record a scan
|
|
14
|
+
metrics.record_scan(action="block", duration=0.003, risk_score=85,
|
|
15
|
+
matched_rules=[("cmd-inject-curl", "command_injection")])
|
|
16
|
+
|
|
17
|
+
# Start HTTP endpoint
|
|
18
|
+
start_metrics_server(port=9090)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import math
|
|
24
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
25
|
+
from typing import Dict, List, Tuple, Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Counter:
|
|
29
|
+
"""Thread-safe counter with optional labels."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
self._values: Dict[tuple, float] = {}
|
|
34
|
+
|
|
35
|
+
def inc(self, labels: Optional[Dict[str, str]] = None, value: float = 1.0):
|
|
36
|
+
key = tuple(sorted((labels or {}).items()))
|
|
37
|
+
with self._lock:
|
|
38
|
+
self._values[key] = self._values.get(key, 0.0) + value
|
|
39
|
+
|
|
40
|
+
def collect(self) -> Dict[tuple, float]:
|
|
41
|
+
with self._lock:
|
|
42
|
+
return dict(self._values)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Gauge:
|
|
46
|
+
"""Thread-safe gauge."""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
self._values: Dict[tuple, float] = {}
|
|
51
|
+
|
|
52
|
+
def set(self, value: float, labels: Optional[Dict[str, str]] = None):
|
|
53
|
+
key = tuple(sorted((labels or {}).items()))
|
|
54
|
+
with self._lock:
|
|
55
|
+
self._values[key] = value
|
|
56
|
+
|
|
57
|
+
def collect(self) -> Dict[tuple, float]:
|
|
58
|
+
with self._lock:
|
|
59
|
+
return dict(self._values)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _HistogramLike:
|
|
63
|
+
"""Tracks min, max, sum, count, and sorted observations for percentile computation."""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self._lock = threading.Lock()
|
|
67
|
+
self._observations: List[float] = []
|
|
68
|
+
self._sum: float = 0.0
|
|
69
|
+
self._count: int = 0
|
|
70
|
+
self._min: float = float("inf")
|
|
71
|
+
self._max: float = float("-inf")
|
|
72
|
+
|
|
73
|
+
def observe(self, value: float):
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._observations.append(value)
|
|
76
|
+
self._sum += value
|
|
77
|
+
self._count += 1
|
|
78
|
+
if value < self._min:
|
|
79
|
+
self._min = value
|
|
80
|
+
if value > self._max:
|
|
81
|
+
self._max = value
|
|
82
|
+
|
|
83
|
+
def collect(self) -> Dict[str, float]:
|
|
84
|
+
with self._lock:
|
|
85
|
+
if self._count == 0:
|
|
86
|
+
return {
|
|
87
|
+
"count": 0, "sum": 0, "min": 0, "max": 0,
|
|
88
|
+
"avg": 0, "p50": 0, "p95": 0, "p99": 0,
|
|
89
|
+
}
|
|
90
|
+
sorted_obs = sorted(self._observations)
|
|
91
|
+
return {
|
|
92
|
+
"count": self._count,
|
|
93
|
+
"sum": self._sum,
|
|
94
|
+
"min": self._min,
|
|
95
|
+
"max": self._max,
|
|
96
|
+
"avg": self._sum / self._count,
|
|
97
|
+
"p50": _percentile(sorted_obs, 0.50),
|
|
98
|
+
"p95": _percentile(sorted_obs, 0.95),
|
|
99
|
+
"p99": _percentile(sorted_obs, 0.99),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _percentile(sorted_data: List[float], pct: float) -> float:
|
|
104
|
+
"""Compute percentile from sorted list using nearest-rank method."""
|
|
105
|
+
if not sorted_data:
|
|
106
|
+
return 0.0
|
|
107
|
+
idx = int(math.ceil(pct * len(sorted_data))) - 1
|
|
108
|
+
return sorted_data[max(0, idx)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MetricsCollector:
|
|
112
|
+
"""Central metrics collector for ProofLayer."""
|
|
113
|
+
|
|
114
|
+
def __init__(self):
|
|
115
|
+
self.scans_total = _Counter()
|
|
116
|
+
self.scan_duration = _HistogramLike()
|
|
117
|
+
self.rules_matched_total = _Counter()
|
|
118
|
+
self.risk_score_last = _Gauge()
|
|
119
|
+
self.risk_score_avg = _Gauge()
|
|
120
|
+
self.active_rules = _Gauge()
|
|
121
|
+
|
|
122
|
+
self._lock = threading.Lock()
|
|
123
|
+
self._score_sum: float = 0.0
|
|
124
|
+
self._score_count: int = 0
|
|
125
|
+
self.enabled: bool = False
|
|
126
|
+
|
|
127
|
+
def record_scan(
|
|
128
|
+
self,
|
|
129
|
+
action: str,
|
|
130
|
+
duration: float,
|
|
131
|
+
risk_score: int,
|
|
132
|
+
matched_rules: Optional[List[Tuple[str, str]]] = None,
|
|
133
|
+
):
|
|
134
|
+
"""
|
|
135
|
+
Record a completed scan.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
action: The action taken (allow, warn, block, kill)
|
|
139
|
+
duration: Scan duration in seconds
|
|
140
|
+
risk_score: Computed risk score (0-100)
|
|
141
|
+
matched_rules: List of (rule_id, category) tuples
|
|
142
|
+
"""
|
|
143
|
+
if not self.enabled:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
self.scans_total.inc(labels={"action": action.lower()})
|
|
147
|
+
self.scan_duration.observe(duration)
|
|
148
|
+
|
|
149
|
+
self.risk_score_last.set(float(risk_score))
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._score_sum += risk_score
|
|
152
|
+
self._score_count += 1
|
|
153
|
+
self.risk_score_avg.set(self._score_sum / self._score_count)
|
|
154
|
+
|
|
155
|
+
for rule_id, category in (matched_rules or []):
|
|
156
|
+
self.rules_matched_total.inc(
|
|
157
|
+
labels={"rule_id": rule_id, "category": category}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def set_active_rules(self, count: int):
|
|
161
|
+
"""Set the number of currently active rules."""
|
|
162
|
+
if not self.enabled:
|
|
163
|
+
return
|
|
164
|
+
self.active_rules.set(float(count))
|
|
165
|
+
|
|
166
|
+
def render_prometheus(self) -> str:
|
|
167
|
+
"""Render all metrics in Prometheus text exposition format."""
|
|
168
|
+
lines: List[str] = []
|
|
169
|
+
|
|
170
|
+
# prooflayer_scans_total
|
|
171
|
+
lines.append("# HELP prooflayer_scans_total Total number of scans performed.")
|
|
172
|
+
lines.append("# TYPE prooflayer_scans_total counter")
|
|
173
|
+
for labels, value in self.scans_total.collect().items():
|
|
174
|
+
label_str = _labels_to_str(labels)
|
|
175
|
+
lines.append(f"prooflayer_scans_total{{{label_str}}} {value}")
|
|
176
|
+
|
|
177
|
+
# prooflayer_scan_duration_seconds
|
|
178
|
+
dur = self.scan_duration.collect()
|
|
179
|
+
lines.append("# HELP prooflayer_scan_duration_seconds Scan duration statistics.")
|
|
180
|
+
lines.append("# TYPE prooflayer_scan_duration_seconds summary")
|
|
181
|
+
lines.append(f'prooflayer_scan_duration_seconds{{quantile="0.5"}} {dur["p50"]}')
|
|
182
|
+
lines.append(f'prooflayer_scan_duration_seconds{{quantile="0.95"}} {dur["p95"]}')
|
|
183
|
+
lines.append(f'prooflayer_scan_duration_seconds{{quantile="0.99"}} {dur["p99"]}')
|
|
184
|
+
lines.append(f'prooflayer_scan_duration_seconds_count {dur["count"]}')
|
|
185
|
+
lines.append(f'prooflayer_scan_duration_seconds_sum {dur["sum"]}')
|
|
186
|
+
lines.append(f'prooflayer_scan_duration_seconds_min {dur["min"]}')
|
|
187
|
+
lines.append(f'prooflayer_scan_duration_seconds_max {dur["max"]}')
|
|
188
|
+
lines.append(f'prooflayer_scan_duration_seconds_avg {dur["avg"]}')
|
|
189
|
+
|
|
190
|
+
# prooflayer_rules_matched_total
|
|
191
|
+
lines.append("# HELP prooflayer_rules_matched_total Total rules matched.")
|
|
192
|
+
lines.append("# TYPE prooflayer_rules_matched_total counter")
|
|
193
|
+
for labels, value in self.rules_matched_total.collect().items():
|
|
194
|
+
label_str = _labels_to_str(labels)
|
|
195
|
+
lines.append(f"prooflayer_rules_matched_total{{{label_str}}} {value}")
|
|
196
|
+
|
|
197
|
+
# prooflayer_risk_score
|
|
198
|
+
lines.append("# HELP prooflayer_risk_score Risk score gauge.")
|
|
199
|
+
lines.append("# TYPE prooflayer_risk_score gauge")
|
|
200
|
+
for labels, value in self.risk_score_last.collect().items():
|
|
201
|
+
lines.append(f'prooflayer_risk_score{{stat="last"}} {value}')
|
|
202
|
+
for labels, value in self.risk_score_avg.collect().items():
|
|
203
|
+
lines.append(f'prooflayer_risk_score{{stat="avg"}} {value}')
|
|
204
|
+
|
|
205
|
+
# prooflayer_active_rules
|
|
206
|
+
lines.append("# HELP prooflayer_active_rules Number of active detection rules.")
|
|
207
|
+
lines.append("# TYPE prooflayer_active_rules gauge")
|
|
208
|
+
for labels, value in self.active_rules.collect().items():
|
|
209
|
+
lines.append(f"prooflayer_active_rules {value}")
|
|
210
|
+
|
|
211
|
+
lines.append("") # trailing newline
|
|
212
|
+
return "\n".join(lines)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _labels_to_str(labels: tuple) -> str:
|
|
216
|
+
"""Convert label tuple to Prometheus label string."""
|
|
217
|
+
parts = [f'{k}="{v}"' for k, v in labels]
|
|
218
|
+
return ",".join(parts)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Singleton collector
|
|
222
|
+
metrics = MetricsCollector()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class _MetricsHandler(BaseHTTPRequestHandler):
|
|
226
|
+
"""HTTP handler that serves Prometheus metrics."""
|
|
227
|
+
|
|
228
|
+
def do_GET(self):
|
|
229
|
+
if self.path == "/metrics":
|
|
230
|
+
body = metrics.render_prometheus().encode("utf-8")
|
|
231
|
+
self.send_response(200)
|
|
232
|
+
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
|
233
|
+
self.send_header("Content-Length", str(len(body)))
|
|
234
|
+
self.end_headers()
|
|
235
|
+
self.wfile.write(body)
|
|
236
|
+
else:
|
|
237
|
+
self.send_response(404)
|
|
238
|
+
self.end_headers()
|
|
239
|
+
|
|
240
|
+
def log_message(self, format, *args):
|
|
241
|
+
# Suppress default access log noise
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_server_instance: Optional[HTTPServer] = None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def start_metrics_server(port: int = 9090) -> HTTPServer:
|
|
249
|
+
"""
|
|
250
|
+
Start the Prometheus metrics HTTP server on a background thread.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
port: TCP port to listen on (default 9090)
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
HTTPServer instance (call .shutdown() to stop)
|
|
257
|
+
"""
|
|
258
|
+
global _server_instance
|
|
259
|
+
if _server_instance is not None:
|
|
260
|
+
return _server_instance
|
|
261
|
+
|
|
262
|
+
server = HTTPServer(("0.0.0.0", port), _MetricsHandler)
|
|
263
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
264
|
+
thread.start()
|
|
265
|
+
_server_instance = server
|
|
266
|
+
return server
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security Reporter
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
Generates security reports in JSON and SARIF formats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Any
|
|
14
|
+
|
|
15
|
+
from ..utils.masking import mask_sensitive_data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SecurityReporter:
|
|
19
|
+
"""
|
|
20
|
+
Generates security reports for detected threats.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, report_dir: str = "./security-reports"):
|
|
24
|
+
"""
|
|
25
|
+
Initialize security reporter.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
report_dir: Directory to write reports
|
|
29
|
+
"""
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self.report_dir = Path(report_dir)
|
|
32
|
+
self.report_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
# Restrict report directory to owner only
|
|
34
|
+
os.chmod(self.report_dir, 0o700)
|
|
35
|
+
|
|
36
|
+
def generate_report(
|
|
37
|
+
self,
|
|
38
|
+
threat_type: str,
|
|
39
|
+
tool_name: str,
|
|
40
|
+
arguments: Dict[str, Any],
|
|
41
|
+
risk_score: int,
|
|
42
|
+
matched_rules: List[Any],
|
|
43
|
+
action: str,
|
|
44
|
+
scan_result: Any = None,
|
|
45
|
+
) -> Dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
Generate a security report.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
threat_type: Type of threat detected
|
|
51
|
+
tool_name: MCP tool name
|
|
52
|
+
arguments: Tool arguments
|
|
53
|
+
risk_score: Calculated risk score
|
|
54
|
+
matched_rules: List of matched detection rules
|
|
55
|
+
action: Action taken (ALLOW/WARN/BLOCK/KILL)
|
|
56
|
+
scan_result: Optional ScanResult to auto-populate extended fields
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Dict containing report data and file path
|
|
60
|
+
"""
|
|
61
|
+
timestamp = datetime.now(timezone.utc)
|
|
62
|
+
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
63
|
+
|
|
64
|
+
# Mask sensitive data before writing to report
|
|
65
|
+
masked_arguments = mask_sensitive_data(arguments) if arguments else {}
|
|
66
|
+
|
|
67
|
+
# Build scoring breakdown from scan_result if available
|
|
68
|
+
scoring_breakdown: Dict[str, Any] = {}
|
|
69
|
+
owasp_mapping: List[str] = []
|
|
70
|
+
latency_ms = 0.0
|
|
71
|
+
scan_timestamp = timestamp_str
|
|
72
|
+
if scan_result is not None:
|
|
73
|
+
scoring_breakdown = getattr(scan_result, "scoring_breakdown", {})
|
|
74
|
+
owasp_mapping = getattr(scan_result, "owasp_mapping", [])
|
|
75
|
+
latency_ms = getattr(scan_result, "latency_ms", 0.0)
|
|
76
|
+
scan_timestamp = getattr(scan_result, "timestamp", timestamp_str)
|
|
77
|
+
|
|
78
|
+
report = {
|
|
79
|
+
"prooflayer_version": "0.1.0",
|
|
80
|
+
"timestamp": timestamp_str,
|
|
81
|
+
"threat": {
|
|
82
|
+
"type": threat_type,
|
|
83
|
+
"tool": tool_name,
|
|
84
|
+
"arguments": masked_arguments,
|
|
85
|
+
"risk_score": risk_score,
|
|
86
|
+
"action": action
|
|
87
|
+
},
|
|
88
|
+
"detection": {
|
|
89
|
+
"rules_matched": [
|
|
90
|
+
{
|
|
91
|
+
"id": rule.id,
|
|
92
|
+
"severity": rule.severity,
|
|
93
|
+
"message": rule.message,
|
|
94
|
+
"category": rule.category
|
|
95
|
+
}
|
|
96
|
+
for rule in matched_rules
|
|
97
|
+
],
|
|
98
|
+
"confidence": "HIGH" if risk_score > 70 else "MEDIUM" if risk_score > 30 else "LOW",
|
|
99
|
+
"scoring_breakdown": scoring_breakdown,
|
|
100
|
+
},
|
|
101
|
+
"context": {
|
|
102
|
+
"timestamp_detected": timestamp_str,
|
|
103
|
+
"timestamp_received": scan_timestamp,
|
|
104
|
+
"latency_ms": round(latency_ms, 3),
|
|
105
|
+
"mcp_message_id": None,
|
|
106
|
+
"mcp_session_id": None,
|
|
107
|
+
"server": os.environ.get("MCP_SERVER_NAME", "unknown")
|
|
108
|
+
},
|
|
109
|
+
"owasp_mapping": owasp_mapping,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Write JSON report with restricted permissions
|
|
113
|
+
report_filename = f"prooflayer-{timestamp.strftime('%Y%m%d-%H%M%S')}-{threat_type}.json"
|
|
114
|
+
report_path = self.report_dir / report_filename
|
|
115
|
+
|
|
116
|
+
with self._lock:
|
|
117
|
+
with open(report_path, "w") as f:
|
|
118
|
+
json.dump(report, f, indent=2)
|
|
119
|
+
os.chmod(report_path, 0o600)
|
|
120
|
+
|
|
121
|
+
report["report_path"] = str(report_path)
|
|
122
|
+
|
|
123
|
+
return report
|
|
124
|
+
|
|
125
|
+
def generate_sarif_report(
|
|
126
|
+
self,
|
|
127
|
+
threats: List[Dict[str, Any]]
|
|
128
|
+
) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Generate SARIF format report for CI/CD integration.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
threats: List of threat detections
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Path to SARIF report file
|
|
137
|
+
"""
|
|
138
|
+
sarif = {
|
|
139
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
140
|
+
"version": "2.1.0",
|
|
141
|
+
"runs": [
|
|
142
|
+
{
|
|
143
|
+
"tool": {
|
|
144
|
+
"driver": {
|
|
145
|
+
"name": "ProofLayer Runtime Security",
|
|
146
|
+
"version": "0.1.0",
|
|
147
|
+
"informationUri": "https://www.proof-layer.com"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"results": [
|
|
151
|
+
{
|
|
152
|
+
"ruleId": threat["threat"]["type"],
|
|
153
|
+
"level": self._sarif_level(threat["threat"]["risk_score"]),
|
|
154
|
+
"message": {
|
|
155
|
+
"text": f"Security threat detected in {threat['threat']['tool']}"
|
|
156
|
+
},
|
|
157
|
+
"locations": [
|
|
158
|
+
{
|
|
159
|
+
"physicalLocation": {
|
|
160
|
+
"artifactLocation": {
|
|
161
|
+
"uri": threat["threat"]["tool"]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
for threat in threats
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
174
|
+
sarif_path = self.report_dir / f"sarif-report-{timestamp}.sarif"
|
|
175
|
+
|
|
176
|
+
with self._lock:
|
|
177
|
+
with open(sarif_path, "w") as f:
|
|
178
|
+
json.dump(sarif, f, indent=2)
|
|
179
|
+
os.chmod(sarif_path, 0o600)
|
|
180
|
+
|
|
181
|
+
return str(sarif_path)
|
|
182
|
+
|
|
183
|
+
def _sarif_level(self, risk_score: int) -> str:
|
|
184
|
+
"""Convert risk score to SARIF severity level."""
|
|
185
|
+
if risk_score >= 70:
|
|
186
|
+
return "error"
|
|
187
|
+
elif risk_score >= 30:
|
|
188
|
+
return "warning"
|
|
189
|
+
else:
|
|
190
|
+
return "note"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response Actions
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
Handles ALLOW/WARN/BLOCK/KILL actions based on threat detection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import signal
|
|
11
|
+
import logging
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Dict, Any, Optional
|
|
14
|
+
|
|
15
|
+
from .killer import ServerKiller
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ThreatAction(Enum):
|
|
21
|
+
"""Possible actions in response to a detected threat."""
|
|
22
|
+
ALLOW = "ALLOW"
|
|
23
|
+
WARN = "WARN"
|
|
24
|
+
BLOCK = "BLOCK"
|
|
25
|
+
KILL = "KILL"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResponseAction:
|
|
29
|
+
"""
|
|
30
|
+
Manages response actions for detected threats.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
default_action: str = "warn",
|
|
36
|
+
reporter: Optional[Any] = None
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize response action handler.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
default_action: Default action ("allow", "warn", "block", "kill")
|
|
43
|
+
reporter: SecurityReporter instance
|
|
44
|
+
"""
|
|
45
|
+
self.default_action = default_action.upper()
|
|
46
|
+
self.reporter = reporter
|
|
47
|
+
|
|
48
|
+
def decide_action(self, risk_score: int) -> ThreatAction:
|
|
49
|
+
"""
|
|
50
|
+
Decide action based on risk score.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
risk_score: Risk score (0-100)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
ThreatAction to take
|
|
57
|
+
"""
|
|
58
|
+
if risk_score <= 29:
|
|
59
|
+
return ThreatAction.ALLOW
|
|
60
|
+
elif risk_score <= 69:
|
|
61
|
+
return ThreatAction.WARN
|
|
62
|
+
elif risk_score <= 89:
|
|
63
|
+
return ThreatAction.BLOCK
|
|
64
|
+
else:
|
|
65
|
+
# Score 90-100 = KILL
|
|
66
|
+
if self.default_action == "KILL":
|
|
67
|
+
return ThreatAction.KILL
|
|
68
|
+
else:
|
|
69
|
+
return ThreatAction.BLOCK
|
|
70
|
+
|
|
71
|
+
def execute(
|
|
72
|
+
self,
|
|
73
|
+
action: ThreatAction,
|
|
74
|
+
context: Dict[str, Any]
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Execute the chosen action.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
action: Action to execute
|
|
81
|
+
context: Context information (tool, arguments, risk_score, etc.)
|
|
82
|
+
"""
|
|
83
|
+
if action == ThreatAction.ALLOW:
|
|
84
|
+
logger.info(f"ALLOW: {context.get('tool_name')} (score: {context.get('risk_score')})")
|
|
85
|
+
|
|
86
|
+
elif action == ThreatAction.WARN:
|
|
87
|
+
logger.warning(
|
|
88
|
+
f"WARN: {context.get('tool_name')} "
|
|
89
|
+
f"(score: {context.get('risk_score')}) - Suspicious activity detected"
|
|
90
|
+
)
|
|
91
|
+
if self.reporter:
|
|
92
|
+
self.reporter.generate_report(
|
|
93
|
+
threat_type="suspicious_activity",
|
|
94
|
+
tool_name=context.get("tool_name"),
|
|
95
|
+
arguments=context.get("arguments", {}),
|
|
96
|
+
risk_score=context.get("risk_score", 0),
|
|
97
|
+
matched_rules=context.get("matched_rules", []),
|
|
98
|
+
action="WARN"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
elif action == ThreatAction.BLOCK:
|
|
102
|
+
logger.error(
|
|
103
|
+
f"BLOCK: {context.get('tool_name')} "
|
|
104
|
+
f"(score: {context.get('risk_score')}) - Threat blocked"
|
|
105
|
+
)
|
|
106
|
+
if self.reporter:
|
|
107
|
+
report = self.reporter.generate_report(
|
|
108
|
+
threat_type="prompt_injection",
|
|
109
|
+
tool_name=context.get("tool_name"),
|
|
110
|
+
arguments=context.get("arguments", {}),
|
|
111
|
+
risk_score=context.get("risk_score", 0),
|
|
112
|
+
matched_rules=context.get("matched_rules", []),
|
|
113
|
+
action="BLOCK"
|
|
114
|
+
)
|
|
115
|
+
raise SecurityViolation(
|
|
116
|
+
f"Tool call blocked: {context.get('tool_name')} "
|
|
117
|
+
f"(risk score: {context.get('risk_score')})"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
elif action == ThreatAction.KILL:
|
|
121
|
+
logger.critical(
|
|
122
|
+
f"KILL: {context.get('tool_name')} "
|
|
123
|
+
f"(score: {context.get('risk_score')}) - Terminating MCP server"
|
|
124
|
+
)
|
|
125
|
+
if self.reporter:
|
|
126
|
+
report = self.reporter.generate_report(
|
|
127
|
+
threat_type="critical_threat",
|
|
128
|
+
tool_name=context.get("tool_name"),
|
|
129
|
+
arguments=context.get("arguments", {}),
|
|
130
|
+
risk_score=context.get("risk_score", 0),
|
|
131
|
+
matched_rules=context.get("matched_rules", []),
|
|
132
|
+
action="SERVER_KILLED"
|
|
133
|
+
)
|
|
134
|
+
logger.critical(f"Security report written: {report['report_path']}")
|
|
135
|
+
|
|
136
|
+
self.kill_server(context)
|
|
137
|
+
|
|
138
|
+
def kill_server(self, context: Dict[str, Any]) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Terminate the MCP server process.
|
|
141
|
+
|
|
142
|
+
Delegates to ServerKiller.kill().
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
context: Context information for logging
|
|
146
|
+
"""
|
|
147
|
+
ServerKiller().kill(context)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SecurityViolation(Exception):
|
|
151
|
+
"""Raised when a security threat is detected and blocked."""
|
|
152
|
+
pass
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server Killer
|
|
3
|
+
=============
|
|
4
|
+
|
|
5
|
+
Emergency MCP server termination for critical threats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import signal
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerKiller:
|
|
19
|
+
"""Handles emergency termination of MCP server processes."""
|
|
20
|
+
|
|
21
|
+
def kill(self, context: Dict[str, Any]) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Terminate the MCP server process.
|
|
24
|
+
|
|
25
|
+
Writes an emergency log, sends SIGTERM, then SIGKILL if needed.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
context: Context information (tool_name, risk_score, arguments, etc.)
|
|
29
|
+
"""
|
|
30
|
+
logger.critical("=" * 80)
|
|
31
|
+
logger.critical("PROOFLAYER RUNTIME SECURITY: CRITICAL THREAT DETECTED")
|
|
32
|
+
logger.critical("Tool: %s", context.get("tool_name"))
|
|
33
|
+
logger.critical("Risk Score: %s", context.get("risk_score"))
|
|
34
|
+
logger.critical("Action: MCP SERVER TERMINATED")
|
|
35
|
+
logger.critical("=" * 80)
|
|
36
|
+
|
|
37
|
+
# Write emergency log with restricted permissions
|
|
38
|
+
emergency_path = "/tmp/prooflayer-emergency.log"
|
|
39
|
+
try:
|
|
40
|
+
fd = os.open(
|
|
41
|
+
emergency_path,
|
|
42
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
43
|
+
0o600,
|
|
44
|
+
)
|
|
45
|
+
with os.fdopen(fd, "a") as f:
|
|
46
|
+
f.write(f"\n{'=' * 80}\n")
|
|
47
|
+
f.write(f"TIMESTAMP: {context.get('timestamp', 'N/A')}\n")
|
|
48
|
+
f.write(f"TOOL: {context.get('tool_name')}\n")
|
|
49
|
+
f.write(f"RISK SCORE: {context.get('risk_score')}\n")
|
|
50
|
+
f.write(f"ARGUMENTS: {context.get('arguments')}\n")
|
|
51
|
+
f.write(f"{'=' * 80}\n")
|
|
52
|
+
os.chmod(emergency_path, 0o600)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error("Failed to write emergency log: %s", e)
|
|
55
|
+
|
|
56
|
+
# Kill the process
|
|
57
|
+
pid = os.getpid()
|
|
58
|
+
logger.critical("Sending SIGTERM to process %d", pid)
|
|
59
|
+
|
|
60
|
+
sys.stdout.flush()
|
|
61
|
+
sys.stderr.flush()
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
65
|
+
except (PermissionError, ProcessLookupError):
|
|
66
|
+
os.kill(pid, signal.SIGTERM)
|
|
67
|
+
|
|
68
|
+
# If still running after 1 second, force kill
|
|
69
|
+
time.sleep(1)
|
|
70
|
+
try:
|
|
71
|
+
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
72
|
+
except (PermissionError, ProcessLookupError):
|
|
73
|
+
os.kill(pid, signal.SIGKILL)
|