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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +278 -0
- attackmap/analyzer_contracts.py +104 -0
- attackmap/analyzers.py +561 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +140 -0
- attackmap/cli.py +185 -0
- attackmap/context_pack.py +82 -0
- attackmap/control_model.py +272 -0
- attackmap/defensive_review.py +597 -0
- attackmap/detection_opportunities.py +343 -0
- attackmap/graph.py +26 -0
- attackmap/insights.py +702 -0
- attackmap/llm_review.py +346 -0
- attackmap/models.py +352 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +121 -0
- attackmap/report.py +74 -0
- attackmap/review_eval.py +281 -0
- attackmap/review_json.py +300 -0
- attackmap/review_prompts.py +230 -0
- attackmap/scanner.py +446 -0
- attackmap/sdk/__init__.py +41 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/models.py +29 -0
- attackmap/security_overlay.py +69 -0
- attackmap/threat_model.py +919 -0
- attackmap-0.1.0.dist-info/METADATA +257 -0
- attackmap-0.1.0.dist-info/RECORD +33 -0
- attackmap-0.1.0.dist-info/WHEEL +5 -0
- attackmap-0.1.0.dist-info/entry_points.txt +2 -0
- attackmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- attackmap-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|