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/report.py ADDED
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .md import md_code, md_text
8
+ from .context_pack import build_review_context_pack
9
+ from .diagrams import (
10
+ render_attack_paths_dot,
11
+ render_attack_paths_mermaid,
12
+ render_topology_dot,
13
+ render_topology_mermaid,
14
+ )
15
+ from .detections import RULE_FILE, rule_filename, rule_yaml
16
+ from .diff import finding_id
17
+ from .exploitability import score_exploitability
18
+ from .models import AttackPath, AttackSurface, ExploitabilityScore, Finding, ScanResult
19
+ from .review_json import build_defensive_review_json
20
+ from .taxonomy import taxonomy_label
21
+ from .sarif import build_sarif
22
+ from .safe_fs import ensure_output_dir, safe_write_text
23
+ from .suppress import SuppressedFinding
24
+ from .topology import build_service_graph
25
+
26
+
27
+ def _severity_rank(value: str) -> int:
28
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
29
+
30
+
31
+ # Values accepted by ``--format`` / ``write_reports(output_format=...)``.
32
+ OUTPUT_FORMATS = ("all", "markdown", "json")
33
+
34
+
35
+ def write_reports(
36
+ output_dir: str | Path,
37
+ scan: ScanResult,
38
+ architecture_md: str,
39
+ attack_surface_md: str,
40
+ defensive_review_md: str,
41
+ attack_surfaces: list[AttackSurface],
42
+ findings: list[Finding],
43
+ attack_paths: list[AttackPath],
44
+ analyzer_metadata: list[dict[str, object]] | None = None,
45
+ suppressed: list[SuppressedFinding] | None = None,
46
+ output_format: str = "all",
47
+ ) -> None:
48
+ """Write the report set for one scan.
49
+
50
+ ``output_format`` selects which artifacts are emitted (``--format``):
51
+ ``"json"`` writes the machine-readable files (``*.json`` + SARIF),
52
+ ``"markdown"`` writes the human-readable files (``*.md`` + Graphviz
53
+ ``*.dot``), and ``"all"`` (default) writes both.
54
+ """
55
+ if output_format not in OUTPUT_FORMATS:
56
+ raise ValueError(
57
+ f"Unknown output format {output_format!r}; expected one of: {', '.join(OUTPUT_FORMATS)}."
58
+ )
59
+ emit_json = output_format in {"all", "json"}
60
+ emit_markdown = output_format in {"all", "markdown"}
61
+
62
+ out = ensure_output_dir(output_dir, mark=True)
63
+ suppressed = suppressed or []
64
+
65
+ def write(name: str, text: str) -> None:
66
+ safe_write_text(out, out / name, text)
67
+
68
+ if emit_markdown:
69
+ write("architecture.md", architecture_md + "\n")
70
+ write("attack-surface.md", attack_surface_md + "\n")
71
+ write("defensive-review.md", defensive_review_md + "\n")
72
+ defensive_review_json = build_defensive_review_json(scan, attack_surfaces, findings, attack_paths)
73
+ review_context_pack = build_review_context_pack(
74
+ defensive_review_json,
75
+ scan,
76
+ analyzer_metadata if analyzer_metadata is not None else [],
77
+ )
78
+ if emit_json:
79
+ write("defensive-review.json", json.dumps(defensive_review_json, indent=2) + "\n")
80
+ write("review-context-pack.json", json.dumps(review_context_pack, indent=2) + "\n")
81
+ write_detection_rules(out, defensive_review_json.get("detection_opportunities", []))
82
+
83
+ exploitability = score_exploitability(scan, attack_surfaces)
84
+ if emit_markdown:
85
+ write("attackmap-exploitability.md", render_exploitability_ranking(exploitability) + "\n")
86
+
87
+ json_report = {
88
+ "scan": scan.model_dump(),
89
+ "architecture_summary": architecture_md,
90
+ "attack_surface_summary": attack_surface_md,
91
+ "defensive_review": defensive_review_md,
92
+ "defensive_review_json": defensive_review_json,
93
+ "review_context_pack": review_context_pack,
94
+ "attack_surfaces": [surface.model_dump() for surface in attack_surfaces],
95
+ "findings": [
96
+ {"id": finding_id(finding.title), **finding.model_dump()} for finding in findings
97
+ ],
98
+ # Suppressed findings are retained (not dropped) with their reason so
99
+ # audits can see what was silenced and why (#144).
100
+ "suppressed_findings": [
101
+ {
102
+ "id": s.id,
103
+ "rule": s.rule,
104
+ "reason": s.reason,
105
+ "suppressed_by": [m.label() for m in s.matched],
106
+ **s.finding.model_dump(),
107
+ }
108
+ for s in suppressed
109
+ ],
110
+ "attack_paths": [path.model_dump() for path in attack_paths],
111
+ "exploitability": [score.model_dump() for score in exploitability],
112
+ }
113
+ if emit_json:
114
+ write("attackmap-report.json", json.dumps(json_report, indent=2) + "\n")
115
+
116
+ # SARIF 2.1.0 for GitHub Code Scanning / VS Code / other SARIF
117
+ # consumers. Emitted alongside JSON, not in place of it.
118
+ sarif_report = build_sarif(
119
+ findings,
120
+ attack_paths,
121
+ suppressed=[(s.finding, s.reason) for s in suppressed],
122
+ )
123
+ write("attackmap-report.sarif", json.dumps(sarif_report, indent=2) + "\n")
124
+
125
+ if not emit_markdown:
126
+ return
127
+
128
+ # Mermaid + Graphviz DOT export of attack paths and service topology
129
+ # (#49). Nothing new is computed — pure output transform of shapes
130
+ # that already exist in the JSON report.
131
+ service_graph = build_service_graph(scan)
132
+ write("attackmap-paths.md", render_attack_paths_mermaid(attack_paths))
133
+ write("attackmap-topology.md", render_topology_mermaid(service_graph))
134
+ write("attackmap-paths.dot", render_attack_paths_dot(attack_paths))
135
+ write("attackmap-topology.dot", render_topology_dot(service_graph))
136
+
137
+
138
+ DETECTIONS_DIR = "attackmap-detections"
139
+ DETECTIONS_INDEX = "index.json"
140
+ # Our record in the rules folder: proof we created it, and the files we wrote
141
+ # there, which are the only ones a later run may remove.
142
+ DETECTIONS_MARKER = ".attackmap-detections.json"
143
+
144
+
145
+ def _owned_detection_files(target: Path) -> list[str] | None:
146
+ """Rule files a previous run recorded in ``target``'s marker, or ``None``
147
+ when ``target`` exists but isn't ours (no readable marker)."""
148
+ marker = target / DETECTIONS_MARKER
149
+ if not marker.is_file() or marker.is_symlink():
150
+ return None
151
+ try:
152
+ data = json.loads(marker.read_text(encoding="utf-8"))
153
+ except (ValueError, OSError):
154
+ return None
155
+ if not isinstance(data, dict) or data.get("tool") != "attackmap":
156
+ return None
157
+ files = data.get("files")
158
+ return [f for f in files if isinstance(f, str)] if isinstance(files, list) else []
159
+
160
+
161
+ def write_detection_rules(out: Path, opportunities: list[dict]) -> list[str]:
162
+ """Write each opportunity's Sigma rule to ``<out>/attackmap-detections/*.yml``
163
+ (#251) with an ``index.json``, ready for `sigma convert` or a SIEM import.
164
+
165
+ The folder is used only if this function created it (it holds our marker
166
+ file); an existing folder of the same name that isn't ours is left alone
167
+ and nothing is written, with a message on stderr. Rule files from an
168
+ earlier run that this run no longer produces are removed only when the
169
+ marker recorded them. Returns the file names written."""
170
+ rules = [o for o in opportunities if isinstance(o.get("sigma"), dict)]
171
+ target = out / DETECTIONS_DIR
172
+ previous: list[str] = []
173
+ if target.exists() or target.is_symlink():
174
+ owned = None if target.is_symlink() or not target.is_dir() else _owned_detection_files(target)
175
+ if owned is None:
176
+ print(
177
+ f"attackmap: not writing Sigma rules: {target} exists and was not created by AttackMap",
178
+ file=sys.stderr,
179
+ )
180
+ return []
181
+ previous = owned
182
+ elif not rules:
183
+ return []
184
+ # Marked like the report directory itself, so a later scan never reads
185
+ # the rules back in, even with `-o .` (#216).
186
+ target = ensure_output_dir(target, mark=True)
187
+ index: list[dict] = []
188
+ written: list[str] = []
189
+ for opp in rules:
190
+ sigma = opp["sigma"]
191
+ name = rule_filename(str(opp.get("id", "")), str(sigma.get("id", "")))
192
+ if name in written:
193
+ continue
194
+ safe_write_text(target, target / name, rule_yaml(sigma, opp.get("sigma_correlation")))
195
+ written.append(name)
196
+ logsource = opp.get("logsource") or {}
197
+ index.append(
198
+ {
199
+ "file": name,
200
+ "id": sigma.get("id"),
201
+ "title": sigma.get("title"),
202
+ "level": sigma.get("level"),
203
+ "logsource": {k: v for k, v in logsource.items() if v},
204
+ "rule_ids": opp.get("related_rule_ids", []),
205
+ "correlation_id": (opp.get("sigma_correlation") or {}).get("id"),
206
+ }
207
+ )
208
+ for name in previous:
209
+ if name in written or not RULE_FILE.fullmatch(name):
210
+ continue
211
+ stale = target / name
212
+ if stale.is_file() and not stale.is_symlink():
213
+ stale.unlink()
214
+ safe_write_text(target, target / DETECTIONS_INDEX, json.dumps(index, indent=2) + "\n")
215
+ safe_write_text(
216
+ target, target / DETECTIONS_MARKER, json.dumps({"tool": "attackmap", "files": written}, indent=2) + "\n"
217
+ )
218
+ return written
219
+
220
+
221
+ def render_exploitability_ranking(scores: list[ExploitabilityScore]) -> str:
222
+ """Render the 'Most exploitable now' section — fused route→sink risk,
223
+ ranked, with every score showing its contributing factors."""
224
+ lines = ["# Most exploitable now", ""]
225
+ if not scores:
226
+ lines.append(
227
+ "No request-to-sink data-flow paths were found, so there is nothing to "
228
+ "fuse into an exploitability ranking."
229
+ )
230
+ return "\n".join(lines)
231
+ lines.append(
232
+ "Fused 0–100 exploitability for each route→sink path (deterministic; every "
233
+ "score is the clamped sum of the listed factors). Highest risk first."
234
+ )
235
+ lines.append("")
236
+ # Route, method, location and factor text carry repo-derived route paths,
237
+ # files and package names: escaped here, at render time, so the score
238
+ # objects (JSON, SARIF, PR comment) keep the raw values. The heading is
239
+ # `s.subject` rebuilt from its parts, so only the repo-derived ones are escaped.
240
+ for rank, s in enumerate(scores, start=1):
241
+ subject = f"{md_text(s.method)} {md_text(s.route)} → {s.sink_kind}"
242
+ lines.append(f"## {rank}. {subject} — {s.score}/100 ({s.tier.upper()})")
243
+ lines.append(f"- sink location: {md_code(s.location)}")
244
+ lines.append("- factors:")
245
+ for f in s.factors:
246
+ sign = "+" if f.points >= 0 else ""
247
+ lines.append(f" - {sign}{f.points} {md_text(f.name)} — {md_text(f.detail)}")
248
+ if s.raw_score != s.score:
249
+ lines.append(f" - (raw {s.raw_score} clamped to {s.score})")
250
+ lines.append("")
251
+ return "\n".join(lines).rstrip()
252
+
253
+
254
+ def _taxonomy_suffix(item: object) -> str:
255
+ """`` (CWE-918 · A10:2021)`` for a finding or snapshot with a registered
256
+ rule id (#250); empty otherwise."""
257
+ label = taxonomy_label(getattr(item, "rule_id", None))
258
+ return f" ({label})" if label else ""
259
+
260
+
261
+ def render_pr_comment(
262
+ findings: list[Finding], diff: object | None = None, *, suppressions: dict | None = None
263
+ ) -> str:
264
+ """Render a compact Markdown PR summary comment (#105).
265
+
266
+ With a `DiffReport` (duck-typed: `.new`/`.resolved`/`.has_new_high`), leads
267
+ with what the PR introduced/resolved; otherwise summarizes current findings.
268
+ Always surfaces the top 'most exploitable now' entries. Pure function —
269
+ the GitHub Action posts the string via `gh pr comment` / github-script."""
270
+ sev_rank = {"high": 0, "medium": 1, "low": 2}
271
+ lines = ["## 🗺️ AttackMap security review", ""]
272
+
273
+ new = list(getattr(diff, "new", []) or []) if diff is not None else []
274
+ resolved = list(getattr(diff, "resolved", []) or []) if diff is not None else []
275
+
276
+ if diff is not None:
277
+ gate = "⚠️ introduces new HIGH-severity findings" if getattr(diff, "has_new_high", False) else "no new HIGH findings"
278
+ new_instances = list(getattr(diff, "new_instances", []) or [])
279
+ instance_total = sum(len(labels) for _, labels in new_instances)
280
+ lines.append(
281
+ f"**{len(new)} new**, **{instance_total} new instance(s) of existing findings**, "
282
+ f"**{len(resolved)} resolved** vs. baseline — {gate}."
283
+ )
284
+ lines.append("")
285
+ if new:
286
+ lines.append("### New findings")
287
+ for s in sorted(new, key=lambda s: sev_rank.get(s.severity, 3)):
288
+ lines.append(f"- **[{s.severity.upper()}]** {md_text(s.title)}{_taxonomy_suffix(s)}")
289
+ lines.append("")
290
+ newly_suppressed = list(getattr(diff, "newly_suppressed", []) or [])
291
+ if newly_suppressed:
292
+ lines.append("### Newly suppressed in this change")
293
+ lines.append("_Active in the baseline, silenced by a suppression added here — review the reason._")
294
+ for s in sorted(newly_suppressed, key=lambda s: sev_rank.get(s.severity, 3)):
295
+ lines.append(f"- **[{s.severity.upper()}]** {md_text(s.title)}")
296
+ lines.append("")
297
+ newly_excluded = list(getattr(diff, "newly_excluded", []) or [])
298
+ if newly_excluded:
299
+ lines.append("### Newly excluded from the scan")
300
+ lines.append(
301
+ "_Active in the baseline, now under a directory skipped because of an "
302
+ "`.attackmap-output` marker committed here — the code was not analyzed._"
303
+ )
304
+ for s in sorted(newly_excluded, key=lambda s: sev_rank.get(s.severity, 3)):
305
+ lines.append(f"- **[{s.severity.upper()}]** {md_text(s.title)}")
306
+ lines.append("")
307
+ if new_instances:
308
+ lines.append("### New instances of existing findings")
309
+ for s, labels in sorted(new_instances, key=lambda t: sev_rank.get(t[0].severity, 3)):
310
+ where = ", ".join(md_code(label) for label in labels[:5])
311
+ more = f" (+{len(labels) - 5} more)" if len(labels) > 5 else ""
312
+ lines.append(f"- **[{s.severity.upper()}]** {md_text(s.title)}: {where}{more}")
313
+ lines.append("")
314
+ else:
315
+ by_sev = {"high": 0, "medium": 0, "low": 0}
316
+ for f in findings:
317
+ by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
318
+ lines.append(
319
+ f"**{by_sev['high']} high**, **{by_sev['medium']} medium**, **{by_sev['low']} low** findings."
320
+ )
321
+ lines.append("")
322
+
323
+ exploitable = sorted(
324
+ (f for f in findings if f.exploitability is not None),
325
+ key=lambda f: -(f.exploitability or 0),
326
+ )
327
+ if exploitable:
328
+ lines.append("### Most exploitable now")
329
+ for f in exploitable[:3]:
330
+ lines.append(
331
+ f"- `{f.exploitability}/100` **{(f.exploitability_tier or '').upper()}** — {md_text(f.title)}"
332
+ )
333
+ lines.append("")
334
+
335
+ if suppressions:
336
+ pending = suppressions.get("pending") or []
337
+ by_rule = suppressions.get("by_rule") or {}
338
+ if pending or by_rule:
339
+ lines.append("### Suppressions")
340
+ if pending:
341
+ ref = suppressions.get("trusted_ref") or "the base"
342
+ state = "applied anyway" if suppressions.get("pending_applied") else "**not applied** until merged"
343
+ lines.append(f"{len(pending)} suppression(s) added by this change since {md_code(ref)} — {state}:")
344
+ lines.extend(f"- {md_text(entry)}" for entry in pending[:20])
345
+ if by_rule:
346
+ counts = ", ".join(f"{md_code(rule)} ×{n}" for rule, n in sorted(by_rule.items()))
347
+ lines.append(f"Suppressed by rule: {counts}")
348
+ lines.append("")
349
+
350
+ lines.append(
351
+ "<sub>Heuristic static analysis — findings are confidence-tiered evidence, "
352
+ "not proof. See the uploaded SARIF for inline annotations.</sub>"
353
+ )
354
+ return "\n".join(lines)
355
+
356
+
357
+ def render_console_summary(scan: ScanResult, findings: list[Finding], attack_paths: list[AttackPath]) -> str:
358
+ return (
359
+ _render_console_summary(scan, findings, attack_paths)
360
+ + _limitations_note(scan)
361
+ + _analyzer_errors_note(scan)
362
+ )
363
+
364
+
365
+ def _analyzer_errors_note(scan: ScanResult) -> str:
366
+ """One line per analyzer that failed and was skipped (#220)."""
367
+ if not scan.analyzer_errors:
368
+ return ""
369
+ lines = [
370
+ f"Analyzer '{e.analyzer}' failed and was skipped: {e.error_type}: {e.message}".rstrip(": ")
371
+ for e in scan.analyzer_errors
372
+ ]
373
+ return "\n\n" + "\n".join(lines) + "\n(Results are partial; --strict-analyzers fails fast instead.)"
374
+
375
+
376
+ def _limitations_note(scan: ScanResult) -> str:
377
+ """One line per kind of thing the scan skipped (#217): unreadable files,
378
+ oversized files, symlinks — so a partial scan doesn't look complete."""
379
+ if not scan.limitations:
380
+ return ""
381
+ kinds: dict[str, int] = {}
382
+ for item in scan.limitations:
383
+ kinds[item.split(":", 1)[0].split(" (", 1)[0]] = kinds.get(item.split(":", 1)[0].split(" (", 1)[0], 0) + 1
384
+ parts = ", ".join(f"{n} {kind}" for kind, n in sorted(kinds.items()))
385
+ return f"\n\nNot analyzed: {parts} (details under scan.limitations in attackmap-report.json)."
386
+
387
+
388
+ def _render_console_summary(scan: ScanResult, findings: list[Finding], attack_paths: list[AttackPath]) -> str:
389
+ ordered_findings = sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
390
+ lines = [
391
+ f"Scanned {scan.files_scanned} files",
392
+ f"Detected languages: {', '.join(scan.languages) if scan.languages else 'none'}",
393
+ f"Routes: {len(scan.routes)}",
394
+ f"External calls: {len(scan.external_calls)}",
395
+ f"Datastores: {len(scan.databases)}",
396
+ "",
397
+ "Findings:",
398
+ ]
399
+ for finding in ordered_findings:
400
+ exploit = (
401
+ f" (exploitability {finding.exploitability}/100, {finding.exploitability_tier})"
402
+ if finding.exploitability is not None
403
+ else ""
404
+ )
405
+ lines.append(f"- [{finding.severity.upper()}] {finding.title}{exploit}")
406
+
407
+ exploitable = sorted(
408
+ (f for f in findings if f.exploitability is not None),
409
+ key=lambda f: -(f.exploitability or 0),
410
+ )
411
+ if exploitable:
412
+ lines.append("")
413
+ lines.append("Most exploitable now:")
414
+ for finding in exploitable[:5]:
415
+ lines.append(
416
+ f"- {finding.exploitability}/100 [{finding.exploitability_tier}] {finding.title}"
417
+ )
418
+
419
+ lines.append("")
420
+ lines.append("Attack paths:")
421
+ for path in attack_paths:
422
+ lines.append(f"- {path.name}: {path.impact}")
423
+
424
+ return "\n".join(lines)