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/__init__.py +3 -0
- agent_latch/action_summary.py +211 -0
- agent_latch/agent_code.py +487 -0
- agent_latch/astutil.py +20 -0
- agent_latch/cli.py +500 -0
- agent_latch/findings.py +65 -0
- agent_latch/gates/__init__.py +17 -0
- agent_latch/gates/alr04.py +816 -0
- agent_latch/manifest.py +256 -0
- agent_latch/oversight.py +225 -0
- agent_latch/owasp.py +105 -0
- agent_latch/prompts.py +90 -0
- agent_latch/report.py +241 -0
- agent_latch/rules.py +495 -0
- agent_latch/runtime/__init__.py +34 -0
- agent_latch/runtime/cli.py +314 -0
- agent_latch/runtime/demo.py +78 -0
- agent_latch/runtime/demo_server.py +54 -0
- agent_latch/runtime/events.py +91 -0
- agent_latch/runtime/importer.py +543 -0
- agent_latch/runtime/middleware.py +201 -0
- agent_latch/runtime/normalizer.py +106 -0
- agent_latch/runtime/payloads.py +190 -0
- agent_latch/runtime/proxy.py +44 -0
- agent_latch/runtime/redact.py +100 -0
- agent_latch/runtime/results.py +274 -0
- agent_latch/runtime/sqlite_store.py +230 -0
- agent_latch/runtime/trace.py +199 -0
- agent_latch/suppress.py +169 -0
- agent_latch/taint.py +264 -0
- agent_latch/yamlload.py +43 -0
- agentlatch_security-0.4.0.dist-info/METADATA +391 -0
- agentlatch_security-0.4.0.dist-info/RECORD +37 -0
- agentlatch_security-0.4.0.dist-info/WHEEL +5 -0
- agentlatch_security-0.4.0.dist-info/entry_points.txt +3 -0
- agentlatch_security-0.4.0.dist-info/licenses/LICENSE +21 -0
- agentlatch_security-0.4.0.dist-info/top_level.txt +1 -0
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
|
+
]
|