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/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ProofLayer Runtime Security
|
|
3
|
+
============================
|
|
4
|
+
|
|
5
|
+
Runtime prompt injection firewall for MCP servers.
|
|
6
|
+
Detects malicious prompts, kills compromised servers, generates security reports.
|
|
7
|
+
|
|
8
|
+
Built for SUSE Multi-Linux Manager and enterprise Kubernetes deployments.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .runtime.wrapper import ProofLayerRuntime
|
|
12
|
+
from .detection.engine import DetectionEngine
|
|
13
|
+
from .detection.models import ScanResult, DetectionRule
|
|
14
|
+
from .detection.scanner import PatternScanner
|
|
15
|
+
from .detection.scorer import RiskScorer
|
|
16
|
+
from .detection.semantic import SemanticAnalyzer
|
|
17
|
+
from .response.actions import ThreatAction, ResponseAction
|
|
18
|
+
from .response.killer import ServerKiller
|
|
19
|
+
from .runtime.interceptor import MCPInterceptor
|
|
20
|
+
from .runtime.middleware import ProofLayerMiddleware
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
__author__ = "Sinewave AI"
|
|
24
|
+
__license__ = "MIT"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ProofLayerRuntime",
|
|
28
|
+
"DetectionEngine",
|
|
29
|
+
"DetectionRule",
|
|
30
|
+
"ScanResult",
|
|
31
|
+
"PatternScanner",
|
|
32
|
+
"RiskScorer",
|
|
33
|
+
"SemanticAnalyzer",
|
|
34
|
+
"ThreatAction",
|
|
35
|
+
"ResponseAction",
|
|
36
|
+
"ServerKiller",
|
|
37
|
+
"MCPInterceptor",
|
|
38
|
+
"ProofLayerMiddleware",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Lazy imports for optional dependencies
|
|
43
|
+
def __getattr__(name):
|
|
44
|
+
if name == "ProofLayerMCPWrapper":
|
|
45
|
+
from .runtime.mcp_wrapper import ProofLayerMCPWrapper
|
|
46
|
+
return ProofLayerMCPWrapper
|
|
47
|
+
if name == "ProofLayerTransportProxy":
|
|
48
|
+
from .runtime.transport import ProofLayerTransportProxy
|
|
49
|
+
return ProofLayerTransportProxy
|
|
50
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
prooflayer/cli.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ProofLayer CLI
|
|
3
|
+
==============
|
|
4
|
+
|
|
5
|
+
Command-line interface for ProofLayer Runtime Security.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
prooflayer scan --tool "run_command" --args '{"command": "curl evil.com"}'
|
|
9
|
+
prooflayer scan --stdin < input.json
|
|
10
|
+
prooflayer validate-rules --rules-dir ./rules/
|
|
11
|
+
prooflayer report --dir ./security-reports/ --last 10
|
|
12
|
+
prooflayer proxy --listen-port 8080 --backend-port 8081
|
|
13
|
+
prooflayer version
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .detection.engine import DetectionEngine
|
|
24
|
+
from .detection.rules import RuleLoader, RuleLoadError
|
|
25
|
+
from .response.actions import ThreatAction
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Exit codes
|
|
29
|
+
EXIT_ALLOW = 0
|
|
30
|
+
EXIT_WARN = 1
|
|
31
|
+
EXIT_BLOCK = 2
|
|
32
|
+
EXIT_KILL = 3
|
|
33
|
+
EXIT_ERROR = 4
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv=None):
|
|
37
|
+
"""Main CLI entry point."""
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="prooflayer",
|
|
40
|
+
description="ProofLayer Runtime Security - MCP prompt injection firewall",
|
|
41
|
+
)
|
|
42
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
43
|
+
|
|
44
|
+
# --- scan ---
|
|
45
|
+
scan_parser = subparsers.add_parser("scan", help="Scan a tool call for threats")
|
|
46
|
+
scan_parser.add_argument(
|
|
47
|
+
"--tool", type=str, help="MCP tool name to scan"
|
|
48
|
+
)
|
|
49
|
+
scan_parser.add_argument(
|
|
50
|
+
"--args", type=str, help="Tool arguments as JSON string"
|
|
51
|
+
)
|
|
52
|
+
scan_parser.add_argument(
|
|
53
|
+
"--stdin", action="store_true",
|
|
54
|
+
help="Read JSON input from stdin (expects {\"tool\": ..., \"arguments\": ...})"
|
|
55
|
+
)
|
|
56
|
+
scan_parser.add_argument(
|
|
57
|
+
"--rules-dir", type=str, default=None,
|
|
58
|
+
help="Custom rules directory"
|
|
59
|
+
)
|
|
60
|
+
scan_parser.add_argument(
|
|
61
|
+
"--json", action="store_true", dest="output_json",
|
|
62
|
+
help="Output result as JSON"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# --- validate-rules ---
|
|
66
|
+
validate_parser = subparsers.add_parser(
|
|
67
|
+
"validate-rules", help="Validate YAML rule files"
|
|
68
|
+
)
|
|
69
|
+
validate_parser.add_argument(
|
|
70
|
+
"--rules-dir", type=str, required=True,
|
|
71
|
+
help="Directory containing YAML rule files"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# --- report ---
|
|
75
|
+
report_parser = subparsers.add_parser(
|
|
76
|
+
"report", help="View and summarize security reports"
|
|
77
|
+
)
|
|
78
|
+
report_parser.add_argument(
|
|
79
|
+
"--dir", type=str, default="./security-reports",
|
|
80
|
+
help="Directory containing security reports"
|
|
81
|
+
)
|
|
82
|
+
report_parser.add_argument(
|
|
83
|
+
"--last", type=int, default=10,
|
|
84
|
+
help="Number of recent reports to show"
|
|
85
|
+
)
|
|
86
|
+
report_parser.add_argument(
|
|
87
|
+
"--json", action="store_true", dest="output_json",
|
|
88
|
+
help="Output as JSON"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# --- proxy ---
|
|
92
|
+
proxy_parser = subparsers.add_parser(
|
|
93
|
+
"proxy", help="Start HTTP proxy for MCP server security scanning"
|
|
94
|
+
)
|
|
95
|
+
proxy_parser.add_argument(
|
|
96
|
+
"--listen-port", type=int, default=8080,
|
|
97
|
+
help="Port to listen on (default: 8080)"
|
|
98
|
+
)
|
|
99
|
+
proxy_parser.add_argument(
|
|
100
|
+
"--backend-port", type=int, default=8081,
|
|
101
|
+
help="Port where the MCP server is running (default: 8081)"
|
|
102
|
+
)
|
|
103
|
+
proxy_parser.add_argument(
|
|
104
|
+
"--backend-host", type=str, default="127.0.0.1",
|
|
105
|
+
help="Backend host (default: 127.0.0.1)"
|
|
106
|
+
)
|
|
107
|
+
proxy_parser.add_argument(
|
|
108
|
+
"--config", type=str, default=None,
|
|
109
|
+
help="Path to prooflayer.yaml config file"
|
|
110
|
+
)
|
|
111
|
+
proxy_parser.add_argument(
|
|
112
|
+
"--rules-dir", type=str, default=None,
|
|
113
|
+
help="Custom rules directory"
|
|
114
|
+
)
|
|
115
|
+
proxy_parser.add_argument(
|
|
116
|
+
"--report-dir", type=str, default="./security-reports",
|
|
117
|
+
help="Directory for security reports"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# --- version ---
|
|
121
|
+
subparsers.add_parser("version", help="Show version information")
|
|
122
|
+
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
|
|
125
|
+
if not args.command:
|
|
126
|
+
parser.print_help()
|
|
127
|
+
return EXIT_ERROR
|
|
128
|
+
|
|
129
|
+
if args.command == "scan":
|
|
130
|
+
return cmd_scan(args)
|
|
131
|
+
elif args.command == "validate-rules":
|
|
132
|
+
return cmd_validate_rules(args)
|
|
133
|
+
elif args.command == "report":
|
|
134
|
+
return cmd_report(args)
|
|
135
|
+
elif args.command == "proxy":
|
|
136
|
+
return cmd_proxy(args)
|
|
137
|
+
elif args.command == "version":
|
|
138
|
+
return cmd_version(args)
|
|
139
|
+
|
|
140
|
+
parser.print_help()
|
|
141
|
+
return EXIT_ERROR
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_scan(args):
|
|
145
|
+
"""Handle the 'scan' subcommand."""
|
|
146
|
+
tool_name = None
|
|
147
|
+
arguments = {}
|
|
148
|
+
|
|
149
|
+
if args.stdin:
|
|
150
|
+
try:
|
|
151
|
+
raw = sys.stdin.read()
|
|
152
|
+
data = json.loads(raw)
|
|
153
|
+
tool_name = data.get("tool")
|
|
154
|
+
arguments = data.get("arguments", {})
|
|
155
|
+
except json.JSONDecodeError as e:
|
|
156
|
+
print(f"ERROR: Invalid JSON from stdin: {e}", file=sys.stderr)
|
|
157
|
+
return EXIT_ERROR
|
|
158
|
+
else:
|
|
159
|
+
tool_name = args.tool
|
|
160
|
+
if args.args:
|
|
161
|
+
try:
|
|
162
|
+
arguments = json.loads(args.args)
|
|
163
|
+
except json.JSONDecodeError as e:
|
|
164
|
+
print(f"ERROR: Invalid JSON for --args: {e}", file=sys.stderr)
|
|
165
|
+
return EXIT_ERROR
|
|
166
|
+
|
|
167
|
+
if not tool_name:
|
|
168
|
+
print("ERROR: --tool is required (or use --stdin)", file=sys.stderr)
|
|
169
|
+
return EXIT_ERROR
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
engine = DetectionEngine(rules_dir=args.rules_dir)
|
|
173
|
+
except RuleLoadError as e:
|
|
174
|
+
print(f"ERROR: Failed to load rules: {e}", file=sys.stderr)
|
|
175
|
+
return EXIT_ERROR
|
|
176
|
+
|
|
177
|
+
risk_score, matched_rules = engine.scan(tool_name=tool_name, arguments=arguments)
|
|
178
|
+
|
|
179
|
+
# Determine action from score
|
|
180
|
+
if risk_score <= 29:
|
|
181
|
+
action = ThreatAction.ALLOW
|
|
182
|
+
elif risk_score <= 69:
|
|
183
|
+
action = ThreatAction.WARN
|
|
184
|
+
elif risk_score <= 89:
|
|
185
|
+
action = ThreatAction.BLOCK
|
|
186
|
+
else:
|
|
187
|
+
action = ThreatAction.KILL
|
|
188
|
+
|
|
189
|
+
if getattr(args, "output_json", False):
|
|
190
|
+
result = {
|
|
191
|
+
"tool": tool_name,
|
|
192
|
+
"arguments": arguments,
|
|
193
|
+
"risk_score": risk_score,
|
|
194
|
+
"action": action.value,
|
|
195
|
+
"rules_matched": [
|
|
196
|
+
{
|
|
197
|
+
"id": r.id,
|
|
198
|
+
"severity": r.severity,
|
|
199
|
+
"message": r.message,
|
|
200
|
+
"category": r.category,
|
|
201
|
+
"score": r.score,
|
|
202
|
+
}
|
|
203
|
+
for r in matched_rules
|
|
204
|
+
],
|
|
205
|
+
}
|
|
206
|
+
print(json.dumps(result, indent=2))
|
|
207
|
+
else:
|
|
208
|
+
rule_ids = ", ".join(r.id for r in matched_rules) if matched_rules else "none"
|
|
209
|
+
print(f"RISK: {risk_score}/100 | ACTION: {action.value} | Rules matched: {rule_ids}")
|
|
210
|
+
|
|
211
|
+
if matched_rules:
|
|
212
|
+
print()
|
|
213
|
+
for r in matched_rules:
|
|
214
|
+
print(f" [{r.severity.upper()}] {r.id}: {r.message} (+{r.score})")
|
|
215
|
+
|
|
216
|
+
# Map action to exit code
|
|
217
|
+
exit_codes = {
|
|
218
|
+
ThreatAction.ALLOW: EXIT_ALLOW,
|
|
219
|
+
ThreatAction.WARN: EXIT_WARN,
|
|
220
|
+
ThreatAction.BLOCK: EXIT_BLOCK,
|
|
221
|
+
ThreatAction.KILL: EXIT_KILL,
|
|
222
|
+
}
|
|
223
|
+
return exit_codes.get(action, EXIT_ERROR)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_validate_rules(args):
|
|
227
|
+
"""Handle the 'validate-rules' subcommand."""
|
|
228
|
+
rules_dir = args.rules_dir
|
|
229
|
+
rules_path = Path(rules_dir)
|
|
230
|
+
|
|
231
|
+
if not rules_path.exists():
|
|
232
|
+
print(f"ERROR: Rules directory not found: {rules_dir}", file=sys.stderr)
|
|
233
|
+
return EXIT_ERROR
|
|
234
|
+
|
|
235
|
+
if not rules_path.is_dir():
|
|
236
|
+
print(f"ERROR: Not a directory: {rules_dir}", file=sys.stderr)
|
|
237
|
+
return EXIT_ERROR
|
|
238
|
+
|
|
239
|
+
yaml_files = list(rules_path.glob("*.yaml"))
|
|
240
|
+
if not yaml_files:
|
|
241
|
+
print(f"ERROR: No YAML files found in {rules_dir}", file=sys.stderr)
|
|
242
|
+
return EXIT_ERROR
|
|
243
|
+
|
|
244
|
+
total_rules = 0
|
|
245
|
+
errors = []
|
|
246
|
+
|
|
247
|
+
for yaml_file in sorted(yaml_files):
|
|
248
|
+
try:
|
|
249
|
+
rules = RuleLoader.load_from_file(str(yaml_file))
|
|
250
|
+
total_rules += len(rules)
|
|
251
|
+
|
|
252
|
+
# Check for compilation failures
|
|
253
|
+
for rule in rules:
|
|
254
|
+
if rule.compiled_pattern is None:
|
|
255
|
+
errors.append(f" {yaml_file.name}: rule '{rule.id}' pattern failed to compile")
|
|
256
|
+
|
|
257
|
+
print(f" {yaml_file.name}: {len(rules)} rules OK")
|
|
258
|
+
|
|
259
|
+
except Exception as e:
|
|
260
|
+
errors.append(f" {yaml_file.name}: {e}")
|
|
261
|
+
|
|
262
|
+
print()
|
|
263
|
+
if errors:
|
|
264
|
+
print(f"Loaded {total_rules} rules from {len(yaml_files)} files with ERRORS:")
|
|
265
|
+
for err in errors:
|
|
266
|
+
print(err)
|
|
267
|
+
return EXIT_ERROR
|
|
268
|
+
else:
|
|
269
|
+
print(f"Loaded {total_rules} rules from {len(yaml_files)} files. All patterns compile successfully.")
|
|
270
|
+
return EXIT_ALLOW
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cmd_report(args):
|
|
274
|
+
"""Handle the 'report' subcommand."""
|
|
275
|
+
report_dir = Path(args.dir)
|
|
276
|
+
|
|
277
|
+
if not report_dir.exists():
|
|
278
|
+
print(f"ERROR: Report directory not found: {args.dir}", file=sys.stderr)
|
|
279
|
+
return EXIT_ERROR
|
|
280
|
+
|
|
281
|
+
# Find JSON report files
|
|
282
|
+
report_files = sorted(report_dir.glob("prooflayer-*.json"), reverse=True)
|
|
283
|
+
|
|
284
|
+
if not report_files:
|
|
285
|
+
print(f"No reports found in {args.dir}")
|
|
286
|
+
return EXIT_ALLOW
|
|
287
|
+
|
|
288
|
+
# Limit to --last N
|
|
289
|
+
report_files = report_files[: args.last]
|
|
290
|
+
|
|
291
|
+
reports = []
|
|
292
|
+
for rf in report_files:
|
|
293
|
+
try:
|
|
294
|
+
with open(rf, "r") as f:
|
|
295
|
+
report = json.load(f)
|
|
296
|
+
reports.append(report)
|
|
297
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
298
|
+
print(f"WARNING: Skipping {rf.name}: {e}", file=sys.stderr)
|
|
299
|
+
|
|
300
|
+
if getattr(args, "output_json", False):
|
|
301
|
+
print(json.dumps(reports, indent=2))
|
|
302
|
+
else:
|
|
303
|
+
# Table format
|
|
304
|
+
print(f"{'TIMESTAMP':<28} {'TOOL':<20} {'SCORE':>5} {'ACTION':<8} {'RULES MATCHED'}")
|
|
305
|
+
print("-" * 95)
|
|
306
|
+
|
|
307
|
+
for report in reports:
|
|
308
|
+
threat = report.get("threat", {})
|
|
309
|
+
detection = report.get("detection", {})
|
|
310
|
+
timestamp = report.get("timestamp", "N/A")
|
|
311
|
+
tool = threat.get("tool", "N/A")
|
|
312
|
+
score = threat.get("risk_score", "N/A")
|
|
313
|
+
action = threat.get("action", "N/A")
|
|
314
|
+
rules = [r.get("id", "?") for r in detection.get("rules_matched", [])]
|
|
315
|
+
rules_str = ", ".join(rules) if rules else "none"
|
|
316
|
+
|
|
317
|
+
print(f"{timestamp:<28} {tool:<20} {score:>5} {action:<8} {rules_str}")
|
|
318
|
+
|
|
319
|
+
print()
|
|
320
|
+
print(f"Showing {len(reports)} of {len(list(report_dir.glob('prooflayer-*.json')))} reports")
|
|
321
|
+
|
|
322
|
+
return EXIT_ALLOW
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_proxy(args):
|
|
326
|
+
"""Handle the 'proxy' subcommand."""
|
|
327
|
+
from .runtime.transport import ProofLayerTransportProxy
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
proxy = ProofLayerTransportProxy(
|
|
331
|
+
listen_port=args.listen_port,
|
|
332
|
+
backend_port=args.backend_port,
|
|
333
|
+
backend_host=args.backend_host,
|
|
334
|
+
rules_dir=args.rules_dir,
|
|
335
|
+
report_dir=args.report_dir,
|
|
336
|
+
)
|
|
337
|
+
except Exception as e:
|
|
338
|
+
print(f"ERROR: Failed to start proxy: {e}", file=sys.stderr)
|
|
339
|
+
return EXIT_ERROR
|
|
340
|
+
|
|
341
|
+
print(f"ProofLayer proxy listening on :{args.listen_port}")
|
|
342
|
+
print(f"Forwarding to {args.backend_host}:{args.backend_port}")
|
|
343
|
+
print(f"Security reports: {args.report_dir}")
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
proxy.start()
|
|
347
|
+
except KeyboardInterrupt:
|
|
348
|
+
print("\nShutting down...")
|
|
349
|
+
proxy.stop()
|
|
350
|
+
|
|
351
|
+
return EXIT_ALLOW
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def cmd_version(args):
|
|
355
|
+
"""Handle the 'version' subcommand."""
|
|
356
|
+
print(f"ProofLayer Runtime Security v{__version__}")
|
|
357
|
+
print(f"Python {sys.version}")
|
|
358
|
+
return EXIT_ALLOW
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
sys.exit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Allowlist
|
|
3
|
+
=========
|
|
4
|
+
|
|
5
|
+
Allowlisting mechanism for known-safe tool calls so legitimate
|
|
6
|
+
operations are not flagged by the detection engine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Allowlist:
|
|
17
|
+
"""
|
|
18
|
+
Manages allowlist rules loaded from YAML configuration.
|
|
19
|
+
|
|
20
|
+
Supports allowlisting by:
|
|
21
|
+
- Exact tool name
|
|
22
|
+
- Tool name glob pattern
|
|
23
|
+
- Argument value patterns (glob)
|
|
24
|
+
- Combined tool + argument bypass rules
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
28
|
+
"""
|
|
29
|
+
Initialize allowlist from configuration dictionary.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config: The ``allowlist`` section from the YAML config.
|
|
33
|
+
Expected keys: tools, tool_patterns, argument_allowlist,
|
|
34
|
+
bypass_rules.
|
|
35
|
+
"""
|
|
36
|
+
config = config or {}
|
|
37
|
+
|
|
38
|
+
self.tools: List[str] = config.get("tools", [])
|
|
39
|
+
self.tool_patterns: List[str] = config.get("tool_patterns", [])
|
|
40
|
+
self.argument_allowlist: Dict[str, List[str]] = config.get(
|
|
41
|
+
"argument_allowlist", {}
|
|
42
|
+
)
|
|
43
|
+
self.bypass_rules: List[Dict[str, Any]] = config.get("bypass_rules", [])
|
|
44
|
+
|
|
45
|
+
logger.info(
|
|
46
|
+
"Allowlist loaded: %d tools, %d patterns, %d argument rules, %d bypass rules",
|
|
47
|
+
len(self.tools),
|
|
48
|
+
len(self.tool_patterns),
|
|
49
|
+
len(self.argument_allowlist),
|
|
50
|
+
len(self.bypass_rules),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def is_allowed(
|
|
54
|
+
self, tool_name: str, arguments: Optional[Dict[str, Any]] = None
|
|
55
|
+
) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Check whether a tool call matches any allowlist rule.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
tool_name: Name of the MCP tool being called.
|
|
61
|
+
arguments: Tool call arguments (may be None).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True if the call is allowlisted and should skip detection.
|
|
65
|
+
"""
|
|
66
|
+
arguments = arguments or {}
|
|
67
|
+
|
|
68
|
+
# 1. Exact tool name match
|
|
69
|
+
if tool_name in self.tools:
|
|
70
|
+
logger.info("Allowlisted by exact tool name: %s", tool_name)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
# 2. Tool name glob pattern match
|
|
74
|
+
for pattern in self.tool_patterns:
|
|
75
|
+
if fnmatch.fnmatch(tool_name, pattern):
|
|
76
|
+
logger.info(
|
|
77
|
+
"Allowlisted by tool pattern '%s': %s", pattern, tool_name
|
|
78
|
+
)
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
# 3. Argument value pattern match — all specified argument keys must
|
|
82
|
+
# have a value matching at least one of the allowed patterns.
|
|
83
|
+
if self.argument_allowlist and arguments:
|
|
84
|
+
if self._arguments_match(arguments):
|
|
85
|
+
logger.info(
|
|
86
|
+
"Allowlisted by argument values: %s %s", tool_name, arguments
|
|
87
|
+
)
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
# 4. Combined bypass rules
|
|
91
|
+
for rule in self.bypass_rules:
|
|
92
|
+
if self._bypass_rule_matches(rule, tool_name, arguments):
|
|
93
|
+
logger.info(
|
|
94
|
+
"Allowlisted by bypass rule for tool '%s': %s",
|
|
95
|
+
rule.get("tool"),
|
|
96
|
+
tool_name,
|
|
97
|
+
)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Internal helpers
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _arguments_match(self, arguments: Dict[str, Any]) -> bool:
|
|
107
|
+
"""Check if all specified argument keys match their allowed patterns."""
|
|
108
|
+
matched_any = False
|
|
109
|
+
for key, patterns in self.argument_allowlist.items():
|
|
110
|
+
value = arguments.get(key)
|
|
111
|
+
if value is None:
|
|
112
|
+
continue
|
|
113
|
+
value_str = str(value)
|
|
114
|
+
if any(fnmatch.fnmatch(value_str, p) for p in patterns):
|
|
115
|
+
matched_any = True
|
|
116
|
+
else:
|
|
117
|
+
# If the key is present but does not match, fail.
|
|
118
|
+
return False
|
|
119
|
+
return matched_any
|
|
120
|
+
|
|
121
|
+
def _bypass_rule_matches(
|
|
122
|
+
self,
|
|
123
|
+
rule: Dict[str, Any],
|
|
124
|
+
tool_name: str,
|
|
125
|
+
arguments: Dict[str, Any],
|
|
126
|
+
) -> bool:
|
|
127
|
+
"""Check if a combined bypass rule matches."""
|
|
128
|
+
rule_tool = rule.get("tool")
|
|
129
|
+
if rule_tool and rule_tool != tool_name:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
rule_args = rule.get("arguments", {})
|
|
133
|
+
for key, expected_value in rule_args.items():
|
|
134
|
+
actual = arguments.get(key)
|
|
135
|
+
if actual is None or str(actual) != str(expected_value):
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
return True
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Configuration file loader."""
|
|
2
|
+
|
|
3
|
+
import yaml # type: ignore[import-untyped]
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigLoader:
|
|
9
|
+
"""Load ProofLayer configuration from YAML files."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def load(config_path: str) -> Dict[str, Any]:
|
|
13
|
+
"""
|
|
14
|
+
Load configuration from YAML file.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config_path: Path to YAML config file
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Configuration dictionary
|
|
21
|
+
"""
|
|
22
|
+
path = Path(config_path)
|
|
23
|
+
if not path.exists():
|
|
24
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
25
|
+
|
|
26
|
+
with open(path, "r") as f:
|
|
27
|
+
config = yaml.safe_load(f)
|
|
28
|
+
|
|
29
|
+
return config or {}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Threat detection engine."""
|
|
2
|
+
|
|
3
|
+
from .models import DetectionRule, ScanResult
|
|
4
|
+
from .engine import DetectionEngine
|
|
5
|
+
from .rules import RuleLoadError
|
|
6
|
+
from .normalizer import normalize_text, flatten_arguments
|
|
7
|
+
from .scanner import PatternScanner
|
|
8
|
+
from .scorer import RiskScorer
|
|
9
|
+
from .semantic import SemanticAnalyzer
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DetectionEngine",
|
|
13
|
+
"DetectionRule",
|
|
14
|
+
"ScanResult",
|
|
15
|
+
"RuleLoadError",
|
|
16
|
+
"PatternScanner",
|
|
17
|
+
"RiskScorer",
|
|
18
|
+
"SemanticAnalyzer",
|
|
19
|
+
"normalize_text",
|
|
20
|
+
"flatten_arguments",
|
|
21
|
+
]
|