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.
@@ -0,0 +1,3 @@
1
+ """AgentLatch: local-first checks for selected AI-agent security risks."""
2
+
3
+ __version__ = "0.4.0"
@@ -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())