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/cli.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Command-line entry point for AgentLatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Confirm, Prompt
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich_argparse import RawDescriptionRichHelpFormatter
|
|
15
|
+
|
|
16
|
+
from agent_latch import __version__
|
|
17
|
+
from agent_latch.findings import dedupe
|
|
18
|
+
from agent_latch.manifest import ManifestError, find_manifest, scan_manifest
|
|
19
|
+
from agent_latch.owasp import OWASP_AGENTIC_TITLE, category_label, coverage, coverage_status
|
|
20
|
+
from agent_latch.report import json_report, sarif_report, severity_sort_key, text_report
|
|
21
|
+
from agent_latch.rules import (
|
|
22
|
+
DependencyAuditError,
|
|
23
|
+
Finding,
|
|
24
|
+
audit_requirements,
|
|
25
|
+
scan_project,
|
|
26
|
+
)
|
|
27
|
+
from agent_latch.runtime.results import report_runtime_results
|
|
28
|
+
from agent_latch.suppress import IGNORE_FILE, ConfigError, filter_findings, load_ignore_rules
|
|
29
|
+
|
|
30
|
+
_EPILOG = """\
|
|
31
|
+
[bold bright_cyan]EXAMPLES:[/bold bright_cyan]
|
|
32
|
+
[cyan]agent-latch scan[/cyan] scan the current directory
|
|
33
|
+
[cyan]agent-latch scan --config agent-manifest.yaml[/cyan] audit tools and prompts in a manifest
|
|
34
|
+
[cyan]agent-latch scan ./my-agent --fail-on high[/cyan] fail CI on high-severity findings
|
|
35
|
+
[cyan]agent-latch scan . --project-ignores[/cyan] your own repo: apply its .agent-latch-ignore
|
|
36
|
+
[cyan]agent-latch scan . --min-severity medium[/cyan] hide low and info findings
|
|
37
|
+
[cyan]agent-latch scan --format sarif --output results.sarif[/cyan]
|
|
38
|
+
[cyan]agent-latch --interactive[/cyan] guided scan
|
|
39
|
+
[cyan]agent-latch --runtime-result[/cyan] print the runtime gate report
|
|
40
|
+
|
|
41
|
+
[dim]Scans run locally. Findings are heuristics, not a security certification.[/dim]
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
46
|
+
|
|
47
|
+
SEVERITY_STYLES = {
|
|
48
|
+
"critical": "bold white on red",
|
|
49
|
+
"high": "bold red",
|
|
50
|
+
"medium": "yellow",
|
|
51
|
+
"low": "blue",
|
|
52
|
+
"info": "dim",
|
|
53
|
+
"unknown": "dim",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
58
|
+
RawDescriptionRichHelpFormatter.styles.update(
|
|
59
|
+
{"argparse.groups": "bold bright_cyan", "argparse.prog": "bold bright_cyan"}
|
|
60
|
+
)
|
|
61
|
+
RawDescriptionRichHelpFormatter.group_name_formatter = str.upper
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="agent-latch",
|
|
64
|
+
usage="agent-latch [scan] [path] [options]",
|
|
65
|
+
description=(
|
|
66
|
+
f"[bold bright_cyan]AgentLatch[/bold bright_cyan] [dim]v{__version__}[/dim]\n"
|
|
67
|
+
"Local-first security checks for AI-agent projects."
|
|
68
|
+
),
|
|
69
|
+
epilog=_EPILOG,
|
|
70
|
+
formatter_class=RawDescriptionRichHelpFormatter,
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("path", nargs="?", default=".", help="Project directory or file (default: current directory)")
|
|
73
|
+
parser.add_argument("-V", "--version", action="version", version=f"agent-latch {__version__}")
|
|
74
|
+
|
|
75
|
+
checks = parser.add_argument_group("Checks")
|
|
76
|
+
checks.add_argument(
|
|
77
|
+
"--config",
|
|
78
|
+
metavar="MANIFEST",
|
|
79
|
+
help="Agent manifest declaring tools and prompts (default: agent-manifest.yaml in the target, if present)",
|
|
80
|
+
)
|
|
81
|
+
checks.add_argument(
|
|
82
|
+
"--exclude",
|
|
83
|
+
metavar="PATH",
|
|
84
|
+
action="append",
|
|
85
|
+
default=[],
|
|
86
|
+
help="Skip findings under this path or glob, relative to the target (repeatable)",
|
|
87
|
+
)
|
|
88
|
+
checks.add_argument(
|
|
89
|
+
"--ignore-file",
|
|
90
|
+
metavar="FILE",
|
|
91
|
+
help="Ignore file of accepted findings (default: .agent-latch-ignore in the target, if present)",
|
|
92
|
+
)
|
|
93
|
+
checks.add_argument(
|
|
94
|
+
"--project-ignores",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help=(
|
|
97
|
+
"Apply suppressions from the scanned project itself (.agent-latch-ignore, pyproject excludes, "
|
|
98
|
+
"inline ignore comments). Off by default, because the project's author controls them; "
|
|
99
|
+
"use only for code you trust. --exclude and --ignore-file always apply"
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
checks.add_argument(
|
|
103
|
+
"--dependencies",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Audit requirements*.txt with pip-audit (contacts advisory service; no package install)",
|
|
106
|
+
)
|
|
107
|
+
checks.add_argument(
|
|
108
|
+
"-i",
|
|
109
|
+
"--interactive",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="Launch the guided terminal scanner",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
output = parser.add_argument_group("Output")
|
|
115
|
+
output.add_argument(
|
|
116
|
+
"--runtime-result",
|
|
117
|
+
nargs="?",
|
|
118
|
+
const="runtime-logs/gate-results.csv",
|
|
119
|
+
metavar="CSV",
|
|
120
|
+
help=(
|
|
121
|
+
"Print the runtime gate report from this CSV instead of scanning"
|
|
122
|
+
" (default: runtime-logs/gate-results.csv). Every gate uses the same"
|
|
123
|
+
" columns. The path argument is the project searched for a local tool's file and line"
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
output.add_argument("--format", choices=("text", "json", "sarif"), default="text", help="Report format (default: text)")
|
|
127
|
+
output.add_argument("--output", metavar="FILE", help="Write report to a file instead of stdout")
|
|
128
|
+
output.add_argument(
|
|
129
|
+
"--plain",
|
|
130
|
+
action="store_true",
|
|
131
|
+
help="Plain-text report even in a terminal (the table is used only when stdout is a terminal)",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
output.add_argument(
|
|
135
|
+
"--min-severity",
|
|
136
|
+
choices=("info", "low", "medium", "high", "critical"),
|
|
137
|
+
default="info",
|
|
138
|
+
help=(
|
|
139
|
+
"Report only findings at or above this severity (default: info, i.e. all). Hidden findings are "
|
|
140
|
+
"counted in the report; unknown-severity findings are always shown"
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
ci = parser.add_argument_group("CI")
|
|
145
|
+
ci.add_argument(
|
|
146
|
+
"--fail-on",
|
|
147
|
+
choices=("none", "low", "medium", "high", "critical"),
|
|
148
|
+
default="none",
|
|
149
|
+
help="Exit 1 for this severity or higher; unknown-severity findings also fail closed",
|
|
150
|
+
)
|
|
151
|
+
return parser
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_coverage(console: Console, findings: list[Finding]) -> None:
|
|
155
|
+
table = Table(
|
|
156
|
+
title=f"{OWASP_AGENTIC_TITLE} · informational mapping",
|
|
157
|
+
title_style="bold",
|
|
158
|
+
show_edge=False,
|
|
159
|
+
)
|
|
160
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
161
|
+
table.add_column("Category")
|
|
162
|
+
table.add_column("Status")
|
|
163
|
+
for entry in coverage(findings):
|
|
164
|
+
if not entry["has_checks"]:
|
|
165
|
+
style = "dim"
|
|
166
|
+
elif entry["finding_count"]:
|
|
167
|
+
style = "bold red"
|
|
168
|
+
else:
|
|
169
|
+
style = "green"
|
|
170
|
+
table.add_row(entry["id"], entry["name"], Text(coverage_status(entry), style=style))
|
|
171
|
+
console.print(table)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _render_findings(console: Console, findings: list[Finding]) -> None:
|
|
175
|
+
if not findings:
|
|
176
|
+
console.print(
|
|
177
|
+
Panel(
|
|
178
|
+
Text("No findings from the enabled checks. This does not mean the project is secure."),
|
|
179
|
+
title="[green]Scan complete[/green]",
|
|
180
|
+
border_style="green",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
_render_coverage(console, findings)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
table = Table(title=f"Security findings · {len(findings)}", show_lines=True)
|
|
187
|
+
table.add_column("Severity", no_wrap=True)
|
|
188
|
+
table.add_column("Rule", style="cyan", no_wrap=True)
|
|
189
|
+
table.add_column("Location", style="dim")
|
|
190
|
+
table.add_column("OWASP Agentic")
|
|
191
|
+
table.add_column("Finding")
|
|
192
|
+
|
|
193
|
+
for finding in sorted(findings, key=severity_sort_key):
|
|
194
|
+
style = SEVERITY_STYLES.get(finding.severity, "white")
|
|
195
|
+
detail = Text(finding.title, style="bold")
|
|
196
|
+
detail.append("\n" + finding.message)
|
|
197
|
+
detail.append("\nEvidence: " + finding.evidence, style="dim")
|
|
198
|
+
table.add_row(
|
|
199
|
+
Text(finding.severity.upper(), style=style),
|
|
200
|
+
Text(finding.rule_id),
|
|
201
|
+
Text(f"{finding.path}:{finding.line}:{finding.column}"),
|
|
202
|
+
Text("\n".join(category_label(cid) for cid in finding.owasp) or "—"),
|
|
203
|
+
detail,
|
|
204
|
+
)
|
|
205
|
+
console.print(table)
|
|
206
|
+
_render_coverage(console, findings)
|
|
207
|
+
console.print("[dim]Findings need human review; they are not a security certification.[/dim]")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _print_fail_summary(blocking: list[Finding], fail_on: str) -> None:
|
|
211
|
+
# stderr keeps machine-readable stdout (json/sarif) intact; color only on a terminal.
|
|
212
|
+
console = Console(stderr=True, highlight=False, soft_wrap=True)
|
|
213
|
+
rule_width = max(len(finding.rule_id) for finding in blocking)
|
|
214
|
+
locations = [f"{finding.path}:{finding.line}" for finding in blocking]
|
|
215
|
+
location_width = max(len(location) for location in locations)
|
|
216
|
+
console.print(
|
|
217
|
+
f"\n[bold red]✗ Failing (exit 1):[/] {len(blocking)} finding(s) at or above "
|
|
218
|
+
f"[bold]--fail-on {fail_on}[/]"
|
|
219
|
+
)
|
|
220
|
+
for finding, location in zip(blocking, locations):
|
|
221
|
+
line = Text(" ")
|
|
222
|
+
line.append(f"{finding.severity.upper():<8}", style=SEVERITY_STYLES.get(finding.severity, "white"))
|
|
223
|
+
line.append(f" {finding.rule_id:<{rule_width}} ")
|
|
224
|
+
line.append(f"{location:<{location_width}}", style="cyan")
|
|
225
|
+
line.append(f" {finding.title}")
|
|
226
|
+
console.print(line)
|
|
227
|
+
console.print(
|
|
228
|
+
"[dim]Fix these, list accepted ones in .agent-latch-ignore (applied with --project-ignores), "
|
|
229
|
+
"or raise --fail-on.[/]"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def filter_min_severity(findings: list[Finding], min_severity: str) -> tuple[list[Finding], int]:
|
|
234
|
+
"""Drop findings below min_severity. Unknown severity is kept, so it can still fail closed."""
|
|
235
|
+
threshold = _SEVERITY_RANK[min_severity]
|
|
236
|
+
kept = [
|
|
237
|
+
finding
|
|
238
|
+
for finding in findings
|
|
239
|
+
if finding.severity not in _SEVERITY_RANK or _SEVERITY_RANK[finding.severity] <= threshold
|
|
240
|
+
]
|
|
241
|
+
return kept, len(findings) - len(kept)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render_scan(
|
|
245
|
+
console: Console,
|
|
246
|
+
findings: list[Finding],
|
|
247
|
+
target: Path,
|
|
248
|
+
manifest: Path | None,
|
|
249
|
+
dependency_manifests: int | None,
|
|
250
|
+
suppressed: int = 0,
|
|
251
|
+
min_severity: str = "info",
|
|
252
|
+
hidden: int = 0,
|
|
253
|
+
) -> None:
|
|
254
|
+
target_message = Text("Target: ", style="bold")
|
|
255
|
+
target_message.append(str(target))
|
|
256
|
+
console.print(target_message)
|
|
257
|
+
if manifest is not None:
|
|
258
|
+
manifest_message = Text("Manifest: ", style="bold")
|
|
259
|
+
manifest_message.append(str(manifest))
|
|
260
|
+
console.print(manifest_message)
|
|
261
|
+
if dependency_manifests is not None:
|
|
262
|
+
if dependency_manifests:
|
|
263
|
+
console.print(
|
|
264
|
+
f"[dim]Dependency audit checked {dependency_manifests} requirements manifest(s).[/dim]"
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
console.print(
|
|
268
|
+
"[yellow]No supported requirements*.txt files found; dependency audit had no coverage.[/yellow]"
|
|
269
|
+
)
|
|
270
|
+
if suppressed:
|
|
271
|
+
console.print(f"[dim]{suppressed} finding(s) suppressed by ignore rules or inline comments.[/dim]")
|
|
272
|
+
if hidden:
|
|
273
|
+
console.print(f"[dim]{hidden} finding(s) below --min-severity {min_severity} not shown.[/dim]")
|
|
274
|
+
_render_findings(console, findings)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _write_interactive_report(
|
|
278
|
+
console: Console,
|
|
279
|
+
findings: list[Finding],
|
|
280
|
+
target: Path,
|
|
281
|
+
dependency_manifests: int | None,
|
|
282
|
+
) -> None:
|
|
283
|
+
report_format = Prompt.ask(
|
|
284
|
+
"Export a machine-readable report?",
|
|
285
|
+
choices=["skip", "json", "sarif"],
|
|
286
|
+
default="skip",
|
|
287
|
+
)
|
|
288
|
+
if report_format == "skip":
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
default_name = f"agent-latch-report.{report_format}"
|
|
292
|
+
output_text = Prompt.ask("Report filename", default=default_name)
|
|
293
|
+
output_path = Path(output_text).expanduser()
|
|
294
|
+
if output_path.exists() and not Confirm.ask(
|
|
295
|
+
Text(f"{output_path} already exists. Overwrite it?", style="yellow"), default=False
|
|
296
|
+
):
|
|
297
|
+
console.print("[yellow]Report export skipped.[/yellow]")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
report = (
|
|
301
|
+
json_report(findings, str(target), dependency_manifests)
|
|
302
|
+
if report_format == "json"
|
|
303
|
+
else sarif_report(findings, str(target), dependency_manifests)
|
|
304
|
+
)
|
|
305
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
output_path.write_text(report + "\n", encoding="utf-8")
|
|
307
|
+
saved_message = Text("Report saved: ", style="green")
|
|
308
|
+
saved_message.append(str(output_path.resolve()))
|
|
309
|
+
console.print(saved_message)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def interactive_main() -> int:
|
|
313
|
+
console = Console()
|
|
314
|
+
console.print(
|
|
315
|
+
Panel.fit(
|
|
316
|
+
"[bold bright_cyan]AgentLatch[/bold bright_cyan]"
|
|
317
|
+
f" [dim]v{__version__}[/dim]\n"
|
|
318
|
+
"[white]Local-first security checks for AI-agent projects[/white]",
|
|
319
|
+
border_style="bright_cyan",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
console.print("[dim]Selected static checks only—not a complete security assessment.[/dim]\n")
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
target = Path(Prompt.ask("Project folder or file", default=".")).expanduser().resolve()
|
|
326
|
+
if not target.exists():
|
|
327
|
+
console.print(Text(f"Path not found: {target}", style="bold red"))
|
|
328
|
+
return 2
|
|
329
|
+
|
|
330
|
+
project_ignores = Confirm.ask(
|
|
331
|
+
"Apply this project's own ignore file and inline ignore comments? Choose yes only for code you trust.",
|
|
332
|
+
default=False,
|
|
333
|
+
)
|
|
334
|
+
include_dependencies = Confirm.ask(
|
|
335
|
+
"Run optional Python dependency audit? It contacts the advisory service with package names and versions.",
|
|
336
|
+
default=False,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
manifest = find_manifest(target)
|
|
340
|
+
with console.status("[bold cyan]Scanning source and configuration files…"):
|
|
341
|
+
findings = scan_project(target)
|
|
342
|
+
if manifest is not None:
|
|
343
|
+
findings = dedupe([*scan_manifest(manifest, target), *findings])
|
|
344
|
+
|
|
345
|
+
dependency_manifests: int | None = None
|
|
346
|
+
if include_dependencies:
|
|
347
|
+
with console.status("[bold cyan]Checking dependency advisories…"):
|
|
348
|
+
dependency_findings, dependency_manifests = audit_requirements(target)
|
|
349
|
+
findings.extend(dependency_findings)
|
|
350
|
+
|
|
351
|
+
base = target if target.is_dir() else target.parent
|
|
352
|
+
findings, suppressed = filter_findings(
|
|
353
|
+
findings,
|
|
354
|
+
base,
|
|
355
|
+
load_ignore_rules(target, project_config=project_ignores),
|
|
356
|
+
inline=project_ignores,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
console.print()
|
|
360
|
+
_render_scan(console, findings, target, manifest, dependency_manifests, suppressed)
|
|
361
|
+
_write_interactive_report(console, findings, target, dependency_manifests)
|
|
362
|
+
return 0
|
|
363
|
+
except (ManifestError, ConfigError) as exc:
|
|
364
|
+
error_message = Text("Configuration error: ", style="bold red")
|
|
365
|
+
error_message.append(str(exc))
|
|
366
|
+
console.print(error_message)
|
|
367
|
+
return 2
|
|
368
|
+
except DependencyAuditError as exc:
|
|
369
|
+
error_message = Text("Dependency audit failed: ", style="bold red")
|
|
370
|
+
error_message.append(str(exc))
|
|
371
|
+
console.print(error_message)
|
|
372
|
+
return 2
|
|
373
|
+
except (KeyboardInterrupt, EOFError):
|
|
374
|
+
console.print("\n[yellow]Scan cancelled.[/yellow]")
|
|
375
|
+
return 130
|
|
376
|
+
except OSError as exc:
|
|
377
|
+
error_message = Text("Could not complete the scan: ", style="bold red")
|
|
378
|
+
error_message.append(str(exc))
|
|
379
|
+
console.print(error_message)
|
|
380
|
+
return 2
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main(argv: list[str] | None = None) -> int:
|
|
384
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
385
|
+
# "scan" is an optional verb; `agent-latch <path>` keeps working. Use ./scan for a folder named scan.
|
|
386
|
+
if argv[:1] == ["scan"]:
|
|
387
|
+
argv = argv[1:]
|
|
388
|
+
args = build_parser().parse_args(argv)
|
|
389
|
+
if args.runtime_result is not None:
|
|
390
|
+
return report_runtime_results(
|
|
391
|
+
Path(args.runtime_result).expanduser(),
|
|
392
|
+
Path(args.path).expanduser().resolve(),
|
|
393
|
+
plain=args.plain,
|
|
394
|
+
min_severity=args.min_severity,
|
|
395
|
+
fail_on=args.fail_on,
|
|
396
|
+
)
|
|
397
|
+
if args.interactive:
|
|
398
|
+
return interactive_main()
|
|
399
|
+
|
|
400
|
+
if args.fail_on != "none" and _SEVERITY_RANK[args.min_severity] < _SEVERITY_RANK[args.fail_on]:
|
|
401
|
+
print(
|
|
402
|
+
f"Error: --min-severity {args.min_severity} would hide {args.fail_on} findings that "
|
|
403
|
+
f"--fail-on {args.fail_on} fails on. Use a --min-severity at or below --fail-on.",
|
|
404
|
+
file=sys.stderr,
|
|
405
|
+
)
|
|
406
|
+
return 2
|
|
407
|
+
|
|
408
|
+
target = Path(args.path).expanduser().resolve()
|
|
409
|
+
if not target.exists():
|
|
410
|
+
print(f"Error: scan target does not exist: {target}", file=sys.stderr)
|
|
411
|
+
return 2
|
|
412
|
+
|
|
413
|
+
if args.config:
|
|
414
|
+
manifest: Path | None = Path(args.config).expanduser()
|
|
415
|
+
if not manifest.is_file():
|
|
416
|
+
print(f"Error: manifest does not exist: {manifest}", file=sys.stderr)
|
|
417
|
+
return 2
|
|
418
|
+
else:
|
|
419
|
+
manifest = find_manifest(target)
|
|
420
|
+
|
|
421
|
+
# Source checks always run; a manifest only adds findings and can never hide them.
|
|
422
|
+
findings = scan_project(target)
|
|
423
|
+
if manifest is not None:
|
|
424
|
+
try:
|
|
425
|
+
findings = dedupe([*scan_manifest(manifest, target), *findings])
|
|
426
|
+
except ManifestError as exc:
|
|
427
|
+
print(f"Manifest error: {exc}", file=sys.stderr)
|
|
428
|
+
return 2
|
|
429
|
+
manifest_text = str(manifest) if manifest is not None else None
|
|
430
|
+
|
|
431
|
+
dependency_manifests: int | None = None
|
|
432
|
+
if args.dependencies:
|
|
433
|
+
try:
|
|
434
|
+
dependency_findings, dependency_manifests = audit_requirements(target)
|
|
435
|
+
except DependencyAuditError as exc:
|
|
436
|
+
print(f"Dependency audit error: {exc}", file=sys.stderr)
|
|
437
|
+
return 2
|
|
438
|
+
findings.extend(dependency_findings)
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
ignore_file = Path(args.ignore_file).expanduser() if args.ignore_file else None
|
|
442
|
+
ignore_rules = load_ignore_rules(
|
|
443
|
+
target, args.exclude, ignore_file, project_config=args.project_ignores
|
|
444
|
+
)
|
|
445
|
+
except ConfigError as exc:
|
|
446
|
+
print(f"Configuration error: {exc}", file=sys.stderr)
|
|
447
|
+
return 2
|
|
448
|
+
base = target if target.is_dir() else target.parent
|
|
449
|
+
findings, suppressed = filter_findings(findings, base, ignore_rules, inline=args.project_ignores)
|
|
450
|
+
findings, hidden = filter_min_severity(findings, args.min_severity)
|
|
451
|
+
shown_min = args.min_severity if args.min_severity != "info" else None
|
|
452
|
+
if not args.project_ignores and ignore_file is None and (base / IGNORE_FILE).is_file():
|
|
453
|
+
print(
|
|
454
|
+
f"Note: {IGNORE_FILE} in the scanned project was not applied. "
|
|
455
|
+
"Add --project-ignores if you trust this project.",
|
|
456
|
+
file=sys.stderr,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
if args.format == "json":
|
|
460
|
+
report = json_report(
|
|
461
|
+
findings, str(target), dependency_manifests, manifest_text, suppressed, shown_min, hidden
|
|
462
|
+
)
|
|
463
|
+
elif args.format == "sarif":
|
|
464
|
+
report = sarif_report(
|
|
465
|
+
findings, str(target), dependency_manifests, manifest_text, suppressed, shown_min, hidden
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
report = text_report(
|
|
469
|
+
findings, str(target), dependency_manifests, manifest_text, suppressed, shown_min, hidden
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if args.format == "text" and not args.output and not args.plain and sys.stdout.isatty():
|
|
473
|
+
_render_scan(
|
|
474
|
+
Console(), findings, target, manifest, dependency_manifests, suppressed, args.min_severity, hidden
|
|
475
|
+
)
|
|
476
|
+
elif args.output:
|
|
477
|
+
output = Path(args.output).expanduser()
|
|
478
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
479
|
+
output.write_text(report + "\n", encoding="utf-8")
|
|
480
|
+
print(f"Wrote {args.format} report with {len(findings)} finding(s) to {output}", file=sys.stderr)
|
|
481
|
+
else:
|
|
482
|
+
print(report)
|
|
483
|
+
|
|
484
|
+
if args.fail_on == "none":
|
|
485
|
+
return 0
|
|
486
|
+
severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
487
|
+
threshold = severity_rank[args.fail_on]
|
|
488
|
+
blocking = [
|
|
489
|
+
finding
|
|
490
|
+
for finding in sorted(findings, key=severity_sort_key)
|
|
491
|
+
if finding.severity == "unknown" or severity_rank.get(finding.severity, 99) <= threshold
|
|
492
|
+
]
|
|
493
|
+
if not blocking:
|
|
494
|
+
return 0
|
|
495
|
+
_print_fail_summary(blocking, args.fail_on)
|
|
496
|
+
return 1
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
raise SystemExit(main())
|
agent_latch/findings.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The Finding value type and helpers shared by every rule module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
|
|
9
|
+
# Tests use fake credentials and switch safety features off on purpose. Rules that are noisy there
|
|
10
|
+
# still report test-file findings, at low severity, so a real problem in a test is not hidden.
|
|
11
|
+
_TEST_DIRS = {"test", "tests", "__tests__", "spec", "specs", "testing", "fixtures"}
|
|
12
|
+
_TEST_FILE = re.compile(r"(?i)^(?:test_.*|.*_test\.\w+|.*\.(?:test|spec)\.\w+|conftest\.py)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_test_path(relative_path: str) -> bool:
|
|
16
|
+
parts = relative_path.split("/")
|
|
17
|
+
return bool(_TEST_FILE.match(parts[-1])) or any(part.lower() in _TEST_DIRS for part in parts[:-1])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Rules whose findings in test files are reported at low severity (SEC001 handles this itself).
|
|
21
|
+
TEST_DEMOTED_RULES = frozenset({"AGENTLATCH-PY001", "AGENTLATCH-AG004", "AGENTLATCH-AG006", "AGENTLATCH-AG007"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def demote_test_findings(findings: list[Finding], relative_path: str) -> list[Finding]:
|
|
25
|
+
"""In a test file, report TEST_DEMOTED_RULES findings at low severity instead of dropping them."""
|
|
26
|
+
if not is_test_path(relative_path):
|
|
27
|
+
return findings
|
|
28
|
+
return [
|
|
29
|
+
replace(
|
|
30
|
+
finding,
|
|
31
|
+
severity="low",
|
|
32
|
+
confidence="low",
|
|
33
|
+
message=finding.message + " This is a test file, so it is reported at low severity.",
|
|
34
|
+
)
|
|
35
|
+
if finding.rule_id in TEST_DEMOTED_RULES
|
|
36
|
+
else finding
|
|
37
|
+
for finding in findings
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Finding:
|
|
43
|
+
rule_id: str
|
|
44
|
+
title: str
|
|
45
|
+
severity: str
|
|
46
|
+
message: str
|
|
47
|
+
path: str
|
|
48
|
+
line: int
|
|
49
|
+
column: int
|
|
50
|
+
owasp: tuple[str, ...]
|
|
51
|
+
confidence: str = "medium"
|
|
52
|
+
evidence: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dedupe(findings: Iterable[Finding]) -> list[Finding]:
|
|
56
|
+
"""Keep the first finding per rule and location, e.g. when the manifest and the project walker
|
|
57
|
+
both check the same prompt file."""
|
|
58
|
+
seen: set[tuple[str, str, int, int]] = set()
|
|
59
|
+
kept: list[Finding] = []
|
|
60
|
+
for finding in findings:
|
|
61
|
+
key = (finding.rule_id, finding.path, finding.line, finding.column)
|
|
62
|
+
if key not in seen:
|
|
63
|
+
seen.add(key)
|
|
64
|
+
kept.append(finding)
|
|
65
|
+
return sorted(kept, key=lambda finding: (finding.path, finding.line, finding.rule_id))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Runtime gate plugin namespace.
|
|
2
|
+
|
|
3
|
+
Each gate module exports a unique string ``GATE_ID`` and an
|
|
4
|
+
``evaluate(event: dict) -> {"findings": list}`` function. The importer supplies each gate with
|
|
5
|
+
the same payload-event JSON object and writes a common result envelope to its CSV output.
|
|
6
|
+
|
|
7
|
+
``alr04`` ships with the package. It is a monitor-only preview: it reports findings and never
|
|
8
|
+
blocks. The runtime report lists findings only.
|
|
9
|
+
|
|
10
|
+
``agent-latch --runtime-result`` prints every gate's alerts from ``gate-results.csv``. A finding
|
|
11
|
+
is included when ``is_finding`` is true, or when that key is absent and ``outcome`` is ``FAIL``
|
|
12
|
+
or ``INCONCLUSIVE``. These fields are what the report reads, for this gate and later ones:
|
|
13
|
+
``rule_id``, ``severity``, ``message``, ``owasp``, ``family`` (the title), ``entity.server``,
|
|
14
|
+
``entity.tool``, and ``evidence`` entries with ``signal`` and ``field``. Optional ``path``,
|
|
15
|
+
``line``, and ``column`` are the source location. Without them, the report looks up
|
|
16
|
+
``entity.tool`` as a function in the project and uses that function's docstring line.
|
|
17
|
+
"""
|