agentlatch-security 0.4.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.
agent_latch/rules.py ADDED
@@ -0,0 +1,495 @@
1
+ """Small, explainable static checks. Findings are heuristics, not proof of exploitability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import fnmatch
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agent_latch.agent_code import scan_agent_code, scan_prompt_yaml
16
+ from agent_latch.astutil import walk
17
+ from agent_latch.findings import Finding, demote_test_findings, is_test_path
18
+ from agent_latch.prompts import check_prompt_text
19
+ from agent_latch.taint import find_untrusted_prompt_flows
20
+
21
+ __all__ = ["DependencyAuditError", "Finding", "audit_requirements", "scan_project"]
22
+
23
+ MANIFEST_NAMES = ("agent-manifest.yaml", "agent-manifest.yml")
24
+ # Standalone prompt templates. .md and .txt count only inside a prompts/ (or prompt/) folder.
25
+ PROMPT_SUFFIXES = {".prompt", ".prompty", ".j2", ".jinja", ".jinja2"}
26
+ _PROMPT_DIRS = {"prompt", "prompts"}
27
+
28
+
29
+ class DependencyAuditError(RuntimeError):
30
+ """Raised when the optional dependency audit cannot complete reliably."""
31
+
32
+
33
+ # Deliberately avoids returning matched secret text as evidence. The optional quote after the key
34
+ # matches JSON and JavaScript object literals.
35
+ _SECRET_ASSIGNMENT = re.compile(
36
+ r"(?i)\b(?P<key>api[_-]?key|secret(?:[_-]?key)?|password|access[_-]?token|auth[_-]?token)"
37
+ r"\b['\"]?\s*[:=]\s*(?P<quote>['\"])(?P<value>[^'\"\s]{8,})(?P=quote)"
38
+ )
39
+ # Values that are obviously not real credentials: placeholders, test values, and template
40
+ # references such as ${API_KEY}, {{ secret }}, <token>, or $TOKEN.
41
+ _PLACEHOLDER_PREFIXES = (
42
+ "your", "test", "fake", "dummy", "mock", "example", "sample", "placeholder", "changeme",
43
+ "not-", "not_", "my-", "my_", "replace", "insert", "xxx", "***", "<", "${", "{{", "{", "%(", "$",
44
+ )
45
+ # "\\u" catches JSON-escaped text, such as translated UI labels.
46
+ _PLACEHOLDER_PARTS = ("...", "xxxx", "****", "redacted", "placeholder", "\\u")
47
+ # UI translation folders map keys like "password" to words; letter-only values there are labels.
48
+ _I18N_DIRS = {"translations", "locales", "locale", "i18n", "l10n", "lang", "langs"}
49
+
50
+
51
+ def _relative(path: Path, root: Path) -> str:
52
+ return path.relative_to(root).as_posix()
53
+
54
+
55
+ def scan_python(path: Path, root: Path, source: str) -> list[Finding]:
56
+ findings: list[Finding] = []
57
+ try:
58
+ tree = ast.parse(source, filename=str(path))
59
+ except SyntaxError:
60
+ return findings
61
+
62
+ dataframe_agent_names = {"create_pandas_dataframe_agent"}
63
+ for node in walk(tree):
64
+ if isinstance(node, ast.ImportFrom):
65
+ for imported in node.names:
66
+ if imported.name == "create_pandas_dataframe_agent":
67
+ dataframe_agent_names.add(imported.asname or imported.name)
68
+
69
+ for node in walk(tree):
70
+ if not isinstance(node, ast.Call):
71
+ continue
72
+ called = node.func.id if isinstance(node.func, ast.Name) else None
73
+ qualified_call = (
74
+ f"{node.func.value.id}.{node.func.attr}"
75
+ if isinstance(node.func, ast.Attribute)
76
+ and isinstance(node.func.value, ast.Name)
77
+ else None
78
+ )
79
+ if called in {"eval", "exec"}:
80
+ findings.append(
81
+ Finding(
82
+ rule_id="AGENTLATCH-PY001",
83
+ title="Dynamic code execution",
84
+ severity="high",
85
+ message=f"{called}() executes dynamically supplied code; verify input provenance and necessity.",
86
+ path=_relative(path, root),
87
+ line=node.lineno,
88
+ column=node.col_offset + 1,
89
+ owasp=("ASI05", "ASI02"),
90
+ confidence="high",
91
+ evidence=f"{called}(...) call",
92
+ )
93
+ )
94
+
95
+ if (
96
+ called in dataframe_agent_names
97
+ or (
98
+ qualified_call is not None
99
+ and qualified_call.endswith(".create_pandas_dataframe_agent")
100
+ )
101
+ ):
102
+ findings.append(
103
+ Finding(
104
+ rule_id="AGENTLATCH-AG002",
105
+ title="Agent can execute model-generated Python",
106
+ severity="high",
107
+ message=(
108
+ "This LangChain dataframe-agent factory can execute generated Python. "
109
+ "Review allow_dangerous_code gating and isolate execution from secrets and sensitive files."
110
+ ),
111
+ path=_relative(path, root),
112
+ line=node.lineno,
113
+ column=node.col_offset + 1,
114
+ owasp=("ASI05", "ASI02"),
115
+ confidence="high",
116
+ evidence="create_pandas_dataframe_agent(...) call",
117
+ )
118
+ )
119
+
120
+ if (
121
+ qualified_call
122
+ and qualified_call.startswith("subprocess.")
123
+ and any(
124
+ keyword.arg == "shell"
125
+ and isinstance(keyword.value, ast.Constant)
126
+ and keyword.value.value is True
127
+ for keyword in node.keywords
128
+ )
129
+ ):
130
+ findings.append(
131
+ Finding(
132
+ rule_id="AGENTLATCH-PY002",
133
+ title="Subprocess uses shell=True",
134
+ severity="high",
135
+ message="Shell command construction can enable command injection, especially with untrusted or model-generated input.",
136
+ path=_relative(path, root),
137
+ line=node.lineno,
138
+ column=node.col_offset + 1,
139
+ owasp=("ASI05", "ASI02"),
140
+ confidence="high",
141
+ evidence="call with shell=True",
142
+ )
143
+ )
144
+
145
+ if any(
146
+ keyword.arg == "verify"
147
+ and isinstance(keyword.value, ast.Constant)
148
+ and keyword.value.value is False
149
+ for keyword in node.keywords
150
+ ):
151
+ findings.append(
152
+ Finding(
153
+ rule_id="AGENTLATCH-PY003",
154
+ title="TLS certificate verification disabled",
155
+ severity="high",
156
+ message="Disabling TLS verification weakens protection against interception of tool or API traffic.",
157
+ path=_relative(path, root),
158
+ line=node.lineno,
159
+ column=node.col_offset + 1,
160
+ owasp=("ASI02",),
161
+ confidence="high",
162
+ evidence="call with verify=False",
163
+ )
164
+ )
165
+
166
+ if called == "Agent" and any(
167
+ keyword.arg == "allow_delegation"
168
+ and isinstance(keyword.value, ast.Constant)
169
+ and keyword.value.value is True
170
+ for keyword in node.keywords
171
+ ):
172
+ findings.append(
173
+ Finding(
174
+ rule_id="AGENTLATCH-AG001",
175
+ title="CrewAI delegation enabled",
176
+ severity="low",
177
+ message="Delegation expands agent actions; review delegated tools, permissions, and approval boundaries.",
178
+ path=_relative(path, root),
179
+ line=node.lineno,
180
+ column=node.col_offset + 1,
181
+ owasp=("ASI02", "ASI03"),
182
+ confidence="low",
183
+ evidence="Agent(..., allow_delegation=True)",
184
+ )
185
+ )
186
+
187
+ for flow in find_untrusted_prompt_flows(tree):
188
+ findings.append(
189
+ Finding(
190
+ rule_id="AGENTLATCH-AG003",
191
+ title="Untrusted web/tool output inserted into prompt",
192
+ severity="medium",
193
+ message=(
194
+ "Content fetched from the web or a search/loader tool reaches an LLM message unfenced, "
195
+ "so instructions hidden in that content can hijack the agent (indirect prompt injection). "
196
+ "Wrap it in delimiters such as <search_results> tags, tell the model to treat it as data, "
197
+ "and limit what tools the agent can call afterwards."
198
+ ),
199
+ path=_relative(path, root),
200
+ line=flow.line,
201
+ column=flow.column,
202
+ owasp=("ASI01",),
203
+ confidence="low",
204
+ evidence=f"web/tool output reaches {flow.sink}",
205
+ )
206
+ )
207
+
208
+ findings.extend(scan_agent_code(tree, _relative(path, root)))
209
+ return demote_test_findings(findings, _relative(path, root))
210
+
211
+
212
+ def is_prompt_file(relative_parts: tuple[str, ...]) -> bool:
213
+ suffix = Path(relative_parts[-1]).suffix.lower()
214
+ if suffix in PROMPT_SUFFIXES:
215
+ return True
216
+ return suffix in {".md", ".txt"} and any(part.lower() in _PROMPT_DIRS for part in relative_parts[:-1])
217
+
218
+
219
+ def scan_prompt_file(path: Path, root: Path, source: str) -> list[Finding]:
220
+ """PRM001 on a prompt template; PRM002 too when the file name says it is a system prompt."""
221
+ is_system = "system" in path.name.lower()
222
+ label = "System prompt file" if is_system else "Prompt file"
223
+ return check_prompt_text(source, _relative(path, root), 1, label, is_system)
224
+
225
+
226
+ def _normalize_key(text: str) -> str:
227
+ return re.sub(r"[^a-z]", "", text.lower())
228
+
229
+
230
+ def is_placeholder_secret(value: str, key: str = "", relative_path: str = "") -> bool:
231
+ lowered = value.lower()
232
+ if (
233
+ lowered.startswith(_PLACEHOLDER_PREFIXES)
234
+ or any(part in lowered for part in _PLACEHOLDER_PARTS)
235
+ or len(set(lowered)) <= 2
236
+ or (key and _normalize_key(value) == _normalize_key(key))
237
+ ):
238
+ return True
239
+ in_i18n = any(part.lower() in _I18N_DIRS for part in relative_path.split("/")[:-1])
240
+ return in_i18n and value.isalpha()
241
+
242
+
243
+ def scan_secrets(path: Path, root: Path, source: str) -> list[Finding]:
244
+ findings: list[Finding] = []
245
+ relative_path = _relative(path, root)
246
+ in_tests = is_test_path(relative_path)
247
+ for line_number, line in enumerate(source.splitlines(), start=1):
248
+ match = _SECRET_ASSIGNMENT.search(line)
249
+ if not match or is_placeholder_secret(match["value"], match["key"], relative_path):
250
+ continue
251
+ message = "A credential-like variable appears to contain a literal value. Use a secret manager or environment variable."
252
+ if in_tests:
253
+ message += " This is a test file, so it is reported at low severity; confirm the value is fake."
254
+ findings.append(
255
+ Finding(
256
+ rule_id="AGENTLATCH-SEC001",
257
+ title="Possible hardcoded credential",
258
+ severity="low" if in_tests else "high",
259
+ message=message,
260
+ path=relative_path,
261
+ line=line_number,
262
+ column=match.start() + 1,
263
+ owasp=("ASI03", "ASI04"),
264
+ confidence="low" if in_tests else "medium",
265
+ evidence="credential-like assignment; value redacted",
266
+ )
267
+ )
268
+ return findings
269
+
270
+
271
+ # Installed environments hold third-party packages, not project code. A folder is skipped only when
272
+ # it really is one, so a project cannot hide code from the scan by naming a folder "venv".
273
+ _NODE_MODULES_MARKERS = (".package-lock.json", ".yarn-integrity", ".yarn-state.yml", ".modules.yaml")
274
+
275
+
276
+ def is_installed_environment(directory: Path) -> bool:
277
+ name = directory.name
278
+ try:
279
+ if (directory / "pyvenv.cfg").is_file():
280
+ return True
281
+ if name == ".tox":
282
+ return any((child / "pyvenv.cfg").is_file() for child in directory.iterdir())
283
+ if name == "node_modules":
284
+ return any((directory / marker).exists() for marker in _NODE_MODULES_MARKERS)
285
+ except OSError:
286
+ return False
287
+ return False
288
+
289
+
290
+ def _is_text(sample: bytes) -> bool:
291
+ return b"\0" not in sample
292
+
293
+
294
+ def iter_project_files(root: Path) -> list[Path]:
295
+ """Every regular file under root, minus .git, installed environments, and symlinks.
296
+
297
+ Symlinks are not followed because they can point outside the project (for example to ~/.ssh).
298
+ """
299
+ if root.is_file():
300
+ return [root]
301
+ files: list[Path] = []
302
+ for directory, subdirectories, names in os.walk(root, followlinks=False):
303
+ current = Path(directory)
304
+ subdirectories[:] = sorted(
305
+ name
306
+ for name in subdirectories
307
+ if name != ".git" and not is_installed_environment(current / name)
308
+ )
309
+ files.extend(current / name for name in sorted(names))
310
+ return files
311
+
312
+
313
+ def scan_project(target: Path, max_file_bytes: int = 1_000_000) -> list[Finding]:
314
+ """Scan every text file under target: source and prompt rules where they apply, secrets everywhere."""
315
+ root = target.resolve()
316
+ base = root if root.is_dir() else root.parent
317
+ findings: list[Finding] = []
318
+
319
+ for path in iter_project_files(root):
320
+ try:
321
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > max_file_bytes:
322
+ continue
323
+ raw = path.read_bytes()
324
+ except OSError:
325
+ continue
326
+ if not _is_text(raw[:8192]):
327
+ continue
328
+ source = raw.decode("utf-8", errors="replace")
329
+ relative_parts = path.relative_to(base).parts
330
+
331
+ suffix = path.suffix.lower()
332
+ if suffix == ".py":
333
+ findings.extend(scan_python(path, base, source))
334
+ elif suffix in {".yaml", ".yml"} and path.name not in MANIFEST_NAMES:
335
+ # Manifests get their own prompt checks in manifest.py; any other YAML may hold prompts.
336
+ findings.extend(scan_prompt_yaml(_relative(path, base), source))
337
+ elif is_prompt_file(relative_parts):
338
+ findings.extend(scan_prompt_file(path, base, source))
339
+ findings.extend(scan_secrets(path, base, source))
340
+
341
+ return sorted(findings, key=lambda finding: (finding.path, finding.line, finding.rule_id))
342
+
343
+
344
+ def _requirement_line(path: Path, package_name: str) -> int:
345
+ normalized = re.sub(r"[-_.]+", "-", package_name).lower()
346
+ try:
347
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
348
+ candidate = line.strip()
349
+ if not candidate or candidate.startswith(("#", "-")):
350
+ continue
351
+ match = re.match(r"([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:[<>=!~;\[]|$)", candidate)
352
+ if match and re.sub(r"[-_.]+", "-", match.group(1)).lower() == normalized:
353
+ return line_number
354
+ except OSError:
355
+ pass
356
+ return 1
357
+
358
+
359
+ def audit_requirements(target: Path) -> tuple[list[Finding], int]:
360
+ """Audit exact-pinned requirements via pip-audit without installing packages.
361
+
362
+ This contacts the configured vulnerability service and discloses package names
363
+ and versions, but not project source. Dependency resolution and pip are disabled.
364
+ """
365
+ root = target.resolve()
366
+ if root.is_file():
367
+ manifests = [root] if root.name.startswith("requirements") and root.suffix == ".txt" else []
368
+ base = root.parent
369
+ else:
370
+ base = root
371
+ manifests = [
372
+ path
373
+ for path in iter_project_files(root)
374
+ if fnmatch.fnmatch(path.name, "requirements*.txt") and path.is_file() and not path.is_symlink()
375
+ ]
376
+
377
+ if not manifests:
378
+ return [], 0
379
+
380
+ findings: list[Finding] = []
381
+ seen_advisories: set[tuple[str, str, str, str]] = set()
382
+ for manifest in manifests:
383
+ command = [
384
+ sys.executable,
385
+ "-m",
386
+ "pip_audit",
387
+ "--requirement",
388
+ str(manifest),
389
+ "--no-deps",
390
+ "--disable-pip",
391
+ "--format",
392
+ "json",
393
+ "--progress-spinner",
394
+ "off",
395
+ ]
396
+ try:
397
+ completed = subprocess.run(
398
+ command,
399
+ cwd=base,
400
+ capture_output=True,
401
+ text=True,
402
+ timeout=180,
403
+ check=False,
404
+ )
405
+ except (OSError, subprocess.TimeoutExpired) as exc:
406
+ raise DependencyAuditError(
407
+ "Could not run pip-audit; install AgentLatch with its audit extra and retry"
408
+ ) from exc
409
+
410
+ try:
411
+ payload: Any = json.loads(completed.stdout)
412
+ except json.JSONDecodeError as exc:
413
+ raise DependencyAuditError(
414
+ "pip-audit did not return valid JSON; check its availability, requirements format, and network access"
415
+ ) from exc
416
+
417
+ dependencies = dependency_entries(payload)
418
+ if dependencies is None:
419
+ raise DependencyAuditError("pip-audit returned an unexpected JSON format")
420
+
421
+ for dependency in dependencies:
422
+ if not isinstance(dependency, dict):
423
+ continue
424
+ package = str(dependency.get("name", "unknown"))
425
+ version = str(dependency.get("version", "unknown"))
426
+ vulnerabilities = dependency.get("vulns") or []
427
+ if not isinstance(vulnerabilities, list):
428
+ continue
429
+ for vulnerability in vulnerabilities:
430
+ if not isinstance(vulnerability, dict):
431
+ continue
432
+ advisory = str(vulnerability.get("id", "unknown advisory"))
433
+ dedupe_key = (
434
+ manifest.relative_to(base).as_posix(),
435
+ re.sub(r"[-_.]+", "-", package).lower(),
436
+ version,
437
+ advisory.upper(),
438
+ )
439
+ if dedupe_key in seen_advisories:
440
+ continue
441
+ seen_advisories.add(dedupe_key)
442
+ aliases = vulnerability.get("aliases") or []
443
+ fixes = vulnerability.get("fix_versions") or []
444
+ alias_text = ", ".join(sorted({str(alias) for alias in aliases})[:5])
445
+ fix_text = ", ".join(sorted({str(fix) for fix in fixes})[:5]) or "no fix version listed"
446
+ message = (
447
+ f"{package} {version} has known advisory {advisory}; "
448
+ f"suggested fix: {fix_text}. Advisory severity was not provided by pip-audit."
449
+ )
450
+ if alias_text:
451
+ message += f" Aliases: {alias_text}."
452
+ findings.append(
453
+ Finding(
454
+ rule_id="AGENTLATCH-DEP001",
455
+ title="Known vulnerable Python dependency",
456
+ severity="unknown",
457
+ message=message,
458
+ path=manifest.relative_to(base).as_posix(),
459
+ line=_requirement_line(manifest, package),
460
+ column=1,
461
+ owasp=("ASI04",),
462
+ confidence="high",
463
+ evidence=f"{package}=={version} ({advisory})",
464
+ )
465
+ )
466
+
467
+ if completed.returncode not in {0, 1} or (
468
+ completed.returncode == 1 and not vulnerabilities_in_payload(dependencies)
469
+ ):
470
+ raise DependencyAuditError(
471
+ "pip-audit could not complete; verify exact-pinned requirements and network access (raw output is suppressed to avoid exposing credentials)"
472
+ )
473
+
474
+ return (
475
+ sorted(findings, key=lambda finding: (finding.path, finding.line, finding.message)),
476
+ len(manifests),
477
+ )
478
+
479
+
480
+ def dependency_entries(payload: Any) -> list[Any] | None:
481
+ """Accept pip-audit's current object schema and older list-shaped output."""
482
+ if isinstance(payload, list):
483
+ return payload
484
+ if isinstance(payload, dict) and isinstance(payload.get("dependencies"), list):
485
+ return payload["dependencies"]
486
+ return None
487
+
488
+
489
+ def vulnerabilities_in_payload(dependencies: list[Any]) -> bool:
490
+ return any(
491
+ isinstance(dependency, dict)
492
+ and isinstance(dependency.get("vulns"), list)
493
+ and bool(dependency["vulns"])
494
+ for dependency in dependencies
495
+ )
@@ -0,0 +1,34 @@
1
+ """AgentLatch runtime checks for live MCP tool traffic (preview).
2
+
3
+ Today this package records MCP traffic routed through ``agent-latch-runtime proxy`` and never
4
+ changes or blocks it. ``import-logs`` runs the ALR04 gate in ``agent_latch.gates`` and records
5
+ findings. The runtime report lists findings only. The operator policy and enforcement are planned.
6
+
7
+ Importing this package must not import FastMCP: only ``middleware``, ``proxy`` and ``cli`` need
8
+ the optional ``[runtime]`` extra, so the pure modules stay testable without it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from agent_latch.runtime.events import (
14
+ PAYLOAD_SCHEMA,
15
+ PayloadEvent,
16
+ ToolCall,
17
+ ToolInfo,
18
+ ToolOutput,
19
+ )
20
+ from agent_latch.runtime.payloads import PayloadFile, PayloadSink, PayloadTap
21
+ from agent_latch.runtime.trace import TRACE_SCHEMA, TraceLog
22
+
23
+ __all__ = [
24
+ "PAYLOAD_SCHEMA",
25
+ "TRACE_SCHEMA",
26
+ "PayloadEvent",
27
+ "PayloadFile",
28
+ "PayloadSink",
29
+ "PayloadTap",
30
+ "ToolCall",
31
+ "ToolInfo",
32
+ "ToolOutput",
33
+ "TraceLog",
34
+ ]