attackmap 0.1.0__py3-none-any.whl

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