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,919 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .analyzer import identify_attack_surfaces
6
+ from .models import AttackPath, AttackSurface, Finding, Route, ScanResult
7
+
8
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/")
9
+
10
+
11
+ def _surface_label(surface: AttackSurface) -> str:
12
+ return f"{surface.method} {surface.route} in {surface.file}"
13
+
14
+
15
+ def _severity_rank(value: str) -> int:
16
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
17
+
18
+
19
+ def _action_step(label: str, action: str) -> str:
20
+ return f"{label}: {action}"
21
+
22
+
23
+ def _finding_evidence(surface: AttackSurface) -> str:
24
+ details: list[str] = [_surface_label(surface)]
25
+ if surface.auth_signals:
26
+ details.append(f"auth signals: {', '.join(surface.auth_signals)}")
27
+ else:
28
+ details.append("no auth signals observed")
29
+ if surface.data_store_interaction:
30
+ details.append("data store reachable")
31
+ if surface.outbound_integration:
32
+ details.append("external integration reachable")
33
+ return "; ".join(details)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ProbableChain:
38
+ route_method: str
39
+ route_path: str
40
+ route_file: str
41
+ controller: str | None
42
+ action: str | None
43
+ service: str | None
44
+ sink: str
45
+ confidence: float
46
+ evidence: list[str]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ServiceChain:
51
+ route_method: str
52
+ route_path: str
53
+ route_file: str
54
+ entry_service: str
55
+ next_service: str | None
56
+ sink: str
57
+ confidence: float
58
+ evidence: list[str]
59
+ env_risk: str | None = None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class AtprotoChain:
64
+ route_method: str
65
+ route_path: str
66
+ route_file: str
67
+ namespace: str
68
+ entry_service: str
69
+ next_service: str | None
70
+ sink: str
71
+ confidence: float
72
+ evidence: list[str]
73
+ env_risk: str | None = None
74
+
75
+
76
+ def _extract_prefixed_hints(scan: ScanResult, prefix: str) -> list[tuple[str, str]]:
77
+ grouped_hints: list[tuple[str, str]] = []
78
+ for hint in scan.auth_hints:
79
+ if hint.hint.startswith(prefix):
80
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
81
+
82
+ # Phase-2 migration support: allow specialized non-auth hint categories to
83
+ # carry prefixed values while keeping auth_hints fallback behavior.
84
+ if prefix.startswith("service_"):
85
+ for hint in scan.service_hints:
86
+ if hint.hint.startswith(prefix):
87
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
88
+ elif prefix.startswith("edge:"):
89
+ for hint in scan.edge_hints:
90
+ if hint.hint.startswith(prefix):
91
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
92
+ elif prefix.startswith("entrypoint:"):
93
+ for hint in scan.entrypoint_hints:
94
+ if hint.hint.startswith(prefix):
95
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
96
+ elif prefix.startswith(("atproto_", "protocol:")):
97
+ for hint in scan.protocol_hints:
98
+ if hint.hint.startswith(prefix):
99
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
100
+ elif prefix.startswith(("controller:", "service:", "omeka_", "laminas_")):
101
+ for hint in scan.framework_hints:
102
+ if hint.hint.startswith(prefix):
103
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
104
+
105
+ deduped: list[tuple[str, str]] = []
106
+ seen: set[tuple[str, str]] = set()
107
+ for item in grouped_hints:
108
+ if item in seen:
109
+ continue
110
+ seen.add(item)
111
+ deduped.append(item)
112
+ return deduped
113
+
114
+
115
+ def _is_low_quality_source(path_or_text: str) -> bool:
116
+ normalized = path_or_text.replace("\\", "/").lower()
117
+ return any(segment in f"/{normalized}/" for segment in LOW_QUALITY_SEGMENTS)
118
+
119
+
120
+ def _runtime_routes(scan: ScanResult) -> list[Route]:
121
+ return [route for route in scan.routes if not _is_low_quality_source(route.file)]
122
+
123
+
124
+ def _extract_edge_hints(scan: ScanResult) -> list[tuple[str, str, str]]:
125
+ edges: list[tuple[str, str, str]] = []
126
+ all_edge_hints = [*scan.edge_hints, *scan.auth_hints]
127
+ for hint in all_edge_hints:
128
+ if not hint.hint.startswith("edge:"):
129
+ continue
130
+ raw_edge = hint.hint.removeprefix("edge:")
131
+ if "->" not in raw_edge:
132
+ continue
133
+ source, target = raw_edge.split("->", 1)
134
+ source_name = source.strip().lower()
135
+ target_name = target.strip().lower()
136
+ if not source_name or not target_name:
137
+ continue
138
+ edges.append((source_name, target_name, hint.file))
139
+ return edges
140
+
141
+
142
+ def _infer_service_name_from_file(file_path: str) -> str | None:
143
+ normalized = file_path.replace("\\", "/")
144
+ parts = normalized.split("/")
145
+ for parent in ("services", "packages", "apps"):
146
+ if parent in parts:
147
+ idx = parts.index(parent)
148
+ if idx + 1 < len(parts):
149
+ return parts[idx + 1].lower()
150
+ return None
151
+
152
+
153
+ def _file_service_map(scan: ScanResult) -> dict[str, str]:
154
+ mapping: dict[str, str] = {}
155
+ for service_name, service_file in _extract_prefixed_hints(scan, "service_name:"):
156
+ mapping[service_file] = service_name.lower()
157
+ for path in [*mapping.keys(), *(route.file for route in scan.routes), *(db.file for db in scan.databases), *(call.file for call in scan.external_calls)]:
158
+ if path in mapping:
159
+ continue
160
+ inferred = _infer_service_name_from_file(path)
161
+ if inferred:
162
+ mapping[path] = inferred
163
+ return mapping
164
+
165
+
166
+ def _build_service_chains(scan: ScanResult) -> list[ServiceChain]:
167
+ service_edges = _extract_edge_hints(scan)
168
+ has_service_hints = any(h.hint.startswith("service_name:") for h in [*scan.service_hints, *scan.auth_hints])
169
+ if not service_edges and not has_service_hints:
170
+ return []
171
+
172
+ file_service = _file_service_map(scan)
173
+ edge_targets_by_source: dict[str, list[str]] = {}
174
+ for source, target, _file in service_edges:
175
+ edge_targets_by_source.setdefault(source, []).append(target)
176
+
177
+ databases_by_service: dict[str, DatabaseHint] = {}
178
+ for database in scan.databases:
179
+ service = file_service.get(database.file)
180
+ if service and service not in databases_by_service:
181
+ databases_by_service[service] = database
182
+
183
+ outbound_by_service: dict[str, ExternalCall] = {}
184
+ env_url_by_service: dict[str, str] = {}
185
+ for call in scan.external_calls:
186
+ service = file_service.get(call.file)
187
+ if not service:
188
+ continue
189
+ if call.target.startswith("env://"):
190
+ env_url_by_service.setdefault(service, call.target)
191
+ continue
192
+ outbound_by_service.setdefault(service, call)
193
+
194
+ chains: list[ServiceChain] = []
195
+ for route in _runtime_routes(scan):
196
+ evidence = [f"route {route.method} {route.path} in {route.file}"]
197
+ confidence = 0.35
198
+ entry_service = file_service.get(route.file) or _infer_service_name_from_file(route.file) or "entry-service"
199
+ if file_service.get(route.file):
200
+ confidence += 0.25
201
+ evidence.append(f"entry service from file-local hint: {entry_service}")
202
+ else:
203
+ confidence += 0.1
204
+ evidence.append(f"entry service inferred from repository layout: {entry_service}")
205
+
206
+ next_service = None
207
+ sink = "privileged downstream action"
208
+ env_risk = env_url_by_service.get(entry_service)
209
+
210
+ candidates = edge_targets_by_source.get(entry_service, [])
211
+ if candidates:
212
+ next_service = candidates[0]
213
+ confidence += 0.15
214
+ evidence.append(f"inter-service edge: {entry_service}->{next_service}")
215
+
216
+ sink_service = next_service or entry_service
217
+ if sink_service in databases_by_service:
218
+ db_hint = databases_by_service[sink_service]
219
+ sink = "database"
220
+ confidence += 0.2
221
+ evidence.append(f"database sink in service {sink_service}: {db_hint.kind} ({db_hint.file})")
222
+ elif sink_service in outbound_by_service:
223
+ ext_hint = outbound_by_service[sink_service]
224
+ sink = "external dependency"
225
+ confidence += 0.15
226
+ evidence.append(f"external sink in service {sink_service}: {ext_hint.target} ({ext_hint.file})")
227
+ elif scan.databases:
228
+ sink = "database"
229
+ confidence += 0.05
230
+ evidence.append(f"database hint elsewhere in repo: {scan.databases[0].kind} ({scan.databases[0].file})")
231
+ elif scan.external_calls:
232
+ sink = "external dependency"
233
+ confidence += 0.05
234
+ evidence.append(f"external call elsewhere in repo: {scan.external_calls[0].target} ({scan.external_calls[0].file})")
235
+
236
+ if env_risk:
237
+ confidence += 0.05
238
+ evidence.append(f"env-configured dependency in entry service: {env_risk}")
239
+
240
+ chains.append(
241
+ ServiceChain(
242
+ route_method=route.method,
243
+ route_path=route.path,
244
+ route_file=route.file,
245
+ entry_service=entry_service,
246
+ next_service=next_service,
247
+ sink=sink,
248
+ confidence=min(confidence, 0.95),
249
+ evidence=evidence,
250
+ env_risk=env_risk,
251
+ )
252
+ )
253
+
254
+ chains.sort(key=lambda chain: chain.confidence, reverse=True)
255
+ return chains
256
+
257
+
258
+ def _is_atproto_scan(scan: ScanResult) -> bool:
259
+ return any(hint.hint.startswith("atproto_") for hint in [*scan.protocol_hints, *scan.auth_hints])
260
+
261
+
262
+ def _extract_xrpc_namespace(route_path: str) -> str | None:
263
+ marker = "/xrpc/"
264
+ if marker not in route_path:
265
+ return None
266
+ value = route_path.split(marker, 1)[1].strip("/")
267
+ if not value:
268
+ return None
269
+ if value.startswith("com.atproto."):
270
+ return "com.atproto"
271
+ if value.startswith("app.bsky."):
272
+ return "app.bsky"
273
+ return None
274
+
275
+
276
+ def _build_atproto_chains(scan: ScanResult) -> list[AtprotoChain]:
277
+ if not _is_atproto_scan(scan):
278
+ return []
279
+
280
+ file_service = _file_service_map(scan)
281
+ for service_name, service_file in _extract_prefixed_hints(scan, "atproto_service_note:"):
282
+ file_service.setdefault(service_file, service_name.lower())
283
+
284
+ edge_targets_by_source: dict[str, list[str]] = {}
285
+ for source, target, _file in _extract_edge_hints(scan):
286
+ edge_targets_by_source.setdefault(source, []).append(target)
287
+
288
+ namespaces = {name for name, _file in _extract_prefixed_hints(scan, "atproto_namespace:")}
289
+ lexicons = {name for name, _file in _extract_prefixed_hints(scan, "atproto_lexicon:")}
290
+ xrpc_refs = {name for name, _file in _extract_prefixed_hints(scan, "atproto_xrpc_ref:")}
291
+ service_edges = {name for name, _file in _extract_prefixed_hints(scan, "atproto_service_edge:")}
292
+ stream_hints = {name for name, _file in _extract_prefixed_hints(scan, "atproto_event_stream:")}
293
+
294
+ databases_by_service: dict[str, tuple[str, str]] = {}
295
+ for database in scan.databases:
296
+ service = file_service.get(database.file)
297
+ if service and service not in databases_by_service:
298
+ databases_by_service[service] = (database.kind, database.file)
299
+
300
+ outbound_by_service: dict[str, tuple[str, str]] = {}
301
+ env_url_by_service: dict[str, str] = {}
302
+ for call in scan.external_calls:
303
+ service = file_service.get(call.file)
304
+ if not service:
305
+ continue
306
+ if call.target.startswith("env://"):
307
+ env_url_by_service.setdefault(service, call.target)
308
+ continue
309
+ outbound_by_service.setdefault(service, (call.target, call.file))
310
+
311
+ chains: list[AtprotoChain] = []
312
+ for route in _runtime_routes(scan):
313
+ namespace = _extract_xrpc_namespace(route.path)
314
+ if namespace is None:
315
+ continue
316
+
317
+ evidence = [f"xrpc route {route.method} {route.path} in {route.file}"]
318
+ confidence = 0.45
319
+ if namespace in namespaces:
320
+ confidence += 0.1
321
+ evidence.append(f"namespace signal observed: {namespace}")
322
+
323
+ endpoint_name = route.path.removeprefix("/xrpc/")
324
+ if endpoint_name in lexicons:
325
+ confidence += 0.1
326
+ evidence.append(f"lexicon-defined endpoint: {endpoint_name}")
327
+ if endpoint_name in xrpc_refs:
328
+ confidence += 0.05
329
+ evidence.append(f"code-level xrpc reference: {endpoint_name}")
330
+
331
+ entry_service = file_service.get(route.file) or _infer_service_name_from_file(route.file) or "entry-service"
332
+ if file_service.get(route.file):
333
+ confidence += 0.15
334
+ evidence.append(f"entry service from analyzer hints: {entry_service}")
335
+ else:
336
+ confidence += 0.05
337
+ evidence.append(f"entry service inferred from repository layout: {entry_service}")
338
+
339
+ next_service = None
340
+ sink = "privileged downstream action"
341
+ env_risk = env_url_by_service.get(entry_service)
342
+
343
+ edge_candidates = edge_targets_by_source.get(entry_service, [])
344
+ if edge_candidates:
345
+ next_service = edge_candidates[0]
346
+ confidence += 0.1
347
+ evidence.append(f"inter-service edge: {entry_service}->{next_service}")
348
+ elif service_edges:
349
+ next_service = sorted(service_edges)[0]
350
+ confidence += 0.05
351
+ evidence.append(f"atproto env-derived service edge: {entry_service}->{next_service}")
352
+
353
+ sink_service = next_service or entry_service
354
+ if sink_service in databases_by_service:
355
+ kind, db_file = databases_by_service[sink_service]
356
+ sink = "database"
357
+ confidence += 0.15
358
+ evidence.append(f"database sink in service {sink_service}: {kind} ({db_file})")
359
+ elif sink_service in outbound_by_service:
360
+ target, outbound_file = outbound_by_service[sink_service]
361
+ sink = "external dependency"
362
+ confidence += 0.12
363
+ evidence.append(f"outbound sink in service {sink_service}: {target} ({outbound_file})")
364
+ elif scan.databases:
365
+ sink = "database"
366
+ confidence += 0.03
367
+ evidence.append(f"database hint elsewhere in repo: {scan.databases[0].kind} ({scan.databases[0].file})")
368
+ elif scan.external_calls:
369
+ sink = "external dependency"
370
+ confidence += 0.03
371
+ evidence.append(f"external call elsewhere in repo: {scan.external_calls[0].target} ({scan.external_calls[0].file})")
372
+
373
+ if env_risk:
374
+ confidence += 0.04
375
+ evidence.append(f"env-configured dependency in entry service: {env_risk}")
376
+ if stream_hints:
377
+ confidence += 0.03
378
+ evidence.append(f"event stream exposure hints: {', '.join(sorted(stream_hints)[:2])}")
379
+
380
+ chains.append(
381
+ AtprotoChain(
382
+ route_method=route.method,
383
+ route_path=route.path,
384
+ route_file=route.file,
385
+ namespace=namespace,
386
+ entry_service=entry_service,
387
+ next_service=next_service,
388
+ sink=sink,
389
+ confidence=min(confidence, 0.95),
390
+ evidence=evidence,
391
+ env_risk=env_risk,
392
+ )
393
+ )
394
+
395
+ chains.sort(key=lambda chain: chain.confidence, reverse=True)
396
+ return chains
397
+
398
+
399
+ def _file_module_key(file_path: str) -> str:
400
+ normalized = file_path.replace("\\", "/")
401
+ marker = "/module/"
402
+ if marker in normalized:
403
+ suffix = normalized.split(marker, 1)[1]
404
+ parts = suffix.split("/")
405
+ if parts and parts[0]:
406
+ return f"module/{parts[0].lower()}"
407
+ parts = normalized.split("/")
408
+ if parts:
409
+ return parts[0].lower()
410
+ return normalized.lower()
411
+
412
+
413
+ def _guess_action(route_path: str) -> str | None:
414
+ lower = route_path.lower()
415
+ if "/admin" in lower:
416
+ return "admin action"
417
+ if "/api" in lower:
418
+ return "api action"
419
+ if lower.startswith("/s/") or "/site" in lower:
420
+ return "site action"
421
+ return None
422
+
423
+
424
+ def _is_framework_mvc_scan(scan: ScanResult) -> bool:
425
+ framework_values = [*scan.framework_hints, *scan.auth_hints]
426
+ return any(
427
+ hint.hint.startswith("controller:")
428
+ or hint.hint.startswith("service:")
429
+ or hint.hint.startswith("omeka_")
430
+ or hint.hint.startswith("laminas_")
431
+ for hint in framework_values
432
+ )
433
+
434
+
435
+ def _build_probable_chains(scan: ScanResult) -> list[ProbableChain]:
436
+ controllers = _extract_prefixed_hints(scan, "controller:")
437
+ services = _extract_prefixed_hints(scan, "service:")
438
+ db_by_module = {_file_module_key(db.file): db for db in scan.databases}
439
+ external_by_module = {_file_module_key(call.file): call for call in scan.external_calls}
440
+
441
+ chains: list[ProbableChain] = []
442
+ for route in _runtime_routes(scan):
443
+ module_key = _file_module_key(route.file)
444
+ evidence = [f"route {route.method} {route.path} in {route.file}"]
445
+ confidence = 0.35
446
+
447
+ route_controller = next((name for name, file in controllers if file == route.file), None)
448
+ if route_controller is None:
449
+ route_controller = next((name for name, file in controllers if _file_module_key(file) == module_key), None)
450
+ if route_controller:
451
+ confidence += 0.15
452
+ evidence.append(f"controller inferred by module proximity: {route_controller}")
453
+ else:
454
+ confidence += 0.25
455
+ evidence.append(f"controller in same file: {route_controller}")
456
+
457
+ route_service = next((name for name, file in services if file == route.file), None)
458
+ if route_service is None:
459
+ route_service = next((name for name, file in services if _file_module_key(file) == module_key), None)
460
+ if route_service:
461
+ confidence += 0.10
462
+ evidence.append(f"service inferred by module proximity: {route_service}")
463
+ else:
464
+ confidence += 0.20
465
+ evidence.append(f"service in same file: {route_service}")
466
+
467
+ sink = "privileged action"
468
+ if module_key in db_by_module:
469
+ sink = "database"
470
+ confidence += 0.2
471
+ evidence.append(f"database hint in same module: {db_by_module[module_key].kind} ({db_by_module[module_key].file})")
472
+ elif module_key in external_by_module:
473
+ sink = "external integration"
474
+ confidence += 0.15
475
+ evidence.append(f"external call in same module: {external_by_module[module_key].target} ({external_by_module[module_key].file})")
476
+ elif scan.databases:
477
+ sink = "database"
478
+ confidence += 0.05
479
+ evidence.append(f"database hint elsewhere in repo: {scan.databases[0].kind} ({scan.databases[0].file})")
480
+ elif scan.external_calls:
481
+ sink = "external integration"
482
+ confidence += 0.05
483
+ evidence.append(
484
+ f"external call elsewhere in repo: {scan.external_calls[0].target} ({scan.external_calls[0].file})"
485
+ )
486
+
487
+ action = _guess_action(route.path)
488
+ if action:
489
+ confidence += 0.05
490
+ evidence.append(f"route naming suggests {action}")
491
+
492
+ chains.append(
493
+ ProbableChain(
494
+ route_method=route.method,
495
+ route_path=route.path,
496
+ route_file=route.file,
497
+ controller=route_controller,
498
+ action=action,
499
+ service=route_service,
500
+ sink=sink,
501
+ confidence=min(confidence, 0.95),
502
+ evidence=evidence,
503
+ )
504
+ )
505
+
506
+ chains.sort(key=lambda chain: chain.confidence, reverse=True)
507
+ return chains
508
+
509
+
510
+ def generate_findings(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[Finding]:
511
+ surfaces = attack_surfaces if attack_surfaces is not None else identify_attack_surfaces(scan)
512
+ runtime_surfaces = [surface for surface in surfaces if not _is_low_quality_source(surface.file)]
513
+ findings: list[Finding] = []
514
+ atproto_chains = _build_atproto_chains(scan)
515
+ service_chains = _build_service_chains(scan)
516
+ chains = _build_probable_chains(scan) if _is_framework_mvc_scan(scan) else []
517
+ webhook_surfaces = [
518
+ surface
519
+ for surface in runtime_surfaces
520
+ if surface.category == "webhook"
521
+ and surface.exposure == "public"
522
+ and surface.method in {"POST", "PUT", "PATCH", "DELETE", "ANY"}
523
+ and (surface.data_store_interaction or surface.outbound_integration)
524
+ and not any(signal in {"jwt", "oauth", "authorization", "service_auth"} for signal in surface.auth_signals)
525
+ ]
526
+ admin_surfaces = [surface for surface in runtime_surfaces if surface.category == "admin"]
527
+ upload_surfaces = [surface for surface in runtime_surfaces if surface.category == "upload"]
528
+ auth_surfaces = [surface for surface in runtime_surfaces if surface.category == "auth"]
529
+ public_data_surfaces = [
530
+ surface
531
+ for surface in runtime_surfaces
532
+ if surface.exposure == "public" and surface.data_store_interaction and surface.category != "health"
533
+ ]
534
+ public_integration_surfaces = [
535
+ surface
536
+ for surface in runtime_surfaces
537
+ if surface.exposure == "public" and surface.outbound_integration
538
+ ]
539
+
540
+ if webhook_surfaces:
541
+ findings.append(
542
+ Finding(
543
+ title="Public webhook endpoint may trust attacker-controlled events",
544
+ severity="high",
545
+ evidence=[_finding_evidence(surface) for surface in webhook_surfaces[:10]],
546
+ mitigation="Require signature verification before processing webhook payloads, reject replays, and keep any downstream state change behind strict validation.",
547
+ confidence="high",
548
+ )
549
+ )
550
+
551
+ if admin_surfaces:
552
+ findings.append(
553
+ Finding(
554
+ title="Administrative routes appear reachable from the main application surface",
555
+ severity="high",
556
+ evidence=[_finding_evidence(surface) for surface in admin_surfaces[:10]],
557
+ mitigation="Require strong authentication and explicit server-side authorization on every admin action, and move admin routes behind a narrower exposure boundary where possible.",
558
+ confidence="high",
559
+ )
560
+ )
561
+
562
+ if upload_surfaces:
563
+ findings.append(
564
+ Finding(
565
+ title="Upload or import routes expand attacker-controlled input handling",
566
+ severity="high",
567
+ evidence=[_finding_evidence(surface) for surface in upload_surfaces[:10]],
568
+ mitigation="Constrain accepted formats, isolate parsers, scan uploaded content, and treat imported files as untrusted all the way through storage and processing.",
569
+ confidence="medium",
570
+ )
571
+ )
572
+
573
+ if auth_surfaces and not any(surface.auth_signals for surface in auth_surfaces):
574
+ findings.append(
575
+ Finding(
576
+ title="Authentication routes were detected without strong nearby auth controls",
577
+ severity="medium",
578
+ evidence=[_finding_evidence(surface) for surface in auth_surfaces[:10]],
579
+ mitigation="Review these routes for rate limiting, credential validation, token or session handling, and the exact point where trust is established server-side.",
580
+ confidence="medium",
581
+ )
582
+ )
583
+
584
+ if public_integration_surfaces and not any(h.hint in {"jwt", "oauth", "bearer", "token"} for h in scan.auth_hints):
585
+ findings.append(
586
+ Finding(
587
+ title="Public routes appear to influence outbound integrations without clear auth signals",
588
+ severity="medium",
589
+ evidence=[_finding_evidence(surface) for surface in public_integration_surfaces[:10]],
590
+ mitigation="Check how outbound requests are authenticated, signed, and authorized, and confirm that untrusted route input cannot directly steer third-party actions.",
591
+ confidence="medium",
592
+ )
593
+ )
594
+
595
+ if scan.secret_hints:
596
+ findings.append(
597
+ Finding(
598
+ title="Secret-bearing environment variables are referenced in executable paths",
599
+ severity="medium",
600
+ evidence=[f"{hint.name} in {hint.file}" for hint in scan.secret_hints[:10]],
601
+ mitigation="Confirm these secrets are injected securely, never logged or returned, rotated regularly, and scoped only to the privileges each route actually needs.",
602
+ confidence="high",
603
+ )
604
+ )
605
+
606
+ if public_data_surfaces:
607
+ findings.append(
608
+ Finding(
609
+ title="Public routes likely sit close to sensitive data operations",
610
+ severity="medium",
611
+ evidence=[_finding_evidence(surface) for surface in public_data_surfaces[:10]],
612
+ mitigation="Validate untrusted input before it reaches business logic, enforce authorization at the route boundary, and verify that downstream queries or writes stay parameterized.",
613
+ confidence="medium",
614
+ )
615
+ )
616
+
617
+ if atproto_chains:
618
+ top_atproto_chain = atproto_chains[0]
619
+ findings.append(
620
+ Finding(
621
+ title="AT Protocol XRPC surface chains into a downstream trust boundary",
622
+ severity="high" if top_atproto_chain.sink in {"database", "privileged downstream action"} else "medium",
623
+ evidence=[f"confidence={top_atproto_chain.confidence:.2f}", *top_atproto_chain.evidence[:6]],
624
+ mitigation=(
625
+ "Enforce namespace-specific authz on XRPC handlers, validate service-auth at each hop, and constrain "
626
+ "downstream service/database permissions per endpoint."
627
+ ),
628
+ confidence="high" if top_atproto_chain.confidence >= 0.7 else "medium",
629
+ )
630
+ )
631
+
632
+ if service_chains:
633
+ top_service_chain = service_chains[0]
634
+ findings.append(
635
+ Finding(
636
+ title="Inter-service trust chain reaches a sensitive downstream sink",
637
+ severity="high" if top_service_chain.sink in {"database", "privileged downstream action"} else "medium",
638
+ evidence=[f"confidence={top_service_chain.confidence:.2f}", *top_service_chain.evidence[:6]],
639
+ mitigation=(
640
+ "Apply explicit authn/authz checks at each service hop, constrain service-to-service callers, and "
641
+ "treat env-configured upstream and downstream endpoints as untrusted until verified."
642
+ ),
643
+ confidence="high" if top_service_chain.confidence >= 0.7 else "medium",
644
+ )
645
+ )
646
+
647
+ if chains:
648
+ top_chain = chains[0]
649
+ findings.append(
650
+ Finding(
651
+ title="Framework route-to-service chain reaches a sensitive sink",
652
+ severity="high" if top_chain.sink in {"database", "privileged action"} else "medium",
653
+ evidence=[f"confidence={top_chain.confidence:.2f}", *top_chain.evidence[:6]],
654
+ mitigation=(
655
+ "Validate and authorize at the route boundary, enforce controller-level policy checks, and gate "
656
+ "service/factory entry points before database writes or privileged actions."
657
+ ),
658
+ confidence="high" if top_chain.confidence >= 0.7 else "medium",
659
+ )
660
+ )
661
+
662
+ if not findings:
663
+ findings.append(
664
+ Finding(
665
+ title="Heuristic scan found only a limited attack surface",
666
+ severity="low",
667
+ evidence=["No major route, secret, or integration patterns triggered a stronger finding."],
668
+ mitigation="Treat this as a weak signal, expand parser coverage, and manually validate the real entry points and trust boundaries.",
669
+ confidence="low",
670
+ )
671
+ )
672
+
673
+ return sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
674
+
675
+
676
+ def generate_attack_paths(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[AttackPath]:
677
+ """
678
+ Generate plausible attacker-centric paths from recon signals.
679
+
680
+ If `attack_surfaces` is provided, reuse it to avoid redundant surface
681
+ recomputation in callers that already translated recon -> surface.
682
+ """
683
+ surfaces_source = attack_surfaces if attack_surfaces is not None else identify_attack_surfaces(scan)
684
+ surfaces = [surface for surface in surfaces_source if not _is_low_quality_source(surface.file)]
685
+ atproto_chains = _build_atproto_chains(scan)
686
+ service_chains = _build_service_chains(scan)
687
+ chains = _build_probable_chains(scan) if _is_framework_mvc_scan(scan) else []
688
+
689
+ if atproto_chains:
690
+ top_chain = atproto_chains[0]
691
+ chain_label = (
692
+ f"{top_chain.entry_service} -> {top_chain.next_service}"
693
+ if top_chain.next_service
694
+ else top_chain.entry_service
695
+ )
696
+ steps = [
697
+ _action_step(
698
+ "Entry",
699
+ f"Attacker reaches {top_chain.route_method} {top_chain.route_path} in {top_chain.route_file}",
700
+ ),
701
+ _action_step("Namespace", f"Endpoint is exposed in AT Protocol namespace `{top_chain.namespace}`"),
702
+ _action_step("Service entry", f"XRPC request is handled by service `{top_chain.entry_service}`"),
703
+ ]
704
+ if top_chain.next_service:
705
+ steps.append(
706
+ _action_step(
707
+ "Propagation",
708
+ f"Service trust edge allows request influence to reach `{top_chain.next_service}`",
709
+ )
710
+ )
711
+ if top_chain.env_risk:
712
+ steps.append(
713
+ _action_step(
714
+ "Config risk",
715
+ f"Runtime behavior depends on env-configured endpoint {top_chain.env_risk}, which can widen downstream trust exposure",
716
+ )
717
+ )
718
+ steps.extend(
719
+ [
720
+ _action_step("Sink", f"Influence reaches {top_chain.sink} through protocol-driven service flow"),
721
+ _action_step("Evidence", f"confidence={top_chain.confidence:.2f}; {'; '.join(top_chain.evidence[:4])}"),
722
+ ]
723
+ )
724
+ return [
725
+ AttackPath(
726
+ name="AT Protocol namespace trust-chain abuse",
727
+ steps=steps,
728
+ impact=(
729
+ f"An exposed XRPC namespace can be abused to propagate across `{chain_label}` and affect {top_chain.sink} "
730
+ "when per-namespace authorization and inter-service trust controls are weak."
731
+ ),
732
+ )
733
+ ]
734
+
735
+ if service_chains:
736
+ top_chain = service_chains[0]
737
+ chain_label = (
738
+ f"{top_chain.entry_service} -> {top_chain.next_service}"
739
+ if top_chain.next_service
740
+ else top_chain.entry_service
741
+ )
742
+ steps = [
743
+ _action_step(
744
+ "Entry",
745
+ f"Attacker reaches {top_chain.route_method} {top_chain.route_path} in {top_chain.route_file}",
746
+ ),
747
+ _action_step("Service entry", f"Request is handled by service `{top_chain.entry_service}`"),
748
+ ]
749
+ if top_chain.next_service:
750
+ steps.append(
751
+ _action_step(
752
+ "Propagation",
753
+ f"Service-to-service trust edge allows request influence to reach `{top_chain.next_service}`",
754
+ )
755
+ )
756
+ if top_chain.env_risk:
757
+ steps.append(
758
+ _action_step(
759
+ "Config risk",
760
+ f"Runtime behavior depends on env-configured endpoint {top_chain.env_risk}, which can widen trust assumptions if misconfigured",
761
+ )
762
+ )
763
+ steps.extend(
764
+ [
765
+ _action_step(
766
+ "Sink",
767
+ f"Influence reaches {top_chain.sink}, creating a plausible unauthorized downstream action path",
768
+ ),
769
+ _action_step(
770
+ "Evidence",
771
+ f"confidence={top_chain.confidence:.2f}; {'; '.join(top_chain.evidence[:4])}",
772
+ ),
773
+ ]
774
+ )
775
+ return [
776
+ AttackPath(
777
+ name="Distributed service trust-chain abuse",
778
+ steps=steps,
779
+ impact=(
780
+ f"A public API foothold can propagate across `{chain_label}` and affect {top_chain.sink} "
781
+ "if service boundaries or downstream trust checks are weak."
782
+ ),
783
+ )
784
+ ]
785
+
786
+ if chains:
787
+ top_chain = chains[0]
788
+ controller_text = top_chain.controller or "framework controller mapping"
789
+ service_text = top_chain.service or "framework service/factory"
790
+ action_text = top_chain.action or "application action"
791
+ sink_text = top_chain.sink
792
+ evidence_text = "; ".join(top_chain.evidence[:4])
793
+ return [
794
+ AttackPath(
795
+ name="Framework route-to-sink attack chain",
796
+ steps=[
797
+ _action_step(
798
+ "Entry",
799
+ f"Attacker reaches {top_chain.route_method} {top_chain.route_path} in {top_chain.route_file}",
800
+ ),
801
+ _action_step("Routing", f"Framework route config maps request toward {controller_text} ({action_text})"),
802
+ _action_step("Execution", f"Controller path likely invokes {service_text}"),
803
+ _action_step(
804
+ "Sink",
805
+ f"Request influence reaches {sink_text}, creating an opportunity for unauthorized state change or abuse",
806
+ ),
807
+ _action_step("Evidence", f"confidence={top_chain.confidence:.2f}; {evidence_text}"),
808
+ ],
809
+ impact=(
810
+ "A public request can be chained through framework routing and service execution to sensitive operations "
811
+ "if boundary validation and authorization checks are weak or misplaced."
812
+ ),
813
+ )
814
+ ]
815
+
816
+ webhook_surface = next((surface for surface in surfaces if surface.category == "webhook"), None)
817
+ admin_surface = next((surface for surface in surfaces if surface.category == "admin"), None)
818
+ auth_surface = next((surface for surface in surfaces if surface.category == "auth"), None)
819
+ upload_surface = next((surface for surface in surfaces if surface.category == "upload"), None)
820
+ public_data_surface = next(
821
+ (surface for surface in surfaces if surface.exposure == "public" and surface.data_store_interaction and surface.category != "health"),
822
+ None,
823
+ )
824
+ integration_surface = next(
825
+ (surface for surface in surfaces if surface.exposure == "public" and surface.outbound_integration),
826
+ None,
827
+ )
828
+
829
+ if webhook_surface and (public_data_surface or integration_surface):
830
+ strongest_surface = webhook_surface
831
+ impact = "Unauthorized state changes can be triggered from the internet and then propagated into internal data or downstream systems."
832
+ steps = [
833
+ _action_step("Entry", f"An attacker reaches {strongest_surface.method} {strongest_surface.route} in {strongest_surface.file}, a webhook-style endpoint that accepts untrusted inbound events"),
834
+ _action_step("Weak point", "The endpoint is treated like a trusted integration boundary before its input is fully verified"),
835
+ ]
836
+ if public_data_surface:
837
+ steps.append(_action_step("Propagation", "Attacker-controlled input is processed close to a data store, making unauthorized writes or state changes plausible"))
838
+ if integration_surface:
839
+ steps.append(_action_step("Propagation", "The same request path can also influence outbound service calls, which widens the blast radius beyond the application itself"))
840
+ steps.append(_action_step("Impact", "The attacker drives business actions that should only occur after a trusted event or validated request"))
841
+ return [
842
+ AttackPath(
843
+ name="External event spoofing into internal state change",
844
+ steps=steps,
845
+ impact=impact,
846
+ )
847
+ ]
848
+
849
+ if admin_surface:
850
+ return [
851
+ AttackPath(
852
+ name="Administrative route abuse",
853
+ steps=[
854
+ _action_step("Entry", f"An attacker reaches {admin_surface.method} {admin_surface.route} in {admin_surface.file}, a route associated with privileged behavior"),
855
+ _action_step("Weak point", "Authentication or authorization around that route is bypassed, reused, or enforced too late"),
856
+ _action_step("Propagation", "Administrative actions execute with attacker influence and affect higher-value parts of the system"),
857
+ _action_step("Impact", "Privileged changes, sensitive data access, or configuration abuse follow from a single foothold"),
858
+ ],
859
+ impact="Privilege escalation or destructive administrative actions from a route that should be tightly controlled.",
860
+ )
861
+ ]
862
+
863
+ if auth_surface:
864
+ return [
865
+ AttackPath(
866
+ name="Authentication boundary bypass",
867
+ steps=[
868
+ _action_step("Entry", f"An attacker targets {auth_surface.method} {auth_surface.route} in {auth_surface.file}, which controls login, tokens, or session state"),
869
+ _action_step("Weak point", "Credential handling, token validation, or session establishment is weaker than the route implies"),
870
+ _action_step("Propagation", "The attacker converts that weakness into an authenticated foothold"),
871
+ _action_step("Impact", "The foothold becomes the starting point for deeper movement into protected application behavior"),
872
+ ],
873
+ impact="Account takeover or a trusted session that opens access to additional internal actions.",
874
+ )
875
+ ]
876
+
877
+ if upload_surface:
878
+ return [
879
+ AttackPath(
880
+ name="Untrusted file handling abuse",
881
+ steps=[
882
+ _action_step("Entry", f"An attacker submits content to {upload_surface.method} {upload_surface.route} in {upload_surface.file}"),
883
+ _action_step("Weak point", "The application accepts or parses attacker-controlled files too broadly"),
884
+ _action_step("Propagation", "Storage, parsing, or downstream consumers treat that content as safer than it is"),
885
+ _action_step("Impact", "The result is execution, persistence of malicious content, or operational disruption"),
886
+ ],
887
+ impact="Stored malicious content, parser abuse, or denial of service from untrusted file input.",
888
+ )
889
+ ]
890
+
891
+ if public_data_surface:
892
+ return [
893
+ AttackPath(
894
+ name="Public input into sensitive data path",
895
+ steps=[
896
+ _action_step("Entry", f"An attacker uses {public_data_surface.method} {public_data_surface.route} in {public_data_surface.file} as a public foothold"),
897
+ _action_step("Weak point", "Input validation or authorization is weaker than the route exposure suggests"),
898
+ _action_step("Propagation", "Attacker-controlled data reaches code operating close to the data store"),
899
+ _action_step("Impact", "Confidentiality, integrity, or authorization guarantees around application data are weakened"),
900
+ ],
901
+ impact="Unauthorized data access or modification through a public-facing application route.",
902
+ )
903
+ ]
904
+
905
+ if integration_surface:
906
+ return [
907
+ AttackPath(
908
+ name="Outbound trust boundary abuse",
909
+ steps=[
910
+ _action_step("Entry", f"An attacker influences {integration_surface.method} {integration_surface.route} in {integration_surface.file}, which sits near an outbound integration"),
911
+ _action_step("Weak point", "The application assumes too much trust in external calls or responses"),
912
+ _action_step("Propagation", "Spoofed, replayed, or attacker-steered third-party interactions affect internal logic"),
913
+ _action_step("Impact", "Unsafe business decisions or downstream actions follow from a weak external trust boundary"),
914
+ ],
915
+ impact="Poisoned state or unsafe downstream actions caused by over-trusting an external dependency.",
916
+ )
917
+ ]
918
+
919
+ return []