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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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
+ ]