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/__init__.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""GitHub Action post-processing: repo-relative SARIF paths and the job summary page.
|
|
2
|
+
|
|
3
|
+
Run by action.yml as `python -m agent_latch.action_summary` after the scan. Reads SARIF_FILE,
|
|
4
|
+
SCAN_PATH, FAIL_ON, EXIT_CODE, GITHUB_WORKSPACE, GITHUB_STEP_SUMMARY, and GITHUB_OUTPUT.
|
|
5
|
+
|
|
6
|
+
Paths, evidence, and messages come from the scanned project, which is untrusted, so every value is
|
|
7
|
+
escaped before it goes into Markdown: a crafted file name cannot add links, images, or HTML.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from agent_latch.owasp import OWASP_AGENTIC_TITLE, coverage_status
|
|
20
|
+
|
|
21
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info", "unknown")
|
|
22
|
+
_RANK = {severity: rank for rank, severity in enumerate(SEVERITIES)}
|
|
23
|
+
_FAIL_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
24
|
+
_ICONS = {"critical": "🟥", "high": "🔴", "medium": "🟠", "low": "🔵", "info": "⚪", "unknown": "⚪"}
|
|
25
|
+
# GitHub caps a step summary at 1 MiB; these keep large scans well under it.
|
|
26
|
+
MAX_BLOCKING_ROWS = 100
|
|
27
|
+
MAX_OTHER_ROWS = 200
|
|
28
|
+
_MARKDOWN_SPECIAL = re.compile(r"([\\`*_{}\[\]<>()#+!|~])")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cell(text: str, limit: int = 160) -> str:
|
|
32
|
+
"""Untrusted text as a single, inert Markdown table cell."""
|
|
33
|
+
flat = " ".join(str(text).split())
|
|
34
|
+
if len(flat) > limit:
|
|
35
|
+
flat = flat[: limit - 1] + "…"
|
|
36
|
+
return _MARKDOWN_SPECIAL.sub(r"\\\1", flat)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def code(text: str) -> str:
|
|
40
|
+
"""Untrusted text as inline code. Backticks and pipes are removed so the span cannot break out."""
|
|
41
|
+
return "`" + " ".join(str(text).split()).replace("`", "'").replace("|", "/") + "`"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rewrite_uris(run: dict[str, Any], workspace: Path, target: Path) -> None:
|
|
45
|
+
"""AgentLatch reports paths relative to the scan target; code scanning needs repo-relative URIs."""
|
|
46
|
+
base = target if target.is_dir() else target.parent
|
|
47
|
+
prefix = base.relative_to(workspace).as_posix() if base.is_relative_to(workspace) else ""
|
|
48
|
+
for result in run.get("results", []):
|
|
49
|
+
for location in result.get("locations", []):
|
|
50
|
+
artifact = location["physicalLocation"]["artifactLocation"]
|
|
51
|
+
if prefix not in ("", ".") and not artifact["uri"].startswith("/"):
|
|
52
|
+
artifact["uri"] = f"{prefix}/{artifact['uri']}"
|
|
53
|
+
run.pop("artifacts", None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _rows(run: dict[str, Any]) -> list[dict[str, str]]:
|
|
57
|
+
titles = {rule["id"]: rule.get("name", rule["id"]) for rule in run["tool"]["driver"].get("rules", [])}
|
|
58
|
+
rows = []
|
|
59
|
+
for result in run.get("results", []):
|
|
60
|
+
physical = result["locations"][0]["physicalLocation"]
|
|
61
|
+
region = physical.get("region", {})
|
|
62
|
+
severity = str(result.get("properties", {}).get("severity", "unknown")).lower()
|
|
63
|
+
rows.append(
|
|
64
|
+
{
|
|
65
|
+
"severity": severity if severity in _RANK else "unknown",
|
|
66
|
+
"rule": result["ruleId"],
|
|
67
|
+
"title": titles.get(result["ruleId"], result["ruleId"]),
|
|
68
|
+
"location": f"{physical['artifactLocation']['uri']}:{region.get('startLine', 1)}",
|
|
69
|
+
"evidence": region.get("snippet", {}).get("text", ""),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
rows.sort(key=lambda row: (_RANK[row["severity"]], row["location"], row["rule"]))
|
|
73
|
+
return rows
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_blocking(severity: str, fail_on: str) -> bool:
|
|
77
|
+
if fail_on not in _FAIL_RANK:
|
|
78
|
+
return False
|
|
79
|
+
# Mirrors the CLI: unknown severity fails closed.
|
|
80
|
+
return severity == "unknown" or _RANK.get(severity, 99) <= _FAIL_RANK[fail_on]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _short_rule(rule_id: str) -> str:
|
|
84
|
+
return rule_id.removeprefix("AGENTLATCH-")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _finding_table(rows: list[dict[str, str]], limit: int) -> list[str]:
|
|
88
|
+
lines = ["| Severity | Rule | Location | Finding | Evidence |", "|---|---|---|---|---|"]
|
|
89
|
+
for row in rows[:limit]:
|
|
90
|
+
lines.append(
|
|
91
|
+
f"| {_ICONS[row['severity']]} {row['severity'].upper()} | `{_short_rule(row['rule'])}` "
|
|
92
|
+
f"| {code(row['location'])} | {cell(row['title'], 80)} | {cell(row['evidence'], 100)} |"
|
|
93
|
+
)
|
|
94
|
+
if len(rows) > limit:
|
|
95
|
+
lines += ["", f"_…and {len(rows) - limit} more. Download the SARIF report for the full list._"]
|
|
96
|
+
return lines
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_summary(run: dict[str, Any], fail_on: str, exit_code: str) -> str:
|
|
100
|
+
rows = _rows(run)
|
|
101
|
+
counts = Counter(row["severity"] for row in rows)
|
|
102
|
+
blocking = [row for row in rows if is_blocking(row["severity"], fail_on)]
|
|
103
|
+
others = [row for row in rows if not is_blocking(row["severity"], fail_on)]
|
|
104
|
+
|
|
105
|
+
if exit_code == "1" or blocking:
|
|
106
|
+
headline = f"## ❌ AgentLatch: {len(blocking)} finding(s) at or above `{fail_on}`"
|
|
107
|
+
elif fail_on == "none":
|
|
108
|
+
headline = f"## ℹ️ AgentLatch: {len(rows)} finding(s) (`fail-on: none`)"
|
|
109
|
+
else:
|
|
110
|
+
headline = f"## ✅ AgentLatch: no findings at or above `{fail_on}`"
|
|
111
|
+
lines = [headline, ""]
|
|
112
|
+
|
|
113
|
+
shown = [severity for severity in SEVERITIES if counts[severity] or severity in ("critical", "high", "medium", "low")]
|
|
114
|
+
lines += [
|
|
115
|
+
"| " + " | ".join(f"{_ICONS[s]} {s.capitalize()}" for s in shown) + " | **Total** |",
|
|
116
|
+
"|" + "---|" * (len(shown) + 1),
|
|
117
|
+
"| " + " | ".join(str(counts[s]) for s in shown) + f" | **{len(rows)}** |",
|
|
118
|
+
"",
|
|
119
|
+
]
|
|
120
|
+
properties = run.get("properties", {})
|
|
121
|
+
notes = []
|
|
122
|
+
if properties.get("hiddenBelowMinSeverityCount"):
|
|
123
|
+
notes.append(
|
|
124
|
+
f"{properties['hiddenBelowMinSeverityCount']} finding(s) below `min-severity: "
|
|
125
|
+
f"{cell(properties.get('minSeverity') or '', 20)}` not shown"
|
|
126
|
+
)
|
|
127
|
+
if properties.get("suppressedCount"):
|
|
128
|
+
notes.append(f"{properties['suppressedCount']} finding(s) suppressed by `project-ignores`")
|
|
129
|
+
if notes:
|
|
130
|
+
lines += ["_" + "; ".join(notes) + "._", ""]
|
|
131
|
+
if not rows:
|
|
132
|
+
lines += ["No findings from the enabled checks. This does not mean the project is secure.", ""]
|
|
133
|
+
|
|
134
|
+
if rows:
|
|
135
|
+
by_rule: dict[str, dict[str, Any]] = {}
|
|
136
|
+
for row in rows:
|
|
137
|
+
entry = by_rule.setdefault(row["rule"], {"title": row["title"], "count": 0, "worst": row["severity"]})
|
|
138
|
+
entry["count"] += 1
|
|
139
|
+
if _RANK[row["severity"]] < _RANK[entry["worst"]]:
|
|
140
|
+
entry["worst"] = row["severity"]
|
|
141
|
+
lines += ["### Findings by rule", "", "| Rule | Title | Highest severity | Count |", "|---|---|---|---:|"]
|
|
142
|
+
for rule, entry in sorted(by_rule.items(), key=lambda item: (_RANK[item[1]["worst"]], -item[1]["count"])):
|
|
143
|
+
lines.append(
|
|
144
|
+
f"| `{_short_rule(rule)}` | {cell(entry['title'], 80)} "
|
|
145
|
+
f"| {_ICONS[entry['worst']]} {entry['worst'].upper()} | {entry['count']} |"
|
|
146
|
+
)
|
|
147
|
+
lines.append("")
|
|
148
|
+
|
|
149
|
+
categories = run.get("properties", {}).get("owaspAgenticCoverage", [])
|
|
150
|
+
if categories:
|
|
151
|
+
lines += [f"### {OWASP_AGENTIC_TITLE}", "", "| ID | Category | Status |", "|---|---|---|"]
|
|
152
|
+
for entry in categories:
|
|
153
|
+
status = coverage_status(entry)
|
|
154
|
+
if entry.get("finding_count"):
|
|
155
|
+
status = f"**{status}**"
|
|
156
|
+
elif not entry.get("has_checks"):
|
|
157
|
+
status = f"_{status}_"
|
|
158
|
+
lines.append(f"| {entry['id']} | {cell(entry['name'], 60)} | {status} |")
|
|
159
|
+
lines += ["", "_Informational mapping, not a compliance assessment or OWASP endorsement._", ""]
|
|
160
|
+
|
|
161
|
+
if blocking:
|
|
162
|
+
lines += [f"### Blocking findings ({len(blocking)})", "", *_finding_table(blocking, MAX_BLOCKING_ROWS), ""]
|
|
163
|
+
if others:
|
|
164
|
+
label = "Other findings" if blocking else "Findings"
|
|
165
|
+
lines += [
|
|
166
|
+
"<details>",
|
|
167
|
+
f"<summary>{label} ({len(others)})</summary>",
|
|
168
|
+
"",
|
|
169
|
+
*_finding_table(others, MAX_OTHER_ROWS),
|
|
170
|
+
"",
|
|
171
|
+
"</details>",
|
|
172
|
+
"",
|
|
173
|
+
]
|
|
174
|
+
lines.append("_Selected static checks only; findings need human review and are not a security certification._")
|
|
175
|
+
return "\n".join(lines) + "\n"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def log_summary(run: dict[str, Any], fail_on: str) -> str:
|
|
179
|
+
"""A few lines for the step log; the CLI already printed the blocking findings."""
|
|
180
|
+
rows = _rows(run)
|
|
181
|
+
counts = Counter(row["severity"] for row in rows)
|
|
182
|
+
parts = ", ".join(f"{counts[s]} {s}" for s in SEVERITIES if counts[s]) or "none"
|
|
183
|
+
blocking = sum(is_blocking(row["severity"], fail_on) for row in rows)
|
|
184
|
+
return (
|
|
185
|
+
f"AgentLatch: {len(rows)} finding(s) ({parts}); {blocking} at or above fail-on '{fail_on}'.\n"
|
|
186
|
+
"See the job summary for tables, and the SARIF report for full details."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main() -> int:
|
|
191
|
+
sarif_path = Path(os.environ["SARIF_FILE"])
|
|
192
|
+
sarif = json.loads(sarif_path.read_text(encoding="utf-8"))
|
|
193
|
+
run = sarif["runs"][0]
|
|
194
|
+
rewrite_uris(
|
|
195
|
+
run,
|
|
196
|
+
Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve(),
|
|
197
|
+
Path(os.environ.get("SCAN_PATH", ".")).resolve(),
|
|
198
|
+
)
|
|
199
|
+
sarif_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
fail_on = os.environ.get("FAIL_ON", "none")
|
|
202
|
+
print(log_summary(run, fail_on))
|
|
203
|
+
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
|
|
204
|
+
summary.write(render_summary(run, fail_on, os.environ.get("EXIT_CODE", "")))
|
|
205
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
206
|
+
output.write(f"finding-count={len(run.get('results', []))}\n")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
raise SystemExit(main())
|