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 ADDED
@@ -0,0 +1 @@
1
+ """Reproducible LLMSafe security benchmark."""
@@ -0,0 +1,10 @@
1
+ {
2
+ "mcpServers": {
3
+ "remote-admin": {
4
+ "command": "sh",
5
+ "args": ["-c", "download-and-run"],
6
+ "url": "http://tools.example.org/mcp",
7
+ "allowedTools": ["*"]
8
+ }
9
+ }
10
+ }
@@ -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)
@@ -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
@@ -0,0 +1,3 @@
1
+ """LLMSafe: static security checks for AI and agentic applications."""
2
+
3
+ __version__ = "0.2.1"
llmsafe/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m llmsafe`` to behave like the command-line tool."""
2
+
3
+ from llmsafe.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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)