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
attackmap/insights.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
Asset,
|
|
9
|
+
AttackPath,
|
|
10
|
+
AttackSurface,
|
|
11
|
+
Control,
|
|
12
|
+
ControlKind,
|
|
13
|
+
Insight,
|
|
14
|
+
InsightKind,
|
|
15
|
+
ScanResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/", "/test_", "/_test.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_low_quality(path: str) -> bool:
|
|
22
|
+
normalized = ("/" + path.replace("\\", "/").lower() + "/")
|
|
23
|
+
return any(segment in normalized for segment in LOW_QUALITY_SEGMENTS)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class _DetectorContext:
|
|
28
|
+
scan: ScanResult
|
|
29
|
+
attack_surfaces: list[AttackSurface]
|
|
30
|
+
findings_titles: list[str]
|
|
31
|
+
attack_paths: list[AttackPath]
|
|
32
|
+
assets: list[Asset]
|
|
33
|
+
controls: list[Control]
|
|
34
|
+
present_controls_by_kind: dict[ControlKind, list[Control]]
|
|
35
|
+
absent_controls_by_kind: dict[ControlKind, list[Control]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _module_of(file_path: str) -> str:
|
|
39
|
+
parts = file_path.replace("\\", "/").split("/")
|
|
40
|
+
if len(parts) >= 2:
|
|
41
|
+
return "/".join(parts[:-1])
|
|
42
|
+
return parts[0] if parts else file_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _surface_loc(surface: AttackSurface) -> str:
|
|
46
|
+
"""`file:line` for a surface if line known, else `file`."""
|
|
47
|
+
return f"{surface.file}:{surface.line}" if surface.line is not None else surface.file
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _hint_loc(file_path: str, line: int | None) -> str:
|
|
51
|
+
return f"{file_path}:{line}" if line is not None else file_path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_context(
|
|
55
|
+
scan: ScanResult,
|
|
56
|
+
attack_surfaces: list[AttackSurface],
|
|
57
|
+
findings_titles: list[str],
|
|
58
|
+
attack_paths: list[AttackPath],
|
|
59
|
+
assets: list[Asset],
|
|
60
|
+
controls: list[Control],
|
|
61
|
+
) -> _DetectorContext:
|
|
62
|
+
present: dict[ControlKind, list[Control]] = defaultdict(list)
|
|
63
|
+
absent: dict[ControlKind, list[Control]] = defaultdict(list)
|
|
64
|
+
for control in controls:
|
|
65
|
+
if control.strength == "absent":
|
|
66
|
+
absent[control.kind].append(control)
|
|
67
|
+
else:
|
|
68
|
+
present[control.kind].append(control)
|
|
69
|
+
return _DetectorContext(
|
|
70
|
+
scan=scan,
|
|
71
|
+
attack_surfaces=attack_surfaces,
|
|
72
|
+
findings_titles=findings_titles,
|
|
73
|
+
attack_paths=attack_paths,
|
|
74
|
+
assets=assets,
|
|
75
|
+
controls=controls,
|
|
76
|
+
present_controls_by_kind=dict(present),
|
|
77
|
+
absent_controls_by_kind=dict(absent),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _detect_shared_secret_blast_radius(ctx: _DetectorContext) -> list[Insight]:
|
|
82
|
+
"""Same secret name referenced across many files/modules — single compromise = wide blast."""
|
|
83
|
+
locations_by_secret: dict[str, list[tuple[str, int | None]]] = defaultdict(list)
|
|
84
|
+
files_by_secret: dict[str, set[str]] = defaultdict(set)
|
|
85
|
+
for hint in ctx.scan.secret_hints:
|
|
86
|
+
if _is_low_quality(hint.file):
|
|
87
|
+
continue
|
|
88
|
+
locations_by_secret[hint.name].append((hint.file, hint.line))
|
|
89
|
+
files_by_secret[hint.name].add(hint.file)
|
|
90
|
+
|
|
91
|
+
insights: list[Insight] = []
|
|
92
|
+
for secret_name, locations in locations_by_secret.items():
|
|
93
|
+
files = files_by_secret[secret_name]
|
|
94
|
+
modules = {_module_of(file_path) for file_path in files}
|
|
95
|
+
if len(modules) < 3:
|
|
96
|
+
continue
|
|
97
|
+
sensitive_match = any(
|
|
98
|
+
keyword in secret_name.lower()
|
|
99
|
+
for keyword in ("jwt", "session", "auth", "signing", "private", "master", "encryption", "stripe", "webhook")
|
|
100
|
+
)
|
|
101
|
+
severity = "high" if sensitive_match else "medium"
|
|
102
|
+
confidence = "high" if len(modules) >= 4 else "medium"
|
|
103
|
+
related_assets = [asset.id for asset in ctx.assets if any(loc in files for loc in asset.locations)]
|
|
104
|
+
evidence_locations = sorted({_hint_loc(file_path, line) for file_path, line in locations})[:8]
|
|
105
|
+
insights.append(
|
|
106
|
+
Insight(
|
|
107
|
+
id=f"insight:shared-secret:{secret_name.lower()}",
|
|
108
|
+
kind="shared_secret_blast_radius",
|
|
109
|
+
title=f"Shared secret `{secret_name}` referenced in {len(modules)} modules",
|
|
110
|
+
narrative=(
|
|
111
|
+
f"`{secret_name}` is referenced from {len(modules)} distinct modules "
|
|
112
|
+
f"({len(files)} files). A single compromise of this credential affects every "
|
|
113
|
+
"module that consumes it; rotation requires a coordinated multi-service deploy. "
|
|
114
|
+
"If this is a signing key for inter-service trust, the blast radius extends to all "
|
|
115
|
+
"trust relationships derived from it."
|
|
116
|
+
),
|
|
117
|
+
severity=severity, # type: ignore[arg-type]
|
|
118
|
+
confidence=confidence, # type: ignore[arg-type]
|
|
119
|
+
evidence=[f"{secret_name} ({location})" for location in evidence_locations],
|
|
120
|
+
related_assets=related_assets,
|
|
121
|
+
suggested_action=(
|
|
122
|
+
"Scope this secret per service (separate signing keys per trust boundary), or "
|
|
123
|
+
"centralize verification through a single owning service that the others call."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return insights
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _detect_sensitive_asset_reachability(ctx: _DetectorContext) -> list[Insight]:
|
|
131
|
+
"""Public surface that touches a critical asset's location with no auth signals on the path."""
|
|
132
|
+
insights: list[Insight] = []
|
|
133
|
+
sensitive_kinds = {"credentials", "session", "user_pii", "payment"}
|
|
134
|
+
sensitive_assets = [a for a in ctx.assets if a.criticality in {"critical", "high"} or a.kind in sensitive_kinds]
|
|
135
|
+
if not sensitive_assets:
|
|
136
|
+
return insights
|
|
137
|
+
|
|
138
|
+
for asset in sensitive_assets:
|
|
139
|
+
asset_modules = {_module_of(loc) for loc in asset.locations if loc}
|
|
140
|
+
if not asset_modules:
|
|
141
|
+
continue
|
|
142
|
+
risky_surfaces: list[AttackSurface] = []
|
|
143
|
+
for surface in ctx.attack_surfaces:
|
|
144
|
+
if surface.exposure != "public":
|
|
145
|
+
continue
|
|
146
|
+
if _module_of(surface.file) not in asset_modules and surface.file not in asset.locations:
|
|
147
|
+
continue
|
|
148
|
+
if surface.auth_signals:
|
|
149
|
+
continue
|
|
150
|
+
risky_surfaces.append(surface)
|
|
151
|
+
|
|
152
|
+
if not risky_surfaces:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
evidence = [
|
|
156
|
+
f"{s.method} {s.route} ({_surface_loc(s)}) — exposure=public, auth_signals=none"
|
|
157
|
+
for s in risky_surfaces[:5]
|
|
158
|
+
]
|
|
159
|
+
insights.append(
|
|
160
|
+
Insight(
|
|
161
|
+
id=f"insight:asset-reachable:{asset.id}",
|
|
162
|
+
kind="sensitive_asset_reachability",
|
|
163
|
+
title=f"Public route reaches {asset.name} without observed auth",
|
|
164
|
+
narrative=(
|
|
165
|
+
f"{asset.name} (criticality={asset.criticality}) lives in modules "
|
|
166
|
+
f"that are reachable from {len(risky_surfaces)} public route(s) with no "
|
|
167
|
+
"observed authentication signals on the surface. This means an unauthenticated "
|
|
168
|
+
"attacker can directly invoke handlers in the same module as a high-value asset "
|
|
169
|
+
"without traversing an explicit auth boundary."
|
|
170
|
+
),
|
|
171
|
+
severity="critical" if asset.criticality == "critical" else "high", # type: ignore[arg-type]
|
|
172
|
+
confidence="medium",
|
|
173
|
+
evidence=evidence,
|
|
174
|
+
related_assets=[asset.id],
|
|
175
|
+
related_routes=[s.route for s in risky_surfaces[:5]],
|
|
176
|
+
suggested_action=(
|
|
177
|
+
"Add an explicit authentication guard at the route boundary or relocate the "
|
|
178
|
+
"handler out of the asset's module so the trust boundary is visible in code."
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return insights
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _detect_defense_gap_in_chain(ctx: _DetectorContext) -> list[Insight]:
|
|
186
|
+
"""An attack path traverses to a sink without observable auth/validation/rate-limit signals."""
|
|
187
|
+
insights: list[Insight] = []
|
|
188
|
+
if not ctx.attack_paths:
|
|
189
|
+
return insights
|
|
190
|
+
|
|
191
|
+
has_auth = bool(ctx.present_controls_by_kind.get("authentication"))
|
|
192
|
+
has_validation = bool(ctx.present_controls_by_kind.get("input_validation"))
|
|
193
|
+
has_rate_limit = bool(ctx.present_controls_by_kind.get("rate_limiting"))
|
|
194
|
+
|
|
195
|
+
for idx, path in enumerate(ctx.attack_paths[:8]):
|
|
196
|
+
joined = " ".join(path.steps).lower()
|
|
197
|
+
terminates_at_data = any(token in joined for token in ("database", "datastore", "data_store", "db:", "kind="))
|
|
198
|
+
if not terminates_at_data:
|
|
199
|
+
continue
|
|
200
|
+
missing: list[str] = []
|
|
201
|
+
if not has_auth:
|
|
202
|
+
missing.append("authentication")
|
|
203
|
+
if not has_validation:
|
|
204
|
+
missing.append("input_validation")
|
|
205
|
+
if not has_rate_limit:
|
|
206
|
+
missing.append("rate_limiting")
|
|
207
|
+
if not missing:
|
|
208
|
+
continue
|
|
209
|
+
severity = "high" if "authentication" in missing else "medium"
|
|
210
|
+
insights.append(
|
|
211
|
+
Insight(
|
|
212
|
+
id=f"insight:defense-gap:{idx + 1}",
|
|
213
|
+
kind="defense_gap_in_chain",
|
|
214
|
+
title=f"Chain `{path.name}` reaches a data store with no {', '.join(missing)} signals along the way",
|
|
215
|
+
narrative=(
|
|
216
|
+
f"The inferred path `{path.name}` ({path.impact}) terminates at a data store "
|
|
217
|
+
"but the codebase has no observable signals for "
|
|
218
|
+
f"{', '.join(missing)} along this path. That means a request landing on the "
|
|
219
|
+
"entry surface can be carried through to the sink without any of those defenses "
|
|
220
|
+
"being applied — at least, none that the heuristic scanner can see."
|
|
221
|
+
),
|
|
222
|
+
severity=severity, # type: ignore[arg-type]
|
|
223
|
+
confidence="medium",
|
|
224
|
+
evidence=[f"path step: {step}" for step in path.steps[:6]],
|
|
225
|
+
suggested_action=(
|
|
226
|
+
f"Add explicit middleware for {', '.join(missing)} on the entry route, or "
|
|
227
|
+
"document where these controls live so they show up to static analysis."
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
return insights
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _detect_control_strength_mismatch(ctx: _DetectorContext) -> list[Insight]:
|
|
235
|
+
"""Critical asset present but only weak/absent controls observed for a relevant kind."""
|
|
236
|
+
insights: list[Insight] = []
|
|
237
|
+
sensitive_assets = [a for a in ctx.assets if a.criticality in {"critical", "high"}]
|
|
238
|
+
if not sensitive_assets:
|
|
239
|
+
return insights
|
|
240
|
+
|
|
241
|
+
expected_kinds_by_asset: dict[str, list[ControlKind]] = {
|
|
242
|
+
"credentials": ["authentication", "encryption_at_rest", "audit_logging"],
|
|
243
|
+
"session": ["authentication", "rate_limiting", "encryption_in_transit"],
|
|
244
|
+
"user_pii": ["authentication", "authorization", "audit_logging"],
|
|
245
|
+
"payment": ["authentication", "authorization", "audit_logging", "encryption_at_rest"],
|
|
246
|
+
"internal_secret": ["secret_management", "audit_logging"],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for asset in sensitive_assets:
|
|
250
|
+
expected = expected_kinds_by_asset.get(asset.kind)
|
|
251
|
+
if not expected:
|
|
252
|
+
continue
|
|
253
|
+
gaps: list[str] = []
|
|
254
|
+
for kind in expected:
|
|
255
|
+
present = ctx.present_controls_by_kind.get(kind, [])
|
|
256
|
+
absent = ctx.absent_controls_by_kind.get(kind, [])
|
|
257
|
+
if not present:
|
|
258
|
+
gaps.append(f"{kind} (absent)")
|
|
259
|
+
continue
|
|
260
|
+
if all(c.strength == "weak" for c in present):
|
|
261
|
+
gaps.append(f"{kind} (weak only)")
|
|
262
|
+
if not gaps:
|
|
263
|
+
continue
|
|
264
|
+
insights.append(
|
|
265
|
+
Insight(
|
|
266
|
+
id=f"insight:strength-mismatch:{asset.id}",
|
|
267
|
+
kind="control_strength_mismatch",
|
|
268
|
+
title=f"{asset.name} has gaps in expected controls: {', '.join(gaps)}",
|
|
269
|
+
narrative=(
|
|
270
|
+
f"{asset.name} is criticality={asset.criticality}, which would normally warrant "
|
|
271
|
+
f"strong controls of kind: {', '.join(expected)}. The scan observed gaps in: "
|
|
272
|
+
f"{', '.join(gaps)}. These may exist but are not detectable from code signals — "
|
|
273
|
+
"either way, the absence in static evidence is itself worth confirming."
|
|
274
|
+
),
|
|
275
|
+
severity="high" if asset.criticality == "critical" else "medium", # type: ignore[arg-type]
|
|
276
|
+
confidence="medium",
|
|
277
|
+
evidence=[f"asset:{asset.id} → expected:{kind}" for kind in expected],
|
|
278
|
+
related_assets=[asset.id],
|
|
279
|
+
suggested_action=(
|
|
280
|
+
"Either add the missing controls or annotate the code path where they live so "
|
|
281
|
+
"subsequent scans can pick them up."
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
return insights
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _detect_asymmetric_protection(ctx: _DetectorContext) -> list[Insight]:
|
|
289
|
+
"""Same route path with different methods having different auth signals."""
|
|
290
|
+
insights: list[Insight] = []
|
|
291
|
+
by_route: dict[str, list[AttackSurface]] = defaultdict(list)
|
|
292
|
+
for surface in ctx.attack_surfaces:
|
|
293
|
+
by_route[surface.route].append(surface)
|
|
294
|
+
|
|
295
|
+
for route, surfaces in by_route.items():
|
|
296
|
+
if len(surfaces) < 2:
|
|
297
|
+
continue
|
|
298
|
+
protected = [s for s in surfaces if s.auth_signals]
|
|
299
|
+
unprotected = [s for s in surfaces if not s.auth_signals]
|
|
300
|
+
if not protected or not unprotected:
|
|
301
|
+
continue
|
|
302
|
+
insights.append(
|
|
303
|
+
Insight(
|
|
304
|
+
id=f"insight:asymmetric:{route}",
|
|
305
|
+
kind="asymmetric_protection",
|
|
306
|
+
title=f"Route `{route}` is protected on some methods but not others",
|
|
307
|
+
narrative=(
|
|
308
|
+
f"`{route}` has {len(protected)} method(s) with auth signals "
|
|
309
|
+
f"({', '.join(sorted({s.method for s in protected}))}) and "
|
|
310
|
+
f"{len(unprotected)} method(s) without "
|
|
311
|
+
f"({', '.join(sorted({s.method for s in unprotected}))}). Asymmetric protection "
|
|
312
|
+
"is a common source of authorization bypass — an attacker can often achieve the "
|
|
313
|
+
"same effect through the unprotected verb (e.g., reading via GET what is write-protected on POST)."
|
|
314
|
+
),
|
|
315
|
+
severity="medium",
|
|
316
|
+
confidence="medium",
|
|
317
|
+
evidence=[
|
|
318
|
+
f"protected: {s.method} {s.route} ({_surface_loc(s)})" for s in protected[:3]
|
|
319
|
+
] + [
|
|
320
|
+
f"unprotected: {s.method} {s.route} ({_surface_loc(s)})" for s in unprotected[:3]
|
|
321
|
+
],
|
|
322
|
+
related_routes=[route],
|
|
323
|
+
suggested_action="Confirm the unprotected verbs intentionally avoid auth or apply the same guard.",
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
return insights
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _detect_audit_gap(ctx: _DetectorContext) -> list[Insight]:
|
|
330
|
+
"""Sensitive asset detected but no audit_logging control observed in any module touching it."""
|
|
331
|
+
insights: list[Insight] = []
|
|
332
|
+
if ctx.present_controls_by_kind.get("audit_logging"):
|
|
333
|
+
return insights
|
|
334
|
+
sensitive = [a for a in ctx.assets if a.criticality in {"critical", "high"} or a.kind in {"payment", "credentials", "user_pii"}]
|
|
335
|
+
if not sensitive:
|
|
336
|
+
return insights
|
|
337
|
+
insights.append(
|
|
338
|
+
Insight(
|
|
339
|
+
id="insight:audit-gap:global",
|
|
340
|
+
kind="audit_gap",
|
|
341
|
+
title="Sensitive assets present, no audit-logging signals detected",
|
|
342
|
+
narrative=(
|
|
343
|
+
f"{len(sensitive)} sensitive asset(s) were identified — "
|
|
344
|
+
f"{', '.join(sorted({a.name for a in sensitive}))[:160]} — "
|
|
345
|
+
"but no audit-logging signals were detected anywhere in the scan. "
|
|
346
|
+
"Without audit trails for sensitive-asset access, post-incident forensics "
|
|
347
|
+
"and detection-engineering are constrained."
|
|
348
|
+
),
|
|
349
|
+
severity="medium",
|
|
350
|
+
confidence="medium",
|
|
351
|
+
evidence=[f"asset:{a.id} ({a.criticality})" for a in sensitive[:6]],
|
|
352
|
+
related_assets=[a.id for a in sensitive],
|
|
353
|
+
suggested_action=(
|
|
354
|
+
"Add structured audit logging at handlers that touch sensitive assets; "
|
|
355
|
+
"include actor, action, asset id, and outcome."
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return insights
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _detect_single_point_of_failure(ctx: _DetectorContext) -> list[Insight]:
|
|
363
|
+
"""A single auth-related secret underpins the only authentication observed."""
|
|
364
|
+
auth_controls = ctx.present_controls_by_kind.get("authentication", [])
|
|
365
|
+
if not auth_controls:
|
|
366
|
+
return []
|
|
367
|
+
auth_secret_candidates = [
|
|
368
|
+
h.name for h in ctx.scan.secret_hints
|
|
369
|
+
if not _is_low_quality(h.file)
|
|
370
|
+
and any(t in h.name.lower() for t in ("jwt", "session", "auth_secret", "signing", "cookie"))
|
|
371
|
+
]
|
|
372
|
+
if len(set(auth_secret_candidates)) != 1:
|
|
373
|
+
return []
|
|
374
|
+
only_secret = auth_secret_candidates[0]
|
|
375
|
+
return [
|
|
376
|
+
Insight(
|
|
377
|
+
id=f"insight:spof:{only_secret.lower()}",
|
|
378
|
+
kind="single_point_of_failure",
|
|
379
|
+
title=f"Authentication appears to depend on a single secret: `{only_secret}`",
|
|
380
|
+
narrative=(
|
|
381
|
+
f"The scan found a single auth-related secret name (`{only_secret}`) and one or more "
|
|
382
|
+
"authentication controls. If this secret is the sole input to verifying user identity, "
|
|
383
|
+
"its compromise revokes the security of every authenticated surface in the system."
|
|
384
|
+
),
|
|
385
|
+
severity="high",
|
|
386
|
+
confidence="medium",
|
|
387
|
+
evidence=[f"secret:{only_secret}"],
|
|
388
|
+
suggested_action=(
|
|
389
|
+
"Introduce key rotation, layered verification (e.g., MFA), or per-tenant signing "
|
|
390
|
+
"so a single secret leak does not compromise the whole authentication boundary."
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_ADMIN_ROUTE_TOKENS: tuple[str, ...] = (
|
|
397
|
+
"/admin",
|
|
398
|
+
"/manage",
|
|
399
|
+
"/root",
|
|
400
|
+
"/superuser",
|
|
401
|
+
"/sudo",
|
|
402
|
+
"/su/",
|
|
403
|
+
"/role",
|
|
404
|
+
"/roles",
|
|
405
|
+
"/permission",
|
|
406
|
+
"/permissions",
|
|
407
|
+
"/grant",
|
|
408
|
+
"/revoke",
|
|
409
|
+
"/impersonate",
|
|
410
|
+
"/owner",
|
|
411
|
+
"/owners",
|
|
412
|
+
"/membership",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
_STATE_CHANGING_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _detect_admin_action_without_auth(ctx: _DetectorContext) -> list[Insight]:
|
|
420
|
+
"""Public, state-changing admin/role/permission routes with no auth signals on the surface."""
|
|
421
|
+
insights: list[Insight] = []
|
|
422
|
+
risky_surfaces: list[AttackSurface] = []
|
|
423
|
+
for surface in ctx.attack_surfaces:
|
|
424
|
+
if surface.exposure != "public":
|
|
425
|
+
continue
|
|
426
|
+
if surface.method.upper() not in _STATE_CHANGING_METHODS and surface.category != "admin":
|
|
427
|
+
continue
|
|
428
|
+
if surface.auth_signals:
|
|
429
|
+
continue
|
|
430
|
+
path_lower = surface.route.lower()
|
|
431
|
+
is_admin_route = surface.category == "admin" or any(token in path_lower for token in _ADMIN_ROUTE_TOKENS)
|
|
432
|
+
if not is_admin_route:
|
|
433
|
+
continue
|
|
434
|
+
risky_surfaces.append(surface)
|
|
435
|
+
|
|
436
|
+
if not risky_surfaces:
|
|
437
|
+
return insights
|
|
438
|
+
|
|
439
|
+
evidence = [
|
|
440
|
+
f"{s.method} {s.route} ({_surface_loc(s)}) — exposure=public, auth_signals=none"
|
|
441
|
+
for s in risky_surfaces[:6]
|
|
442
|
+
]
|
|
443
|
+
insights.append(
|
|
444
|
+
Insight(
|
|
445
|
+
id="insight:admin-without-auth",
|
|
446
|
+
kind="admin_action_without_auth",
|
|
447
|
+
title=f"{len(risky_surfaces)} admin/role-mutation route(s) reachable without observed auth",
|
|
448
|
+
narrative=(
|
|
449
|
+
f"{len(risky_surfaces)} public route(s) match administrative or "
|
|
450
|
+
"role/permission-mutation patterns and carry no authentication signals on "
|
|
451
|
+
"the surface. Privilege-mutation flows are an outsized target — an "
|
|
452
|
+
"attacker who reaches them can grant themselves access to the rest of "
|
|
453
|
+
"the system, so the absence of an explicit auth boundary here is a "
|
|
454
|
+
"high-priority confirmation item."
|
|
455
|
+
),
|
|
456
|
+
severity="critical",
|
|
457
|
+
confidence="medium",
|
|
458
|
+
evidence=evidence,
|
|
459
|
+
related_routes=[s.route for s in risky_surfaces[:6]],
|
|
460
|
+
suggested_action=(
|
|
461
|
+
"Add an explicit authentication and authorization guard at the route "
|
|
462
|
+
"boundary for every admin/role/permission endpoint, and verify the guard "
|
|
463
|
+
"is exercised in tests."
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
return insights
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
_GLOBAL_CONTROL_KINDS_TO_AUDIT: tuple[ControlKind, ...] = (
|
|
471
|
+
"authentication",
|
|
472
|
+
"csrf_protection",
|
|
473
|
+
"rate_limiting",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
_BYPASS_PRONE_ROUTE_TOKENS: tuple[str, ...] = (
|
|
478
|
+
"/webhook",
|
|
479
|
+
"/callback",
|
|
480
|
+
"/health",
|
|
481
|
+
"/healthz",
|
|
482
|
+
"/ping",
|
|
483
|
+
"/metrics",
|
|
484
|
+
"/internal",
|
|
485
|
+
"/_internal",
|
|
486
|
+
"/debug",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _detect_control_bypass(ctx: _DetectorContext) -> list[Insight]:
|
|
491
|
+
"""A control is broadly observed (multi-module) but specific routes' files have no evidence of it."""
|
|
492
|
+
insights: list[Insight] = []
|
|
493
|
+
for kind in _GLOBAL_CONTROL_KINDS_TO_AUDIT:
|
|
494
|
+
controls = [c for c in ctx.present_controls_by_kind.get(kind, []) if c.placements]
|
|
495
|
+
if not controls:
|
|
496
|
+
continue
|
|
497
|
+
placement_files: set[str] = set()
|
|
498
|
+
for control in controls:
|
|
499
|
+
placement_files.update(control.placements)
|
|
500
|
+
if len(placement_files) < 2:
|
|
501
|
+
continue
|
|
502
|
+
bypass_surfaces: list[AttackSurface] = []
|
|
503
|
+
for surface in ctx.attack_surfaces:
|
|
504
|
+
if surface.exposure != "public":
|
|
505
|
+
continue
|
|
506
|
+
path_lower = surface.route.lower()
|
|
507
|
+
if not any(token in path_lower for token in _BYPASS_PRONE_ROUTE_TOKENS):
|
|
508
|
+
continue
|
|
509
|
+
if surface.file in placement_files:
|
|
510
|
+
continue
|
|
511
|
+
bypass_surfaces.append(surface)
|
|
512
|
+
if not bypass_surfaces:
|
|
513
|
+
continue
|
|
514
|
+
evidence = [
|
|
515
|
+
f"{s.method} {s.route} ({_surface_loc(s)}) — no `{kind}` evidence in this file"
|
|
516
|
+
for s in bypass_surfaces[:5]
|
|
517
|
+
]
|
|
518
|
+
insights.append(
|
|
519
|
+
Insight(
|
|
520
|
+
id=f"insight:control-bypass:{kind}",
|
|
521
|
+
kind="control_bypass",
|
|
522
|
+
title=f"`{kind}` is observed broadly but {len(bypass_surfaces)} route(s) appear to bypass it",
|
|
523
|
+
narrative=(
|
|
524
|
+
f"`{kind}` controls are present in {len(placement_files)} files, suggesting it's "
|
|
525
|
+
"applied as a global middleware. However, the listed routes — webhooks, health/metrics "
|
|
526
|
+
"endpoints, or internal handlers — live in files where no evidence of that control "
|
|
527
|
+
"appears. These are common deliberate bypass points; confirm each is intentional, "
|
|
528
|
+
"and consider whether the bypass is the right call (webhook signatures vs. CSRF, "
|
|
529
|
+
"for instance, can both be required)."
|
|
530
|
+
),
|
|
531
|
+
severity="medium",
|
|
532
|
+
confidence="medium",
|
|
533
|
+
evidence=evidence,
|
|
534
|
+
related_controls=[c.id for c in controls],
|
|
535
|
+
related_routes=[s.route for s in bypass_surfaces[:5]],
|
|
536
|
+
suggested_action=(
|
|
537
|
+
"For each bypass route, document the rationale (signed webhook, internal-only, etc.) "
|
|
538
|
+
"and ensure the bypass surface enforces an alternative control (HMAC verification, "
|
|
539
|
+
"mTLS, network ACL)."
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
return insights
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
_INTERNAL_FILE_TOKENS: tuple[str, ...] = ("/internal/", "/private/", "/admin_internal/", "_internal_", "/intranet/")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _detect_trust_boundary_violation(ctx: _DetectorContext) -> list[Insight]:
|
|
550
|
+
"""A handler whose file path advertises 'internal' is exposed via a public surface."""
|
|
551
|
+
violations: list[AttackSurface] = []
|
|
552
|
+
for surface in ctx.attack_surfaces:
|
|
553
|
+
if surface.exposure != "public":
|
|
554
|
+
continue
|
|
555
|
+
normalized = "/" + surface.file.replace("\\", "/").lower() + "/"
|
|
556
|
+
if not any(token in normalized for token in _INTERNAL_FILE_TOKENS):
|
|
557
|
+
continue
|
|
558
|
+
violations.append(surface)
|
|
559
|
+
|
|
560
|
+
if not violations:
|
|
561
|
+
return []
|
|
562
|
+
|
|
563
|
+
evidence = [
|
|
564
|
+
f"{s.method} {s.route} ({_surface_loc(s)}) — file path marked internal but exposure=public"
|
|
565
|
+
for s in violations[:5]
|
|
566
|
+
]
|
|
567
|
+
return [
|
|
568
|
+
Insight(
|
|
569
|
+
id="insight:trust-boundary-violation",
|
|
570
|
+
kind="trust_boundary_violation",
|
|
571
|
+
title=f"{len(violations)} handler(s) tagged internal-only by file path are publicly exposed",
|
|
572
|
+
narrative=(
|
|
573
|
+
"The listed handlers live in modules whose file path indicates they were intended "
|
|
574
|
+
"for internal-only use, yet they appear in the public attack surface. Trust-boundary "
|
|
575
|
+
"violations like these often arise from a router accidentally including an internal "
|
|
576
|
+
"module, or from a directory rename that left the original visibility implicit. "
|
|
577
|
+
"Either remove the public route or rename the module to reflect the actual exposure."
|
|
578
|
+
),
|
|
579
|
+
severity="high",
|
|
580
|
+
confidence="medium",
|
|
581
|
+
evidence=evidence,
|
|
582
|
+
related_routes=[s.route for s in violations[:5]],
|
|
583
|
+
suggested_action=(
|
|
584
|
+
"For each violation, decide: relocate the handler out of the internal module, or "
|
|
585
|
+
"remove the public route registration that exposes it."
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
_BENIGN_ROUTE_TOKENS: tuple[str, ...] = (
|
|
592
|
+
"/health",
|
|
593
|
+
"/healthz",
|
|
594
|
+
"/ping",
|
|
595
|
+
"/status",
|
|
596
|
+
"/metrics",
|
|
597
|
+
"/version",
|
|
598
|
+
"/_static/",
|
|
599
|
+
"/assets/",
|
|
600
|
+
"/favicon",
|
|
601
|
+
"/robots.txt",
|
|
602
|
+
"/.well-known/",
|
|
603
|
+
"/public/",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _detect_stale_or_contradictory_signal(ctx: _DetectorContext) -> list[Insight]:
|
|
608
|
+
"""Auth signals appear on routes that are obviously public/benign — false-positive guardrail."""
|
|
609
|
+
contradictions: list[AttackSurface] = []
|
|
610
|
+
for surface in ctx.attack_surfaces:
|
|
611
|
+
if not surface.auth_signals:
|
|
612
|
+
continue
|
|
613
|
+
path_lower = surface.route.lower()
|
|
614
|
+
if not any(token in path_lower for token in _BENIGN_ROUTE_TOKENS):
|
|
615
|
+
continue
|
|
616
|
+
contradictions.append(surface)
|
|
617
|
+
|
|
618
|
+
if not contradictions:
|
|
619
|
+
return []
|
|
620
|
+
|
|
621
|
+
evidence = [
|
|
622
|
+
f"{s.method} {s.route} ({_surface_loc(s)}) — auth_signals present but route looks benign/public: "
|
|
623
|
+
+ ", ".join(s.auth_signals[:3])
|
|
624
|
+
for s in contradictions[:5]
|
|
625
|
+
]
|
|
626
|
+
return [
|
|
627
|
+
Insight(
|
|
628
|
+
id="insight:stale-signal",
|
|
629
|
+
kind="stale_or_contradictory_signal",
|
|
630
|
+
title=f"{len(contradictions)} obviously-benign route(s) carry auth signals — likely scanner false positives",
|
|
631
|
+
narrative=(
|
|
632
|
+
"Health, metrics, static-asset, and well-known paths normally do not require "
|
|
633
|
+
"authentication. The scanner attached auth signals to these routes anyway, which "
|
|
634
|
+
"usually means the auth marker is at file scope (e.g., a global middleware import) "
|
|
635
|
+
"rather than route-specific. Treat the auth annotation on these surfaces with "
|
|
636
|
+
"skepticism when reading the rest of the report."
|
|
637
|
+
),
|
|
638
|
+
severity="informational",
|
|
639
|
+
confidence="medium",
|
|
640
|
+
evidence=evidence,
|
|
641
|
+
related_routes=[s.route for s in contradictions[:5]],
|
|
642
|
+
suggested_action=(
|
|
643
|
+
"If the auth markers really should not apply to these routes, add per-route metadata "
|
|
644
|
+
"the scanner can read (e.g., a `@public` decorator or a routes manifest) so future "
|
|
645
|
+
"scans don't double-count file-scope signals."
|
|
646
|
+
),
|
|
647
|
+
)
|
|
648
|
+
]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
_DETECTORS: tuple[Callable[[_DetectorContext], list[Insight]], ...] = (
|
|
652
|
+
_detect_shared_secret_blast_radius,
|
|
653
|
+
_detect_sensitive_asset_reachability,
|
|
654
|
+
_detect_defense_gap_in_chain,
|
|
655
|
+
_detect_control_strength_mismatch,
|
|
656
|
+
_detect_asymmetric_protection,
|
|
657
|
+
_detect_audit_gap,
|
|
658
|
+
_detect_single_point_of_failure,
|
|
659
|
+
_detect_admin_action_without_auth,
|
|
660
|
+
_detect_control_bypass,
|
|
661
|
+
_detect_trust_boundary_violation,
|
|
662
|
+
_detect_stale_or_contradictory_signal,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def generate_insights(
|
|
667
|
+
scan: ScanResult,
|
|
668
|
+
attack_surfaces: list[AttackSurface],
|
|
669
|
+
findings_titles: list[str],
|
|
670
|
+
attack_paths: list[AttackPath],
|
|
671
|
+
assets: list[Asset],
|
|
672
|
+
controls: list[Control],
|
|
673
|
+
) -> list[Insight]:
|
|
674
|
+
"""Produce cross-cutting insights from scan + assets + controls + chains.
|
|
675
|
+
|
|
676
|
+
These are designed to surface non-obvious, story-bearing observations that
|
|
677
|
+
connect signals into a defensive narrative — not duplicate per-finding output.
|
|
678
|
+
"""
|
|
679
|
+
ctx = _build_context(scan, attack_surfaces, findings_titles, attack_paths, assets, controls)
|
|
680
|
+
seen_ids: set[str] = set()
|
|
681
|
+
insights: list[Insight] = []
|
|
682
|
+
for detector in _DETECTORS:
|
|
683
|
+
for insight in detector(ctx):
|
|
684
|
+
if insight.id in seen_ids:
|
|
685
|
+
continue
|
|
686
|
+
seen_ids.add(insight.id)
|
|
687
|
+
insights.append(insight)
|
|
688
|
+
|
|
689
|
+
severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3, "informational": 4}
|
|
690
|
+
confidence_rank = {"high": 0, "medium": 1, "low": 2}
|
|
691
|
+
insights.sort(
|
|
692
|
+
key=lambda i: (
|
|
693
|
+
severity_rank.get(i.severity, 9),
|
|
694
|
+
confidence_rank.get(i.confidence, 9),
|
|
695
|
+
i.kind,
|
|
696
|
+
i.id,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
return insights
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
__all__ = ["generate_insights"]
|