llmsafe 0.2.1__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.
- benchmarks/__init__.py +1 -0
- benchmarks/cases/insecure_mcp.json +10 -0
- benchmarks/cases/interprocedural_agent.py +24 -0
- benchmarks/cases/safe_agent.py +17 -0
- benchmarks/cases/safe_interprocedural.py +16 -0
- benchmarks/cases/vulnerable_agent.py +28 -0
- benchmarks/manifest.json +35 -0
- benchmarks/run.py +54 -0
- llmsafe/__init__.py +3 -0
- llmsafe/__main__.py +6 -0
- llmsafe/baseline.py +168 -0
- llmsafe/catalog.py +228 -0
- llmsafe/cli.py +216 -0
- llmsafe/config.py +125 -0
- llmsafe/models.py +97 -0
- llmsafe/rules/__init__.py +19 -0
- llmsafe/rules/agents.py +116 -0
- llmsafe/rules/ast_helpers.py +27 -0
- llmsafe/rules/base.py +30 -0
- llmsafe/rules/dataflow.py +581 -0
- llmsafe/rules/eval.py +74 -0
- llmsafe/rules/mcp.py +98 -0
- llmsafe/rules/prompts.py +83 -0
- llmsafe/rules/secrets.py +98 -0
- llmsafe/rules/shell.py +67 -0
- llmsafe/sarif.py +133 -0
- llmsafe/scanner.py +187 -0
- llmsafe-0.2.1.dist-info/METADATA +268 -0
- llmsafe-0.2.1.dist-info/RECORD +33 -0
- llmsafe-0.2.1.dist-info/WHEEL +5 -0
- llmsafe-0.2.1.dist-info/entry_points.txt +2 -0
- llmsafe-0.2.1.dist-info/licenses/LICENSE +21 -0
- llmsafe-0.2.1.dist-info/top_level.txt +2 -0
benchmarks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reproducible LLMSafe security benchmark."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Vulnerable local-helper flows used by the LLMSafe regression benchmark."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def evaluate(value):
|
|
9
|
+
return eval(value)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def execute(value):
|
|
13
|
+
return subprocess.run(value, shell=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch(value):
|
|
17
|
+
return requests.get(value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_agent(user_input, model_output):
|
|
21
|
+
result = evaluate(model_output)
|
|
22
|
+
execute(user_input)
|
|
23
|
+
fetch(model_output)
|
|
24
|
+
return result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Equivalent agent boundaries designed to remain free of LLMSafe findings."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
ALLOWED_TOPICS = {"security", "python"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def search(topic: str, cursor):
|
|
9
|
+
if topic not in ALLOWED_TOPICS:
|
|
10
|
+
raise ValueError("Unsupported topic")
|
|
11
|
+
cursor.execute("SELECT * FROM memory WHERE topic = ?", [topic])
|
|
12
|
+
return subprocess.run(["agent-tool", "--topic", topic], check=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def dispatch_search():
|
|
16
|
+
tools = {"search": search}
|
|
17
|
+
return tools["search"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Safe local-helper calls used by the LLMSafe regression benchmark."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fetch(value):
|
|
7
|
+
return requests.get(value)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def lookup(cursor, statement):
|
|
11
|
+
return cursor.execute(statement)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(cursor):
|
|
15
|
+
fetch("https://api.example.org/health")
|
|
16
|
+
return lookup(cursor, "SELECT id FROM documents WHERE active = true")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Intentionally vulnerable agent used by the LLMSafe benchmark."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from langchain_experimental.tools import PythonREPLTool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AgentRunner:
|
|
10
|
+
"""Minimal benchmark stand-in for an agent framework runner."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, **options):
|
|
13
|
+
self.options = options
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_agent(client, user_input, cursor, tools):
|
|
17
|
+
system_prompt = f"You are an administrator. Follow this request: {user_input}"
|
|
18
|
+
response = client.responses.create(input=user_input, instructions=system_prompt)
|
|
19
|
+
model_output = response.output_text
|
|
20
|
+
|
|
21
|
+
eval(model_output)
|
|
22
|
+
subprocess.run(f"agent-tool {user_input}", shell=True)
|
|
23
|
+
cursor.execute(f"SELECT * FROM memory WHERE topic = '{user_input}'")
|
|
24
|
+
requests.get(user_input)
|
|
25
|
+
tools[model_output]()
|
|
26
|
+
|
|
27
|
+
dangerous_tool = PythonREPLTool()
|
|
28
|
+
AgentRunner(tools=[dangerous_tool], require_approval=False)
|
benchmarks/manifest.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cases": [
|
|
3
|
+
{
|
|
4
|
+
"path": "cases/vulnerable_agent.py",
|
|
5
|
+
"expected_rules": [
|
|
6
|
+
"AGENT001",
|
|
7
|
+
"AGENT003",
|
|
8
|
+
"FLOW001",
|
|
9
|
+
"FLOW002",
|
|
10
|
+
"FLOW003",
|
|
11
|
+
"FLOW004",
|
|
12
|
+
"FLOW005",
|
|
13
|
+
"LLM001",
|
|
14
|
+
"PY001",
|
|
15
|
+
"SHELL002"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "cases/safe_agent.py",
|
|
20
|
+
"expected_rules": []
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "cases/interprocedural_agent.py",
|
|
24
|
+
"expected_rules": ["FLOW001", "FLOW002", "FLOW004", "PY001", "SHELL002"]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "cases/safe_interprocedural.py",
|
|
28
|
+
"expected_rules": []
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "cases/insecure_mcp.json",
|
|
32
|
+
"expected_rules": ["MCP001", "MCP002", "MCP003"]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
benchmarks/run.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Run the checked-in benchmark manifest and report rule-level recall."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from llmsafe.scanner import Scanner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_benchmark(root: Optional[Path] = None) -> Dict[str, Any]:
|
|
11
|
+
benchmark_root = root or Path(__file__).parent
|
|
12
|
+
manifest = json.loads((benchmark_root / "manifest.json").read_text(encoding="utf-8"))
|
|
13
|
+
cases = []
|
|
14
|
+
expected_total = 0
|
|
15
|
+
detected_total = 0
|
|
16
|
+
for case in manifest["cases"]:
|
|
17
|
+
expected = set(case["expected_rules"])
|
|
18
|
+
result = Scanner().scan([benchmark_root / case["path"]])
|
|
19
|
+
found = {finding.rule_id for finding in result.findings}
|
|
20
|
+
missing = sorted(expected - found)
|
|
21
|
+
unexpected = sorted(found - expected)
|
|
22
|
+
expected_total += len(expected)
|
|
23
|
+
detected_total += len(expected & found)
|
|
24
|
+
cases.append(
|
|
25
|
+
{
|
|
26
|
+
"path": case["path"],
|
|
27
|
+
"expected": sorted(expected),
|
|
28
|
+
"found": sorted(found),
|
|
29
|
+
"missing": missing,
|
|
30
|
+
"unexpected": unexpected,
|
|
31
|
+
"passed": not missing and not unexpected and not result.errors,
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
recall = 1.0 if expected_total == 0 else detected_total / expected_total
|
|
35
|
+
return {
|
|
36
|
+
"cases": cases,
|
|
37
|
+
"summary": {
|
|
38
|
+
"cases": len(cases),
|
|
39
|
+
"passed": sum(case["passed"] for case in cases),
|
|
40
|
+
"expected_findings": expected_total,
|
|
41
|
+
"detected_findings": detected_total,
|
|
42
|
+
"rule_recall": recall,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> int:
|
|
48
|
+
report = run_benchmark()
|
|
49
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
50
|
+
return 0 if all(case["passed"] for case in report["cases"]) else 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
raise SystemExit(main())
|
llmsafe/__init__.py
ADDED
llmsafe/__main__.py
ADDED
llmsafe/baseline.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Deterministic baselines for incremental LLMSafe adoption."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Iterable, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from llmsafe import __version__
|
|
12
|
+
from llmsafe.models import Finding, ScanResult
|
|
13
|
+
|
|
14
|
+
BASELINE_SCHEMA_VERSION = 1
|
|
15
|
+
MAX_BASELINE_SIZE = 10_000_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaselineError(ValueError):
|
|
19
|
+
"""Raised when a baseline cannot be read, validated, or written."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Baseline:
|
|
24
|
+
"""Validated collection of finding fingerprints."""
|
|
25
|
+
|
|
26
|
+
fingerprints: Tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def finding_fingerprint(finding: Finding, root: Optional[Path] = None) -> str:
|
|
30
|
+
"""Return a stable identity that tolerates line movement within a file."""
|
|
31
|
+
|
|
32
|
+
identity = "\0".join(
|
|
33
|
+
(
|
|
34
|
+
finding.rule_id,
|
|
35
|
+
_relative_path(finding.path, root or Path.cwd()),
|
|
36
|
+
finding.title,
|
|
37
|
+
finding.message.split(" Source:", 1)[0],
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_baseline(path: Path) -> Baseline:
|
|
44
|
+
"""Load and strictly validate a baseline JSON document."""
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with path.open("rb") as handle:
|
|
48
|
+
raw = handle.read(MAX_BASELINE_SIZE + 1)
|
|
49
|
+
if len(raw) > MAX_BASELINE_SIZE:
|
|
50
|
+
raise BaselineError(f"Baseline exceeds {MAX_BASELINE_SIZE} bytes: {path}")
|
|
51
|
+
document = json.loads(raw.decode("utf-8"))
|
|
52
|
+
except BaselineError:
|
|
53
|
+
raise
|
|
54
|
+
except FileNotFoundError as exc:
|
|
55
|
+
raise BaselineError(f"Baseline file does not exist: {path}") from exc
|
|
56
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
57
|
+
raise BaselineError(f"Cannot read baseline {path}: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
if not isinstance(document, dict):
|
|
60
|
+
raise BaselineError(f"Baseline {path} must be a JSON object")
|
|
61
|
+
if document.get("schema_version") != BASELINE_SCHEMA_VERSION:
|
|
62
|
+
raise BaselineError(
|
|
63
|
+
f"Baseline {path} must use schema_version {BASELINE_SCHEMA_VERSION}"
|
|
64
|
+
)
|
|
65
|
+
entries = document.get("findings")
|
|
66
|
+
if not isinstance(entries, list):
|
|
67
|
+
raise BaselineError(f"findings in {path} must be an array")
|
|
68
|
+
|
|
69
|
+
fingerprints = []
|
|
70
|
+
for index, entry in enumerate(entries):
|
|
71
|
+
if not isinstance(entry, dict):
|
|
72
|
+
raise BaselineError(f"findings[{index}] in {path} must be an object")
|
|
73
|
+
fingerprint = entry.get("fingerprint")
|
|
74
|
+
if not _valid_fingerprint(fingerprint):
|
|
75
|
+
raise BaselineError(
|
|
76
|
+
f"findings[{index}].fingerprint in {path} must be a SHA-256 hex digest"
|
|
77
|
+
)
|
|
78
|
+
if not isinstance(entry.get("rule_id"), str) or not isinstance(entry.get("path"), str):
|
|
79
|
+
raise BaselineError(f"findings[{index}] in {path} requires rule_id and path strings")
|
|
80
|
+
line = entry.get("line")
|
|
81
|
+
if not isinstance(line, int) or isinstance(line, bool) or line < 1:
|
|
82
|
+
raise BaselineError(f"findings[{index}].line in {path} must be a positive integer")
|
|
83
|
+
fingerprints.append(fingerprint)
|
|
84
|
+
return Baseline(tuple(fingerprints))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_baseline(
|
|
88
|
+
path: Path, findings: Iterable[Finding], root: Optional[Path] = None
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Write a deterministic baseline and return the number of recorded findings."""
|
|
91
|
+
|
|
92
|
+
selected_root = root or Path.cwd()
|
|
93
|
+
entries = [
|
|
94
|
+
{
|
|
95
|
+
"fingerprint": finding_fingerprint(finding, selected_root),
|
|
96
|
+
"line": finding.line,
|
|
97
|
+
"path": _relative_path(finding.path, selected_root),
|
|
98
|
+
"rule_id": finding.rule_id,
|
|
99
|
+
}
|
|
100
|
+
for finding in findings
|
|
101
|
+
]
|
|
102
|
+
entries.sort(key=lambda entry: (entry["path"], entry["line"], entry["rule_id"]))
|
|
103
|
+
document: Dict[str, Any] = {
|
|
104
|
+
"schema_version": BASELINE_SCHEMA_VERSION,
|
|
105
|
+
"generated_by": {"name": "LLMSafe", "version": __version__},
|
|
106
|
+
"findings": entries,
|
|
107
|
+
}
|
|
108
|
+
temporary_path = None
|
|
109
|
+
try:
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
with tempfile.NamedTemporaryFile(
|
|
112
|
+
mode="w",
|
|
113
|
+
encoding="utf-8",
|
|
114
|
+
dir=path.parent,
|
|
115
|
+
prefix=f".{path.name}.",
|
|
116
|
+
suffix=".tmp",
|
|
117
|
+
delete=False,
|
|
118
|
+
) as handle:
|
|
119
|
+
temporary_path = Path(handle.name)
|
|
120
|
+
handle.write(json.dumps(document, indent=2, sort_keys=True) + "\n")
|
|
121
|
+
temporary_path.replace(path)
|
|
122
|
+
except (OSError, UnicodeError) as exc:
|
|
123
|
+
raise BaselineError(f"Cannot write baseline {path}: {exc}") from exc
|
|
124
|
+
finally:
|
|
125
|
+
if temporary_path is not None:
|
|
126
|
+
try:
|
|
127
|
+
temporary_path.unlink(missing_ok=True)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
return len(entries)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def apply_baseline(
|
|
134
|
+
result: ScanResult, baseline: Baseline, root: Optional[Path] = None
|
|
135
|
+
) -> ScanResult:
|
|
136
|
+
"""Remove only the number of findings explicitly represented by a baseline."""
|
|
137
|
+
|
|
138
|
+
remaining = Counter(baseline.fingerprints)
|
|
139
|
+
selected_root = root or Path.cwd()
|
|
140
|
+
active = []
|
|
141
|
+
matched = 0
|
|
142
|
+
for finding in result.findings:
|
|
143
|
+
fingerprint = finding_fingerprint(finding, selected_root)
|
|
144
|
+
if remaining[fingerprint] > 0:
|
|
145
|
+
remaining[fingerprint] -= 1
|
|
146
|
+
matched += 1
|
|
147
|
+
else:
|
|
148
|
+
active.append(finding)
|
|
149
|
+
return ScanResult(
|
|
150
|
+
findings=tuple(active),
|
|
151
|
+
errors=result.errors,
|
|
152
|
+
scanned_files=result.scanned_files,
|
|
153
|
+
skipped_files=result.skipped_files,
|
|
154
|
+
baseline_findings=result.baseline_findings + matched,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _relative_path(path: Path, root: Path) -> str:
|
|
159
|
+
try:
|
|
160
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
return path.as_posix()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _valid_fingerprint(value: Any) -> bool:
|
|
166
|
+
if not isinstance(value, str) or len(value) != 64:
|
|
167
|
+
return False
|
|
168
|
+
return all(character in "0123456789abcdef" for character in value)
|
llmsafe/catalog.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Stable metadata for LLMSafe's built-in rule identifiers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Tuple
|
|
5
|
+
|
|
6
|
+
from llmsafe.models import Severity
|
|
7
|
+
|
|
8
|
+
CATALOG_SCHEMA_VERSION = 1
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class RuleMetadata:
|
|
13
|
+
"""Public metadata describing one built-in rule."""
|
|
14
|
+
|
|
15
|
+
rule_id: str
|
|
16
|
+
title: str
|
|
17
|
+
severity: Severity
|
|
18
|
+
family: str
|
|
19
|
+
description: str
|
|
20
|
+
remediation: str
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
23
|
+
return {
|
|
24
|
+
"id": self.rule_id,
|
|
25
|
+
"title": self.title,
|
|
26
|
+
"severity": self.severity.value,
|
|
27
|
+
"family": self.family,
|
|
28
|
+
"description": self.description,
|
|
29
|
+
"remediation": self.remediation,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
RULE_CATALOG: Tuple[RuleMetadata, ...] = (
|
|
34
|
+
RuleMetadata(
|
|
35
|
+
"AGENT001",
|
|
36
|
+
"High-impact tool exposed to an agent",
|
|
37
|
+
Severity.HIGH,
|
|
38
|
+
"agent",
|
|
39
|
+
"Detects shell, terminal, execution, or Python REPL tools instantiated for agent use.",
|
|
40
|
+
"Remove the tool or wrap it with strict arguments, sandboxing, and approval.",
|
|
41
|
+
),
|
|
42
|
+
RuleMetadata(
|
|
43
|
+
"AGENT002",
|
|
44
|
+
"Dangerous agent capability explicitly enabled",
|
|
45
|
+
Severity.HIGH,
|
|
46
|
+
"agent",
|
|
47
|
+
"Detects agent or tool calls that explicitly enable dangerous code or requests.",
|
|
48
|
+
"Keep dangerous-code flags disabled and expose a narrow typed capability.",
|
|
49
|
+
),
|
|
50
|
+
RuleMetadata(
|
|
51
|
+
"AGENT003",
|
|
52
|
+
"Human approval gate disabled",
|
|
53
|
+
Severity.HIGH,
|
|
54
|
+
"agent",
|
|
55
|
+
"Detects agent, MCP, tool, or runner calls with a disabled human approval boundary.",
|
|
56
|
+
"Require approval for high-impact tools and enforce it outside model control.",
|
|
57
|
+
),
|
|
58
|
+
RuleMetadata(
|
|
59
|
+
"FLOW001",
|
|
60
|
+
"Untrusted data reaches code execution",
|
|
61
|
+
Severity.CRITICAL,
|
|
62
|
+
"dataflow",
|
|
63
|
+
"Traces user- or model-controlled data into eval() or exec().",
|
|
64
|
+
"Replace dynamic execution with a typed parser and an allow-listed operation.",
|
|
65
|
+
),
|
|
66
|
+
RuleMetadata(
|
|
67
|
+
"FLOW002",
|
|
68
|
+
"Untrusted data reaches process execution",
|
|
69
|
+
Severity.CRITICAL,
|
|
70
|
+
"dataflow",
|
|
71
|
+
"Traces user- or model-controlled data into operating-system process execution.",
|
|
72
|
+
"Map requests to fixed executables and validated arguments; do not execute generated text.",
|
|
73
|
+
),
|
|
74
|
+
RuleMetadata(
|
|
75
|
+
"FLOW003",
|
|
76
|
+
"Untrusted data reaches a SQL query",
|
|
77
|
+
Severity.HIGH,
|
|
78
|
+
"dataflow",
|
|
79
|
+
"Traces user- or model-controlled data into SQL query text.",
|
|
80
|
+
"Use a constant query with bound parameters and allow-list dynamic identifiers.",
|
|
81
|
+
),
|
|
82
|
+
RuleMetadata(
|
|
83
|
+
"FLOW004",
|
|
84
|
+
"Untrusted data controls an outbound URL",
|
|
85
|
+
Severity.HIGH,
|
|
86
|
+
"dataflow",
|
|
87
|
+
"Traces user- or model-controlled data into an outbound HTTP URL.",
|
|
88
|
+
"Allow-list schemes and hosts, resolve DNS safely, and block private network ranges.",
|
|
89
|
+
),
|
|
90
|
+
RuleMetadata(
|
|
91
|
+
"FLOW005",
|
|
92
|
+
"Untrusted data controls tool dispatch",
|
|
93
|
+
Severity.HIGH,
|
|
94
|
+
"dataflow",
|
|
95
|
+
"Traces user- or model-controlled data into dynamic callable or tool selection.",
|
|
96
|
+
"Resolve tool names through a fixed allow-list and enforce per-tool authorization.",
|
|
97
|
+
),
|
|
98
|
+
RuleMetadata(
|
|
99
|
+
"LLM001",
|
|
100
|
+
"Dynamic data in privileged prompt",
|
|
101
|
+
Severity.HIGH,
|
|
102
|
+
"prompt",
|
|
103
|
+
"Detects dynamic interpolation into system or developer instruction channels.",
|
|
104
|
+
"Keep privileged instructions static and carry untrusted content in a user message.",
|
|
105
|
+
),
|
|
106
|
+
RuleMetadata(
|
|
107
|
+
"MCP001",
|
|
108
|
+
"MCP server launched through a shell",
|
|
109
|
+
Severity.HIGH,
|
|
110
|
+
"mcp",
|
|
111
|
+
"Detects MCP server commands launched through a command shell.",
|
|
112
|
+
"Launch a fixed executable directly and pass each argument as a separate value.",
|
|
113
|
+
),
|
|
114
|
+
RuleMetadata(
|
|
115
|
+
"MCP002",
|
|
116
|
+
"Unencrypted remote MCP transport",
|
|
117
|
+
Severity.HIGH,
|
|
118
|
+
"mcp",
|
|
119
|
+
"Detects non-local MCP endpoints configured with unencrypted HTTP.",
|
|
120
|
+
"Use HTTPS and authenticate the remote MCP endpoint.",
|
|
121
|
+
),
|
|
122
|
+
RuleMetadata(
|
|
123
|
+
"MCP003",
|
|
124
|
+
"Unrestricted MCP tool access",
|
|
125
|
+
Severity.HIGH,
|
|
126
|
+
"mcp",
|
|
127
|
+
"Detects MCP configurations that grant access to every available tool.",
|
|
128
|
+
"Grant only the specific MCP tools required by the application.",
|
|
129
|
+
),
|
|
130
|
+
RuleMetadata(
|
|
131
|
+
"PY001",
|
|
132
|
+
"Dynamic code evaluation",
|
|
133
|
+
Severity.HIGH,
|
|
134
|
+
"python",
|
|
135
|
+
"Detects eval() calls even when static analysis cannot prove an untrusted source.",
|
|
136
|
+
"Parse the expected data format explicitly; never pass model or user output to eval().",
|
|
137
|
+
),
|
|
138
|
+
RuleMetadata(
|
|
139
|
+
"PY002",
|
|
140
|
+
"Dynamic code execution",
|
|
141
|
+
Severity.CRITICAL,
|
|
142
|
+
"python",
|
|
143
|
+
"Detects exec() calls even when static analysis cannot prove an untrusted source.",
|
|
144
|
+
"Replace dynamic execution with an allow-listed command or structured operation.",
|
|
145
|
+
),
|
|
146
|
+
RuleMetadata(
|
|
147
|
+
"PY003",
|
|
148
|
+
"Unsafe deserialization",
|
|
149
|
+
Severity.HIGH,
|
|
150
|
+
"python",
|
|
151
|
+
"Detects pickle deserialization that can execute code from crafted input.",
|
|
152
|
+
"Use JSON or another non-executable format and validate the decoded schema.",
|
|
153
|
+
),
|
|
154
|
+
RuleMetadata(
|
|
155
|
+
"PY004",
|
|
156
|
+
"Potentially unsafe YAML load",
|
|
157
|
+
Severity.MEDIUM,
|
|
158
|
+
"python",
|
|
159
|
+
"Detects yaml.load() calls that may instantiate unsafe Python objects.",
|
|
160
|
+
"Use yaml.safe_load() for data-only YAML.",
|
|
161
|
+
),
|
|
162
|
+
RuleMetadata(
|
|
163
|
+
"SECRET001",
|
|
164
|
+
"OpenAI API key",
|
|
165
|
+
Severity.CRITICAL,
|
|
166
|
+
"secret",
|
|
167
|
+
"Detects values matching an OpenAI API key format.",
|
|
168
|
+
"Revoke the credential, remove it from Git history, and load a replacement securely.",
|
|
169
|
+
),
|
|
170
|
+
RuleMetadata(
|
|
171
|
+
"SECRET002",
|
|
172
|
+
"AWS access key",
|
|
173
|
+
Severity.CRITICAL,
|
|
174
|
+
"secret",
|
|
175
|
+
"Detects values matching an AWS access key identifier.",
|
|
176
|
+
"Revoke the credential, remove it from Git history, and load a replacement securely.",
|
|
177
|
+
),
|
|
178
|
+
RuleMetadata(
|
|
179
|
+
"SECRET003",
|
|
180
|
+
"GitHub token",
|
|
181
|
+
Severity.CRITICAL,
|
|
182
|
+
"secret",
|
|
183
|
+
"Detects values matching a GitHub authentication token.",
|
|
184
|
+
"Revoke the credential, remove it from Git history, and load a replacement securely.",
|
|
185
|
+
),
|
|
186
|
+
RuleMetadata(
|
|
187
|
+
"SECRET004",
|
|
188
|
+
"Private key",
|
|
189
|
+
Severity.CRITICAL,
|
|
190
|
+
"secret",
|
|
191
|
+
"Detects private-key headers committed to a scanned file.",
|
|
192
|
+
"Revoke the key, remove it from Git history, and load a replacement securely.",
|
|
193
|
+
),
|
|
194
|
+
RuleMetadata(
|
|
195
|
+
"SECRET005",
|
|
196
|
+
"Hard-coded credential",
|
|
197
|
+
Severity.HIGH,
|
|
198
|
+
"secret",
|
|
199
|
+
"Detects literal values assigned to credential-like variables.",
|
|
200
|
+
"Revoke the credential, remove it from Git history, and load a replacement securely.",
|
|
201
|
+
),
|
|
202
|
+
RuleMetadata(
|
|
203
|
+
"SHELL001",
|
|
204
|
+
"Shell command execution",
|
|
205
|
+
Severity.HIGH,
|
|
206
|
+
"shell",
|
|
207
|
+
"Detects os.system() command execution.",
|
|
208
|
+
"Use subprocess.run() with an argument list, shell=False, and an allow-list.",
|
|
209
|
+
),
|
|
210
|
+
RuleMetadata(
|
|
211
|
+
"SHELL002",
|
|
212
|
+
"Subprocess launched through a shell",
|
|
213
|
+
Severity.HIGH,
|
|
214
|
+
"shell",
|
|
215
|
+
"Detects subprocess calls configured with shell=True.",
|
|
216
|
+
"Pass an argument list with shell=False and allow-list commands and arguments.",
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _index_rules(rules: Tuple[RuleMetadata, ...]) -> Dict[str, RuleMetadata]:
|
|
222
|
+
indexed = {rule.rule_id: rule for rule in rules}
|
|
223
|
+
if len(indexed) != len(rules):
|
|
224
|
+
raise RuntimeError("LLMSafe rule catalog contains duplicate identifiers")
|
|
225
|
+
return indexed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
RULES_BY_ID = _index_rules(RULE_CATALOG)
|