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
@@ -0,0 +1,647 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .md import defang_markdown, md_code, md_text
6
+ from .models import (
7
+ Asset,
8
+ AttackPath,
9
+ AttackSurface,
10
+ Control,
11
+ DetectionOpportunity,
12
+ Finding,
13
+ Insight,
14
+ ScanResult,
15
+ )
16
+ from .detection_opportunities import SIGMA_POINTER, detection_coverage
17
+ from .security_overlay import SecurityOverlay, build_security_overlay
18
+ from .taxonomy import taxonomy_label
19
+
20
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/")
21
+
22
+
23
+ def _severity_rank(value: str) -> int:
24
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
25
+
26
+
27
+ def _surface_risk_rank(value: str) -> int:
28
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
29
+
30
+
31
+ def _confidence_rank(value: str) -> float:
32
+ return {"high": 3.0, "medium": 2.0, "low": 1.0}.get(value, 1.0)
33
+
34
+
35
+ def _extract_numeric_confidence(text: str) -> float | None:
36
+ match = re.search(r"confidence=([0-9]+(?:\.[0-9]+)?)", text.lower())
37
+ if match is None:
38
+ return None
39
+ try:
40
+ return float(match.group(1))
41
+ except ValueError:
42
+ return None
43
+
44
+
45
+ def _contains_any(text: str, keywords: tuple[str, ...]) -> bool:
46
+ lowered = text.lower()
47
+ return any(keyword in lowered for keyword in keywords)
48
+
49
+
50
+ def _is_low_quality_source(path_or_text: str) -> bool:
51
+ normalized = path_or_text.replace("\\", "/").lower()
52
+ return any(segment in f"/{normalized}/" for segment in LOW_QUALITY_SEGMENTS)
53
+
54
+
55
+ def _is_protocol_derived_surface(surface: AttackSurface) -> bool:
56
+ file_lower = surface.file.lower()
57
+ route_lower = surface.route.lower()
58
+ return "lexicon" in file_lower or "/xrpc/" in route_lower or "atproto_" in " ".join(surface.auth_signals).lower()
59
+
60
+
61
+ def _entrypoint_counts(attack_surfaces: list[AttackSurface]) -> tuple[int, int, int, int]:
62
+ observed_runtime_public = 0
63
+ protocol_derived = 0
64
+ internal_only = 0
65
+ low_quality = 0
66
+ for surface in attack_surfaces:
67
+ if _is_low_quality_source(surface.file):
68
+ low_quality += 1
69
+ continue
70
+ if surface.exposure == "internal":
71
+ internal_only += 1
72
+ continue
73
+ if _is_protocol_derived_surface(surface):
74
+ protocol_derived += 1
75
+ continue
76
+ if surface.exposure == "public":
77
+ observed_runtime_public += 1
78
+ return observed_runtime_public, protocol_derived, internal_only, low_quality
79
+
80
+
81
+ def _surface_provenance(surface: AttackSurface) -> str:
82
+ if _is_low_quality_source(surface.file):
83
+ return "low_quality"
84
+ if _is_protocol_derived_surface(surface):
85
+ return "protocol_derived"
86
+ if surface.exposure == "public":
87
+ return "observed_runtime"
88
+ return "other"
89
+
90
+
91
+ def _provenance_breakdown(surfaces: list[AttackSurface]) -> str:
92
+ if not surfaces:
93
+ return "observed_runtime=0%, protocol_derived=0%, low_quality=0%"
94
+ counts = {"observed_runtime": 0, "protocol_derived": 0, "low_quality": 0}
95
+ for surface in surfaces:
96
+ key = _surface_provenance(surface)
97
+ if key in counts:
98
+ counts[key] += 1
99
+ total = max(len(surfaces), 1)
100
+ observed_pct = round((counts["observed_runtime"] / total) * 100)
101
+ protocol_pct = round((counts["protocol_derived"] / total) * 100)
102
+ low_quality_pct = round((counts["low_quality"] / total) * 100)
103
+ return (
104
+ f"observed_runtime={observed_pct}%, "
105
+ f"protocol_derived={protocol_pct}%, "
106
+ f"low_quality={low_quality_pct}%"
107
+ )
108
+
109
+
110
+ def _provenance_counts(surfaces: list[AttackSurface]) -> dict[str, int]:
111
+ counts = {"observed_runtime": 0, "protocol_derived": 0, "low_quality": 0}
112
+ for surface in surfaces:
113
+ key = _surface_provenance(surface)
114
+ if key in counts:
115
+ counts[key] += 1
116
+ return counts
117
+
118
+
119
+ def _recommendation_basis_label(surfaces: list[AttackSurface]) -> str:
120
+ if not surfaces:
121
+ return "inferred-weak"
122
+ counts = _provenance_counts(surfaces)
123
+ total = max(sum(counts.values()), 1)
124
+ observed_pct = (counts["observed_runtime"] / total) * 100
125
+ protocol_pct = (counts["protocol_derived"] / total) * 100
126
+ low_quality_pct = (counts["low_quality"] / total) * 100
127
+ if observed_pct >= 50:
128
+ return "observed"
129
+ if protocol_pct >= 50:
130
+ return "inferred-protocol"
131
+ if low_quality_pct >= 50:
132
+ return "low-quality-evidence"
133
+ return "mixed-evidence"
134
+
135
+
136
+ def _top_score_reasons(factors: dict[str, float], top_n: int = 3) -> str:
137
+ ranked = sorted(factors.items(), key=lambda item: item[1], reverse=True)
138
+ top = [f"{name}={value:.1f}" for name, value in ranked[:top_n] if value > 0]
139
+ return ", ".join(top)
140
+
141
+
142
+ def _surface_priority(surface: AttackSurface) -> tuple[float, dict[str, float]]:
143
+ exposure_score = {"public": 3.0, "unknown": 2.0, "internal": 1.0}.get(surface.exposure, 1.0)
144
+ privilege_score = {
145
+ "admin": 3.0,
146
+ "auth": 3.0,
147
+ "webhook": 2.0,
148
+ "upload": 2.0,
149
+ "public_api": 1.5,
150
+ "internal": 1.0,
151
+ "health": 0.5,
152
+ }.get(surface.category, 1.0)
153
+ reachability_score = 2.0 if surface.exposure == "public" else 1.0
154
+ trust_boundary_score = (2.0 if surface.data_store_interaction else 0.0) + (2.0 if surface.outbound_integration else 0.0)
155
+ chain_depth_score = 1.0 + (1.0 if surface.data_store_interaction and surface.outbound_integration else 0.0)
156
+ confidence_score = 2.0 if surface.auth_signals else 1.0
157
+ weighted = {
158
+ "exposure": 2.0 * exposure_score,
159
+ "privilege": 2.0 * privilege_score,
160
+ "reachability": 1.5 * reachability_score,
161
+ "trust_boundary": 1.5 * trust_boundary_score,
162
+ "chain_depth": 1.0 * chain_depth_score,
163
+ "confidence": 1.0 * confidence_score,
164
+ }
165
+ if _is_low_quality_source(surface.file):
166
+ weighted["source_quality_penalty"] = -8.0
167
+ elif _is_protocol_derived_surface(surface):
168
+ weighted["source_quality_penalty"] = -3.0
169
+ elif surface.exposure == "public":
170
+ weighted["source_quality_bonus"] = 1.0
171
+ return round(sum(weighted.values()), 2), weighted
172
+
173
+
174
+ def _path_priority(path: AttackPath) -> tuple[float, dict[str, float]]:
175
+ text = f"{path.name} {path.impact} {' '.join(path.steps)}"
176
+ exposure_score = 3.0 if _contains_any(text, ("attacker reaches", "public", "/xrpc/")) else 2.0
177
+ privilege_score = 1.0 + (
178
+ 2.0
179
+ if _contains_any(text, ("admin", "privileged", "authorization", "authz", "token", "identity"))
180
+ else 0.5
181
+ )
182
+ reachability_score = 2.0 if any(step.lower().startswith("entry:") for step in path.steps) else 1.0
183
+ trust_boundary_score = 1.0 + sum(
184
+ 1.0
185
+ for step in path.steps
186
+ if _contains_any(step, ("propagation", "service", "edge", "external", "config risk", "sink"))
187
+ )
188
+ chain_depth_score = max(1.0, min(len(path.steps), 8) / 2.0)
189
+ evidence_text = " ".join(path.steps)
190
+ numeric_confidence = _extract_numeric_confidence(evidence_text)
191
+ confidence_score = 1.5 + (numeric_confidence if numeric_confidence is not None else 0.5)
192
+ weighted = {
193
+ "exposure": 2.0 * exposure_score,
194
+ "privilege": 2.0 * privilege_score,
195
+ "reachability": 1.5 * reachability_score,
196
+ "trust_boundary": 2.0 * trust_boundary_score,
197
+ "chain_depth": 1.2 * chain_depth_score,
198
+ "confidence": 1.5 * confidence_score,
199
+ }
200
+ if _is_low_quality_source(text):
201
+ weighted["source_quality_penalty"] = -10.0
202
+ return round(sum(weighted.values()), 2), weighted
203
+
204
+
205
+ def _related_surfaces_for_finding(finding: Finding, attack_surfaces: list[AttackSurface]) -> list[AttackSurface]:
206
+ combined = f"{finding.title} {' '.join(finding.evidence)}".lower()
207
+ related = [
208
+ surface
209
+ for surface in attack_surfaces
210
+ if surface.route.lower() in combined
211
+ or surface.file.lower() in combined
212
+ or surface.category.lower() in combined
213
+ ]
214
+ return related
215
+
216
+
217
+ def _related_paths_for_finding(finding: Finding, attack_paths: list[AttackPath], surfaces: list[AttackSurface]) -> list[AttackPath]:
218
+ if not attack_paths:
219
+ return []
220
+ combined = f"{finding.title} {' '.join(finding.evidence)}".lower()
221
+ surface_tokens = [surface.route.lower() for surface in surfaces if surface.route]
222
+ related = [
223
+ path
224
+ for path in attack_paths
225
+ if path.name.lower() in combined
226
+ or any(step.lower() in combined for step in path.steps[:2])
227
+ or any(token in " ".join(path.steps).lower() for token in surface_tokens)
228
+ ]
229
+ return related
230
+
231
+
232
+ def _finding_priority(finding: Finding, attack_surfaces: list[AttackSurface], attack_paths: list[AttackPath]) -> tuple[float, dict[str, float]]:
233
+ surfaces = _related_surfaces_for_finding(finding, attack_surfaces)
234
+ paths = _related_paths_for_finding(finding, attack_paths, surfaces)
235
+
236
+ exposure_score = max((3.0 if surface.exposure == "public" else 1.0 for surface in surfaces), default=1.0)
237
+ privilege_score = max(
238
+ (
239
+ 3.0
240
+ if surface.category in {"admin", "auth"}
241
+ else 2.0
242
+ if surface.category in {"webhook", "upload"}
243
+ else 1.0
244
+ for surface in surfaces
245
+ ),
246
+ default=1.0,
247
+ )
248
+ if _contains_any(finding.title, ("privilege", "admin", "auth", "token", "identity")):
249
+ privilege_score = max(privilege_score, 2.5)
250
+ reachability_score = max((2.0 if surface.exposure == "public" else 1.0 for surface in surfaces), default=1.0)
251
+ trust_boundary_score = max(
252
+ (
253
+ (2.0 if surface.data_store_interaction else 0.0) + (2.0 if surface.outbound_integration else 0.0)
254
+ for surface in surfaces
255
+ ),
256
+ default=1.0,
257
+ )
258
+ chain_depth_score = max((min(len(path.steps), 8) / 2.0 for path in paths), default=0.5)
259
+ numeric_confidence = max(
260
+ (
261
+ value
262
+ for value in (_extract_numeric_confidence(item) for item in finding.evidence)
263
+ if value is not None
264
+ ),
265
+ default=None,
266
+ )
267
+ confidence_score = _confidence_rank(finding.confidence) + (numeric_confidence if numeric_confidence is not None else 0.0)
268
+
269
+ weighted = {
270
+ "exposure": 2.0 * exposure_score,
271
+ "privilege": 2.0 * privilege_score,
272
+ "reachability": 1.5 * reachability_score,
273
+ "trust_boundary": 2.0 * trust_boundary_score,
274
+ "chain_depth": 1.2 * chain_depth_score,
275
+ "confidence": 1.5 * confidence_score,
276
+ }
277
+ if not surfaces:
278
+ weighted["evidence_linkage_penalty"] = -8.0
279
+ elif len(surfaces) == 1:
280
+ weighted["evidence_linkage_bonus"] = 0.5
281
+ else:
282
+ weighted["evidence_linkage_bonus"] = min(2.0, 0.5 * len(surfaces))
283
+ provenance = _provenance_counts(surfaces)
284
+ weighted["source_quality"] = (
285
+ 1.5 * provenance["observed_runtime"]
286
+ - 0.5 * provenance["protocol_derived"]
287
+ - 3.0 * provenance["low_quality"]
288
+ )
289
+ evidence_blob = f"{finding.title} {' '.join(finding.evidence)}"
290
+ if _is_low_quality_source(evidence_blob) and all(_is_low_quality_source(surface.file) for surface in surfaces):
291
+ weighted["source_quality_penalty"] = -10.0
292
+ return round(sum(weighted.values()), 2), weighted
293
+
294
+
295
+ def _overview_line(scan: ScanResult) -> str:
296
+ repo_type = "web/service-facing" if scan.routes else "non-web"
297
+ return (
298
+ f"- AttackMap reviewed a {repo_type} codebase with {len(scan.routes)} inferred entry point"
299
+ f"{'s' if len(scan.routes) != 1 else ''} across {scan.files_scanned} scanned files."
300
+ )
301
+
302
+
303
+ def _strengths(scan: ScanResult, attack_surfaces: list[AttackSurface]) -> list[str]:
304
+ items: list[tuple[float, str]] = []
305
+ auth_hints = sorted({hint.hint for hint in scan.auth_hints})
306
+ secure_surfaces = [surface for surface in attack_surfaces if surface.auth_signals]
307
+ internal_surfaces = [surface for surface in attack_surfaces if surface.exposure == "internal"]
308
+ low_risk_surfaces = [surface for surface in attack_surfaces if surface.risk == "low"]
309
+
310
+ if auth_hints:
311
+ items.append(
312
+ (
313
+ 9.0 + min(len(auth_hints), 5),
314
+ f"- Authentication/identity indicators are present in code signals ({', '.join(auth_hints[:6])}).",
315
+ )
316
+ )
317
+ if secure_surfaces:
318
+ items.append(
319
+ (
320
+ 8.0 + min(len(secure_surfaces), 5),
321
+ f"- {len(secure_surfaces)} route(s) include nearby auth indicators, which may support defensive enforcement if validated at runtime.",
322
+ )
323
+ )
324
+ if internal_surfaces:
325
+ items.append((7.0, f"- {len(internal_surfaces)} route(s) were inferred as internal-only, reducing direct internet exposure if correctly segmented."))
326
+ if low_risk_surfaces:
327
+ items.append((6.0, f"- {len(low_risk_surfaces)} route(s) were classified as low-risk operational surfaces."))
328
+ if scan.secret_hints:
329
+ items.append((6.5, "- Secret usage appears environment-driven rather than hardcoded literals."))
330
+ else:
331
+ items.append((5.0, "- No obvious secret-like environment references were detected in executable paths."))
332
+ if not items:
333
+ return ["- No strong defensive indicators were detected heuristically; manual review is still recommended."]
334
+ return [line for _, line in sorted(items, key=lambda item: item[0], reverse=True)[:4]]
335
+
336
+
337
+ def _weaknesses(attack_surfaces: list[AttackSurface], findings: list[Finding], attack_paths: list[AttackPath]) -> list[str]:
338
+ items: list[str] = []
339
+ finding_scores = sorted(
340
+ (
341
+ (_finding_priority(finding, attack_surfaces, attack_paths), finding)
342
+ for finding in findings
343
+ ),
344
+ key=lambda item: (item[0][0], -_severity_rank(item[1].severity)),
345
+ reverse=True,
346
+ )
347
+ for (score, factors), finding in finding_scores[:3]:
348
+ related_surfaces = _related_surfaces_for_finding(finding, attack_surfaces)
349
+ label = taxonomy_label(finding.rule_id)
350
+ items.append(
351
+ f"- [{finding.severity.upper()} | score {score:.1f}] {md_text(finding.title)}"
352
+ + (f" ({label})" if label else "")
353
+ )
354
+ items.append(f"- Reason: {_top_score_reasons(factors, top_n=3)}")
355
+ items.append(f"- Provenance: {_provenance_breakdown(related_surfaces)}")
356
+
357
+ hotspot_surfaces = [surface for surface in attack_surfaces if not _is_low_quality_source(surface.file)]
358
+ if not hotspot_surfaces:
359
+ hotspot_surfaces = list(attack_surfaces)
360
+
361
+ hotspot_scores = sorted(
362
+ (
363
+ (_surface_priority(surface), surface)
364
+ for surface in hotspot_surfaces
365
+ ),
366
+ key=lambda item: (item[0][0], -_surface_risk_rank(item[1].risk), item[1].route),
367
+ reverse=True,
368
+ )
369
+ # Avoid duplicate hotspot rows for the same logical surface (e.g., ANY/SUBSCRIBE variants in one lexicon file).
370
+ deduped_hotspots: list[tuple[tuple[float, dict[str, float]], AttackSurface]] = []
371
+ seen_hotspot_keys: set[tuple[str, str, str, str]] = set()
372
+ for scored_surface in hotspot_scores:
373
+ (_score, _factors), surface = scored_surface
374
+ key = (surface.route, surface.file, surface.category, surface.exposure)
375
+ if key in seen_hotspot_keys:
376
+ continue
377
+ seen_hotspot_keys.add(key)
378
+ deduped_hotspots.append(scored_surface)
379
+ if len(deduped_hotspots) >= 2:
380
+ break
381
+
382
+ for (score, factors), surface in deduped_hotspots:
383
+ items.append(
384
+ f"- Hotspot [score {score:.1f}]: {surface.method} {md_code(surface.route)} ({md_code(surface.location())}) -> {surface.category} / {surface.risk}"
385
+ )
386
+ items.append(f"- Reason: {_top_score_reasons(factors, top_n=2)}")
387
+ items.append(f"- Provenance: {_provenance_breakdown([surface])}")
388
+ return items[:12]
389
+
390
+
391
+ def _evidence_chains(attack_paths: list[AttackPath]) -> list[str]:
392
+ if not attack_paths:
393
+ return ["- No explicit attack path was generated from current signals."]
394
+ lines: list[str] = []
395
+ scored_paths = sorted(
396
+ ((_path_priority(path), path) for path in attack_paths),
397
+ key=lambda item: item[0][0],
398
+ reverse=True,
399
+ )
400
+ for (score, factors), path in scored_paths[:3]:
401
+ lines.append(f"- [score {score:.1f}] {md_text(path.name)}: {md_text(path.impact)}")
402
+ lines.append(f"- Reason: {_top_score_reasons(factors, top_n=3)}")
403
+ if path.steps:
404
+ lines.append(f"- Key step: {md_text(path.steps[0])}")
405
+ return lines
406
+
407
+
408
+ def _recommendations(findings: list[Finding], attack_paths: list[AttackPath], attack_surfaces: list[AttackSurface]) -> list[str]:
409
+ recommendations: list[str] = []
410
+ low_quality_recommendations: list[str] = []
411
+ scored_findings = sorted(
412
+ (
413
+ (_finding_priority(finding, attack_surfaces, attack_paths), finding)
414
+ for finding in findings
415
+ ),
416
+ key=lambda item: item[0][0],
417
+ reverse=True,
418
+ )
419
+ for (_score, _factors), finding in scored_findings:
420
+ if not finding.mitigation:
421
+ continue
422
+ related_surfaces = _related_surfaces_for_finding(finding, attack_surfaces)
423
+ basis = _recommendation_basis_label(related_surfaces)
424
+ line = f"- [{basis}] {finding.mitigation}"
425
+ if basis == "low-quality-evidence":
426
+ if line not in low_quality_recommendations:
427
+ low_quality_recommendations.append(line)
428
+ continue
429
+ if line not in recommendations:
430
+ recommendations.append(line)
431
+ if len(recommendations) >= 4:
432
+ break
433
+
434
+ for line in low_quality_recommendations:
435
+ if len(recommendations) >= 4:
436
+ break
437
+ if line not in recommendations:
438
+ recommendations.append(line)
439
+
440
+ high_scored_path = max((_path_priority(path)[0] for path in attack_paths), default=0.0)
441
+ if attack_paths and len(recommendations) < 5 and high_scored_path >= 22.0:
442
+ recommendations.append(
443
+ "- Validate every trust boundary hop in attack paths with explicit authn/authz and least-privilege service credentials."
444
+ )
445
+
446
+ if len(recommendations) < 5:
447
+ recommendations.append(
448
+ "- Add targeted tests for highest-risk routes and service edges to prevent regressions in boundary enforcement."
449
+ )
450
+ return recommendations[:5]
451
+
452
+
453
+ def _render_assets_section(assets: list[Asset]) -> list[str]:
454
+ if not assets:
455
+ return ["- No high-value assets identified from code signals."]
456
+ lines: list[str] = []
457
+ for asset in assets[:8]:
458
+ head = f"- **{md_text(asset.name)}** ({asset.kind}, criticality={asset.criticality})"
459
+ evidence_inline = "; ".join(md_code(e) for e in asset.evidence[:3]) if asset.evidence else ""
460
+ if evidence_inline:
461
+ head = f"{head} — {evidence_inline}"
462
+ lines.append(head)
463
+ if len(assets) > 8:
464
+ lines.append(f"- …and {len(assets) - 8} more.")
465
+ return lines
466
+
467
+
468
+ def _render_controls_section(controls: list[Control]) -> list[str]:
469
+ if not controls:
470
+ return ["- No defensive controls observed from code signals."]
471
+ present = [c for c in controls if c.strength != "absent"]
472
+ absent = [c for c in controls if c.strength == "absent"]
473
+ lines: list[str] = []
474
+ if present:
475
+ lines.append("**Observed:**")
476
+ for control in present[:8]:
477
+ placement = f" — placements: {len(control.placements)}" if control.placements else ""
478
+ lines.append(f"- `{control.kind}` · {control.name} (strength={control.strength}){placement}")
479
+ if len(present) > 8:
480
+ lines.append(f"- …and {len(present) - 8} more.")
481
+ if absent:
482
+ lines.append("")
483
+ lines.append("**Expected but not observed (control absences):**")
484
+ for control in absent[:6]:
485
+ note = f" — {control.notes}" if control.notes else ""
486
+ lines.append(f"- `{control.kind}` · {control.name}{note}")
487
+ return lines
488
+
489
+
490
+ def _render_notable_observations(insights: list[Insight]) -> list[str]:
491
+ if not insights:
492
+ return ["- No notable cross-cutting observations were generated for this scan."]
493
+ lines: list[str] = []
494
+ for insight in insights[:6]:
495
+ head = f"- **[{insight.severity.upper()}]** {md_text(insight.title)} _(confidence={insight.confidence}, kind={insight.kind})_"
496
+ lines.append(head)
497
+ lines.append(f" - {insight.narrative}")
498
+ if insight.suggested_action:
499
+ lines.append(f" - _Suggested action:_ {insight.suggested_action}")
500
+ if insight.attack_techniques:
501
+ techniques_text = ", ".join(
502
+ f"`{t.technique_id}` {t.name}" for t in insight.attack_techniques[:4]
503
+ )
504
+ lines.append(f" - _ATT&CK:_ {techniques_text}")
505
+ if insight.evidence:
506
+ lines.append(f" - _Evidence:_ {'; '.join(md_code(e) for e in insight.evidence[:3])}")
507
+ if len(insights) > 6:
508
+ lines.append(f"- …and {len(insights) - 6} more notable observations in JSON output.")
509
+ return lines
510
+
511
+
512
+ def _render_detection_opportunities(
513
+ opportunities: list[DetectionOpportunity], findings: list[Finding] | None = None
514
+ ) -> list[str]:
515
+ if not opportunities:
516
+ return ["- No detection opportunities were generated for this scan."]
517
+ lines: list[str] = []
518
+ # Prose sketches: everything except the per-route rules whose suggested
519
+ # rule is just a pointer to their Sigma rule.
520
+ sketches = [o for o in opportunities if not o.suggested_rule.startswith(SIGMA_POINTER)]
521
+ rules = [o for o in opportunities if o.sigma is not None]
522
+ for opp in sketches[:5]:
523
+ lines.append(f"- **{opp.title}** _(signal={opp.signal_kind})_")
524
+ lines.append(f" - _Why:_ {opp.rationale}")
525
+ lines.append(f" - _Suggested rule:_ {opp.suggested_rule}")
526
+ if opp.attack_techniques:
527
+ techniques_text = ", ".join(
528
+ f"`{t.technique_id}`" for t in opp.attack_techniques[:4]
529
+ )
530
+ lines.append(f" - _ATT&CK:_ {techniques_text}")
531
+ if len(sketches) > 5:
532
+ lines.append(f"- …and {len(sketches) - 5} more detection opportunities in JSON output.")
533
+ if rules:
534
+ lines += ["", "### Deployable Sigma rules", ""]
535
+ for opp in rules[:_MAX_RULES_SHOWN]:
536
+ sigma = opp.sigma or {}
537
+ source = opp.logsource.label() if opp.logsource else "unknown"
538
+ extra = " + correlation" if opp.sigma_correlation else ""
539
+ lines.append(
540
+ f"- **{md_text(sigma.get('title', opp.title))}** _(logsource={md_text(source)}, "
541
+ f"level={md_text(sigma.get('level', ''))}{extra})_ — Sigma id {md_code(sigma.get('id', ''))}"
542
+ )
543
+ if len(rules) > _MAX_RULES_SHOWN:
544
+ lines.append(f"- …and {len(rules) - _MAX_RULES_SHOWN} more Sigma rules in JSON output.")
545
+ lines.append(
546
+ "- The rules are written to `attackmap-detections/*.yml` (with `--format json` or `all`); each rule's "
547
+ "`test_events` in the JSON report holds sample log lines it must and must not match."
548
+ )
549
+ coverage = detection_coverage(findings or [], opportunities)
550
+ if coverage:
551
+ lines += [
552
+ "",
553
+ "### Detection coverage",
554
+ "",
555
+ "| Finding class | Sigma rule | Log sources |",
556
+ "| --- | --- | --- |",
557
+ ]
558
+ for row in coverage:
559
+ count = int(row["sigma_rules"]) # type: ignore[call-overload]
560
+ present = f"yes ({count})" if count else "no"
561
+ sources = ", ".join(str(s) for s in row["logsources"]) or "—" # type: ignore[attr-defined]
562
+ rule_cell = md_code(row["rule_id"]).replace("|", "\\|")
563
+ lines.append(f"| {rule_cell} | {present} | {md_text(sources)} |")
564
+ return lines
565
+
566
+
567
+ _MAX_RULES_SHOWN = 12
568
+
569
+
570
+ def _render_attack_techniques_summary(overlay: SecurityOverlay) -> list[str]:
571
+ by_id: dict[str, tuple[str, str]] = {}
572
+ for insight in overlay.insights:
573
+ for t in insight.attack_techniques:
574
+ by_id.setdefault(t.technique_id, (t.name, t.tactic))
575
+ for finding in overlay.findings:
576
+ for t in finding.attack_techniques:
577
+ by_id.setdefault(t.technique_id, (t.name, t.tactic))
578
+ if not by_id:
579
+ return ["- No ATT&CK techniques were mapped from the current findings/insights."]
580
+ lines: list[str] = []
581
+ for tid in sorted(by_id):
582
+ name, tactic = by_id[tid]
583
+ lines.append(f"- `{tid}` **{name}** — _{tactic}_")
584
+ return lines
585
+
586
+
587
+ def render_defensive_review(
588
+ scan: ScanResult,
589
+ attack_surfaces: list[AttackSurface],
590
+ findings: list[Finding],
591
+ attack_paths: list[AttackPath],
592
+ ) -> str:
593
+ observed_runtime_public, protocol_derived, internal_only, low_quality = _entrypoint_counts(attack_surfaces)
594
+ overlay = build_security_overlay(scan, attack_surfaces, findings, attack_paths)
595
+ lines = [
596
+ "# Defensive Review",
597
+ "",
598
+ "## System Overview",
599
+ _overview_line(scan),
600
+ f"- Raw inferred entry points: {len(scan.routes)}",
601
+ f"- Observed runtime/public surfaces: {observed_runtime_public}",
602
+ f"- Protocol/lexicon-derived surfaces (inferred): {protocol_derived}",
603
+ f"- Internal-only surfaces: {internal_only}",
604
+ f"- Test/example/mocked surfaces (down-weighted): {low_quality}",
605
+ f"- Languages: {', '.join(scan.languages) if scan.languages else 'none'}",
606
+ f"- Datastores: {', '.join(sorted({db.kind for db in scan.databases})) if scan.databases else 'none'}",
607
+ f"- External dependencies observed: {len(scan.external_calls)}",
608
+ f"- Identified assets: {len(overlay.assets)} | controls observed: "
609
+ f"{sum(1 for c in overlay.controls if c.strength != 'absent')} | "
610
+ f"control absences: {sum(1 for c in overlay.controls if c.strength == 'absent')}",
611
+ "",
612
+ "## Notable Observations",
613
+ *_render_notable_observations(overlay.insights),
614
+ "",
615
+ "## ATT&CK Techniques Mapped",
616
+ *_render_attack_techniques_summary(overlay),
617
+ "",
618
+ "## Detection Opportunities",
619
+ *_render_detection_opportunities(overlay.detection_opportunities, overlay.findings),
620
+ "",
621
+ "## Asset Inventory",
622
+ *_render_assets_section(overlay.assets),
623
+ "",
624
+ "## Defensive Controls",
625
+ *_render_controls_section(overlay.controls),
626
+ "",
627
+ "## Strengths",
628
+ *_strengths(scan, attack_surfaces),
629
+ "",
630
+ "## Weaknesses / Risk Hotspots",
631
+ *_weaknesses(attack_surfaces, findings, attack_paths),
632
+ "",
633
+ "## Key Evidence Chains",
634
+ *_evidence_chains(attack_paths),
635
+ "",
636
+ "## Recommendations",
637
+ *_recommendations(findings, attack_paths, attack_surfaces),
638
+ "",
639
+ "## Analyst Notes",
640
+ "- This defensive review is heuristic and intended as an engineering triage starting point.",
641
+ "- Validate top risks with repository-specific architecture context and runtime controls.",
642
+ "- Notable observations are cross-cutting hypotheses; treat them as triage prompts, not verdicts.",
643
+ ]
644
+ # Every section interpolates repo-derived text (routes, files, evidence);
645
+ # this document has no intentional links/HTML, so defang the whole thing
646
+ # as a backstop to the per-field escaping above (#233).
647
+ return defang_markdown("\n".join(lines))