attackmap 0.6.4__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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/bench.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Precision/recall benchmark harness (#197).
|
|
2
|
+
|
|
3
|
+
The 1.0 precision gate. Where ``review_eval`` grades LLM *review text*, this
|
|
4
|
+
scores AttackMap's *findings* against a hand-labeled corpus of ground-truth
|
|
5
|
+
vulnerabilities, and reports precision/recall/F1 **per detector class**.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
- A **benchmark manifest** (`evals/benchmark/benchmark.json`) lists cases. Each
|
|
10
|
+
case points at a repo path and declares `expected` — the genuine, exploitable
|
|
11
|
+
weaknesses in that repo, labeled *independently of what the tool emits* (so the
|
|
12
|
+
score isn't circular). Each label carries a canonical `category`, the
|
|
13
|
+
`file`/`line`/`route`/`method`, and a note.
|
|
14
|
+
- Only **scored categories** (precise detector classes — injection, BOLA,
|
|
15
|
+
unauth-state-change, webhook-exposure, crypto, token-auth, session-auth, CVE) are held to
|
|
16
|
+
precision/recall. Architectural / advisory findings (e.g. "public routes sit
|
|
17
|
+
near sensitive data") make no precise claim and are *out of scope* — they never
|
|
18
|
+
count as false positives.
|
|
19
|
+
- Each finding is mapped to a canonical category from its tags/title, and matched
|
|
20
|
+
to an expected label when the category agrees and the finding's evidence cites
|
|
21
|
+
the same file (refined by route substring or a line within a small window).
|
|
22
|
+
|
|
23
|
+
Recall counts *expected labels matched by ≥1 finding*; precision counts *scored
|
|
24
|
+
findings that matched ≥1 label* — so a single grouped finding that legitimately
|
|
25
|
+
cites several true routes helps recall without being penalized.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable, Iterable
|
|
35
|
+
|
|
36
|
+
# --- canonical detector classes ---------------------------------------------
|
|
37
|
+
|
|
38
|
+
# Precise detector classes we hold to precision/recall. Everything else a
|
|
39
|
+
# finding might carry (admin-exposure grouping, secret-env references, generic
|
|
40
|
+
# "public route near data" insight) is advisory and deliberately unscored.
|
|
41
|
+
#
|
|
42
|
+
# `cve` is intentionally NOT here: the default runner is offline+deterministic
|
|
43
|
+
# and never calls query_vulnerabilities, so scoring `cve` through it would only
|
|
44
|
+
# ever record false negatives. CVE benchmarking needs a recorded-OSV fixture
|
|
45
|
+
# path (future work) before the class can be declared scorable.
|
|
46
|
+
SCORED_CATEGORIES: tuple[str, ...] = (
|
|
47
|
+
"injection",
|
|
48
|
+
"bola",
|
|
49
|
+
"unauth_state_change",
|
|
50
|
+
"webhook_exposure",
|
|
51
|
+
"crypto",
|
|
52
|
+
"token_auth",
|
|
53
|
+
"session_auth",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_INJECTION_MARKERS = (
|
|
57
|
+
"injection",
|
|
58
|
+
"sql",
|
|
59
|
+
"ssrf",
|
|
60
|
+
"ssti",
|
|
61
|
+
"nosql",
|
|
62
|
+
"deserial",
|
|
63
|
+
"command exec",
|
|
64
|
+
"code execution",
|
|
65
|
+
"open redirect",
|
|
66
|
+
"path traversal",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def canonical_category(finding: dict[str, Any]) -> str:
|
|
71
|
+
"""Map a report-shaped finding to a canonical detector class.
|
|
72
|
+
|
|
73
|
+
Returns a scored class from :data:`SCORED_CATEGORIES` or one of the advisory
|
|
74
|
+
buckets (``secret_env`` / ``admin_exposure`` / ``advisory``) that the scorer
|
|
75
|
+
ignores. The order is a priority: most specific first.
|
|
76
|
+
"""
|
|
77
|
+
tags = {t.lower() for t in finding.get("tags", [])}
|
|
78
|
+
title = (finding.get("title") or "").lower()
|
|
79
|
+
|
|
80
|
+
if "dependency" in tags or "cve" in tags:
|
|
81
|
+
return "cve"
|
|
82
|
+
if any(m in tags for m in ("crypto", "weak-crypto", "insecure-crypto")) or "weak random" in title:
|
|
83
|
+
return "crypto"
|
|
84
|
+
if "token-auth" in tags:
|
|
85
|
+
return "token_auth"
|
|
86
|
+
if "session-auth" in tags:
|
|
87
|
+
return "session_auth"
|
|
88
|
+
if any(m in title or m in tags for m in _INJECTION_MARKERS):
|
|
89
|
+
return "injection"
|
|
90
|
+
if "broken-authorization" in tags or "bola" in title or "idor" in title:
|
|
91
|
+
return "bola"
|
|
92
|
+
if ("auth-missing" in tags and "webhook" in title) or "webhook-verification" in tags:
|
|
93
|
+
return "webhook_exposure"
|
|
94
|
+
if "auth-missing" in tags and "state-changing" in tags:
|
|
95
|
+
return "unauth_state_change"
|
|
96
|
+
if "secret-exposure" in tags:
|
|
97
|
+
return "secret_env"
|
|
98
|
+
if "privileged" in tags or "administrative" in title:
|
|
99
|
+
return "admin_exposure"
|
|
100
|
+
return "advisory"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- data model --------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Expected:
|
|
108
|
+
category: str
|
|
109
|
+
file: str
|
|
110
|
+
line: int | None = None
|
|
111
|
+
route: str | None = None
|
|
112
|
+
method: str | None = None
|
|
113
|
+
severity: str | None = None
|
|
114
|
+
note: str = ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Case:
|
|
119
|
+
id: str
|
|
120
|
+
path: str
|
|
121
|
+
expected: list[Expected]
|
|
122
|
+
modules: list[str] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Benchmark:
|
|
127
|
+
scored_categories: tuple[str, ...]
|
|
128
|
+
line_window: int
|
|
129
|
+
cases: list[Case]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_benchmark(path: str | Path) -> Benchmark:
|
|
133
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
134
|
+
cases = [
|
|
135
|
+
Case(
|
|
136
|
+
id=c["id"],
|
|
137
|
+
path=c["path"],
|
|
138
|
+
modules=list(c.get("modules", [])),
|
|
139
|
+
expected=[Expected(**e) for e in c.get("expected", [])],
|
|
140
|
+
)
|
|
141
|
+
for c in data.get("cases", [])
|
|
142
|
+
]
|
|
143
|
+
scored = tuple(data.get("scored_categories", SCORED_CATEGORIES))
|
|
144
|
+
return Benchmark(scored_categories=scored, line_window=int(data.get("line_window", 8)), cases=cases)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- matching ----------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
# Start only at a token boundary so a long path-like token is scanned once (#236).
|
|
150
|
+
_LOC_RE = re.compile(r"(?<![\w./\\-])([\w./\\-]+\.[A-Za-z0-9_]+):(\d+)")
|
|
151
|
+
_ROUTE_RE = re.compile(r"\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|ANY)\s+(/[^\s,;]*)", re.IGNORECASE)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _norm_path(p: str) -> str:
|
|
155
|
+
"""Normalize a path for repo-relative comparison (forward slashes, no `./`)."""
|
|
156
|
+
p = p.replace("\\", "/")
|
|
157
|
+
while p.startswith("./"):
|
|
158
|
+
p = p[2:]
|
|
159
|
+
return p.strip("/") if p.strip("/") else p
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _norm_route(r: str) -> str:
|
|
163
|
+
r = r.strip().rstrip("/")
|
|
164
|
+
return r or "/"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _finding_locations(finding: dict[str, Any]) -> list[tuple[str, int]]:
|
|
168
|
+
"""Every ``file:line`` cited in a finding's title + evidence (path normalized)."""
|
|
169
|
+
text = (finding.get("title") or "") + "\n" + "\n".join(finding.get("evidence", []))
|
|
170
|
+
return [(_norm_path(m.group(1)), int(m.group(2))) for m in _LOC_RE.finditer(text)]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _finding_routes(finding: dict[str, Any]) -> list[tuple[str, str]]:
|
|
174
|
+
"""Every ``METHOD /route`` pair cited (method upper-cased, route normalized)."""
|
|
175
|
+
text = (finding.get("title") or "") + "\n" + "\n".join(finding.get("evidence", []))
|
|
176
|
+
return [(m.group(1).upper(), _norm_route(m.group(2))) for m in _ROUTE_RE.finditer(text)]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _file_cited(finding: dict[str, Any], exp_file: str) -> bool:
|
|
180
|
+
"""True if the finding cites ``exp_file`` by its full repo-relative path.
|
|
181
|
+
|
|
182
|
+
Matches ``app.py`` in "… in app.py" and "app.py:14", and full paths like
|
|
183
|
+
``services/b/app.py`` — but never a *different* service's file, because the
|
|
184
|
+
whole relative path must appear on a path boundary (so ``services/b/app.py``
|
|
185
|
+
is not found inside "services/a/app.py")."""
|
|
186
|
+
text = (finding.get("title") or "") + "\n" + "\n".join(finding.get("evidence", []))
|
|
187
|
+
text = text.replace("\\", "/")
|
|
188
|
+
pat = re.compile(r"(?:^|[\s(/'\"])" + re.escape(exp_file) + r"(?![\w.])")
|
|
189
|
+
return pat.search(text) is not None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _matches(finding: dict[str, Any], exp: Expected, window: int) -> bool:
|
|
193
|
+
"""True if ``finding`` corroborates the expected label.
|
|
194
|
+
|
|
195
|
+
The finding must cite ``exp.file`` by its **repository-relative path** (not
|
|
196
|
+
just basename — so `services/a/app.py` never matches a label in
|
|
197
|
+
`services/b/app.py`). It is then confirmed by an **exact route + method**
|
|
198
|
+
match (`/orders` never matches `/orders/search`), falling back to a cited
|
|
199
|
+
line within ``window`` of ``exp.line`` when the finding carries no parseable
|
|
200
|
+
route. A file-only match is accepted when the label pins neither route nor line.
|
|
201
|
+
"""
|
|
202
|
+
exp_file = _norm_path(exp.file)
|
|
203
|
+
if not _file_cited(finding, exp_file):
|
|
204
|
+
return False # the finding doesn't cite this file at all
|
|
205
|
+
file_locs = [ln for (f, ln) in _finding_locations(finding) if f == exp_file]
|
|
206
|
+
|
|
207
|
+
routes = _finding_routes(finding)
|
|
208
|
+
# Exact route + method wins.
|
|
209
|
+
if exp.route is not None:
|
|
210
|
+
want_route = _norm_route(exp.route)
|
|
211
|
+
for method, route in routes:
|
|
212
|
+
if route == want_route and (exp.method is None or method == exp.method.upper()):
|
|
213
|
+
return True
|
|
214
|
+
# The finding names other route(s) but not this one → a different
|
|
215
|
+
# endpoint. Do NOT fall back to line proximity (that would credit
|
|
216
|
+
# `/orders` from `/orders/search`).
|
|
217
|
+
if routes:
|
|
218
|
+
return False
|
|
219
|
+
# Line-window fallback: only when the finding cites no route to disambiguate on.
|
|
220
|
+
if exp.line is not None and any(abs(ln - exp.line) <= window for ln in file_locs):
|
|
221
|
+
return True
|
|
222
|
+
# Label pins neither route nor line — a file-level citation is enough.
|
|
223
|
+
return exp.route is None and exp.line is None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class CategoryScore:
|
|
228
|
+
category: str
|
|
229
|
+
tp: int = 0 # scored findings that matched ≥1 expected
|
|
230
|
+
fp: int = 0 # scored findings that matched no expected
|
|
231
|
+
fn: int = 0 # expected labels matched by no finding
|
|
232
|
+
matched_expected: list[str] = field(default_factory=list)
|
|
233
|
+
missed_expected: list[str] = field(default_factory=list)
|
|
234
|
+
false_positives: list[str] = field(default_factory=list)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def precision(self) -> float | None:
|
|
238
|
+
denom = self.tp + self.fp
|
|
239
|
+
return None if denom == 0 else self.tp / denom
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def recall(self) -> float | None:
|
|
243
|
+
denom = self.tp_expected_total
|
|
244
|
+
return None if denom == 0 else len(self.matched_expected) / denom
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def tp_expected_total(self) -> int:
|
|
248
|
+
return len(self.matched_expected) + len(self.missed_expected)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def f1(self) -> float | None:
|
|
252
|
+
p, r = self.precision, self.recall
|
|
253
|
+
if p is None or r is None:
|
|
254
|
+
return None # undefined only when a component is undefined
|
|
255
|
+
if p + r == 0:
|
|
256
|
+
return 0.0 # a defined zero (e.g. 1 FP + 1 miss) is a real F1 of 0
|
|
257
|
+
return 2 * p * r / (p + r)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def score_case(
|
|
261
|
+
findings: list[dict[str, Any]],
|
|
262
|
+
expected: list[Expected],
|
|
263
|
+
scored_categories: Iterable[str],
|
|
264
|
+
window: int,
|
|
265
|
+
) -> dict[str, CategoryScore]:
|
|
266
|
+
scored = set(scored_categories)
|
|
267
|
+
scores: dict[str, CategoryScore] = {}
|
|
268
|
+
|
|
269
|
+
def cat(name: str) -> CategoryScore:
|
|
270
|
+
return scores.setdefault(name, CategoryScore(category=name))
|
|
271
|
+
|
|
272
|
+
# Categorize findings; only scored-category findings participate.
|
|
273
|
+
finding_cats = [(f, canonical_category(f)) for f in findings]
|
|
274
|
+
scored_findings = [(f, c) for f, c in finding_cats if c in scored]
|
|
275
|
+
|
|
276
|
+
# Recall: which expected labels does any finding of the same category match?
|
|
277
|
+
for exp in expected:
|
|
278
|
+
if exp.category not in scored:
|
|
279
|
+
continue
|
|
280
|
+
cs = cat(exp.category)
|
|
281
|
+
label = f"{exp.method or ''} {exp.route or exp.file}:{exp.line or ''}".strip()
|
|
282
|
+
if any(c == exp.category and _matches(f, exp, window) for f, c in scored_findings):
|
|
283
|
+
cs.matched_expected.append(label)
|
|
284
|
+
else:
|
|
285
|
+
cs.missed_expected.append(label)
|
|
286
|
+
|
|
287
|
+
# Precision: each scored finding is a TP if it matched ≥1 expected, else FP.
|
|
288
|
+
for f, c in scored_findings:
|
|
289
|
+
cs = cat(c)
|
|
290
|
+
exp_in_cat = [e for e in expected if e.category == c]
|
|
291
|
+
if any(_matches(f, e, window) for e in exp_in_cat):
|
|
292
|
+
cs.tp += 1
|
|
293
|
+
else:
|
|
294
|
+
cs.fp += 1
|
|
295
|
+
cs.false_positives.append(f.get("title", "")[:80])
|
|
296
|
+
|
|
297
|
+
return scores
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- running -----------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def findings_for_repo(root: str | Path) -> list[dict[str, Any]]:
|
|
304
|
+
"""Run the heuristic analysis in-process and return report-shaped findings."""
|
|
305
|
+
from .analyzers import analyze_repository
|
|
306
|
+
from .diff import finding_id
|
|
307
|
+
from .threat_model import generate_findings
|
|
308
|
+
|
|
309
|
+
scan = analyze_repository(str(root))
|
|
310
|
+
out = []
|
|
311
|
+
for f in generate_findings(scan):
|
|
312
|
+
d = f.model_dump()
|
|
313
|
+
d["id"] = finding_id(f.title)
|
|
314
|
+
out.append(d)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class CaseResult:
|
|
320
|
+
id: str
|
|
321
|
+
scores: dict[str, CategoryScore]
|
|
322
|
+
finding_count: int
|
|
323
|
+
error: str | None = None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def run_benchmark(
|
|
327
|
+
benchmark: Benchmark,
|
|
328
|
+
root: str | Path,
|
|
329
|
+
analyze: Callable[[str | Path], list[dict[str, Any]]] = findings_for_repo,
|
|
330
|
+
) -> list[CaseResult]:
|
|
331
|
+
root = Path(root)
|
|
332
|
+
results: list[CaseResult] = []
|
|
333
|
+
for case in benchmark.cases:
|
|
334
|
+
case_path = root / case.path
|
|
335
|
+
if not case_path.is_dir():
|
|
336
|
+
# A misspelled/absent case path would otherwise scan as an empty
|
|
337
|
+
# "clean" repo and look like perfect false-positive behavior — record
|
|
338
|
+
# it as an error instead (esp. important for known-clean cases).
|
|
339
|
+
results.append(CaseResult(
|
|
340
|
+
id=case.id, scores={}, finding_count=0,
|
|
341
|
+
error=f"case path not found: {case_path}"))
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
findings = analyze(case_path)
|
|
345
|
+
except Exception as exc: # a scan failure is a benchmark result, not a crash
|
|
346
|
+
results.append(CaseResult(id=case.id, scores={}, finding_count=0, error=str(exc)))
|
|
347
|
+
continue
|
|
348
|
+
scores = score_case(findings, case.expected, benchmark.scored_categories, benchmark.line_window)
|
|
349
|
+
results.append(CaseResult(id=case.id, scores=scores, finding_count=len(findings)))
|
|
350
|
+
return results
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def aggregate(results: list[CaseResult]) -> dict[str, CategoryScore]:
|
|
354
|
+
"""Sum per-category scores across all cases into an overall table."""
|
|
355
|
+
overall: dict[str, CategoryScore] = {}
|
|
356
|
+
for r in results:
|
|
357
|
+
for name, cs in r.scores.items():
|
|
358
|
+
agg = overall.setdefault(name, CategoryScore(category=name))
|
|
359
|
+
agg.tp += cs.tp
|
|
360
|
+
agg.fp += cs.fp
|
|
361
|
+
agg.matched_expected += cs.matched_expected
|
|
362
|
+
agg.missed_expected += cs.missed_expected
|
|
363
|
+
agg.false_positives += cs.false_positives
|
|
364
|
+
return overall
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# --- rendering ---------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _pct(x: float | None) -> str:
|
|
371
|
+
return "—" if x is None else f"{x * 100:.0f}%"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def render_markdown(results: list[CaseResult]) -> str:
|
|
375
|
+
overall = aggregate(results)
|
|
376
|
+
lines = ["# AttackMap detection benchmark", ""]
|
|
377
|
+
lines.append("Precision/recall per detector class, scored against a hand-labeled corpus.")
|
|
378
|
+
lines.append("Only precise detector classes are scored; advisory/architectural findings are out of scope.")
|
|
379
|
+
lines.append("")
|
|
380
|
+
lines.append("## Overall")
|
|
381
|
+
lines.append("")
|
|
382
|
+
lines.append("| Detector class | Precision | Recall | F1 | TP | FP | Expected |")
|
|
383
|
+
lines.append("|---|---|---|---|---|---|---|")
|
|
384
|
+
for name in sorted(overall):
|
|
385
|
+
cs = overall[name]
|
|
386
|
+
lines.append(
|
|
387
|
+
f"| `{name}` | {_pct(cs.precision)} | {_pct(cs.recall)} | {_pct(cs.f1)} "
|
|
388
|
+
f"| {cs.tp} | {cs.fp} | {cs.tp_expected_total} |"
|
|
389
|
+
)
|
|
390
|
+
lines.append("")
|
|
391
|
+
lines.append("## Per case")
|
|
392
|
+
for r in results:
|
|
393
|
+
lines.append("")
|
|
394
|
+
if r.error:
|
|
395
|
+
lines.append(f"### {r.id} — ERROR")
|
|
396
|
+
lines.append(f"```\n{r.error}\n```")
|
|
397
|
+
continue
|
|
398
|
+
lines.append(f"### {r.id} ({r.finding_count} findings)")
|
|
399
|
+
lines.append("")
|
|
400
|
+
lines.append("| Detector class | Precision | Recall | Missed (recall gaps) | False positives |")
|
|
401
|
+
lines.append("|---|---|---|---|---|")
|
|
402
|
+
for name in sorted(r.scores):
|
|
403
|
+
cs = r.scores[name]
|
|
404
|
+
missed = "; ".join(cs.missed_expected) or "—"
|
|
405
|
+
fps = "; ".join(cs.false_positives) or "—"
|
|
406
|
+
lines.append(f"| `{name}` | {_pct(cs.precision)} | {_pct(cs.recall)} | {missed} | {fps} |")
|
|
407
|
+
lines.append("")
|
|
408
|
+
return "\n".join(lines)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def bench_json(results: list[CaseResult]) -> dict[str, Any]:
|
|
412
|
+
def cat_dict(cs: CategoryScore) -> dict[str, Any]:
|
|
413
|
+
return {
|
|
414
|
+
"precision": cs.precision,
|
|
415
|
+
"recall": cs.recall,
|
|
416
|
+
"f1": cs.f1,
|
|
417
|
+
"tp": cs.tp,
|
|
418
|
+
"fp": cs.fp,
|
|
419
|
+
"expected_total": cs.tp_expected_total,
|
|
420
|
+
"matched_expected": cs.matched_expected,
|
|
421
|
+
"missed_expected": cs.missed_expected,
|
|
422
|
+
"false_positives": cs.false_positives,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
overall = aggregate(results)
|
|
426
|
+
return {
|
|
427
|
+
"overall": {name: cat_dict(cs) for name, cs in sorted(overall.items())},
|
|
428
|
+
"cases": [
|
|
429
|
+
{
|
|
430
|
+
"id": r.id,
|
|
431
|
+
"finding_count": r.finding_count,
|
|
432
|
+
"error": r.error,
|
|
433
|
+
"categories": {name: cat_dict(cs) for name, cs in sorted(r.scores.items())},
|
|
434
|
+
}
|
|
435
|
+
for r in results
|
|
436
|
+
],
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def min_scored_metric(results: list[CaseResult]) -> float:
|
|
441
|
+
"""The lowest defined precision/recall across all scored categories — used by
|
|
442
|
+
the CI `--fail-under` gate. Returns 1.0 when nothing scorable ran."""
|
|
443
|
+
vals: list[float] = []
|
|
444
|
+
for cs in aggregate(results).values():
|
|
445
|
+
for m in (cs.precision, cs.recall):
|
|
446
|
+
if m is not None:
|
|
447
|
+
vals.append(m)
|
|
448
|
+
return min(vals) if vals else 1.0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
__all__ = [
|
|
452
|
+
"Benchmark",
|
|
453
|
+
"Case",
|
|
454
|
+
"CaseResult",
|
|
455
|
+
"CategoryScore",
|
|
456
|
+
"Expected",
|
|
457
|
+
"SCORED_CATEGORIES",
|
|
458
|
+
"aggregate",
|
|
459
|
+
"bench_json",
|
|
460
|
+
"canonical_category",
|
|
461
|
+
"findings_for_repo",
|
|
462
|
+
"load_benchmark",
|
|
463
|
+
"min_scored_metric",
|
|
464
|
+
"render_markdown",
|
|
465
|
+
"run_benchmark",
|
|
466
|
+
"score_case",
|
|
467
|
+
]
|