attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3441 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from .analyzer import identify_attack_surfaces
8
+ from .webhook_verification import describe as describe_webhook
9
+ from .exploitability import best_by_sink_kind, score_exploitability
10
+ from .models import (
11
+ AttackPath,
12
+ AttackSurface,
13
+ AttackTechnique,
14
+ BolaCandidate,
15
+ CloudMisconfig,
16
+ DatabaseHint,
17
+ ExternalCall,
18
+ Finding,
19
+ FindingLocation,
20
+ Route,
21
+ ScanResult,
22
+ TaintChain,
23
+ TaintFlowStep,
24
+ )
25
+ from .srcpaths import evidence_locations
26
+ from .taint_sinks import SINK_KINDS, canonical_kind, kind_label
27
+ from .authz import find_bfla_routes
28
+ from .route_auth_fusion import synthesize_unauthenticated_routes, unauthenticated_routes
29
+ from .taxonomy import cwe_tag, taxonomy_for
30
+
31
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/")
32
+
33
+ # Cap on the number of basic-archetype attack paths emitted per scan.
34
+ # Lifted from 1 in #24; capped here to keep reports focused — anything
35
+ # beyond the top few is typically redundant with what findings already
36
+ # surface. Chain-archetype paths (atproto / service / framework) emit
37
+ # alone and don't count toward this cap.
38
+ MAX_ATTACK_PATHS = 5
39
+ _MAX_BOLA_PATHS = 3
40
+
41
+ # Per-archetype fan-out cap (#5). When multiple distinct files satisfy
42
+ # an archetype (e.g. three different public-data routes each writing
43
+ # to their own datastore), emit one path per file up to this many.
44
+ # Keeps the multi-vector story visible without exploding into noise.
45
+ MAX_PER_ARCHETYPE_FANOUT = 3
46
+
47
+
48
+ def _surface_label(surface: AttackSurface) -> str:
49
+ return f"{surface.method} {surface.route} in {surface.file}"
50
+
51
+
52
+ def _severity_rank(value: str) -> int:
53
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
54
+
55
+
56
+ # #4: prioritization scoring. Higher score = triage first within severity.
57
+ # Formula: severity_weight × confidence_multiplier, rounded to int.
58
+ _SEVERITY_WEIGHT = {"high": 100, "medium": 50, "low": 10}
59
+ _CONFIDENCE_MULTIPLIER = {"high": 1.0, "medium": 0.75, "low": 0.5}
60
+
61
+
62
+ def compute_finding_score(severity: str, confidence: str) -> int:
63
+ """Compute the prioritization score for a finding.
64
+
65
+ Range: 5 (low/low) to 100 (high/high). Used as a secondary sort key
66
+ inside a severity band so a HIGH/HIGH lands above a HIGH/LOW.
67
+ """
68
+ return int(_SEVERITY_WEIGHT.get(severity, 0) * _CONFIDENCE_MULTIPLIER.get(confidence, 0.5))
69
+
70
+
71
+ def _action_step(label: str, action: str) -> str:
72
+ return f"{label}: {action}"
73
+
74
+
75
+ def _rule_slug_kind(kind: str) -> str:
76
+ """Rule id for a spec-driven detector family (#223): its kind, dashed."""
77
+ return kind.replace("_", "-")
78
+
79
+
80
+ def _locs(items, file_attr: str = "file", line_attr: str = "line") -> list[FindingLocation]:
81
+ """Structured locations for every item behind a finding (#214)."""
82
+ out: list[FindingLocation] = []
83
+ seen: set[tuple[str, int | None]] = set()
84
+ for item in items:
85
+ file = getattr(item, file_attr, None)
86
+ if not file:
87
+ continue
88
+ line = getattr(item, line_attr, None)
89
+ key = (file, line)
90
+ if key not in seen:
91
+ seen.add(key)
92
+ out.append(FindingLocation(file=file, line=line))
93
+ return out
94
+
95
+
96
+ def _fill_missing_locations(findings: list[Finding]) -> None:
97
+ """Findings without structured locations fall back to evidence parsing."""
98
+ for finding in findings:
99
+ if not finding.locations:
100
+ finding.locations = [FindingLocation(file=f, line=l) for f, l in evidence_locations(finding.evidence)]
101
+
102
+
103
+ def _webhook_state(surface: AttackSurface) -> str:
104
+ return surface.webhook_verification.state if surface.webhook_verification is not None else "unknown"
105
+
106
+
107
+ def _webhook_detail(surface: AttackSurface) -> str:
108
+ v = surface.webhook_verification
109
+ if v is not None and v.state == "absent":
110
+ return "no signature verification found in the handler"
111
+ if v is not None and v.notes:
112
+ return "; ".join(v.notes)
113
+ return "signature verification could not be determined"
114
+
115
+
116
+ def _webhook_extra_notes(surface: AttackSurface) -> list[str]:
117
+ """Notes beyond what the state's description already says."""
118
+ v = surface.webhook_verification
119
+ if v is None:
120
+ return []
121
+ return [note for note in v.notes if not (v.state == "no_replay_guard" and "timestamp" in note)]
122
+
123
+
124
+ def _webhook_locs(surfaces: list[AttackSurface]) -> list[FindingLocation]:
125
+ """The route and the line its verification state rests on."""
126
+ out = _locs(surfaces)
127
+ seen = {(loc.file, loc.line) for loc in out}
128
+ for surface in surfaces:
129
+ evidence = surface.webhook_verification.evidence if surface.webhook_verification else None
130
+ if not evidence or ":" not in evidence:
131
+ continue
132
+ file, _, line = evidence.rpartition(":")
133
+ if line.isdigit() and (file, int(line)) not in seen:
134
+ seen.add((file, int(line)))
135
+ out.append(FindingLocation(file=file, line=int(line)))
136
+ return out
137
+
138
+
139
+ def _webhook_weak_point(surface: AttackSurface) -> str:
140
+ """The attack path's weak point, citing the verification state (#243)."""
141
+ v = surface.webhook_verification
142
+ state = _webhook_state(surface)
143
+ if state == "absent":
144
+ return (
145
+ f"No signature verification was found for the event payload (handler at {surface.location()}), "
146
+ "so a forged event is processed as if it came from the provider"
147
+ )
148
+ if state == "weak_compare" and v is not None:
149
+ return (
150
+ f"The HMAC signature is compared with a non-constant-time equality at {v.evidence}, "
151
+ "so response timing reveals how much of a forged signature matched"
152
+ )
153
+ if state == "no_replay_guard" and v is not None:
154
+ return (
155
+ f"The signature is verified at {v.evidence}, but the signed timestamp is never checked, "
156
+ "so a captured event can be replayed"
157
+ )
158
+ return (
159
+ "The endpoint is treated like a trusted integration boundary before its input is fully verified "
160
+ "(signature verification could not be determined statically)"
161
+ )
162
+
163
+
164
+ def _finding_evidence(surface: AttackSurface) -> str:
165
+ details: list[str] = [_surface_label(surface)]
166
+ if surface.auth_signals:
167
+ details.append(f"auth signals: {', '.join(surface.auth_signals)}")
168
+ else:
169
+ details.append("no auth signals observed")
170
+ if surface.data_store_interaction:
171
+ details.append("data store reachable")
172
+ if surface.outbound_integration:
173
+ details.append("external integration reachable")
174
+ return "; ".join(details)
175
+
176
+
177
+ def _distinct_by_file(
178
+ surfaces: list[AttackSurface],
179
+ predicate,
180
+ limit: int,
181
+ ) -> list[AttackSurface]:
182
+ """Return the first `limit` surfaces matching `predicate`, one per file.
183
+
184
+ Used by #5 fan-out so a repo with three public-data routes in three
185
+ different files each produces its own concrete path — while three
186
+ routes in the same file collapse to one (same story anyway).
187
+ """
188
+ picked: list[AttackSurface] = []
189
+ seen_files: set[str] = set()
190
+ for surface in surfaces:
191
+ if not predicate(surface):
192
+ continue
193
+ if surface.file in seen_files:
194
+ continue
195
+ seen_files.add(surface.file)
196
+ picked.append(surface)
197
+ if len(picked) >= limit:
198
+ break
199
+ return picked
200
+
201
+
202
+ def _same_file_datastore(surface: AttackSurface, scan: ScanResult) -> tuple[str, str] | None:
203
+ """Return `(kind, file)` of a datastore in the same file as the surface,
204
+ or None. Used by #5 to make Propagation steps name the concrete sink."""
205
+ for hint in scan.databases:
206
+ if hint.file == surface.file:
207
+ return (hint.kind, hint.file)
208
+ return None
209
+
210
+
211
+ def _same_file_external(surface: AttackSurface, scan: ScanResult) -> tuple[str, str] | None:
212
+ """Return `(target, file)` of an external call in the same file as the
213
+ surface, or None. #5 narrative enrichment."""
214
+ for call in scan.external_calls:
215
+ if call.file == surface.file:
216
+ return (call.target, call.file)
217
+ return None
218
+
219
+
220
+ def _surface_evidence(surface: AttackSurface, scan: ScanResult, max_items: int = 4) -> list[str]:
221
+ """Collect concrete evidence tied to a surface — same-file signals only.
222
+
223
+ Used by basic-archetype attack paths to anchor their narrative in
224
+ specific databases, external calls, and secrets present in the same
225
+ file as the entry surface, rather than emitting a generic 4-step
226
+ story. Keeps the evidence chain narrow and defensible: if it's in
227
+ the same file as the vulnerable route, we can cite it without
228
+ speculating about how signals connect.
229
+ """
230
+ evidence: list[str] = []
231
+ for hint in scan.databases:
232
+ if hint.file == surface.file:
233
+ evidence.append(f"data store in same file: {hint.kind} ({hint.file})")
234
+ for call in scan.external_calls:
235
+ if call.file == surface.file:
236
+ evidence.append(f"external call in same file: {call.target} ({call.file})")
237
+ for secret in scan.secret_hints:
238
+ if secret.file == surface.file:
239
+ evidence.append(f"env-configured secret in same file: {secret.name} ({secret.file})")
240
+ return evidence[:max_items]
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class ProbableChain:
245
+ route_method: str
246
+ route_path: str
247
+ route_file: str
248
+ controller: str | None
249
+ action: str | None
250
+ service: str | None
251
+ sink: str
252
+ confidence: float
253
+ evidence: list[str]
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class ServiceChain:
258
+ route_method: str
259
+ route_path: str
260
+ route_file: str
261
+ entry_service: str
262
+ next_service: str | None
263
+ sink: str
264
+ confidence: float
265
+ evidence: list[str]
266
+ env_risk: str | None = None
267
+
268
+
269
+ @dataclass(frozen=True)
270
+ class AtprotoChain:
271
+ route_method: str
272
+ route_path: str
273
+ route_file: str
274
+ namespace: str
275
+ entry_service: str
276
+ next_service: str | None
277
+ sink: str
278
+ confidence: float
279
+ evidence: list[str]
280
+ env_risk: str | None = None
281
+
282
+
283
+ # SecretHint kinds that name a secret without containing its value.
284
+ NON_LITERAL_SECRET_KINDS = frozenset({"env_reference", "env_template"})
285
+
286
+
287
+ def _extract_prefixed_hints(scan: ScanResult, prefix: str) -> list[tuple[str, str]]:
288
+ grouped_hints: list[tuple[str, str]] = []
289
+ for hint in scan.auth_hints:
290
+ if hint.hint.startswith(prefix):
291
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
292
+
293
+ # Phase-2 migration support: allow specialized non-auth hint categories to
294
+ # carry prefixed values while keeping auth_hints fallback behavior.
295
+ if prefix.startswith("service_"):
296
+ for hint in scan.service_hints:
297
+ if hint.hint.startswith(prefix):
298
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
299
+ elif prefix.startswith("edge:"):
300
+ for hint in scan.edge_hints:
301
+ if hint.hint.startswith(prefix):
302
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
303
+ elif prefix.startswith("entrypoint:"):
304
+ for hint in scan.entrypoint_hints:
305
+ if hint.hint.startswith(prefix):
306
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
307
+ elif prefix.startswith(("atproto_", "protocol:")):
308
+ for hint in scan.protocol_hints:
309
+ if hint.hint.startswith(prefix):
310
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
311
+ elif prefix.startswith(("controller:", "service:", "omeka_", "laminas_")):
312
+ for hint in scan.framework_hints:
313
+ if hint.hint.startswith(prefix):
314
+ grouped_hints.append((hint.hint.removeprefix(prefix), hint.file))
315
+
316
+ deduped: list[tuple[str, str]] = []
317
+ seen: set[tuple[str, str]] = set()
318
+ for item in grouped_hints:
319
+ if item in seen:
320
+ continue
321
+ seen.add(item)
322
+ deduped.append(item)
323
+ return deduped
324
+
325
+
326
+ def _provenance_by_hint(scan: ScanResult) -> dict[str, str]:
327
+ """Index hint text -> `source_analyzer` across framework/auth hints.
328
+
329
+ Used by the chain linker (#13) to name the analyzer that produced
330
+ each contributing signal in the emitted evidence line. Empty dict
331
+ when no signals carry provenance (e.g. hand-constructed test scans).
332
+ """
333
+ index: dict[str, str] = {}
334
+ for hint_list in (scan.framework_hints, scan.auth_hints):
335
+ for hint in hint_list:
336
+ src = getattr(hint, "source_analyzer", None)
337
+ if src and hint.hint not in index:
338
+ index[hint.hint] = src
339
+ return index
340
+
341
+
342
+ def _cited(label: str, value: str, provenance: dict[str, str], hint_key: str) -> str:
343
+ """Format `label: value` and append `[via <analyzer>]` when provenance exists."""
344
+ source = provenance.get(hint_key)
345
+ base = f"{label}: {value}"
346
+ return f"{base} [via {source}]" if source else base
347
+
348
+
349
+ def _is_low_quality_source(path_or_text: str) -> bool:
350
+ normalized = path_or_text.replace("\\", "/").lower()
351
+ return any(segment in f"/{normalized}/" for segment in LOW_QUALITY_SEGMENTS)
352
+
353
+
354
+ def _runtime_routes(scan: ScanResult) -> list[Route]:
355
+ return [route for route in scan.routes if not _is_low_quality_source(route.file)]
356
+
357
+
358
+ def _extract_edge_hints(scan: ScanResult) -> list[tuple[str, str, str]]:
359
+ edges: list[tuple[str, str, str]] = []
360
+ all_edge_hints = [*scan.edge_hints, *scan.auth_hints]
361
+ for hint in all_edge_hints:
362
+ if not hint.hint.startswith("edge:"):
363
+ continue
364
+ raw_edge = hint.hint.removeprefix("edge:")
365
+ if "->" not in raw_edge:
366
+ continue
367
+ source, target = raw_edge.split("->", 1)
368
+ source_name = source.strip().lower()
369
+ target_name = target.strip().lower()
370
+ if not source_name or not target_name:
371
+ continue
372
+ edges.append((source_name, target_name, hint.file))
373
+ return edges
374
+
375
+
376
+ def _infer_service_name_from_file(file_path: str) -> str | None:
377
+ normalized = file_path.replace("\\", "/")
378
+ parts = normalized.split("/")
379
+ for parent in ("services", "packages", "apps"):
380
+ if parent in parts:
381
+ idx = parts.index(parent)
382
+ if idx + 1 < len(parts):
383
+ return parts[idx + 1].lower()
384
+ return None
385
+
386
+
387
+ def _file_service_map(scan: ScanResult) -> dict[str, str]:
388
+ mapping: dict[str, str] = {}
389
+ for service_name, service_file in _extract_prefixed_hints(scan, "service_name:"):
390
+ mapping[service_file] = service_name.lower()
391
+ 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)]:
392
+ if path in mapping:
393
+ continue
394
+ inferred = _infer_service_name_from_file(path)
395
+ if inferred:
396
+ mapping[path] = inferred
397
+ return mapping
398
+
399
+
400
+ def _sink_on_chain(evidence: list[str]) -> bool:
401
+ """False when the chain's sink was only borrowed from elsewhere in the repo."""
402
+ return not any("elsewhere in repo" in item for item in evidence)
403
+
404
+
405
+ def _build_service_chains(scan: ScanResult) -> list[ServiceChain]:
406
+ service_edges = _extract_edge_hints(scan)
407
+ has_service_hints = any(h.hint.startswith("service_name:") for h in [*scan.service_hints, *scan.auth_hints])
408
+ if not service_edges and not has_service_hints:
409
+ return []
410
+
411
+ file_service = _file_service_map(scan)
412
+ edge_targets_by_source: dict[str, list[str]] = {}
413
+ for source, target, _file in service_edges:
414
+ edge_targets_by_source.setdefault(source, []).append(target)
415
+
416
+ databases_by_service: dict[str, DatabaseHint] = {}
417
+ for database in scan.databases:
418
+ service = file_service.get(database.file)
419
+ if service and service not in databases_by_service:
420
+ databases_by_service[service] = database
421
+
422
+ outbound_by_service: dict[str, ExternalCall] = {}
423
+ env_url_by_service: dict[str, str] = {}
424
+ for call in scan.external_calls:
425
+ service = file_service.get(call.file)
426
+ if not service:
427
+ continue
428
+ if call.target.startswith("env://"):
429
+ env_url_by_service.setdefault(service, call.target)
430
+ continue
431
+ outbound_by_service.setdefault(service, call)
432
+
433
+ chains: list[ServiceChain] = []
434
+ for route in _runtime_routes(scan):
435
+ evidence = [f"route {route.method} {route.path} in {route.file}"]
436
+ confidence = 0.35
437
+ entry_service = file_service.get(route.file) or _infer_service_name_from_file(route.file) or "entry-service"
438
+ if file_service.get(route.file):
439
+ confidence += 0.25
440
+ evidence.append(f"entry service from file-local hint: {entry_service}")
441
+ else:
442
+ confidence += 0.1
443
+ evidence.append(f"entry service inferred from repository layout: {entry_service}")
444
+
445
+ next_service = None
446
+ sink = "privileged downstream action"
447
+ env_risk = env_url_by_service.get(entry_service)
448
+
449
+ candidates = edge_targets_by_source.get(entry_service, [])
450
+ if candidates:
451
+ next_service = candidates[0]
452
+ confidence += 0.15
453
+ evidence.append(f"inter-service edge: {entry_service}->{next_service}")
454
+
455
+ sink_service = next_service or entry_service
456
+ if sink_service in databases_by_service:
457
+ db_hint = databases_by_service[sink_service]
458
+ sink = "database"
459
+ confidence += 0.2
460
+ evidence.append(f"database sink in service {sink_service}: {db_hint.kind} ({db_hint.file})")
461
+ elif sink_service in outbound_by_service:
462
+ ext_hint = outbound_by_service[sink_service]
463
+ sink = "external dependency"
464
+ confidence += 0.15
465
+ evidence.append(f"external sink in service {sink_service}: {ext_hint.target} ({ext_hint.file})")
466
+ elif scan.databases:
467
+ sink = "database"
468
+ confidence += 0.05
469
+ evidence.append(f"database hint elsewhere in repo: {scan.databases[0].kind} ({scan.databases[0].file})")
470
+ elif scan.external_calls:
471
+ sink = "external dependency"
472
+ confidence += 0.05
473
+ evidence.append(f"external call elsewhere in repo: {scan.external_calls[0].target} ({scan.external_calls[0].file})")
474
+
475
+ if env_risk:
476
+ confidence += 0.05
477
+ evidence.append(f"env-configured dependency in entry service: {env_risk}")
478
+
479
+ chains.append(
480
+ ServiceChain(
481
+ route_method=route.method,
482
+ route_path=route.path,
483
+ route_file=route.file,
484
+ entry_service=entry_service,
485
+ next_service=next_service,
486
+ sink=sink,
487
+ confidence=min(confidence, 0.95),
488
+ evidence=evidence,
489
+ env_risk=env_risk,
490
+ )
491
+ )
492
+
493
+ chains.sort(key=lambda chain: chain.confidence, reverse=True)
494
+ return chains
495
+
496
+
497
+ def _is_atproto_scan(scan: ScanResult) -> bool:
498
+ return any(hint.hint.startswith("atproto_") for hint in [*scan.protocol_hints, *scan.auth_hints])
499
+
500
+
501
+ def _extract_xrpc_namespace(route_path: str) -> str | None:
502
+ marker = "/xrpc/"
503
+ if marker not in route_path:
504
+ return None
505
+ value = route_path.split(marker, 1)[1].strip("/")
506
+ if not value:
507
+ return None
508
+ if value.startswith("com.atproto."):
509
+ return "com.atproto"
510
+ if value.startswith("app.bsky."):
511
+ return "app.bsky"
512
+ return None
513
+
514
+
515
+ def _build_atproto_chains(scan: ScanResult) -> list[AtprotoChain]:
516
+ if not _is_atproto_scan(scan):
517
+ return []
518
+
519
+ file_service = _file_service_map(scan)
520
+ for service_name, service_file in _extract_prefixed_hints(scan, "atproto_service_note:"):
521
+ file_service.setdefault(service_file, service_name.lower())
522
+
523
+ edge_targets_by_source: dict[str, list[str]] = {}
524
+ for source, target, _file in _extract_edge_hints(scan):
525
+ edge_targets_by_source.setdefault(source, []).append(target)
526
+
527
+ namespaces = {name for name, _file in _extract_prefixed_hints(scan, "atproto_namespace:")}
528
+ lexicons = {name for name, _file in _extract_prefixed_hints(scan, "atproto_lexicon:")}
529
+ xrpc_refs = {name for name, _file in _extract_prefixed_hints(scan, "atproto_xrpc_ref:")}
530
+ service_edges = {name for name, _file in _extract_prefixed_hints(scan, "atproto_service_edge:")}
531
+ stream_hints = {name for name, _file in _extract_prefixed_hints(scan, "atproto_event_stream:")}
532
+
533
+ databases_by_service: dict[str, tuple[str, str]] = {}
534
+ for database in scan.databases:
535
+ service = file_service.get(database.file)
536
+ if service and service not in databases_by_service:
537
+ databases_by_service[service] = (database.kind, database.file)
538
+
539
+ outbound_by_service: dict[str, tuple[str, str]] = {}
540
+ env_url_by_service: dict[str, str] = {}
541
+ for call in scan.external_calls:
542
+ service = file_service.get(call.file)
543
+ if not service:
544
+ continue
545
+ if call.target.startswith("env://"):
546
+ env_url_by_service.setdefault(service, call.target)
547
+ continue
548
+ outbound_by_service.setdefault(service, (call.target, call.file))
549
+
550
+ chains: list[AtprotoChain] = []
551
+ for route in _runtime_routes(scan):
552
+ namespace = _extract_xrpc_namespace(route.path)
553
+ if namespace is None:
554
+ continue
555
+
556
+ evidence = [f"xrpc route {route.method} {route.path} in {route.file}"]
557
+ confidence = 0.45
558
+ if namespace in namespaces:
559
+ confidence += 0.1
560
+ evidence.append(f"namespace signal observed: {namespace}")
561
+
562
+ endpoint_name = route.path.removeprefix("/xrpc/")
563
+ if endpoint_name in lexicons:
564
+ confidence += 0.1
565
+ evidence.append(f"lexicon-defined endpoint: {endpoint_name}")
566
+ if endpoint_name in xrpc_refs:
567
+ confidence += 0.05
568
+ evidence.append(f"code-level xrpc reference: {endpoint_name}")
569
+
570
+ entry_service = file_service.get(route.file) or _infer_service_name_from_file(route.file) or "entry-service"
571
+ if file_service.get(route.file):
572
+ confidence += 0.15
573
+ evidence.append(f"entry service from analyzer hints: {entry_service}")
574
+ else:
575
+ confidence += 0.05
576
+ evidence.append(f"entry service inferred from repository layout: {entry_service}")
577
+
578
+ next_service = None
579
+ sink = "privileged downstream action"
580
+ env_risk = env_url_by_service.get(entry_service)
581
+
582
+ edge_candidates = edge_targets_by_source.get(entry_service, [])
583
+ if edge_candidates:
584
+ next_service = edge_candidates[0]
585
+ confidence += 0.1
586
+ evidence.append(f"inter-service edge: {entry_service}->{next_service}")
587
+ elif service_edges:
588
+ next_service = sorted(service_edges)[0]
589
+ confidence += 0.05
590
+ evidence.append(f"atproto env-derived service edge: {entry_service}->{next_service}")
591
+
592
+ sink_service = next_service or entry_service
593
+ if sink_service in databases_by_service:
594
+ kind, db_file = databases_by_service[sink_service]
595
+ sink = "database"
596
+ confidence += 0.15
597
+ evidence.append(f"database sink in service {sink_service}: {kind} ({db_file})")
598
+ elif sink_service in outbound_by_service:
599
+ target, outbound_file = outbound_by_service[sink_service]
600
+ sink = "external dependency"
601
+ confidence += 0.12
602
+ evidence.append(f"outbound sink in service {sink_service}: {target} ({outbound_file})")
603
+ elif scan.databases:
604
+ sink = "database"
605
+ confidence += 0.03
606
+ evidence.append(f"database hint elsewhere in repo: {scan.databases[0].kind} ({scan.databases[0].file})")
607
+ elif scan.external_calls:
608
+ sink = "external dependency"
609
+ confidence += 0.03
610
+ evidence.append(f"external call elsewhere in repo: {scan.external_calls[0].target} ({scan.external_calls[0].file})")
611
+
612
+ if env_risk:
613
+ confidence += 0.04
614
+ evidence.append(f"env-configured dependency in entry service: {env_risk}")
615
+ if stream_hints:
616
+ confidence += 0.03
617
+ evidence.append(f"event stream exposure hints: {', '.join(sorted(stream_hints)[:2])}")
618
+
619
+ chains.append(
620
+ AtprotoChain(
621
+ route_method=route.method,
622
+ route_path=route.path,
623
+ route_file=route.file,
624
+ namespace=namespace,
625
+ entry_service=entry_service,
626
+ next_service=next_service,
627
+ sink=sink,
628
+ confidence=min(confidence, 0.95),
629
+ evidence=evidence,
630
+ env_risk=env_risk,
631
+ )
632
+ )
633
+
634
+ chains.sort(key=lambda chain: chain.confidence, reverse=True)
635
+ return chains
636
+
637
+
638
+ def _file_module_key(file_path: str) -> str:
639
+ normalized = file_path.replace("\\", "/")
640
+ marker = "/module/"
641
+ if marker in normalized:
642
+ suffix = normalized.split(marker, 1)[1]
643
+ parts = suffix.split("/")
644
+ if parts and parts[0]:
645
+ return f"module/{parts[0].lower()}"
646
+ parts = normalized.split("/")
647
+ if parts:
648
+ return parts[0].lower()
649
+ return normalized.lower()
650
+
651
+
652
+ def _guess_action(route_path: str) -> str | None:
653
+ lower = route_path.lower()
654
+ if "/admin" in lower:
655
+ return "admin action"
656
+ if "/api" in lower:
657
+ return "api action"
658
+ if lower.startswith("/s/") or "/site" in lower:
659
+ return "site action"
660
+ return None
661
+
662
+
663
+ def _is_framework_mvc_scan(scan: ScanResult) -> bool:
664
+ framework_values = [*scan.framework_hints, *scan.auth_hints]
665
+ return any(
666
+ hint.hint.startswith("controller:")
667
+ or hint.hint.startswith("service:")
668
+ or hint.hint.startswith("omeka_")
669
+ or hint.hint.startswith("laminas_")
670
+ for hint in framework_values
671
+ )
672
+
673
+
674
+ def confidence_bucket(value: float) -> str:
675
+ """Bucket a numeric confidence into the low/medium/high labels #13 asks
676
+ for. Thresholds picked so a bare route+sink lands in `low`, a chain
677
+ with controller+sink lands in `medium`, and a chain that also picks
678
+ up an Omeka/Laminas framework signal (extension or dependency hint)
679
+ lands in `high`.
680
+ """
681
+ if value >= 0.75:
682
+ return "high"
683
+ if value >= 0.55:
684
+ return "medium"
685
+ return "low"
686
+
687
+
688
+ _TAINT_SINK_LABEL: dict[str, str] = {
689
+ "sql_execute": "database (SQL execute)",
690
+ "subprocess_shell": "command execution",
691
+ "eval": "code eval",
692
+ "exec": "code exec",
693
+ "dynamic_open": "filesystem open",
694
+ "unsafe_deserialization": "unsafe deserialization",
695
+ "ssti": "template injection",
696
+ "ssrf": "outbound request (SSRF)",
697
+ "nosql_injection": "NoSQL query",
698
+ "open_redirect": "redirect target",
699
+ }
700
+ # Kinds added through the sink registry (#240) take its labels.
701
+ for _kind in SINK_KINDS:
702
+ _TAINT_SINK_LABEL.setdefault(_kind, kind_label(_kind))
703
+
704
+
705
+ # Per-sink-kind specification for the dedicated taint findings (#68).
706
+ # Each dangerous sink kind reachable from a route produces one aggregated
707
+ # Finding. `severity` is the Finding severity; `technique` is an ATT&CK
708
+ # reference. Sinks NOT in this table (sql_execute, dynamic_open) are
709
+ # surfaced through the probable-chain finding path instead of a dedicated
710
+ # finding.
711
+ _TAINT_FINDING_SPEC: dict[str, dict[str, str]] = {
712
+ "eval": {
713
+ "severity": "high",
714
+ "title": "Request-reachable code execution via eval",
715
+ "mitigation": "Never pass request-derived data to eval. Use a domain-specific parser or an explicit allow-list of operations.",
716
+ "technique_id": "T1059",
717
+ "technique_name": "Command and Scripting Interpreter",
718
+ "tactic": "Execution",
719
+ },
720
+ "exec": {
721
+ "severity": "high",
722
+ "title": "Request-reachable code execution via exec",
723
+ "mitigation": "Never pass request-derived data to exec. Refactor to call named functions selected via a validated allow-list.",
724
+ "technique_id": "T1059",
725
+ "technique_name": "Command and Scripting Interpreter",
726
+ "tactic": "Execution",
727
+ },
728
+ "subprocess_shell": {
729
+ "severity": "high",
730
+ "title": "Request-reachable OS command execution",
731
+ "mitigation": "Prefer argv-list subprocess calls with shell=False. Never interpolate request data into a shell string; validate and constrain any external command inputs.",
732
+ "technique_id": "T1059",
733
+ "technique_name": "Command and Scripting Interpreter",
734
+ "tactic": "Execution",
735
+ },
736
+ "unsafe_deserialization": {
737
+ "severity": "high",
738
+ "title": "Request-reachable unsafe deserialization",
739
+ "mitigation": "Do not deserialize attacker-controlled data with pickle/marshal/yaml.load/ObjectInputStream/unserialize. Use a data-only format (JSON) with a schema, or a safe loader (yaml.safe_load).",
740
+ "technique_id": "T1059",
741
+ "technique_name": "Command and Scripting Interpreter",
742
+ "tactic": "Execution",
743
+ },
744
+ "ssti": {
745
+ "severity": "high",
746
+ "title": "Request-reachable server-side template injection",
747
+ "mitigation": "Never render request-derived strings as templates. Pass untrusted data as template *variables* (auto-escaped), not as the template source.",
748
+ "technique_id": "T1059",
749
+ "technique_name": "Command and Scripting Interpreter",
750
+ "tactic": "Execution",
751
+ },
752
+ "ssrf": {
753
+ "severity": "medium",
754
+ "title": "Request-reachable server-side request forgery (SSRF)",
755
+ "mitigation": "Validate and allow-list outbound destinations, resolve and pin DNS, block link-local/metadata ranges (169.254.0.0/16, fd00::/8), and disable redirects to internal hosts.",
756
+ "technique_id": "T1190",
757
+ "technique_name": "Exploit Public-Facing Application",
758
+ "tactic": "Initial Access",
759
+ },
760
+ "nosql_injection": {
761
+ "severity": "medium",
762
+ "title": "Request-reachable NoSQL injection",
763
+ "mitigation": "Never pass a raw request object as a query filter. Cast and validate each field, reject operator keys ($where, $gt, ...) in user input, and disable server-side JavaScript execution.",
764
+ "technique_id": "T1190",
765
+ "technique_name": "Exploit Public-Facing Application",
766
+ "tactic": "Initial Access",
767
+ },
768
+ # --- #240 sink catalog expansion -------------------------------------
769
+ "zip_slip": {
770
+ "severity": "high",
771
+ "title": "Request-reachable archive extraction without path checks (zip-slip)",
772
+ "mitigation": "Extract only after validating every member path resolves inside the destination (tarfile: `extractall(filter=\"data\")` on Python 3.12+; adm-zip/unzipper: check `path.resolve(dest, entry)` starts with `dest`). Reject absolute paths, `..` segments and links.",
773
+ "technique_id": "T1190",
774
+ "technique_name": "Exploit Public-Facing Application",
775
+ "tactic": "Initial Access",
776
+ },
777
+ "code_injection": {
778
+ "severity": "high",
779
+ "title": "Request input selects or builds code to run (code injection)",
780
+ "mitigation": "Never build code (new Function, vm.run*, string setTimeout) or pick modules/methods (import_module, getattr, send, constantize) from request input. Map an allow-listed key to a fixed callable instead.",
781
+ "technique_id": "T1059",
782
+ "technique_name": "Command and Scripting Interpreter",
783
+ "tactic": "Execution",
784
+ },
785
+ "expression_injection": {
786
+ "severity": "high",
787
+ "title": "Request input evaluated as an expression (SpEL/OGNL/MVEL/script injection)",
788
+ "mitigation": "Do not parse or evaluate request-derived strings with SpEL, OGNL, MVEL or a ScriptEngine. Use a SimpleEvaluationContext / sandbox if expressions are a feature, and pass user data only as variables.",
789
+ "technique_id": "T1059",
790
+ "technique_name": "Command and Scripting Interpreter",
791
+ "tactic": "Execution",
792
+ },
793
+ "jndi_injection": {
794
+ "severity": "high",
795
+ "title": "Request input reaches a JNDI lookup (JNDI injection)",
796
+ "mitigation": "Never pass request-derived names to `Context.lookup`. Look up fixed, configured names only, and disable remote codebases (`com.sun.jndi.*.object.trustURLCodebase=false`).",
797
+ "technique_id": "T1059",
798
+ "technique_name": "Command and Scripting Interpreter",
799
+ "tactic": "Execution",
800
+ },
801
+ "ldap_injection": {
802
+ "severity": "medium",
803
+ "title": "Request input reaches an LDAP search filter (LDAP injection)",
804
+ "mitigation": "Escape every request-derived value with an LDAP filter encoder (ldap.filter.escape_filter_chars, ldap_escape(..., LDAP_ESCAPE_FILTER), Spring LdapEncoder.filterEncode) or build filters with a parameterized API.",
805
+ "technique_id": "T1190",
806
+ "technique_name": "Exploit Public-Facing Application",
807
+ "tactic": "Initial Access",
808
+ },
809
+ "xpath_injection": {
810
+ "severity": "medium",
811
+ "title": "Request input reaches an XPath expression (XPath injection)",
812
+ "mitigation": "Use XPath variables (XPathVariableResolver, lxml `xpath(expr, name=value)`) instead of concatenating request data into the expression.",
813
+ "technique_id": "T1190",
814
+ "technique_name": "Exploit Public-Facing Application",
815
+ "tactic": "Initial Access",
816
+ },
817
+ "header_injection": {
818
+ "severity": "low",
819
+ "title": "Request input sets an HTTP response header (header injection)",
820
+ "mitigation": "Most frameworks now reject CR/LF in header values, but a caller-controlled value can still steer Location, Content-Disposition or cookies. Allow-list or encode the value before setting the header.",
821
+ "technique_id": "T1190",
822
+ "technique_name": "Exploit Public-Facing Application",
823
+ "tactic": "Initial Access",
824
+ },
825
+ "regex_injection": {
826
+ "severity": "low",
827
+ "title": "Request input compiled as a regular expression (ReDoS)",
828
+ "mitigation": "Escape request input before embedding it in a pattern (re.escape, escapeStringRegexp, Pattern.quote, regexp.QuoteMeta, preg_quote), or match it as a literal. Use a linear-time engine (RE2) where user patterns are a feature.",
829
+ "technique_id": "T1499.004",
830
+ "technique_name": "Endpoint Denial of Service: Application or System Exploitation",
831
+ "tactic": "Impact",
832
+ },
833
+ "open_redirect": {
834
+ "severity": "medium",
835
+ "title": "Request-reachable open redirect",
836
+ "mitigation": "Never redirect to a request-derived URL directly. Redirect only to a server-side allow-list of paths/hosts, or map an opaque key to the destination; reject absolute URLs and protocol-relative (`//`) targets.",
837
+ "technique_id": "T1204",
838
+ "technique_name": "User Execution",
839
+ "tactic": "Execution",
840
+ },
841
+ }
842
+
843
+
844
+ # Flow-confirmed findings (#239) for sink kinds that are NOT dangerous on
845
+ # reachability alone — a SQL call or file open is everywhere. These fire only
846
+ # when the intra-procedural pass traced a request source into the sink's
847
+ # dangerous argument (`TaintChain.source_kind` set, not sanitized), so the
848
+ # finding names the exact source line and route.
849
+ _FLOW_FINDING_SPEC: dict[str, dict[str, str]] = {
850
+ "sql_execute": {
851
+ "rule_id": "sql-injection",
852
+ "severity": "high",
853
+ "title": "Request input flows into a raw SQL query (SQL injection)",
854
+ "mitigation": "Use parameterized queries / bound parameters for every request-derived value. Never build SQL with f-strings, concatenation, .format or %; allow-list identifiers (table/column names) that can't be bound.",
855
+ "technique_id": "T1190",
856
+ "technique_name": "Exploit Public-Facing Application",
857
+ "tactic": "Initial Access",
858
+ },
859
+ # `dynamic_open` chains are folded in here as an alias (#240).
860
+ "path_traversal": {
861
+ "rule_id": "path-traversal",
862
+ "severity": "high",
863
+ "title": "Request input flows into a filesystem path (path traversal)",
864
+ "mitigation": "Never build filesystem paths from request input directly. Reduce it to a basename (secure_filename / path.basename), resolve it against a fixed base directory and verify the result stays inside it, or map an opaque id to a server-side path.",
865
+ "technique_id": "T1190",
866
+ "technique_name": "Exploit Public-Facing Application",
867
+ "tactic": "Initial Access",
868
+ },
869
+ }
870
+
871
+ # Cap on per-route flow lines cited in one aggregated taint finding, and on
872
+ # the SARIF codeFlows it carries.
873
+ _MAX_FLOW_EVIDENCE = 10
874
+ _MAX_CODE_FLOWS = 20
875
+
876
+
877
+ def _flow_evidence(chains: list[TaintChain]) -> list[str]:
878
+ """One `flow:` evidence line per distinct route with a traced source (#239)."""
879
+ lines: list[str] = []
880
+ seen: set[tuple[str, str, str, int | None]] = set()
881
+ traced = [c for c in chains if c.source_kind]
882
+ for c in sorted(traced, key=lambda c: (c.hops, c.route_file, c.sink_line or 0, c.route_path)):
883
+ key = (c.route_method, c.route_path, c.sink_file, c.sink_line)
884
+ if key in seen:
885
+ continue
886
+ seen.add(key)
887
+ if len(lines) == _MAX_FLOW_EVIDENCE:
888
+ lines.append(f"+{len(traced) - _MAX_FLOW_EVIDENCE} more traced flow(s)")
889
+ break
890
+ lines.append(
891
+ f"flow: {c.route_method} {c.route_path} — {c.source_kind} source at "
892
+ f"{c.sink_file}:{c.source_line} → sink at {c.sink_file}:{c.sink_line}"
893
+ )
894
+ return lines
895
+
896
+
897
+ def _code_flows(chains: list[TaintChain]) -> list[list[TaintFlowStep]]:
898
+ """Distinct traced flows (by sink + source) for SARIF codeFlows."""
899
+ out: list[list[TaintFlowStep]] = []
900
+ seen: set[tuple[str, int | None, int | None]] = set()
901
+ for c in chains:
902
+ if not c.flow:
903
+ continue
904
+ key = (c.sink_file, c.sink_line, c.source_line)
905
+ if key in seen:
906
+ continue
907
+ seen.add(key)
908
+ out.append(list(c.flow))
909
+ if len(out) == _MAX_CODE_FLOWS:
910
+ break
911
+ return out
912
+
913
+
914
+ # Per-kind spec for insecure-crypto findings (#70). One aggregated
915
+ # finding per crypto weakness kind present in the scan.
916
+ _CRYPTO_FINDING_SPEC: dict[str, dict[str, str]] = {
917
+ "weak_password_hash": {
918
+ "severity": "high",
919
+ "title": "Weak password hashing (MD5/SHA-1)",
920
+ "mitigation": "Hash passwords with a memory-hard KDF — argon2id, scrypt, or bcrypt — never a bare MD5/SHA-1/SHA-256 digest. Migrate existing hashes on next login.",
921
+ "technique_id": "T1110.002",
922
+ "technique_name": "Brute Force: Password Cracking",
923
+ "tactic": "Credential Access",
924
+ },
925
+ "weak_cipher": {
926
+ "severity": "high",
927
+ "title": "Weak or broken cipher (DES/3DES/RC4/Blowfish)",
928
+ "mitigation": "Replace legacy ciphers with AES-256-GCM (or ChaCha20-Poly1305). DES/3DES/RC4 are cryptographically broken.",
929
+ "technique_id": "T1600",
930
+ "technique_name": "Weaken Encryption",
931
+ "tactic": "Defense Evasion",
932
+ },
933
+ "ecb_mode": {
934
+ "severity": "medium",
935
+ "title": "Insecure ECB block-cipher mode",
936
+ "mitigation": "ECB leaks plaintext structure. Use an authenticated mode (AES-GCM) with a unique per-message nonce; in Java, always specify the transformation explicitly (e.g. \"AES/GCM/NoPadding\").",
937
+ "technique_id": "T1600",
938
+ "technique_name": "Weaken Encryption",
939
+ "tactic": "Defense Evasion",
940
+ },
941
+ "static_iv_salt": {
942
+ "severity": "medium",
943
+ "title": "Hard-coded IV or salt",
944
+ "mitigation": "Generate the IV/nonce and salt randomly per operation with a CSPRNG and store/transmit them alongside the ciphertext. A static IV/salt defeats the primitive's security.",
945
+ "technique_id": "T1600",
946
+ "technique_name": "Weaken Encryption",
947
+ "tactic": "Defense Evasion",
948
+ },
949
+ "insecure_random": {
950
+ "severity": "medium",
951
+ "title": "Insecure randomness for a security value",
952
+ "mitigation": "Use a CSPRNG for tokens/keys/salts/nonces/OTPs: `secrets` (Python), `crypto.randomBytes` (Node), `crypto/rand` (Go). `Math.random`/`random`/`rand`/`mt_rand` are predictable.",
953
+ "technique_id": "T1600",
954
+ "technique_name": "Weaken Encryption",
955
+ "tactic": "Defense Evasion",
956
+ },
957
+ "insecure_tls": {
958
+ "severity": "high",
959
+ "title": "TLS certificate/hostname verification disabled",
960
+ "mitigation": "Never disable certificate or hostname verification (`verify=False`, `rejectUnauthorized: false`, `InsecureSkipVerify: true`) or use deprecated TLS/SSL versions. Fix the trust store instead of turning off validation.",
961
+ "technique_id": "T1557",
962
+ "technique_name": "Adversary-in-the-Middle",
963
+ "tactic": "Credential Access",
964
+ },
965
+ }
966
+
967
+
968
+ # Per-kind spec for web-hardening findings (#71).
969
+ _WEB_HARDENING_FINDING_SPEC: dict[str, dict[str, str]] = {
970
+ "cors_wildcard_credentials": {
971
+ "severity": "high",
972
+ "title": "CORS allows credentials with a wildcard/reflected origin",
973
+ "mitigation": "Never combine `Access-Control-Allow-Credentials: true` with a `*` or reflected origin. Allow-list explicit trusted origins; if credentials aren't needed, drop the credentials flag.",
974
+ "technique_id": "T1539",
975
+ "technique_name": "Steal Web Session Cookie",
976
+ "tactic": "Credential Access",
977
+ },
978
+ "cors_wildcard_origin": {
979
+ "severity": "low",
980
+ "title": "CORS allows any origin (without credentials)",
981
+ "mitigation": "A wildcard or reflected origin lets any site read these responses. That is fine for genuinely public, unauthenticated data; otherwise allow-list the trusted origins. Never add credentials to this policy.",
982
+ "technique_id": "T1190",
983
+ "technique_name": "Exploit Public-Facing Application",
984
+ "tactic": "Initial Access",
985
+ },
986
+ "cors_untrusted_origin": {
987
+ # high when the file also allows credentials, else medium
988
+ "title": "Origin allow-list admits untrusted origins (unanchored regex, partial match or null)",
989
+ "mitigation": "Compare the Origin against an exact allow-list of scheme://host[:port] values. Anchor origin regexes with `^` and `$` and escape dots; check subdomains with `.endsWith('.example.com')` (leading dot), never `includes`/`endsWith('example.com')`. Never allow the `null` origin, which sandboxed iframes and `file:` pages send.",
990
+ "technique_id": "T1539",
991
+ "technique_name": "Steal Web Session Cookie",
992
+ "tactic": "Credential Access",
993
+ },
994
+ "csrf_disabled": {
995
+ "severity": "medium",
996
+ "title": "CSRF protection disabled or exempted",
997
+ "mitigation": "Keep CSRF protection on for cookie-authenticated, state-changing routes. If an endpoint is a token-authenticated API that legitimately doesn't need it, scope the exemption narrowly and document why.",
998
+ "technique_id": "T1190",
999
+ "technique_name": "Exploit Public-Facing Application",
1000
+ "tactic": "Initial Access",
1001
+ },
1002
+ "csrf_unprotected_session": {
1003
+ "severity": "medium",
1004
+ "title": "Cookie-session auth with state-changing routes and no CSRF protection",
1005
+ "mitigation": "Cookie-authenticated state-changing routes need a CSRF defence: CSRF-token middleware (`csrf-csrf`/`csrf-sync`, Django `CsrfViewMiddleware`, Flask-WTF `CSRFProtect`) or a session cookie with `SameSite=Strict` (or `Lax`, if no state changes on GET).",
1006
+ "technique_id": "T1190",
1007
+ "technique_name": "Exploit Public-Facing Application",
1008
+ "tactic": "Initial Access",
1009
+ },
1010
+ "insecure_cookie": {
1011
+ "severity": "medium",
1012
+ "title": "Session cookie set without secure flags",
1013
+ "mitigation": "Set `HttpOnly`, `Secure`, and `SameSite=Lax` (or `Strict`) on session cookies. `SameSite=None` must always be paired with `Secure`.",
1014
+ "technique_id": "T1539",
1015
+ "technique_name": "Steal Web Session Cookie",
1016
+ "tactic": "Credential Access",
1017
+ },
1018
+ "weak_csp": {
1019
+ "severity": "medium",
1020
+ "title": "Content-Security-Policy allows unsafe-inline/unsafe-eval",
1021
+ "mitigation": "Remove `'unsafe-inline'` and `'unsafe-eval'` from the CSP; use nonces or hashes for inline scripts. These directives largely defeat CSP's XSS protection.",
1022
+ "technique_id": "T1190",
1023
+ "technique_name": "Exploit Public-Facing Application",
1024
+ "tactic": "Initial Access",
1025
+ },
1026
+ "debug_enabled": {
1027
+ "severity": "medium",
1028
+ "title": "Debug mode or unrestricted management endpoints enabled",
1029
+ "mitigation": "Never ship with debug mode on (Flask/Django `DEBUG`, Rails `consider_all_requests_local`) or Spring actuator endpoints exposed with `*`. Debug consoles leak internals and can enable RCE.",
1030
+ "technique_id": "T1190",
1031
+ "technique_name": "Exploit Public-Facing Application",
1032
+ "tactic": "Initial Access",
1033
+ },
1034
+ }
1035
+
1036
+
1037
+ # Methods a forged cross-site request can use to change state (#244). `ANY`
1038
+ # covers routers that don't record a method (Django URLconfs).
1039
+ _CSRF_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE", "ANY"})
1040
+
1041
+
1042
+ # Per-kind spec for novel vuln-class findings (#77).
1043
+ _CODE_WEAKNESS_FINDING_SPEC: dict[str, dict[str, str]] = {
1044
+ "prototype_pollution": {
1045
+ "severity": "high",
1046
+ "title": "Prototype pollution",
1047
+ "mitigation": "Never write attacker-controlled keys to `__proto__`/`constructor.prototype` or deep-merge a raw request object. Use a null-prototype object (`Object.create(null)`), a Map, or a merge that rejects `__proto__`/`constructor` keys.",
1048
+ "technique_id": "T1059.007",
1049
+ "technique_name": "Command and Scripting Interpreter: JavaScript",
1050
+ "tactic": "Execution",
1051
+ },
1052
+ "mass_assignment": {
1053
+ "severity": "high",
1054
+ "title": "Mass assignment / overposting",
1055
+ "mitigation": "Never bind a whole request body to a model. Explicitly allow-list assignable fields (serializers, DTOs, strong params with a field list) so attackers can't set privileged attributes (is_admin, role, owner_id).",
1056
+ "technique_id": "T1190",
1057
+ "technique_name": "Exploit Public-Facing Application",
1058
+ "tactic": "Initial Access",
1059
+ },
1060
+ "jwt_weakness": {
1061
+ "severity": "high",
1062
+ "title": "JWT verification weakness (alg=none / signature not verified)",
1063
+ "mitigation": "Pin an explicit allow-list of signing algorithms (never include `none`), always verify the signature, and reject tokens whose header `alg` isn't expected. Don't decode without verifying.",
1064
+ "technique_id": "T1190",
1065
+ "technique_name": "Exploit Public-Facing Application",
1066
+ "tactic": "Initial Access",
1067
+ },
1068
+ # JWT verification (#243)
1069
+ "jwt_alg_confusion": {
1070
+ "severity": "high",
1071
+ "title": "JWT verified without a pinned algorithm (algorithm confusion)",
1072
+ "mitigation": "Pass an explicit algorithm allow-list on every verify: jsonwebtoken `verify(token, key, { algorithms: ['RS256'] })`, PyJWT `decode(token, key, algorithms=['RS256'])`, golang-jwt `jwt.WithValidMethods([]string{\"RS256\"})` or a keyfunc that checks `token.Method`. Otherwise a token signed with HS256 using the public key as the HMAC secret can pass as an RS256 token.",
1073
+ "technique_id": "T1606",
1074
+ "technique_name": "Forge Web Credentials",
1075
+ "tactic": "Credential Access",
1076
+ },
1077
+ "jwt_unverified_decode": {
1078
+ "severity": "high",
1079
+ "title": "JWT decoded without signature verification",
1080
+ "mitigation": "`decode` / `decodeJwt` / `ParseUnverified` / `get_unverified_claims` only base64-decode the token. Trust its claims only after a verifying call (`jwt.verify`, jose `jwtVerify`, PyJWT `decode` with a key and `algorithms`, golang-jwt `Parse` with a keyfunc).",
1081
+ "technique_id": "T1606",
1082
+ "technique_name": "Forge Web Credentials",
1083
+ "tactic": "Credential Access",
1084
+ },
1085
+ "jwt_claims_unchecked": {
1086
+ "severity": "medium",
1087
+ "title": "JWT expiry or claims not validated",
1088
+ "mitigation": "Leave expiry checking on (no `ignoreExpiration`, `verify_exp: False` or `WithoutClaimsValidation`), and with authlib call `claims.validate()` after `jwt.decode`. Expired or stolen tokens otherwise stay valid forever.",
1089
+ "technique_id": "T1550.001",
1090
+ "technique_name": "Use Alternate Authentication Material: Application Access Token",
1091
+ "tactic": "Lateral Movement",
1092
+ },
1093
+ "jwt_header_trust": {
1094
+ "severity": "high",
1095
+ "title": "JWT key lookup trusts the unverified token header (kid / jku / x5u)",
1096
+ "mitigation": "The header is attacker-controlled until the signature is verified. Look `kid` up in a fixed key map (or a bound SQL parameter), never a file path or an interpolated query, and never fetch keys from a header `jku`/`x5u` URL: pin the JWKS URL in configuration.",
1097
+ "technique_id": "T1606",
1098
+ "technique_name": "Forge Web Credentials",
1099
+ "tactic": "Credential Access",
1100
+ },
1101
+ "jwt_weak_secret": {
1102
+ "severity": "high",
1103
+ "title": "Short JWT signing secret hard-coded in source",
1104
+ "mitigation": "Load the HMAC secret from a secret manager or the environment and make it at least 256 bits of randomness. A short secret can be brute-forced offline from a single captured token, after which any token can be forged.",
1105
+ "technique_id": "T1606",
1106
+ "technique_name": "Forge Web Credentials",
1107
+ "tactic": "Credential Access",
1108
+ },
1109
+ # OAuth clients and sessions (#243, part 2)
1110
+ "oauth_missing_state": {
1111
+ "severity": "medium",
1112
+ "title": "OAuth authorization request without `state` (login CSRF)",
1113
+ "mitigation": "Send an unguessable `state` bound to the user's session on every authorization request and reject callbacks whose `state` doesn't match (passport: `state: true`). Without it an attacker can complete the flow with their own code and log the victim into the attacker's account.",
1114
+ "technique_id": "T1528",
1115
+ "technique_name": "Steal Application Access Token",
1116
+ "tactic": "Credential Access",
1117
+ },
1118
+ "oauth_implicit_flow": {
1119
+ "severity": "medium",
1120
+ "title": "OAuth implicit flow (`response_type=token`)",
1121
+ "mitigation": "Use the authorization code flow with PKCE (`response_type=code`). The implicit flow returns the access token in the redirect URL, where history, referrers and injected scripts can read it; OAuth 2.1 removes it.",
1122
+ "technique_id": "T1528",
1123
+ "technique_name": "Steal Application Access Token",
1124
+ "tactic": "Credential Access",
1125
+ },
1126
+ "oauth_redirect_uri_loose": {
1127
+ "severity": "high",
1128
+ "title": "OAuth redirect URI matched by prefix, suffix, substring or wildcard",
1129
+ "mitigation": "Compare the redirect URI against an allow-list by exact string match (RFC 9700 §4.1.3). Prefix, suffix, substring and wildcard checks let `https://app.example.com.evil.net` or `https://evil.net/?x=app.example.com` through, and the authorization code or token is delivered to the attacker.",
1130
+ "technique_id": "T1528",
1131
+ "technique_name": "Steal Application Access Token",
1132
+ "tactic": "Credential Access",
1133
+ },
1134
+ "oauth_missing_pkce": {
1135
+ "severity": "medium",
1136
+ "title": "Public OAuth client without PKCE",
1137
+ "mitigation": "Browser and mobile clients can't keep a client secret, so send a `code_challenge` with `code_challenge_method=S256` and the matching `code_verifier` at the token exchange. Never disable PKCE or use `plain`. Without it an intercepted authorization code can be redeemed by anyone.",
1138
+ "technique_id": "T1528",
1139
+ "technique_name": "Steal Application Access Token",
1140
+ "tactic": "Credential Access",
1141
+ },
1142
+ "session_fixation": {
1143
+ "severity": "medium",
1144
+ "title": "Session not regenerated at login (session fixation)",
1145
+ "mitigation": "Issue a new session id when the user authenticates: `req.session.regenerate()` before storing the user (express-session), `session_regenerate_id(true)` in PHP. Otherwise a session id planted before login becomes an authenticated session for the attacker.",
1146
+ "technique_id": "T1539",
1147
+ "technique_name": "Steal Web Session Cookie",
1148
+ "tactic": "Credential Access",
1149
+ },
1150
+ "session_weak_secret": {
1151
+ "severity": "high",
1152
+ "title": "Short session signing secret hard-coded in source",
1153
+ "mitigation": "Load the session secret from a secret manager or the environment (no hard-coded fallback such as `'dev'`) and make it at least 256 bits of randomness. A known or brute-forced secret lets anyone sign session cookies; for Flask and cookie-session that is the whole session, including the user id.",
1154
+ "technique_id": "T1606",
1155
+ "technique_name": "Forge Web Credentials",
1156
+ "tactic": "Credential Access",
1157
+ },
1158
+ "xxe": {
1159
+ "severity": "high",
1160
+ "title": "XML external entity (XXE) processing enabled",
1161
+ "mitigation": "Disable DTD/external-entity resolution in the XML parser (lxml `resolve_entities=False`, Java `disallow-doctype-decl`/secure processing, PHP keep `libxml_disable_entity_loader` default). Prefer a data format without entities where possible.",
1162
+ "technique_id": "T1190",
1163
+ "technique_name": "Exploit Public-Facing Application",
1164
+ "tactic": "Initial Access",
1165
+ },
1166
+ "redos": {
1167
+ "severity": "medium",
1168
+ "title": "Regular expression vulnerable to catastrophic backtracking (ReDoS)",
1169
+ "mitigation": "Rewrite the pattern to avoid nested quantifiers ((a+)+, (.*)*) and ambiguous alternation; use possessive quantifiers/atomic groups, a linear-time engine (RE2), or cap the length of user-supplied strings matched against it.",
1170
+ "technique_id": "T1499",
1171
+ "technique_name": "Endpoint Denial of Service",
1172
+ "tactic": "Impact",
1173
+ },
1174
+ "insecure_upload": {
1175
+ "severity": "high",
1176
+ "title": "Uploaded file persisted with a client-controlled name/path",
1177
+ "mitigation": "Never derive the stored path from the client filename. Generate a server-side name, store outside the web root, validate content type/size, and strip path separators before use.",
1178
+ "technique_id": "T1190",
1179
+ "technique_name": "Exploit Public-Facing Application",
1180
+ "tactic": "Initial Access",
1181
+ },
1182
+ "graphql_exposure": {
1183
+ "severity": "low",
1184
+ "title": "GraphQL introspection / playground enabled",
1185
+ "mitigation": "Disable introspection and the GraphiQL/playground UI in production, and add query depth/complexity limits to prevent schema disclosure and expensive-query DoS.",
1186
+ "technique_id": "T1190",
1187
+ "technique_name": "Exploit Public-Facing Application",
1188
+ "tactic": "Initial Access",
1189
+ },
1190
+ "graphql_no_query_limits": {
1191
+ "severity": "low",
1192
+ "title": "GraphQL server without query depth or complexity limits",
1193
+ "mitigation": "Bound query cost at the server: a depth limit (`graphql-depth-limit`, Strawberry `QueryDepthLimiter`, graphene `depth_limit_validator`), a complexity or cost limit (`graphql-query-complexity`, gqlgen `FixedComplexityLimit`), or GraphQL Armor. Deeply nested or aliased queries otherwise exhaust the server.",
1194
+ "technique_id": "T1499.003",
1195
+ "technique_name": "Endpoint Denial of Service: Application Exhaustion Flood",
1196
+ "tactic": "Impact",
1197
+ },
1198
+ "graphql_batching": {
1199
+ "severity": "medium",
1200
+ "title": "GraphQL HTTP batching enabled",
1201
+ "mitigation": "Disable batched HTTP requests unless clients need them. If they do, cap the batch size and rate-limit per operation, not per HTTP request, so one request can't carry thousands of login or OTP attempts.",
1202
+ "technique_id": "T1110",
1203
+ "technique_name": "Brute Force",
1204
+ "tactic": "Credential Access",
1205
+ },
1206
+ "prompt_injection_attempt": {
1207
+ "severity": "low",
1208
+ "title": "Text aimed at automated reviewers (possible prompt injection)",
1209
+ "mitigation": "Review who added these lines. Text addressed to automated (LLM-based) reviewers, instructions to disregard earlier rules, chat-template role tags or invisible/bidirectional Unicode in source are attempts to steer automated review (or hide code from humans, as in Trojan Source). AttackMap fences such content as untrusted and strips flagged lines from LLM excerpts; remove them from the code.",
1210
+ "technique_id": "T1027",
1211
+ "technique_name": "Obfuscated Files or Information",
1212
+ "tactic": "Defense Evasion",
1213
+ },
1214
+ }
1215
+
1216
+
1217
+ # Token-authentication kinds (#243: JWT and OAuth), tagged `token-auth` so
1218
+ # the benchmark scores them as one class.
1219
+ _TOKEN_AUTH_KINDS = frozenset(
1220
+ {
1221
+ "jwt_weakness",
1222
+ "jwt_alg_confusion",
1223
+ "jwt_unverified_decode",
1224
+ "jwt_claims_unchecked",
1225
+ "jwt_header_trust",
1226
+ "jwt_weak_secret",
1227
+ "oauth_missing_state",
1228
+ "oauth_implicit_flow",
1229
+ "oauth_redirect_uri_loose",
1230
+ "oauth_missing_pkce",
1231
+ }
1232
+ )
1233
+ # Session kinds (#243, part 2): the benchmark's `session_auth` class.
1234
+ _SESSION_AUTH_KINDS = frozenset({"session_fixation", "session_weak_secret"})
1235
+
1236
+
1237
+ def _auth_class_tag(kind: str) -> list[str]:
1238
+ if kind in _TOKEN_AUTH_KINDS:
1239
+ return ["token-auth"]
1240
+ if kind in _SESSION_AUTH_KINDS:
1241
+ return ["session-auth"]
1242
+ return []
1243
+
1244
+
1245
+ # Rank for picking the most severe issue in an aggregated group.
1246
+ _SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2}
1247
+
1248
+
1249
+ def _cwe_tags(kind: str) -> list[str]:
1250
+ """`external/cwe/cwe-NNN` finding tags from the taxonomy registry."""
1251
+ entry = taxonomy_for(_rule_slug_kind(kind))
1252
+ return [cwe_tag(c) for c in entry.cwe] if entry else []
1253
+
1254
+
1255
+ # Native C/C++ sinks (#189), emitted by the C/C++ analyzer plugins with a
1256
+ # per-instance severity and confidence: an input from the network or a request
1257
+ # traced to the sink in the same function is high, a local input (argv, env,
1258
+ # stdin, a file) medium, no traced input low. Findings are split per
1259
+ # (kind, severity tier) with `tiered_findings`; `tier` is the kind's usual
1260
+ # severity, which keeps the plain title and sets the SARIF rule metadata.
1261
+ _NATIVE_WEAKNESS_FINDING_SPEC: dict[str, dict[str, str]] = {
1262
+ "unbounded_copy": {
1263
+ "tier": "high",
1264
+ "title": "Unbounded buffer copy (strcpy/strcat/sprintf/gets)",
1265
+ "mitigation": "Copy with an explicit destination size: `snprintf(dst, sizeof dst, \"%s\", src)`, `strlcpy`/`strlcat`, or `std::string`. Never use `gets` (use `fgets(buf, sizeof buf, stdin)`), and give every `scanf` `%s` a field width. Check the input length against the destination before copying.",
1266
+ "technique_id": "T1190",
1267
+ "technique_name": "Exploit Public-Facing Application",
1268
+ "tactic": "Initial Access",
1269
+ },
1270
+ "format_string": {
1271
+ "tier": "high",
1272
+ "title": "Non-constant printf-family format string",
1273
+ "mitigation": "Pass data as an argument, never as the format: `printf(\"%s\", msg)`, `syslog(LOG_ERR, \"%s\", msg)`. Build with `-Wformat -Wformat-security` (and `-Werror=format-security`) so the compiler rejects non-literal formats.",
1274
+ "technique_id": "T1190",
1275
+ "technique_name": "Exploit Public-Facing Application",
1276
+ "tactic": "Initial Access",
1277
+ },
1278
+ "native_command_exec": {
1279
+ "tier": "high",
1280
+ "title": "OS command execution with a non-constant command (system/popen/exec)",
1281
+ "mitigation": "Don't build shell command lines from input. Run the program directly with `execv`/`posix_spawn` and a fixed path and argument vector, validate each argument against an allow-list, and never pass input to `sh -c`, `system` or `popen`.",
1282
+ "technique_id": "T1059.004",
1283
+ "technique_name": "Command and Scripting Interpreter: Unix Shell",
1284
+ "tactic": "Execution",
1285
+ },
1286
+ "tainted_copy_length": {
1287
+ "tier": "high",
1288
+ "title": "Memory copy with an input-controlled length (memcpy/memmove)",
1289
+ "mitigation": "Bound the length by the destination before copying (`if (n > sizeof dst) reject;`), and never take a copy length straight from a packet or request header. Prefer containers that carry their own size.",
1290
+ "technique_id": "T1190",
1291
+ "technique_name": "Exploit Public-Facing Application",
1292
+ "tactic": "Initial Access",
1293
+ },
1294
+ "alloc_size_overflow": {
1295
+ "tier": "high",
1296
+ "title": "Allocation size computed from input without an overflow check",
1297
+ "mitigation": "Check the multiplication before allocating (`if (n > SIZE_MAX / size) reject;`, `__builtin_mul_overflow`), or use `calloc(n, size)` / `reallocarray`, which fail on overflow. A wrapped size allocates a short buffer that the following writes overflow.",
1298
+ "technique_id": "T1190",
1299
+ "technique_name": "Exploit Public-Facing Application",
1300
+ "tactic": "Initial Access",
1301
+ },
1302
+ }
1303
+
1304
+ # Native weakness evidence (#189): the input the emitter traced to the sink.
1305
+ # `CodeWeakness.source_kind` is validated against these keys.
1306
+ _NATIVE_SOURCE_LABEL = {
1307
+ "request": "request input",
1308
+ "network": "network input",
1309
+ "stdin": "standard input",
1310
+ "argv": "command-line argument",
1311
+ "env": "environment variable",
1312
+ "file": "file contents",
1313
+ "other": "other input",
1314
+ }
1315
+
1316
+
1317
+ def _native_evidence_line(w) -> str:
1318
+ line = f"{w.file}:{w.line}" if w.line else w.file
1319
+ if w.evidence_text:
1320
+ line += f" — {w.evidence_text}"
1321
+ if w.source_kind:
1322
+ label = _NATIVE_SOURCE_LABEL.get(w.source_kind, "other input")
1323
+ at = f" (line {w.source_line})" if w.source_line else ""
1324
+ line += f" [input: {label}{at}]"
1325
+ else:
1326
+ line += " [no input source traced]"
1327
+ return line
1328
+
1329
+
1330
+ # CI-workflow security (#142). Severity is taken from the emitted issues (some
1331
+ # kinds tier per instance — e.g. unpinned semver tag vs. branch ref), so the
1332
+ # spec carries only the title, remediation and ATT&CK mapping. CWE/OWASP ids
1333
+ # live in the taxonomy registry (`taxonomy.py`, #250).
1334
+ _WORKFLOW_FINDING_SPEC: dict[str, dict[str, str]] = {
1335
+ "script_injection": {
1336
+ "title": "CI script injection via untrusted context in a run: step",
1337
+ "mitigation": "Never interpolate `${{ github.event.* }}` / `github.head_ref` directly into a run: script — a crafted issue/PR title or branch name runs arbitrary shell. Bind the value to an `env:` variable and reference it as `\"$ENVVAR\"` so it's passed as data, not expanded into the command.",
1338
+ "technique_id": "T1059",
1339
+ "technique_name": "Command and Scripting Interpreter",
1340
+ "tactic": "Execution",
1341
+ },
1342
+ "pr_target_checkout": {
1343
+ "title": "pull_request_target checks out untrusted PR code",
1344
+ "mitigation": "`pull_request_target` runs with the base repo's secrets in scope. Don't check out and build the PR head ref in that context. Use `pull_request` for untrusted code, or check out only the base ref and never run fork-supplied build/test scripts with secrets available.",
1345
+ "technique_id": "T1195",
1346
+ "technique_name": "Supply Chain Compromise",
1347
+ "tactic": "Initial Access",
1348
+ },
1349
+ "unpinned_action": {
1350
+ "title": "Unpinned GitHub Action (not pinned to a commit SHA)",
1351
+ "mitigation": "Pin third-party actions to a full 40-character commit SHA (`uses: org/action@<sha>`), not a moving tag or branch. A tag/branch lets the action owner — or anyone who compromises them — change what runs in your pipeline. Dependabot can keep the pinned SHAs updated.",
1352
+ "technique_id": "T1195.001",
1353
+ "technique_name": "Supply Chain Compromise: Compromise Software Dependencies and Development Tools",
1354
+ "tactic": "Initial Access",
1355
+ },
1356
+ "secret_in_run": {
1357
+ "title": "Secret interpolated into a run: shell step",
1358
+ "mitigation": "Don't expand `${{ secrets.* }}` directly into a run: script — it can leak via the command line (`ps`), step logs, or a child process. Pass the secret through the step's `env:` block and reference it as an environment variable.",
1359
+ "technique_id": "T1552",
1360
+ "technique_name": "Unsecured Credentials",
1361
+ "tactic": "Credential Access",
1362
+ },
1363
+ "broad_permissions": {
1364
+ "title": "Over-broad GITHUB_TOKEN permissions (write-all)",
1365
+ "mitigation": "Set least-privilege `permissions:` per job (default to `contents: read` and grant only what a job needs). `write-all` gives a compromised step or action full write access to the repo, releases, packages, and more.",
1366
+ "technique_id": "T1078",
1367
+ "technique_name": "Valid Accounts",
1368
+ "tactic": "Privilege Escalation",
1369
+ },
1370
+ "self_hosted_pr": {
1371
+ "title": "Self-hosted runner exposed to pull-request code",
1372
+ "mitigation": "Self-hosted runners on a `pull_request`/`pull_request_target` trigger let fork PRs run arbitrary code on your infrastructure (and persist between jobs). Use ephemeral GitHub-hosted runners for public-repo PR workflows, or gate self-hosted jobs behind an environment/approval and never on `pull_request_target`.",
1373
+ "technique_id": "T1584.004",
1374
+ "technique_name": "Compromise Infrastructure: Server",
1375
+ "tactic": "Resource Development",
1376
+ },
1377
+ # #246
1378
+ "workflow_run_artifact_poisoning": {
1379
+ "title": "workflow_run consumes untrusted artifacts or code from the triggering run",
1380
+ "mitigation": "A `workflow_run` job runs with the base repo's token and secrets, but the triggering run's artifacts and head commit come from the (possibly forked) PR. Treat downloaded artifacts as untrusted data: extract them outside the workspace, never execute or `source` them, validate their contents, and don't check out `github.event.workflow_run.head_sha` in a privileged job.",
1381
+ "technique_id": "T1195.002",
1382
+ "technique_name": "Supply Chain Compromise: Compromise Software Supply Chain",
1383
+ "tactic": "Initial Access",
1384
+ },
1385
+ "issue_comment_pr_checkout": {
1386
+ "title": "issue_comment workflow checks out and runs pull-request code",
1387
+ "mitigation": "`issue_comment` workflows (\"/ok-to-test\", \"/deploy\") run with the base repo's token and secrets. Checking out `refs/pull/N/head` or running `gh pr checkout` there executes fork code with those privileges — and the PR can change after the comment. Gate on `author_association`, pin the checkout to the commit SHA the maintainer reviewed, and run untrusted code in an unprivileged `pull_request` workflow instead.",
1388
+ "technique_id": "T1195.002",
1389
+ "technique_name": "Supply Chain Compromise: Compromise Software Supply Chain",
1390
+ "tactic": "Initial Access",
1391
+ },
1392
+ "github_script_injection": {
1393
+ "title": "CI code injection via untrusted context in actions/github-script",
1394
+ "mitigation": "`actions/github-script`'s `script:` is JavaScript evaluated with the token in scope; `${{ github.event.* }}` inside it is spliced in before evaluation. Pass the value through `env:` and read it as `process.env.NAME`, or read it from `context.payload` inside the script.",
1395
+ "technique_id": "T1059.007",
1396
+ "technique_name": "Command and Scripting Interpreter: JavaScript",
1397
+ "tactic": "Execution",
1398
+ },
1399
+ "github_env_injection": {
1400
+ "title": "Untrusted value written to $GITHUB_ENV / $GITHUB_PATH",
1401
+ "mitigation": "A newline in an attacker-controlled value written to `$GITHUB_ENV` defines arbitrary variables (`LD_PRELOAD`, `NODE_OPTIONS`, `BASH_ENV`) for every later step; `$GITHUB_PATH` hijacks executables. Don't write untrusted input to these files; if you must, strip newlines and use a random heredoc delimiter, or pass it via `env:` on the step that needs it.",
1402
+ "technique_id": "T1574",
1403
+ "technique_name": "Hijack Execution Flow",
1404
+ "tactic": "Persistence",
1405
+ },
1406
+ "default_token_permissions": {
1407
+ "title": "Workflow relies on default GITHUB_TOKEN permissions",
1408
+ "mitigation": "Add a top-level `permissions:` block (e.g. `permissions: { contents: read }`) and grant extra scopes per job. Without it the token gets the repository/organization default, which is read/write on many older repositories and organizations.",
1409
+ "technique_id": "T1078",
1410
+ "technique_name": "Valid Accounts",
1411
+ "tactic": "Privilege Escalation",
1412
+ },
1413
+ "oidc_on_untrusted_trigger": {
1414
+ "title": "OIDC token (id-token: write) available to pull-request-triggered workflow",
1415
+ "mitigation": "`id-token: write` lets any step mint an OIDC token your cloud trusts. Don't grant it on `pull_request_target`, `issue_comment` or `workflow_run` workflows that touch PR input; move deployment to a `push`/`release` workflow behind a protected environment, and scope the cloud trust policy to `ref:refs/heads/main`.",
1416
+ "technique_id": "T1078.004",
1417
+ "technique_name": "Valid Accounts: Cloud Accounts",
1418
+ "tactic": "Privilege Escalation",
1419
+ },
1420
+ "secrets_inherit": {
1421
+ "title": "Reusable workflow receives every secret (secrets: inherit)",
1422
+ "mitigation": "`secrets: inherit` passes all repository and organization secrets to the called workflow. List only the secrets it needs (`secrets: { NPM_TOKEN: ${{ secrets.NPM_TOKEN }} }`) and pin the reusable workflow to a commit SHA.",
1423
+ "technique_id": "T1552",
1424
+ "technique_name": "Unsecured Credentials",
1425
+ "tactic": "Credential Access",
1426
+ },
1427
+ "checkout_persist_credentials": {
1428
+ "title": "actions/checkout leaves the token in .git/config for untrusted code",
1429
+ "mitigation": "`actions/checkout` persists the `GITHUB_TOKEN` in `.git/config` by default. On `pull_request_target` / `issue_comment` / `workflow_run` jobs that later run PR code or artifacts, set `persist-credentials: false` so that code can't read the write-capable token.",
1430
+ "technique_id": "T1552.001",
1431
+ "technique_name": "Unsecured Credentials: Credentials In Files",
1432
+ "tactic": "Credential Access",
1433
+ },
1434
+ "cache_poisoning_pr_target": {
1435
+ "title": "Actions cache written from untrusted PR context (cache poisoning)",
1436
+ "mitigation": "Caches saved in a `pull_request_target` job land in the base branch's cache scope, so PR code (or a PR-chosen key) can poison what `push`/release builds restore. Don't use `actions/cache` or `setup-*` `cache:` in jobs that run PR code; use `actions/cache/restore` only, or run untrusted builds under `pull_request`.",
1437
+ "technique_id": "T1195.002",
1438
+ "technique_name": "Supply Chain Compromise: Compromise Software Supply Chain",
1439
+ "tactic": "Initial Access",
1440
+ },
1441
+ "docker_action_unpinned": {
1442
+ "title": "Docker action image not pinned by digest",
1443
+ "mitigation": "`uses: docker://image:tag` (or a docker `action.yml` image) follows a mutable tag. Pin it by digest (`docker://image@sha256:<digest>`) so a re-pushed tag can't change what runs in the pipeline.",
1444
+ "technique_id": "T1195.001",
1445
+ "technique_name": "Supply Chain Compromise: Compromise Software Dependencies and Development Tools",
1446
+ "tactic": "Initial Access",
1447
+ },
1448
+ "curl_pipe_shell": {
1449
+ "title": "Remote script piped into a shell in CI (curl | sh)",
1450
+ "mitigation": "`curl … | sh` runs whatever the server returns at build time, with the job's token and secrets in scope. Download to a file, verify a pinned checksum or signature, then execute — or use a pinned action/package instead.",
1451
+ "technique_id": "T1105",
1452
+ "technique_name": "Ingress Tool Transfer",
1453
+ "tactic": "Command and Control",
1454
+ },
1455
+ }
1456
+
1457
+
1458
+
1459
+ # Dependency supply-chain risk beyond CVEs (#247). Severity comes from the
1460
+ # emitted issues (tiered per instance, e.g. locked vs unlocked).
1461
+ _SUPPLY_CHAIN_FINDING_SPEC: dict[str, dict[str, str]] = {
1462
+ "dependency_confusion": {
1463
+ "title": "Dependency confusion risk: internal package names resolvable from a public registry",
1464
+ "mitigation": "Install private packages from a single index that proxies the public one (`--index-url`, not `--extra-index-url`); publish npm packages under an owned scope with an `@org:registry=` mapping; register placeholder names on the public registry for internal packages.",
1465
+ "technique_id": "T1195.001",
1466
+ "technique_name": "Supply Chain Compromise: Compromise Software Dependencies and Development Tools",
1467
+ "tactic": "Initial Access",
1468
+ },
1469
+ "typosquat_candidate": {
1470
+ "title": "Possible typosquatted dependency (one edit from a popular package)",
1471
+ "mitigation": "Confirm the dependency is the package you meant (check the registry page, maintainers and download counts). If it's a typo of the popular package, replace it and audit any machine that installed it.",
1472
+ "technique_id": "T1195.001",
1473
+ "technique_name": "Supply Chain Compromise: Compromise Software Dependencies and Development Tools",
1474
+ "tactic": "Initial Access",
1475
+ },
1476
+ "mutable_vcs_dependency": {
1477
+ "title": "Dependency pinned to a mutable git ref, URL or fork",
1478
+ "mitigation": "Pin VCS dependencies to a full commit SHA (`git+https://…@<sha>`, `github:user/repo#<sha>`, Cargo `rev = \"<sha>\"`), add a hash to archive URLs (`#sha256=` / `--hash=`), and prefer published registry releases over forks.",
1479
+ "technique_id": "T1195.001",
1480
+ "technique_name": "Supply Chain Compromise: Compromise Software Dependencies and Development Tools",
1481
+ "tactic": "Initial Access",
1482
+ },
1483
+ "install_script": {
1484
+ "title": "Dependency install scripts run code at install time",
1485
+ "mitigation": "Install with `--ignore-scripts` in CI and allow-list the packages that need build steps. Never pipe a download into a shell from an install hook; vendor or checksum-verify the script instead.",
1486
+ "technique_id": "T1059",
1487
+ "technique_name": "Command and Scripting Interpreter",
1488
+ "tactic": "Execution",
1489
+ },
1490
+ "unlocked_manifest": {
1491
+ "title": "Dependencies resolved without a lockfile or integrity hashes",
1492
+ "mitigation": "Commit the lockfile (`package-lock.json`, `composer.lock`, `Pipfile.lock`) and install with the frozen/ci mode (`npm ci`, `composer install`); regenerate lockfile entries that lack an `integrity` hash.",
1493
+ "technique_id": "T1195.001",
1494
+ "technique_name": "Supply Chain Compromise: Compromise Software Dependencies and Development Tools",
1495
+ "tactic": "Initial Access",
1496
+ },
1497
+ "insecure_registry": {
1498
+ "title": "Package registry reached over plain HTTP or without TLS verification",
1499
+ "mitigation": "Use `https://` for every registry and index URL, and remove `strict-ssl=false` / `--trusted-host` so a network attacker can't substitute packages in transit.",
1500
+ "technique_id": "T1557",
1501
+ "technique_name": "Adversary-in-the-Middle",
1502
+ "tactic": "Credential Access",
1503
+ },
1504
+ }
1505
+
1506
+ # Cloud / IaC / container-runtime misconfigurations (terraform#4, iac#1),
1507
+ # reported by IaC plugins as `CloudMisconfig` records. Severity comes from the
1508
+ # records (plugins tier per instance: a docker.sock mount outranks /var/log),
1509
+ # and core emits one finding per (kind, severity) so each instance keeps its
1510
+ # own severity in SARIF, the diff gate and the report. `tier` is the kind's
1511
+ # usual severity: its finding keeps the plain title; other tiers get a
1512
+ # "(<severity>-severity instances)" suffix, so titles (and finding ids)
1513
+ # depend only on the tier. CWE/OWASP/ATT&CK ids live in `taxonomy.py`.
1514
+ _CLOUD_MISCONFIG_FINDING_SPEC: dict[str, dict[str, str]] = {
1515
+ "iac_module_unpinned": {
1516
+ "tier": "medium",
1517
+ "title": "Infrastructure module is sourced from a mutable reference",
1518
+ "mitigation": "Pin git module sources to a commit SHA or release tag (`?ref=v1.2.3`), and give every registry module an exact `version`. A branch or an unpinned source lets whoever controls it change what `terraform apply` builds.",
1519
+ },
1520
+ "public_network_access": {
1521
+ "tier": "medium",
1522
+ "title": "Cloud resource is reachable from the public internet",
1523
+ "mitigation": "Turn off public network access (`public_network_access_enabled = false`, no public IP, `publicly_accessible = false`) and reach the resource through private endpoints, a VPN or a bastion.",
1524
+ },
1525
+ "public_cluster_endpoint": {
1526
+ "tier": "medium",
1527
+ "title": "Kubernetes API endpoint is open to the internet",
1528
+ "mitigation": "Disable the public endpoint (`endpoint_public_access = false`) or restrict it with `public_access_cidrs` to the networks that administer the cluster.",
1529
+ },
1530
+ "plaintext_listener": {
1531
+ "tier": "medium",
1532
+ "title": "Load balancer serves plain HTTP without redirecting to HTTPS",
1533
+ "mitigation": "Make the HTTP listener's default action a 301 redirect to HTTPS, and serve traffic only on an HTTPS/TLS listener with a current TLS policy.",
1534
+ },
1535
+ "api_no_authorizer": {
1536
+ "tier": "medium",
1537
+ "title": "API Gateway route accepts unauthenticated requests",
1538
+ "mitigation": "Attach an authorizer (IAM, Cognito, JWT or a Lambda authorizer) to every route that isn't meant to be public. API Gateway's default authorization is NONE, so a route without one is open to the internet.",
1539
+ },
1540
+ "unencrypted_storage": {
1541
+ "tier": "medium",
1542
+ "title": "Data store is not encrypted at rest",
1543
+ "mitigation": "Enable encryption at rest (`storage_encrypted = true`, ideally with a customer-managed KMS key). Existing unencrypted instances need a snapshot-and-restore to an encrypted copy.",
1544
+ },
1545
+ "public_data_access": {
1546
+ "tier": "high",
1547
+ "title": "Storage or search data is readable by anyone",
1548
+ "mitigation": "Remove public grants (`allUsers`, `Principal \"*\"` without a condition, public ACLs), keep the account-level public-access block on, and share data through signed URLs or scoped principals instead.",
1549
+ },
1550
+ "public_invoker": {
1551
+ "tier": "low",
1552
+ "title": "Serverless function or service can be invoked by anyone",
1553
+ "mitigation": "If the function isn't meant to be public, require authentication on its endpoint: bind the invoker role to specific service accounts or groups instead of `allUsers` / `allAuthenticatedUsers` (Cloud Run, Cloud Functions), or use `authorization_type = \"AWS_IAM\"` on a Lambda function URL.",
1554
+ },
1555
+ "privileged_container": {
1556
+ "tier": "high",
1557
+ "title": "Container runs privileged (equivalent to root on the host)",
1558
+ "mitigation": "Remove `privileged: true`. Grant the one capability the workload needs (`capabilities.add`) instead, and enforce the Pod Security `restricted` (or at least `baseline`) profile on the namespace.",
1559
+ },
1560
+ "privilege_escalation_allowed": {
1561
+ "tier": "medium",
1562
+ "title": "Container allows privilege escalation",
1563
+ "mitigation": "Set `allowPrivilegeEscalation: false` in every container's securityContext so setuid binaries and file capabilities can't raise the process's privileges.",
1564
+ },
1565
+ "container_runs_as_root": {
1566
+ "tier": "medium",
1567
+ "title": "Container runs as root",
1568
+ "mitigation": "Add a non-root `USER` to the image's final stage, and set `runAsNonRoot: true` with a non-zero `runAsUser` in the pod securityContext.",
1569
+ },
1570
+ "missing_security_context": {
1571
+ "tier": "low",
1572
+ "title": "Workload has no restrictive security context",
1573
+ "mitigation": "Give each pod a securityContext with `runAsNonRoot: true`, and each container `allowPrivilegeEscalation: false`, `readOnlyRootFilesystem: true` and `capabilities: {drop: [ALL]}`.",
1574
+ },
1575
+ "host_path_mount": {
1576
+ "tier": "high",
1577
+ "title": "Workload mounts a sensitive host path",
1578
+ "mitigation": "Replace hostPath volumes with emptyDir, a PVC, a ConfigMap or a CSI driver. Never mount the container runtime socket or the host root; if a node agent truly needs a host path, mount the narrowest path read-only.",
1579
+ },
1580
+ "host_namespace_shared": {
1581
+ "tier": "high",
1582
+ "title": "Workload shares the host's network, PID or IPC namespace",
1583
+ "mitigation": "Remove `hostNetwork`, `hostPID` and `hostIPC` (compose: `network_mode: host`, `pid: host`, `ipc: host`). Publish ports through a Service or port mapping instead.",
1584
+ },
1585
+ "dangerous_capability": {
1586
+ "tier": "high",
1587
+ "title": "Container is granted a dangerous Linux capability",
1588
+ "mitigation": "Drop `ALL` capabilities and add back only what the workload needs. `SYS_ADMIN`, `SYS_PTRACE`, `SYS_MODULE`, `NET_ADMIN` and `ALL` let a compromised process break out of the container or take over the node's network.",
1589
+ },
1590
+ "security_profile_disabled": {
1591
+ "tier": "medium",
1592
+ "title": "Container seccomp or AppArmor confinement is disabled",
1593
+ "mitigation": "Use the runtime's default seccomp profile (`seccompProfile: RuntimeDefault`) and AppArmor profile instead of `unconfined`.",
1594
+ },
1595
+ "exposed_service": {
1596
+ "tier": "low",
1597
+ "title": "Kubernetes Service is exposed outside the cluster without source ranges",
1598
+ "mitigation": "Use a ClusterIP Service behind an authenticated Ingress, mark the load balancer internal, or restrict it with `loadBalancerSourceRanges`.",
1599
+ },
1600
+ "unpinned_container_image": {
1601
+ "tier": "low",
1602
+ "title": "Container image uses a mutable tag (`latest` or none)",
1603
+ "mitigation": "Pin images to a version tag, ideally with its digest (`image:1.2.3@sha256:…`), so a pushed tag can't change what runs.",
1604
+ },
1605
+ "dockerfile_remote_add": {
1606
+ "tier": "medium",
1607
+ "title": "Dockerfile ADD downloads a remote file without a checksum",
1608
+ "mitigation": "Download with a pinned version and verify a checksum or signature (`ADD --checksum=sha256:…`, or `curl` plus `sha256sum -c` in a RUN step) before using the file.",
1609
+ },
1610
+ "dockerfile_curl_pipe_shell": {
1611
+ "tier": "medium",
1612
+ "title": "Image build pipes a downloaded script into a shell",
1613
+ "mitigation": "Download the script to a file, verify its checksum or signature, then run it, or install the tool from a package manager with signature checks.",
1614
+ },
1615
+ "rbac_wildcard": {
1616
+ "tier": "high",
1617
+ "title": "RBAC role grants wildcard verbs or resources",
1618
+ "mitigation": "List the exact resources and verbs the subject needs. A `*` verb or resource also grants every resource type added later, including Secrets and RBAC objects.",
1619
+ },
1620
+ "rbac_escalation": {
1621
+ "tier": "high",
1622
+ "title": "RBAC role can escalate privileges (escalate, bind, impersonate or pods/exec)",
1623
+ "mitigation": "Remove `escalate`, `bind` and `impersonate` from roles that don't administer RBAC, and grant `pods/exec` / `pods/attach` only to operators who need a shell in the pod.",
1624
+ },
1625
+ "rbac_secret_access": {
1626
+ "tier": "medium",
1627
+ "title": "RBAC role can read Kubernetes Secrets",
1628
+ "mitigation": "Scope Secret access to a namespaced Role with `resourceNames` for the specific Secrets the workload uses, and avoid `list`/`watch` (which return every Secret's contents).",
1629
+ },
1630
+ "cluster_admin_binding": {
1631
+ "tier": "high",
1632
+ "title": "Binding grants cluster-admin or binds a broad built-in group",
1633
+ "mitigation": "Bind `cluster-admin` only to named human administrators, never to service accounts or to `system:authenticated` / `system:unauthenticated` / `system:anonymous`. Give workloads a dedicated Role with the permissions they need.",
1634
+ },
1635
+ }
1636
+
1637
+ # Spec tables whose findings are split by severity tier (see `tiered_findings`).
1638
+ _TIERED_SPEC_TABLES: tuple[dict[str, dict[str, str]], ...] = (
1639
+ _CLOUD_MISCONFIG_FINDING_SPEC,
1640
+ _NATIVE_WEAKNESS_FINDING_SPEC,
1641
+ )
1642
+
1643
+
1644
+ def check_tiered_tables(tables: tuple[dict[str, dict[str, str]], ...]) -> None:
1645
+ """Every tiered kind is in one table only and has a valid default tier,
1646
+ so `tiered_rule_defaults` can't silently pick the wrong spec."""
1647
+ seen: dict[str, int] = {}
1648
+ for index, table in enumerate(tables):
1649
+ for kind, spec in table.items():
1650
+ if kind in seen:
1651
+ raise ValueError(f"tiered kind {kind!r} is registered twice (tables {seen[kind]} and {index})")
1652
+ if spec.get("tier") not in {"high", "medium", "low"}:
1653
+ raise ValueError(f"tiered kind {kind!r} has no valid default tier")
1654
+ seen[kind] = index
1655
+
1656
+
1657
+ check_tiered_tables(_TIERED_SPEC_TABLES)
1658
+
1659
+ # App attack surface reported by platform plugins (#188, #190): the Apple
1660
+ # analyzers (Swift, Objective-C). Severity is per instance (a global ATS
1661
+ # exception outranks a per-domain one), so the finding takes the max; the spec
1662
+ # severity is the catalog's default. CWE/OWASP ids live in `taxonomy.py`.
1663
+ _APP_SURFACE_FINDING_SPEC: dict[str, dict[str, str]] = {
1664
+ "apple_ats_insecure_loads": {
1665
+ "severity": "medium",
1666
+ "confidence": "high",
1667
+ "title": "App Transport Security exceptions allow cleartext HTTP",
1668
+ "mitigation": "Remove `NSAllowsArbitraryLoads` and serve every endpoint over HTTPS (TLS 1.2+). If one legacy host truly needs HTTP, scope an `NSExceptionDomains` entry to that host and document why; for devices on the local network use `NSAllowsLocalNetworking` instead of disabling ATS.",
1669
+ "technique_id": "T1557",
1670
+ "technique_name": "Adversary-in-the-Middle",
1671
+ "tactic": "Credential Access / Collection",
1672
+ },
1673
+ "apple_custom_url_scheme": {
1674
+ "severity": "low",
1675
+ "confidence": "high",
1676
+ "title": "Custom URL scheme handler accepts links from any app or web page",
1677
+ "mitigation": "Treat every incoming URL as untrusted input: allow-list the hosts, paths and parameters the handler acts on, and ask the user before any state-changing action. Any app can register the same scheme, so never deliver tokens or OAuth codes through it; use universal links (associated domains) or `ASWebAuthenticationSession` for auth callbacks.",
1678
+ "technique_id": "T1204.001",
1679
+ "technique_name": "User Execution: Malicious Link",
1680
+ "tactic": "Execution",
1681
+ },
1682
+ "apple_webview_js_injection": {
1683
+ "severity": "medium",
1684
+ "confidence": "medium",
1685
+ "title": "WKWebView JavaScript is built by string interpolation (script injection risk)",
1686
+ "mitigation": "Don't splice values into JavaScript source. Pass them as arguments with `callAsyncJavaScript(_:arguments:in:in:)`, or JSON-encode them (`JSONEncoder`/`JSONSerialization`) before embedding, so page content, URL fragments or user input can't break out of the string literal.",
1687
+ "technique_id": "T1059.007",
1688
+ "technique_name": "Command and Scripting Interpreter: JavaScript",
1689
+ "tactic": "Execution",
1690
+ },
1691
+ "apple_insecure_secret_storage": {
1692
+ "severity": "medium",
1693
+ "confidence": "medium",
1694
+ "title": "Secrets are stored in UserDefaults instead of the Keychain",
1695
+ "mitigation": "UserDefaults is an unencrypted plist in the app container and lands in backups. Store tokens, passwords and keys in the Keychain (`SecItemAdd` with a `kSecAttrAccessible...ThisDeviceOnly` class) and delete the old UserDefaults value on upgrade.",
1696
+ "technique_id": "T1552.001",
1697
+ "technique_name": "Unsecured Credentials: Credentials In Files",
1698
+ "tactic": "Credential Access",
1699
+ },
1700
+ "apple_pasteboard_secret": {
1701
+ "severity": "low",
1702
+ "confidence": "medium",
1703
+ "title": "Secret values are copied to the general pasteboard",
1704
+ "mitigation": "Any app (and Universal Clipboard on nearby devices) can read the general pasteboard. Don't copy secrets; if the user must, set `localOnly` and an `expirationDate` with `UIPasteboard.setItems(_:options:)`.",
1705
+ "technique_id": "T1115",
1706
+ "technique_name": "Clipboard Data",
1707
+ "tactic": "Collection",
1708
+ },
1709
+ "apple_hardened_runtime_exception": {
1710
+ "severity": "medium",
1711
+ "confidence": "high",
1712
+ "title": "Entitlements weaken the hardened runtime (library validation, DYLD variables or executable memory)",
1713
+ "mitigation": "Drop `com.apple.security.cs.disable-library-validation`, `allow-dyld-environment-variables`, `allow-unsigned-executable-memory` and `disable-executable-page-protection` unless a plug-in model or JIT truly needs them. They let an attacker inject code into the signed process; if one is required, confine the code that needs it to a separate helper.",
1714
+ "technique_id": "T1574",
1715
+ "technique_name": "Hijack Execution Flow",
1716
+ "tactic": "Stealth / Execution",
1717
+ },
1718
+ "apple_debuggable_entitlement": {
1719
+ "severity": "low",
1720
+ "confidence": "high",
1721
+ "title": "Entitlements allow debugger attachment (get-task-allow)",
1722
+ "mitigation": "`get-task-allow` lets other processes attach to the app and read or patch its memory. Keep it out of committed entitlements and let Xcode add it to Debug builds only; release and notarized builds must not carry it.",
1723
+ "technique_id": "T1055",
1724
+ "technique_name": "Process Injection",
1725
+ "tactic": "Stealth / Privilege Escalation",
1726
+ },
1727
+ "apple_xpc_unvalidated_peer": {
1728
+ "severity": "high",
1729
+ "confidence": "medium",
1730
+ "title": "XPC listener accepts connections without validating the peer",
1731
+ "mitigation": "In `listener(_:shouldAcceptNewConnection:)` verify the client before accepting: set `setCodeSigningRequirement(_:)` (macOS 13+) or check the connection's audit token against a code-signing requirement with `SecCodeCopyGuestWithAttributes` + `SecCodeCheckValidity`. Never authorize by PID alone.",
1732
+ "technique_id": "T1068",
1733
+ "technique_name": "Exploitation for Privilege Escalation",
1734
+ "tactic": "Privilege Escalation",
1735
+ },
1736
+ "apple_dynamic_dispatch": {
1737
+ "severity": "medium",
1738
+ "confidence": "medium",
1739
+ "title": "Classes, selectors or libraries are resolved from strings built at runtime",
1740
+ "mitigation": "Map external values to an allow-list of known classes/selectors instead of building names with string formatting, and load libraries only from fixed paths inside the signed bundle. A name or path an attacker can influence selects arbitrary code.",
1741
+ "technique_id": "T1129",
1742
+ "technique_name": "Shared Modules",
1743
+ "tactic": "Execution",
1744
+ },
1745
+ }
1746
+
1747
+ _ANOMALY_FINDING_SPEC: dict[str, dict[str, str]] = {
1748
+ "auth_outlier": {
1749
+ "severity": "high",
1750
+ "title": "Authorization outlier — route missing an auth check its siblings enforce",
1751
+ "mitigation": "Confirm whether the flagged route is intended to be public. If not, apply the same auth/authorization guard its sibling routes use (decorator, middleware, or policy). Inconsistent enforcement across a resource cohort is a common source of broken access control.",
1752
+ "technique_id": "T1190",
1753
+ "technique_name": "Exploit Public-Facing Application",
1754
+ "tactic": "Initial Access",
1755
+ },
1756
+ "validation_outlier": {
1757
+ "severity": "medium",
1758
+ "title": "Validation outlier — handler skips input validation its peers apply",
1759
+ "mitigation": "Apply the same input validation/schema its sibling handlers use. An unvalidated state-changing endpoint among validated peers is a likely gap for injection or malformed-input bugs.",
1760
+ "technique_id": "T1190",
1761
+ "technique_name": "Exploit Public-Facing Application",
1762
+ "tactic": "Initial Access",
1763
+ },
1764
+ "method_outlier": {
1765
+ "severity": "medium",
1766
+ "title": "Method outlier — lone state-changing endpoint in a read-only cohort",
1767
+ "mitigation": "Verify the state-changing endpoint is intended and adequately protected. A single write method among an otherwise read-only resource cohort can indicate an accidentally-exposed mutation.",
1768
+ "technique_id": "T1190",
1769
+ "technique_name": "Exploit Public-Facing Application",
1770
+ "tactic": "Initial Access",
1771
+ },
1772
+ "invariant_violation": {
1773
+ "severity": "high",
1774
+ "title": "Invariant violation — handler reaches a dangerous sink without the guard its peers apply",
1775
+ "mitigation": "Confirm whether the flagged handler should reach this sink unguarded. If not, apply the same auth/validation guard its sibling handlers place before the sink. A mined invariant broken by a single site is a common shape for a forgotten check on an injection or access-control path.",
1776
+ "technique_id": "T1190",
1777
+ "technique_name": "Exploit Public-Facing Application",
1778
+ "tactic": "Initial Access",
1779
+ },
1780
+ }
1781
+
1782
+
1783
+ def _numeric_to_confidence(value: float) -> str:
1784
+ """Map an anomaly's numeric confidence onto the Finding scale."""
1785
+ if value >= 0.75:
1786
+ return "high"
1787
+ if value >= 0.55:
1788
+ return "medium"
1789
+ return "low"
1790
+
1791
+
1792
+ # ---- Severity-tiered findings (shared) -------------------------------------
1793
+ # A detector family whose records carry their own severity emits one finding
1794
+ # per (kind, severity tier), so each instance keeps its severity in SARIF, the
1795
+ # --fail-on-new-high gate and the report. Its spec table gives every kind a
1796
+ # `tier` (the kind's usual severity) and is listed in `_TIERED_SPEC_TABLES`, so
1797
+ # SARIF rule metadata comes from that default tier, not from whichever tier
1798
+ # happens to come first.
1799
+ _SEVERITY_TIERS = ("high", "medium", "low")
1800
+
1801
+
1802
+ def tier_title(title: str, tier: str, default_tier: str) -> str:
1803
+ """The kind's usual tier keeps the plain title; other tiers get a suffix.
1804
+ Depends only on the tier, so adding a low instance never renames (re-ids)
1805
+ the high finding, and each tier has its own finding id."""
1806
+ return title if tier == default_tier else f"{title} ({tier}-severity instances)"
1807
+
1808
+
1809
+ def tiered_findings(
1810
+ kind: str,
1811
+ spec: dict[str, str],
1812
+ items: list,
1813
+ *,
1814
+ evidence: Callable[[Any], str],
1815
+ locations: Callable[[list], list[FindingLocation]] | None = None,
1816
+ tags: list[str],
1817
+ attack_techniques: list[AttackTechnique] | None = None,
1818
+ ) -> list[Finding]:
1819
+ """One finding per severity tier of ``items`` (records with `severity`
1820
+ and a `confidence`: numeric, low/medium/high, or unset = medium).
1821
+ Confidence is the max over the tier's records; evidence lists the most
1822
+ confident first (stable, so callers can pre-sort ties), capped at 10."""
1823
+ out: list[Finding] = []
1824
+ for tier in _SEVERITY_TIERS:
1825
+ tier_items = sorted((i for i in items if i.severity == tier), key=lambda i: -_confidence_value(i))
1826
+ if not tier_items:
1827
+ continue
1828
+ lines = [evidence(i) for i in tier_items[:10]]
1829
+ if len(tier_items) > 10:
1830
+ lines.append(f"+{len(tier_items) - 10} more occurrence(s)")
1831
+ out.append(
1832
+ Finding(
1833
+ title=tier_title(spec["title"], tier, spec["tier"]),
1834
+ rule_id=_rule_slug_kind(kind),
1835
+ severity=tier, # type: ignore[arg-type]
1836
+ evidence=lines,
1837
+ locations=(locations or instance_locs)(tier_items),
1838
+ mitigation=spec["mitigation"],
1839
+ confidence=_numeric_to_confidence(_confidence_value(tier_items[0])), # type: ignore[arg-type]
1840
+ tags=list(tags),
1841
+ attack_techniques=list(attack_techniques or []),
1842
+ )
1843
+ )
1844
+ return out
1845
+
1846
+
1847
+ # Low/medium/high confidences on the numeric scale `_numeric_to_confidence`
1848
+ # reads back (the same scale `merge.keep_more_severe` ranks by).
1849
+ _CONFIDENCE_VALUE = {"low": 0.3, "medium": 0.6, "high": 0.9}
1850
+
1851
+
1852
+ def _confidence_value(item: Any) -> float:
1853
+ value = getattr(item, "confidence", None)
1854
+ if isinstance(value, str):
1855
+ return _CONFIDENCE_VALUE.get(value, 0.6)
1856
+ return 0.6 if value is None else float(value)
1857
+
1858
+
1859
+ def tiered_rule_defaults(rule_id: str) -> tuple[str, str, str] | None:
1860
+ """``(title, default tier, mitigation)`` of a severity-tiered rule, for
1861
+ SARIF rule metadata; None for other rules."""
1862
+ kind = rule_id.replace("-", "_")
1863
+ for table in _TIERED_SPEC_TABLES:
1864
+ spec = table.get(kind)
1865
+ if spec is not None:
1866
+ return spec["title"], spec["tier"], spec["mitigation"]
1867
+ return None
1868
+
1869
+
1870
+ def instance_locs(items: list) -> list[FindingLocation]:
1871
+ """One location per record of a tiered family, carrying an `instance`
1872
+ key (resource + evidence) for its fingerprint. Unlike `_locs`, two
1873
+ resources at the same (file, line) — or both without a line — stay two
1874
+ locations."""
1875
+ out: list[FindingLocation] = []
1876
+ seen: set[tuple] = set()
1877
+ for item in items:
1878
+ instance = "\x1f".join(str(getattr(item, a, None) or "") for a in ("resource", "evidence_text"))
1879
+ key = (item.file, item.line, instance)
1880
+ if key not in seen:
1881
+ seen.add(key)
1882
+ out.append(FindingLocation(file=item.file, line=item.line, instance=instance))
1883
+ return out
1884
+
1885
+
1886
+ def _cloud_misconfig_evidence(issue: CloudMisconfig) -> str:
1887
+ """`file:line [resource] — evidence (context) → escalation path`."""
1888
+ text = f"{issue.file}:{issue.line}" if issue.line else issue.file
1889
+ if issue.resource:
1890
+ text += f" [{issue.resource}]"
1891
+ if issue.evidence_text:
1892
+ text += f" — {issue.evidence_text}"
1893
+ if issue.context:
1894
+ text += f" ({issue.context})"
1895
+ if issue.escalation_path:
1896
+ text += f" → {issue.escalation_path}"
1897
+ return text
1898
+
1899
+
1900
+ def _taint_by_route_file(scan: ScanResult) -> dict[str, list[TaintChain]]:
1901
+ """Group taint chains by originating route file for fast lookup."""
1902
+ out: dict[str, list[TaintChain]] = {}
1903
+ for chain in scan.taint_chains:
1904
+ out.setdefault(chain.route_file, []).append(chain)
1905
+ return out
1906
+
1907
+
1908
+ def _best_taint_for_route(
1909
+ route: Route, taint_by_file: dict[str, list[TaintChain]]
1910
+ ) -> TaintChain | None:
1911
+ candidates = [
1912
+ c
1913
+ for c in taint_by_file.get(route.file, ())
1914
+ if c.route_path == route.path
1915
+ and c.route_method == route.method
1916
+ and not c.sanitized # #137: a neutralized path isn't a probable exploit
1917
+ and not c.speculative # #148a: an unconfirmed recall lead isn't a probable exploit
1918
+ ]
1919
+ if not candidates:
1920
+ return None
1921
+ # Prefer the closest sink (fewest import hops); break ties by confidence.
1922
+ return min(candidates, key=lambda c: (c.hops, -c.confidence))
1923
+
1924
+
1925
+ def _build_probable_chains(scan: ScanResult) -> list[ProbableChain]:
1926
+ controllers = _extract_prefixed_hints(scan, "controller:")
1927
+ services = _extract_prefixed_hints(scan, "service:")
1928
+ taint_by_file = _taint_by_route_file(scan)
1929
+ # Framework-family hints from the Omeka/Laminas analyzers. These aren't
1930
+ # required to build a chain but they *strengthen* the evidence when
1931
+ # present — see #13. Each contributes a small confidence bump and an
1932
+ # evidence line naming the file it came from.
1933
+ laminas_mappings = _extract_prefixed_hints(scan, "laminas_controller_mapping")
1934
+ laminas_deps = _extract_prefixed_hints(scan, "laminas_dependency")
1935
+ omeka_deps = _extract_prefixed_hints(scan, "omeka_dependency")
1936
+ omeka_extensions = _extract_prefixed_hints(scan, "omeka_extension:")
1937
+ omeka_surfaces = _extract_prefixed_hints(scan, "omeka_surface:")
1938
+
1939
+ # Provenance index: hint value -> source_analyzer, so we can name the
1940
+ # analyzer that emitted each contributing signal in the chain evidence
1941
+ # (#14).
1942
+ provenance = _provenance_by_hint(scan)
1943
+
1944
+ db_by_module = {_file_module_key(db.file): db for db in scan.databases}
1945
+ external_by_module = {_file_module_key(call.file): call for call in scan.external_calls}
1946
+
1947
+ chains: list[ProbableChain] = []
1948
+ for route in _runtime_routes(scan):
1949
+ module_key = _file_module_key(route.file)
1950
+ evidence = [f"route {route.method} {route.path} in {route.file}"]
1951
+ confidence = 0.35
1952
+
1953
+ route_controller = next((name for name, file in controllers if file == route.file), None)
1954
+ if route_controller is None:
1955
+ route_controller = next((name for name, file in controllers if _file_module_key(file) == module_key), None)
1956
+ if route_controller:
1957
+ confidence += 0.15
1958
+ evidence.append(
1959
+ _cited("controller inferred by module proximity", route_controller, provenance, f"controller:{route_controller}")
1960
+ )
1961
+ else:
1962
+ confidence += 0.25
1963
+ evidence.append(
1964
+ _cited("controller in same file", route_controller, provenance, f"controller:{route_controller}")
1965
+ )
1966
+
1967
+ route_service = next((name for name, file in services if file == route.file), None)
1968
+ if route_service is None:
1969
+ route_service = next((name for name, file in services if _file_module_key(file) == module_key), None)
1970
+ if route_service:
1971
+ confidence += 0.10
1972
+ evidence.append(
1973
+ _cited("service inferred by module proximity", route_service, provenance, f"service:{route_service}")
1974
+ )
1975
+ else:
1976
+ confidence += 0.20
1977
+ evidence.append(
1978
+ _cited("service in same file", route_service, provenance, f"service:{route_service}")
1979
+ )
1980
+
1981
+ # Framework-family reinforcement. Each additional Omeka/Laminas
1982
+ # signal in the same module bumps confidence a little; a route
1983
+ # that lands on Omeka admin surface with an extension binding
1984
+ # ends up in the `high` bucket.
1985
+ for label, hints, weight, key_prefix in (
1986
+ ("laminas controller mapping", laminas_mappings, 0.05, "laminas_controller_mapping"),
1987
+ ("laminas dependency", laminas_deps, 0.03, "laminas_dependency"),
1988
+ ("omeka dependency", omeka_deps, 0.03, "omeka_dependency"),
1989
+ ("omeka extension binding", omeka_extensions, 0.05, "omeka_extension:"),
1990
+ ):
1991
+ same_module_hint = next(
1992
+ (name for name, file in hints if _file_module_key(file) == module_key),
1993
+ None,
1994
+ )
1995
+ if same_module_hint is not None:
1996
+ confidence += weight
1997
+ evidence.append(
1998
+ _cited(label, same_module_hint or "(marker)", provenance, f"{key_prefix}{same_module_hint or ''}".rstrip(":"))
1999
+ )
2000
+
2001
+ # Omeka surface (admin / api / site) classification. Doesn't
2002
+ # affect chain confidence directly — the route's surface risk is
2003
+ # handled elsewhere — but naming the surface in evidence gives
2004
+ # readers of the attack path a domain-shaped hook.
2005
+ omeka_surface = next(
2006
+ (name for name, file in omeka_surfaces if _file_module_key(file) == module_key),
2007
+ None,
2008
+ )
2009
+ if omeka_surface is not None:
2010
+ evidence.append(
2011
+ _cited("omeka surface classification", omeka_surface, provenance, f"omeka_surface:{omeka_surface}")
2012
+ )
2013
+
2014
+ sink = "privileged action"
2015
+ best_taint = _best_taint_for_route(route, taint_by_file)
2016
+ if module_key in db_by_module:
2017
+ sink = "database"
2018
+ confidence += 0.2
2019
+ evidence.append(f"database hint in same module: {db_by_module[module_key].kind} ({db_by_module[module_key].file})")
2020
+ elif module_key in external_by_module:
2021
+ sink = "external integration"
2022
+ confidence += 0.15
2023
+ evidence.append(f"external call in same module: {external_by_module[module_key].target} ({external_by_module[module_key].file})")
2024
+ elif best_taint is not None:
2025
+ # Taint chain gives us a specific cross-file sink cite —
2026
+ # stronger than the "sink somewhere in repo" fallback (#45).
2027
+ sink = _TAINT_SINK_LABEL[best_taint.sink_kind]
2028
+ confidence += 0.15
2029
+ evidence.append(
2030
+ f"taint chain: {best_taint.sink_kind} at {best_taint.sink_file}:{best_taint.sink_line} "
2031
+ f"reachable in {best_taint.hops} import hop(s) via {' → '.join(best_taint.files)}"
2032
+ )
2033
+ elif scan.databases:
2034
+ sink = "database"
2035
+ confidence += 0.05
2036
+ evidence.append(f"database hint elsewhere in repo: {scan.databases[0].kind} ({scan.databases[0].file})")
2037
+ elif scan.external_calls:
2038
+ sink = "external integration"
2039
+ confidence += 0.05
2040
+ evidence.append(
2041
+ f"external call elsewhere in repo: {scan.external_calls[0].target} ({scan.external_calls[0].file})"
2042
+ )
2043
+
2044
+ # If we already attributed a same-module sink, taint chain still
2045
+ # corroborates — add it as evidence without re-shaping the sink.
2046
+ if best_taint is not None and sink not in {
2047
+ _TAINT_SINK_LABEL[best_taint.sink_kind],
2048
+ "privileged action",
2049
+ }:
2050
+ confidence += 0.05
2051
+ evidence.append(
2052
+ f"taint chain corroborates: {best_taint.sink_kind} at "
2053
+ f"{best_taint.sink_file}:{best_taint.sink_line} ({best_taint.hops} hop(s))"
2054
+ )
2055
+
2056
+ action = _guess_action(route.path)
2057
+ if action:
2058
+ confidence += 0.05
2059
+ evidence.append(f"route naming suggests {action}")
2060
+
2061
+ chains.append(
2062
+ ProbableChain(
2063
+ route_method=route.method,
2064
+ route_path=route.path,
2065
+ route_file=route.file,
2066
+ controller=route_controller,
2067
+ action=action,
2068
+ service=route_service,
2069
+ sink=sink,
2070
+ confidence=min(confidence, 0.95),
2071
+ evidence=evidence,
2072
+ )
2073
+ )
2074
+
2075
+ chains.sort(key=lambda chain: chain.confidence, reverse=True)
2076
+ return chains
2077
+
2078
+
2079
+ def generate_findings(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[Finding]:
2080
+ surfaces = attack_surfaces if attack_surfaces is not None else identify_attack_surfaces(scan)
2081
+ runtime_surfaces = [surface for surface in surfaces if not _is_low_quality_source(surface.file)]
2082
+ findings: list[Finding] = []
2083
+ # Chain *findings* need evidence for every hop (#258 follow-up): a sink found
2084
+ # only "elsewhere in repo" is not on the chain, and an "inter-service" chain
2085
+ # needs an actual inter-service edge. Attack paths keep the looser chains.
2086
+ atproto_chains = [c for c in _build_atproto_chains(scan) if _sink_on_chain(c.evidence)]
2087
+ service_chains = [
2088
+ c for c in _build_service_chains(scan) if c.next_service and _sink_on_chain(c.evidence)
2089
+ ]
2090
+ chains = (
2091
+ [c for c in _build_probable_chains(scan) if _sink_on_chain(c.evidence)]
2092
+ if _is_framework_mvc_scan(scan)
2093
+ else []
2094
+ )
2095
+ webhook_surfaces = [
2096
+ surface
2097
+ for surface in runtime_surfaces
2098
+ if surface.category == "webhook"
2099
+ and surface.exposure == "public"
2100
+ and surface.method in {"POST", "PUT", "PATCH", "DELETE", "ANY"}
2101
+ and (surface.data_store_interaction or surface.outbound_integration)
2102
+ and not any(
2103
+ signal in {"jwt", "jwt_verified", "oauth", "authorization", "service_auth"} for signal in surface.auth_signals
2104
+ )
2105
+ and _webhook_state(surface) in {"absent", "unknown"}
2106
+ and not (surface.webhook_verification is not None and surface.webhook_verification.inert)
2107
+ ]
2108
+ weak_webhook_surfaces = [
2109
+ surface
2110
+ for surface in runtime_surfaces
2111
+ if surface.category == "webhook"
2112
+ and surface.exposure == "public"
2113
+ and surface.method in {"POST", "PUT", "PATCH", "DELETE", "ANY"}
2114
+ and _webhook_state(surface) in {"weak_compare", "no_replay_guard"}
2115
+ ]
2116
+ admin_surfaces = [surface for surface in runtime_surfaces if surface.category == "admin"]
2117
+ upload_surfaces = [surface for surface in runtime_surfaces if surface.category == "upload"]
2118
+ auth_surfaces = [surface for surface in runtime_surfaces if surface.category == "auth"]
2119
+ public_data_surfaces = [
2120
+ surface
2121
+ for surface in runtime_surfaces
2122
+ if surface.exposure == "public" and surface.data_store_interaction and surface.category != "health"
2123
+ ]
2124
+ public_integration_surfaces = [
2125
+ surface
2126
+ for surface in runtime_surfaces
2127
+ if surface.exposure == "public" and surface.outbound_integration
2128
+ ]
2129
+
2130
+ if webhook_surfaces:
2131
+ findings.append(
2132
+ Finding(
2133
+ title="Public webhook endpoint may trust attacker-controlled events",
2134
+ rule_id="public-webhook",
2135
+ severity="high",
2136
+ evidence=[
2137
+ f"{_finding_evidence(surface)}; {_webhook_detail(surface)}" for surface in webhook_surfaces[:10]
2138
+ ],
2139
+ locations=_locs(webhook_surfaces),
2140
+ mitigation="Require signature verification before processing webhook payloads, reject replays, and keep any downstream state change behind strict validation.",
2141
+ confidence="high",
2142
+ tags=["exposed-endpoint", "auth-missing"],
2143
+ )
2144
+ )
2145
+
2146
+ if weak_webhook_surfaces:
2147
+ findings.append(
2148
+ Finding(
2149
+ title="Webhook signature verification is weak or replayable",
2150
+ rule_id="webhook-weak-verification",
2151
+ severity="medium",
2152
+ evidence=[
2153
+ "; ".join([describe_webhook(surface), *_webhook_extra_notes(surface)])
2154
+ for surface in weak_webhook_surfaces[:10]
2155
+ ],
2156
+ locations=_webhook_locs(weak_webhook_surfaces),
2157
+ mitigation=(
2158
+ "Compare signatures with a constant-time function (`crypto.timingSafeEqual`, "
2159
+ "`hmac.compare_digest`, `hmac.Equal`, `hash_equals`), or use the provider's SDK verifier. "
2160
+ "For schemes that sign a timestamp, reject events older than a few minutes; "
2161
+ "deduplicate on the event or delivery id."
2162
+ ),
2163
+ confidence="medium",
2164
+ tags=["exposed-endpoint", "webhook-verification"],
2165
+ )
2166
+ )
2167
+
2168
+ if admin_surfaces:
2169
+ findings.append(
2170
+ Finding(
2171
+ title="Administrative routes appear reachable from the main application surface",
2172
+ rule_id="exposed-admin-route",
2173
+ severity="high",
2174
+ evidence=[_finding_evidence(surface) for surface in admin_surfaces[:10]],
2175
+ locations=_locs(admin_surfaces),
2176
+ mitigation="Require strong authentication and explicit server-side authorization on every admin action, and move admin routes behind a narrower exposure boundary where possible.",
2177
+ confidence="high",
2178
+ tags=["exposed-endpoint", "auth-missing", "privileged"],
2179
+ )
2180
+ )
2181
+
2182
+ if upload_surfaces:
2183
+ findings.append(
2184
+ Finding(
2185
+ title="Upload or import routes expand attacker-controlled input handling",
2186
+ rule_id="upload-route",
2187
+ severity="high",
2188
+ evidence=[_finding_evidence(surface) for surface in upload_surfaces[:10]],
2189
+ locations=_locs(upload_surfaces),
2190
+ mitigation="Constrain accepted formats, isolate parsers, scan uploaded content, and treat imported files as untrusted all the way through storage and processing.",
2191
+ confidence="medium",
2192
+ tags=["exposed-endpoint", "input-handling"],
2193
+ )
2194
+ )
2195
+
2196
+ # Per surface (#340): one guarded auth-category route used to suppress
2197
+ # the finding for all of them, including the real login endpoint.
2198
+ weak_auth_surfaces = [surface for surface in auth_surfaces if not surface.auth_signals]
2199
+ if weak_auth_surfaces:
2200
+ findings.append(
2201
+ Finding(
2202
+ title="Authentication routes were detected without strong nearby auth controls",
2203
+ rule_id="weak-auth-route",
2204
+ severity="medium",
2205
+ evidence=[_finding_evidence(surface) for surface in weak_auth_surfaces[:10]],
2206
+ locations=_locs(weak_auth_surfaces),
2207
+ mitigation="Review these routes for rate limiting, credential validation, token or session handling, and the exact point where trust is established server-side.",
2208
+ confidence="medium",
2209
+ tags=["exposed-endpoint", "auth-missing"],
2210
+ )
2211
+ )
2212
+
2213
+ # Fuse per-route middleware/guard chains + auth_hints into precise
2214
+ # "unauthenticated state-changing route" findings (#140). Complements the
2215
+ # category-specific findings above (webhook/upload/auth) by covering
2216
+ # public_api and privileged (admin/internal, #242) mutating routes with
2217
+ # per-route chain resolution.
2218
+ unauth_routes = unauthenticated_routes(scan, runtime_surfaces)
2219
+ findings.extend(synthesize_unauthenticated_routes(scan, runtime_surfaces, unauth_routes))
2220
+ unauth_keys = {(r.method, r.path, r.file) for r, _ in unauth_routes}
2221
+
2222
+ # Function-level authorization (#242, OWASP API5): privileged routes that
2223
+ # check authentication but never a role or permission.
2224
+ bfla = find_bfla_routes(scan, runtime_surfaces)
2225
+ if bfla:
2226
+ evidence = [line for _, line in bfla[:10]]
2227
+ if len(bfla) > 10:
2228
+ evidence.append(f"+{len(bfla) - 10} more privileged route(s) with no role check")
2229
+ findings.append(
2230
+ Finding(
2231
+ title="Privileged route checks authentication but not role (BFLA)",
2232
+ rule_id="bfla-privileged-route",
2233
+ severity="high",
2234
+ evidence=evidence,
2235
+ locations=[FindingLocation(file=r.file, line=r.line) for r, _ in bfla],
2236
+ mitigation=(
2237
+ "Authenticate AND authorize privileged actions: require an explicit role or "
2238
+ "permission check (`requireRole('admin')`, `@roles_required`, "
2239
+ "`permission_classes=[IsAdminUser]`, `@PreAuthorize(\"hasRole('ADMIN')\")`) on "
2240
+ "the route, its router, or the service it calls, so any logged-in user cannot "
2241
+ "invoke admin functions."
2242
+ ),
2243
+ confidence="medium",
2244
+ tags=["exposed-endpoint", "privileged", "function-level-authz"],
2245
+ attack_techniques=[
2246
+ AttackTechnique(
2247
+ technique_id="T1068",
2248
+ name="Exploitation for Privilege Escalation",
2249
+ tactic="Privilege Escalation",
2250
+ url="https://attack.mitre.org/techniques/T1068/",
2251
+ )
2252
+ ],
2253
+ )
2254
+ )
2255
+
2256
+ if public_integration_surfaces and not any(
2257
+ h.hint in {"jwt", "jwt_verified", "oauth", "bearer", "token"} for h in scan.auth_hints
2258
+ ):
2259
+ findings.append(
2260
+ Finding(
2261
+ title="Public routes appear to influence outbound integrations without clear auth signals",
2262
+ rule_id="unauth-outbound-integration",
2263
+ severity="medium",
2264
+ evidence=[_finding_evidence(surface) for surface in public_integration_surfaces[:10]],
2265
+ locations=_locs(public_integration_surfaces),
2266
+ mitigation="Check how outbound requests are authenticated, signed, and authorized, and confirm that untrusted route input cannot directly steer third-party actions.",
2267
+ confidence="medium",
2268
+ tags=["integration-risk", "auth-missing"],
2269
+ )
2270
+ )
2271
+
2272
+ # Split secret findings by kind (#39). Hard-coded literals get HIGH
2273
+ # severity — exposure is broader than env references (visible to
2274
+ # anyone with repo read access, not just runtime access) — and their
2275
+ # own tags so downstream triage and reporting can distinguish them.
2276
+ # `env_template` is a variable *declared* in a template such as
2277
+ # `.env.example` (iac plugin): a name, not a value, so it belongs with the
2278
+ # env references rather than the HIGH hard-coded-literal finding.
2279
+ hardcoded_secrets = [h for h in scan.secret_hints if h.kind not in NON_LITERAL_SECRET_KINDS]
2280
+ env_reference_secrets = [h for h in scan.secret_hints if h.kind in NON_LITERAL_SECRET_KINDS]
2281
+
2282
+ if hardcoded_secrets:
2283
+ # Group by kind for evidence line density; still cap at 10 total.
2284
+ evidence_lines = [
2285
+ f"[{hint.kind}] {hint.name} in {hint.file}"
2286
+ for hint in hardcoded_secrets[:10]
2287
+ ]
2288
+ findings.append(
2289
+ Finding(
2290
+ title="Hard-coded secret literals were found in source or config",
2291
+ rule_id="hardcoded-secret",
2292
+ severity="high",
2293
+ evidence=evidence_lines,
2294
+ locations=_locs(hardcoded_secrets),
2295
+ mitigation=(
2296
+ "Rotate every detected credential immediately, purge it from git history, "
2297
+ "and move to environment-injected or vault-managed values. Anyone with read "
2298
+ "access to the repository already has these secrets."
2299
+ ),
2300
+ # Keyword-proximity assignments alone (#252) are a weaker signal.
2301
+ confidence="high" if any(h.confidence >= 0.6 for h in hardcoded_secrets) else "medium",
2302
+ tags=["secret-exposure", "data-risk", "hardcoded-literal"],
2303
+ )
2304
+ )
2305
+
2306
+ # Secrets in git history (#252, opt-in --secrets-history). One finding;
2307
+ # evidence says whether each value is still in HEAD.
2308
+ if scan.secret_history:
2309
+ history = scan.secret_history
2310
+ removed = sum(1 for h in history if not h.still_in_head)
2311
+ findings.append(
2312
+ Finding(
2313
+ title="Hard-coded secrets were found in git history",
2314
+ rule_id="secret-in-git-history",
2315
+ severity="high",
2316
+ evidence=[
2317
+ f"[{h.kind}] {h.name} in {h.file} — introduced in {h.introduced_in[:12]}, "
2318
+ f"{'still in HEAD' if h.still_in_head else 'removed from HEAD (still in history)'}"
2319
+ for h in history[:10]
2320
+ ]
2321
+ + ([f"+{len(history) - 10} more"] if len(history) > 10 else []),
2322
+ locations=_locs(history),
2323
+ mitigation=(
2324
+ "Rotate every listed credential: removing it in a later commit does not revoke it, and "
2325
+ f"{removed} of {len(history)} survive only in history, where every clone and fork keeps them. "
2326
+ "Then rewrite history (git filter-repo) if the repository is shared, and add a pre-commit secret scanner."
2327
+ ),
2328
+ confidence="high",
2329
+ tags=["secret-exposure", "data-risk", "git-history"],
2330
+ )
2331
+ )
2332
+
2333
+ if env_reference_secrets:
2334
+ findings.append(
2335
+ Finding(
2336
+ title="Secret-bearing environment variables are referenced in executable paths",
2337
+ rule_id="secret-env-reference",
2338
+ severity="medium",
2339
+ evidence=[f"{hint.name} in {hint.file}" for hint in env_reference_secrets[:10]],
2340
+ locations=_locs(env_reference_secrets),
2341
+ mitigation="Confirm these secrets are injected securely, never logged or returned, rotated regularly, and scoped only to the privileges each route actually needs.",
2342
+ confidence="high",
2343
+ tags=["secret-exposure"],
2344
+ )
2345
+ )
2346
+
2347
+ if public_data_surfaces:
2348
+ findings.append(
2349
+ Finding(
2350
+ title="Public routes likely sit close to sensitive data operations",
2351
+ rule_id="public-data-route",
2352
+ severity="medium",
2353
+ evidence=[_finding_evidence(surface) for surface in public_data_surfaces[:10]],
2354
+ locations=_locs(public_data_surfaces),
2355
+ mitigation="Validate untrusted input before it reaches business logic, enforce authorization at the route boundary, and verify that downstream queries or writes stay parameterized.",
2356
+ confidence="medium",
2357
+ tags=["exposed-endpoint", "data-risk"],
2358
+ )
2359
+ )
2360
+
2361
+ if atproto_chains:
2362
+ top_atproto_chain = atproto_chains[0]
2363
+ findings.append(
2364
+ Finding(
2365
+ title="AT Protocol XRPC surface chains into a downstream trust boundary",
2366
+ rule_id="atproto-trust-chain",
2367
+ severity="high" if top_atproto_chain.sink in {"database", "privileged downstream action"} else "medium",
2368
+ evidence=[f"confidence={top_atproto_chain.confidence:.2f}", *top_atproto_chain.evidence[:6]],
2369
+ mitigation=(
2370
+ "Enforce namespace-specific authz on XRPC handlers, validate service-auth at each hop, and constrain "
2371
+ "downstream service/database permissions per endpoint."
2372
+ ),
2373
+ confidence="high" if top_atproto_chain.confidence >= 0.7 else "medium",
2374
+ tags=["atproto-chain", "data-risk"],
2375
+ )
2376
+ )
2377
+
2378
+ if service_chains:
2379
+ top_service_chain = service_chains[0]
2380
+ findings.append(
2381
+ Finding(
2382
+ title="Inter-service trust chain reaches a sensitive downstream sink",
2383
+ rule_id="service-trust-chain",
2384
+ severity="high" if top_service_chain.sink in {"database", "privileged downstream action"} else "medium",
2385
+ evidence=[f"confidence={top_service_chain.confidence:.2f}", *top_service_chain.evidence[:6]],
2386
+ mitigation=(
2387
+ "Apply explicit authn/authz checks at each service hop, constrain service-to-service callers, and "
2388
+ "treat env-configured upstream and downstream endpoints as untrusted until verified."
2389
+ ),
2390
+ confidence="high" if top_service_chain.confidence >= 0.7 else "medium",
2391
+ tags=["service-chain", "data-risk"],
2392
+ )
2393
+ )
2394
+
2395
+ if chains:
2396
+ top_chain = chains[0]
2397
+ findings.append(
2398
+ Finding(
2399
+ title="Framework route-to-service chain reaches a sensitive sink",
2400
+ rule_id="framework-sink-chain",
2401
+ severity="high" if top_chain.sink in {"database", "privileged action"} else "medium",
2402
+ evidence=[f"confidence={top_chain.confidence:.2f}", *top_chain.evidence[:6]],
2403
+ mitigation=(
2404
+ "Validate and authorize at the route boundary, enforce controller-level policy checks, and gate "
2405
+ "service/factory entry points before database writes or privileged actions."
2406
+ ),
2407
+ confidence="high" if top_chain.confidence >= 0.7 else "medium",
2408
+ tags=["framework-chain", "data-risk"],
2409
+ )
2410
+ )
2411
+
2412
+ # #60 — CVE hits on SBOM entries. One Finding per vulnerable dep,
2413
+ # aggregating every advisory affecting it in the evidence list.
2414
+ vulns_by_pkg: dict[tuple[str, str, str], list] = {}
2415
+ for vuln in scan.vulnerabilities:
2416
+ vulns_by_pkg.setdefault(
2417
+ (vuln.ecosystem, vuln.package_name, vuln.package_version), []
2418
+ ).append(vuln)
2419
+ for (eco, name, version), pkg_vulns in vulns_by_pkg.items():
2420
+ top_severity = min(
2421
+ (_severity_rank(v.severity) for v in pkg_vulns), default=_severity_rank("medium")
2422
+ )
2423
+ title_severity_word = {0: "critical", 1: "moderate", 2: "low"}.get(top_severity, "moderate")
2424
+ evidence_lines: list[str] = []
2425
+ for v in sorted(pkg_vulns, key=lambda x: (_severity_rank(x.severity), x.id)):
2426
+ summary_snip = (v.summary[:120] + "…") if len(v.summary) > 120 else v.summary
2427
+ aliases = f" (aka {', '.join(v.aliases[:3])})" if v.aliases else ""
2428
+ score = f" CVSS {v.cvss_score:.1f}" if v.cvss_score is not None else ""
2429
+ ref = v.references[0] if v.references else ""
2430
+ ref_part = f" — {ref}" if ref else ""
2431
+ evidence_lines.append(
2432
+ f"{v.id}{aliases} [{v.severity}{score}]: {summary_snip}{ref_part}"
2433
+ )
2434
+ finding_severity = "high" if top_severity == 0 else ("medium" if top_severity == 1 else "low")
2435
+ # Transitive-dependency provenance (#143): cite the resolution path so a
2436
+ # reviewer can see the vulnerable package was pulled in indirectly.
2437
+ transitive = next((v for v in pkg_vulns if not v.direct), None)
2438
+ provenance_lines: list[str] = []
2439
+ if transitive is not None:
2440
+ if transitive.resolution_path:
2441
+ provenance_lines.append(
2442
+ f"Transitive dependency — resolution path: {transitive.resolution_path}"
2443
+ )
2444
+ else:
2445
+ provenance_lines.append("Transitive (indirect) dependency")
2446
+ findings.append(
2447
+ Finding(
2448
+ title=f"Vulnerable dependency: {name}@{version} ({eco})",
2449
+ rule_id="vulnerable-dependency",
2450
+ severity=finding_severity, # type: ignore[arg-type]
2451
+ evidence=[
2452
+ f"{len(pkg_vulns)} known advisor{'y' if len(pkg_vulns) == 1 else 'ies'} for "
2453
+ f"{title_severity_word}-severity impact",
2454
+ *provenance_lines,
2455
+ *evidence_lines[:10],
2456
+ ],
2457
+ mitigation=(
2458
+ f"Upgrade `{name}` beyond the affected range shown, verify no direct"
2459
+ " or transitive callers depend on removed symbols, and re-run"
2460
+ " `attackmap analyze --cve` to confirm the finding clears."
2461
+ ),
2462
+ confidence="high",
2463
+ tags=["cve", "dependency", eco],
2464
+ )
2465
+ )
2466
+
2467
+ # Broken object-level authorization (BOLA/IDOR, #69). Routes with a
2468
+ # resource-id param that reach a datastore with no ownership check
2469
+ # nearby. Split into a HIGH finding for write methods and a MEDIUM
2470
+ # finding for reads, per the OWASP API #1 severity shape.
2471
+ # An unauthenticated write is already reported (with its missing control)
2472
+ # by the unauthenticated-route finding; BOLA is about *which* object an
2473
+ # identified caller may touch (#242). Test/fixture/example routes are
2474
+ # skipped like every other route finding's.
2475
+ bola = [
2476
+ c for c in scan.authz_candidates
2477
+ if (c.route_method, c.route_path, c.route_file) not in unauth_keys
2478
+ and not _is_low_quality_source(c.route_file)
2479
+ ]
2480
+ if bola:
2481
+ # Object identifiers arrive via path params, query params, RPC methods
2482
+ # (XRPC/tRPC), or GraphQL fields (#139). A write is any mutating HTTP
2483
+ # verb or a GraphQL mutation.
2484
+ _write_methods = {"POST", "PUT", "PATCH", "DELETE", "MUTATION"}
2485
+ _surface_label = {
2486
+ "path_param": "path id",
2487
+ "query_param": "query param",
2488
+ "rpc_method": "RPC method",
2489
+ "graphql_field": "field arg",
2490
+ }
2491
+ write_candidates = [c for c in bola if c.route_method in _write_methods]
2492
+ read_candidates = [c for c in bola if c.route_method not in _write_methods]
2493
+ for group, severity, verb in (
2494
+ (write_candidates, "high", "modify"),
2495
+ (read_candidates, "medium", "read"),
2496
+ ):
2497
+ if not group:
2498
+ continue
2499
+ # Authenticated-but-unscoped routes (the classic IDOR) lead.
2500
+ group = sorted(group, key=lambda c: c.confidence != "high")
2501
+ evidence = [
2502
+ f"{c.route_method} {c.route_path} "
2503
+ f"({_surface_label.get(c.surface, 'id')} `{c.id_param}`) "
2504
+ f"in {c.route_file} — {c.db_evidence}"
2505
+ + (f"; {c.authn_evidence}, but no object-level check in the handler" if c.authn_evidence else "")
2506
+ for c in group[:10]
2507
+ ]
2508
+ if len(group) > 10:
2509
+ evidence.append(f"+{len(group) - 10} more route(s) with the same pattern")
2510
+ findings.append(
2511
+ Finding(
2512
+ title=f"Possible broken object-level authorization (BOLA/IDOR) on {verb} routes",
2513
+ rule_id=f"bola-{verb}",
2514
+ severity=severity, # type: ignore[arg-type]
2515
+ evidence=evidence,
2516
+ locations=_locs(group, "route_file", "route_line"),
2517
+ mitigation=(
2518
+ "Enforce an object-level authorization check on every access: confirm the "
2519
+ "authenticated principal owns or may access the requested resource id "
2520
+ "server-side — scope the query by the caller's identity or evaluate a policy "
2521
+ "— before reading or modifying the record. Never trust the id from the "
2522
+ "request alone."
2523
+ ),
2524
+ confidence="high" if any(c.confidence == "high" for c in group) else "medium",
2525
+ tags=["broken-authorization", "exposed-endpoint", "data-risk"],
2526
+ attack_techniques=[
2527
+ AttackTechnique(
2528
+ technique_id="T1190",
2529
+ name="Exploit Public-Facing Application",
2530
+ tactic="Initial Access",
2531
+ url="https://attack.mitre.org/techniques/T1190/",
2532
+ )
2533
+ ],
2534
+ )
2535
+ )
2536
+
2537
+ # Insecure crypto / weak randomness (#70). One aggregated finding per
2538
+ # weakness kind present, ordered by the spec's declaration.
2539
+ crypto_by_kind: dict[str, list] = {}
2540
+ for weakness in scan.crypto_weaknesses:
2541
+ crypto_by_kind.setdefault(weakness.kind, []).append(weakness)
2542
+ for kind, spec in _CRYPTO_FINDING_SPEC.items():
2543
+ items = crypto_by_kind.get(kind)
2544
+ if not items:
2545
+ continue
2546
+ evidence = [
2547
+ f"{w.file}:{w.line} — {w.evidence_text}" if w.evidence_text else f"{w.file}:{w.line}"
2548
+ for w in items[:10]
2549
+ ]
2550
+ if len(items) > 10:
2551
+ evidence.append(f"+{len(items) - 10} more occurrence(s)")
2552
+ findings.append(
2553
+ Finding(
2554
+ title=spec["title"],
2555
+ rule_id=_rule_slug_kind(kind),
2556
+ severity=spec["severity"], # type: ignore[arg-type]
2557
+ evidence=evidence,
2558
+ locations=_locs(items),
2559
+ mitigation=spec["mitigation"],
2560
+ confidence="medium",
2561
+ tags=["insecure-crypto"],
2562
+ attack_techniques=[
2563
+ AttackTechnique(
2564
+ technique_id=spec["technique_id"],
2565
+ name=spec["technique_name"],
2566
+ tactic=spec["tactic"],
2567
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2568
+ )
2569
+ ],
2570
+ )
2571
+ )
2572
+
2573
+ # Web-hardening gaps (#71). One aggregated finding per issue kind.
2574
+ web_by_kind: dict[str, list] = {}
2575
+ for issue in scan.web_hardening_issues:
2576
+ web_by_kind.setdefault(issue.kind, []).append(issue)
2577
+ for kind, spec in _WEB_HARDENING_FINDING_SPEC.items():
2578
+ items = web_by_kind.get(kind)
2579
+ if not items:
2580
+ continue
2581
+ if kind == "csrf_unprotected_session" and not any(
2582
+ r.method.upper() in _CSRF_MUTATING_METHODS for r in scan.routes
2583
+ ):
2584
+ continue # no state-changing route to forge a request against
2585
+ evidence = [
2586
+ f"{i.file}:{i.line} — {i.evidence_text}" if i.evidence_text else f"{i.file}:{i.line}"
2587
+ for i in items[:10]
2588
+ ]
2589
+ if len(items) > 10:
2590
+ evidence.append(f"+{len(items) - 10} more occurrence(s)")
2591
+ findings.append(
2592
+ Finding(
2593
+ title=spec["title"],
2594
+ rule_id=_rule_slug_kind(kind),
2595
+ # Some kinds tier per instance (CORS: credentials or not, #244).
2596
+ severity=max((i.severity for i in items), key=_SEVERITY_ORDER.__getitem__), # type: ignore[arg-type]
2597
+ evidence=evidence,
2598
+ locations=_locs(items),
2599
+ mitigation=spec["mitigation"],
2600
+ confidence="medium",
2601
+ tags=["web-hardening"],
2602
+ attack_techniques=[
2603
+ AttackTechnique(
2604
+ technique_id=spec["technique_id"],
2605
+ name=spec["technique_name"],
2606
+ tactic=spec["tactic"],
2607
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2608
+ )
2609
+ ],
2610
+ )
2611
+ )
2612
+
2613
+ # Novel vuln classes (#77). One aggregated finding per weakness kind.
2614
+ code_by_kind: dict[str, list] = {}
2615
+ for weakness in scan.code_weaknesses:
2616
+ code_by_kind.setdefault(weakness.kind, []).append(weakness)
2617
+ for kind, spec in _CODE_WEAKNESS_FINDING_SPEC.items():
2618
+ items = code_by_kind.get(kind)
2619
+ if not items:
2620
+ continue
2621
+ evidence = [
2622
+ f"{i.file}:{i.line} — {i.evidence_text}" if i.evidence_text else f"{i.file}:{i.line}"
2623
+ for i in items[:10]
2624
+ ]
2625
+ if len(items) > 10:
2626
+ evidence.append(f"+{len(items) - 10} more occurrence(s)")
2627
+ findings.append(
2628
+ Finding(
2629
+ title=spec["title"],
2630
+ rule_id=_rule_slug_kind(kind),
2631
+ severity=spec["severity"], # type: ignore[arg-type]
2632
+ evidence=evidence,
2633
+ locations=_locs(items),
2634
+ mitigation=spec["mitigation"],
2635
+ confidence="medium",
2636
+ tags=["novel-vuln", *_auth_class_tag(kind)],
2637
+ attack_techniques=[
2638
+ AttackTechnique(
2639
+ technique_id=spec["technique_id"],
2640
+ name=spec["technique_name"],
2641
+ tactic=spec["tactic"],
2642
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2643
+ )
2644
+ ],
2645
+ )
2646
+ )
2647
+
2648
+ # CI workflow security (#142). One aggregated finding per issue kind; the
2649
+ # finding severity is the max over that kind's issues (some kinds tier per
2650
+ # instance — semver-tag vs. branch unpinned action, PR vs. PR-target runner).
2651
+ workflow_by_kind: dict[str, list] = {}
2652
+ for issue in scan.workflow_issues:
2653
+ workflow_by_kind.setdefault(issue.kind, []).append(issue)
2654
+ for kind, spec in _WORKFLOW_FINDING_SPEC.items():
2655
+ items = workflow_by_kind.get(kind)
2656
+ if not items:
2657
+ continue
2658
+ severity = max((i.severity for i in items), key=_SEVERITY_ORDER.__getitem__)
2659
+ evidence = []
2660
+ for i in items[:10]:
2661
+ loc = f"{i.file}:{i.line}" if i.line else i.file
2662
+ detail = f" — {i.evidence_text}" if i.evidence_text else ""
2663
+ ctx = f" [{i.context}]" if i.context else ""
2664
+ evidence.append(f"{loc}{ctx}{detail}")
2665
+ if len(items) > 10:
2666
+ evidence.append(f"+{len(items) - 10} more occurrence(s)")
2667
+ findings.append(
2668
+ Finding(
2669
+ title=spec["title"],
2670
+ rule_id=_rule_slug_kind(kind),
2671
+ severity=severity, # type: ignore[arg-type]
2672
+ evidence=evidence,
2673
+ locations=_locs(items),
2674
+ mitigation=spec["mitigation"],
2675
+ confidence="high",
2676
+ tags=["ci-security", "supply-chain", *_cwe_tags(kind)],
2677
+ attack_techniques=[
2678
+ AttackTechnique(
2679
+ technique_id=spec["technique_id"],
2680
+ name=spec["technique_name"],
2681
+ tactic=spec["tactic"],
2682
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2683
+ )
2684
+ ],
2685
+ )
2686
+ )
2687
+
2688
+ # Dependency supply-chain risk (#247). One aggregated finding per kind,
2689
+ # severity the max over that kind's issues.
2690
+ supply_by_kind: dict[str, list] = {}
2691
+ for issue in scan.supply_chain_issues:
2692
+ supply_by_kind.setdefault(issue.kind, []).append(issue)
2693
+ for kind, spec in _SUPPLY_CHAIN_FINDING_SPEC.items():
2694
+ items = supply_by_kind.get(kind)
2695
+ if not items:
2696
+ continue
2697
+ severity = max((i.severity for i in items), key=_SEVERITY_ORDER.__getitem__)
2698
+ evidence = []
2699
+ for i in items[:10]:
2700
+ loc = f"{i.file}:{i.line}" if i.line else i.file
2701
+ evidence.append(f"{loc} — {i.evidence_text}" if i.evidence_text else loc)
2702
+ if len(items) > 10:
2703
+ evidence.append(f"+{len(items) - 10} more occurrence(s)")
2704
+ findings.append(
2705
+ Finding(
2706
+ title=spec["title"],
2707
+ rule_id=_rule_slug_kind(kind),
2708
+ severity=severity, # type: ignore[arg-type]
2709
+ evidence=evidence,
2710
+ locations=_locs(items),
2711
+ mitigation=spec["mitigation"],
2712
+ confidence="medium" if kind == "typosquat_candidate" else "high",
2713
+ tags=["supply-chain", "dependency-risk", *_cwe_tags(kind)],
2714
+ attack_techniques=[
2715
+ AttackTechnique(
2716
+ technique_id=spec["technique_id"],
2717
+ name=spec["technique_name"],
2718
+ tactic=spec["tactic"],
2719
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2720
+ )
2721
+ ],
2722
+ )
2723
+ )
2724
+
2725
+ # Native C/C++ weaknesses (#189): one finding per (kind, severity tier).
2726
+ # Within a tier, traced instances lead, then by location.
2727
+ for kind, spec in _NATIVE_WEAKNESS_FINDING_SPEC.items():
2728
+ items = sorted(
2729
+ code_by_kind.get(kind, []),
2730
+ key=lambda w: (w.source_kind is None, w.file, w.line or 0),
2731
+ )
2732
+ findings.extend(
2733
+ tiered_findings(
2734
+ kind, spec, items,
2735
+ evidence=_native_evidence_line,
2736
+ tags=[
2737
+ "novel-vuln", "native",
2738
+ *(["memory-safety"] if kind != "native_command_exec" else []),
2739
+ *_cwe_tags(kind),
2740
+ ],
2741
+ attack_techniques=[
2742
+ AttackTechnique(
2743
+ technique_id=spec["technique_id"],
2744
+ name=spec["technique_name"],
2745
+ tactic=spec["tactic"],
2746
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2747
+ )
2748
+ ],
2749
+ )
2750
+ )
2751
+
2752
+ # Cloud / IaC / container misconfigurations (terraform#4, iac#1). One
2753
+ # finding per (kind, severity tier); confidence is the max over that
2754
+ # tier's records, and evidence lists the most confident first.
2755
+ cloud_by_kind: dict[str, list] = {}
2756
+ for issue in scan.cloud_misconfigs:
2757
+ cloud_by_kind.setdefault(issue.kind, []).append(issue)
2758
+ for kind, spec in _CLOUD_MISCONFIG_FINDING_SPEC.items():
2759
+ findings.extend(
2760
+ tiered_findings(
2761
+ kind, spec, cloud_by_kind.get(kind, []),
2762
+ evidence=_cloud_misconfig_evidence,
2763
+ tags=["cloud-misconfig", *_cwe_tags(kind)],
2764
+ )
2765
+ )
2766
+
2767
+ # App attack surface from platform plugins (#188, #190). One aggregated
2768
+ # finding per kind, at its most severe instance.
2769
+ app_by_kind: dict[str, list] = {}
2770
+ for issue in scan.app_surface_issues:
2771
+ app_by_kind.setdefault(issue.kind, []).append(issue)
2772
+ for kind, spec in _APP_SURFACE_FINDING_SPEC.items():
2773
+ items = app_by_kind.get(kind)
2774
+ if not items:
2775
+ continue
2776
+ items.sort(key=lambda i: -_SEVERITY_ORDER[i.severity])
2777
+ evidence = []
2778
+ for i in items[:10]:
2779
+ loc = f"{i.file}:{i.line}" if i.line else i.file
2780
+ evidence.append(f"{loc} — {i.evidence_text}" if i.evidence_text else loc)
2781
+ if len(items) > 10:
2782
+ evidence.append(f"+{len(items) - 10} more occurrence(s)")
2783
+ findings.append(
2784
+ Finding(
2785
+ title=spec["title"],
2786
+ rule_id=_rule_slug_kind(kind),
2787
+ severity=items[0].severity,
2788
+ evidence=evidence,
2789
+ locations=_locs(items),
2790
+ mitigation=spec["mitigation"],
2791
+ confidence=spec["confidence"], # type: ignore[arg-type]
2792
+ tags=["app-surface", *_cwe_tags(kind)],
2793
+ attack_techniques=[
2794
+ AttackTechnique(
2795
+ technique_id=spec["technique_id"],
2796
+ name=spec["technique_name"],
2797
+ tactic=spec["tactic"],
2798
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2799
+ )
2800
+ ],
2801
+ )
2802
+ )
2803
+
2804
+ # Anomaly / outlier findings (#78). One aggregated finding per kind,
2805
+ # each evidence line naming the peer group and the deviation. The
2806
+ # finding's confidence tracks the strongest (most consistent) cohort.
2807
+ anomalies_by_kind: dict[str, list] = {}
2808
+ for anomaly in scan.anomalies:
2809
+ anomalies_by_kind.setdefault(anomaly.kind, []).append(anomaly)
2810
+ for kind, spec in _ANOMALY_FINDING_SPEC.items():
2811
+ items = anomalies_by_kind.get(kind)
2812
+ if not items:
2813
+ continue
2814
+ items.sort(key=lambda a: -a.confidence)
2815
+ evidence = []
2816
+ for a in items[:10]:
2817
+ loc = f"{a.route_file}:{a.route_line}" if a.route_line else a.route_file
2818
+ peers = f" (peers: {', '.join(a.peer_examples)})" if a.peer_examples else ""
2819
+ # Cite the mined invariant (invariant_violation) as the norm broken.
2820
+ invariant = f" [invariant: {a.invariant}]" if a.invariant else ""
2821
+ evidence.append(
2822
+ f"{a.route_method} {a.route_path} [{loc}] — {a.deviation}{invariant}{peers} "
2823
+ f"[confidence={a.confidence:.2f}]"
2824
+ )
2825
+ if len(items) > 10:
2826
+ evidence.append(f"+{len(items) - 10} more outlier(s)")
2827
+ findings.append(
2828
+ Finding(
2829
+ title=spec["title"],
2830
+ rule_id=_rule_slug_kind(kind),
2831
+ severity=spec["severity"], # type: ignore[arg-type]
2832
+ evidence=evidence,
2833
+ locations=_locs(items, "route_file", "route_line"),
2834
+ mitigation=spec["mitigation"],
2835
+ confidence=_numeric_to_confidence(items[0].confidence),
2836
+ tags=["anomaly"],
2837
+ attack_techniques=[
2838
+ AttackTechnique(
2839
+ technique_id=spec["technique_id"],
2840
+ name=spec["technique_name"],
2841
+ tactic=spec["tactic"],
2842
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2843
+ )
2844
+ ],
2845
+ )
2846
+ )
2847
+
2848
+ # Dangerous taint sinks each get a dedicated, sink-specific finding
2849
+ # (#68) — surfaced even outside the framework-MVC gate that `chains`
2850
+ # sits behind. One aggregated finding per sink kind reachable from a
2851
+ # route, ordered by the spec's declaration.
2852
+ taint_by_kind: dict[str, list] = {}
2853
+ speculative_by_kind: dict[str, list] = {}
2854
+ flow_by_kind: dict[str, list] = {}
2855
+ for chain in scan.taint_chains:
2856
+ # Sanitized chains (#137) are neutralized before the sink — keep them
2857
+ # as evidence in scan.taint_chains but don't raise a finding for them.
2858
+ if chain.sanitized:
2859
+ continue
2860
+ flow_kind = canonical_kind(chain.sink_kind)
2861
+ if flow_kind in _FLOW_FINDING_SPEC:
2862
+ # Reach alone isn't a finding for SQL / generic file opens — only a
2863
+ # traced flow (#239). The #240 path sinks (send_file, sendFile,
2864
+ # include…) are request-gated already, so their chains count.
2865
+ confirmed = chain.source_kind or chain.sink_kind not in ("sql_execute", "dynamic_open")
2866
+ if confirmed and not chain.speculative:
2867
+ flow_by_kind.setdefault(flow_kind, []).append(chain)
2868
+ continue
2869
+ if chain.sink_kind not in _TAINT_FINDING_SPEC:
2870
+ continue
2871
+ # Speculative recall chains (#148a) are segregated into their own
2872
+ # clearly-marked, low-severity finding below — so the confirmed finding
2873
+ # (and default behavior) is untouched and they can't trip fail-on-new-high.
2874
+ if chain.speculative:
2875
+ speculative_by_kind.setdefault(chain.sink_kind, []).append(chain)
2876
+ else:
2877
+ taint_by_kind.setdefault(chain.sink_kind, []).append(chain)
2878
+ taint_findings_by_kind: dict[str, Finding] = {}
2879
+ for kind, spec in _TAINT_FINDING_SPEC.items():
2880
+ kind_chains = taint_by_kind.get(kind)
2881
+ if not kind_chains:
2882
+ continue
2883
+ top = min(kind_chains, key=lambda c: (c.hops, -c.confidence))
2884
+ evidence = [
2885
+ f"route {top.route_method} {top.route_path} in {top.route_file}",
2886
+ f"sink at {top.sink_file}:{top.sink_line} ({_TAINT_SINK_LABEL.get(kind, kind)})",
2887
+ f"import path: {' → '.join(top.files)} ({top.hops} hop(s))",
2888
+ ]
2889
+ if len(kind_chains) > 1:
2890
+ evidence.append(
2891
+ f"+{len(kind_chains) - 1} more route(s) reach a {_TAINT_SINK_LABEL.get(kind, kind)} sink"
2892
+ )
2893
+ evidence += _flow_evidence(kind_chains)
2894
+ taint_finding = Finding(
2895
+ title=spec["title"],
2896
+ rule_id=_rule_slug_kind(kind),
2897
+ severity=spec["severity"], # type: ignore[arg-type]
2898
+ evidence=evidence,
2899
+ locations=_locs(kind_chains, "sink_file", "sink_line") + _locs(kind_chains, "route_file", "route_line"),
2900
+ mitigation=spec["mitigation"],
2901
+ confidence="medium",
2902
+ tags=["taint-chain", "input-handling", "data-risk"],
2903
+ attack_techniques=[
2904
+ AttackTechnique(
2905
+ technique_id=spec["technique_id"],
2906
+ name=spec["technique_name"],
2907
+ tactic=spec["tactic"],
2908
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2909
+ )
2910
+ ],
2911
+ )
2912
+ taint_finding.code_flows = _code_flows(kind_chains)
2913
+ taint_findings_by_kind[kind] = taint_finding
2914
+ findings.append(taint_finding)
2915
+
2916
+ # Flow-confirmed SQL injection / path traversal (#239): one aggregated
2917
+ # finding per kind, citing each traced route→source→sink.
2918
+ for kind, spec in _FLOW_FINDING_SPEC.items():
2919
+ kind_chains = flow_by_kind.get(kind)
2920
+ if not kind_chains:
2921
+ continue
2922
+ top = min(kind_chains, key=lambda c: (c.hops, -c.confidence))
2923
+ evidence = [
2924
+ f"route {top.route_method} {top.route_path} in {top.route_file}",
2925
+ f"sink at {top.sink_file}:{top.sink_line} ({_TAINT_SINK_LABEL.get(kind, kind)})",
2926
+ f"import path: {' → '.join(top.files)} ({top.hops} hop(s))",
2927
+ ] + _flow_evidence(kind_chains)
2928
+ flow_finding = Finding(
2929
+ title=spec["title"],
2930
+ rule_id=spec["rule_id"],
2931
+ severity=spec["severity"], # type: ignore[arg-type]
2932
+ evidence=evidence,
2933
+ locations=_locs(kind_chains, "sink_file", "sink_line") + _locs(kind_chains, "route_file", "route_line"),
2934
+ mitigation=spec["mitigation"],
2935
+ confidence="high" if any(c.confidence >= 0.75 for c in kind_chains) else "medium",
2936
+ tags=["taint-chain", "taint-flow", "input-handling", "data-risk"],
2937
+ attack_techniques=[
2938
+ AttackTechnique(
2939
+ technique_id=spec["technique_id"],
2940
+ name=spec["technique_name"],
2941
+ tactic=spec["tactic"],
2942
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
2943
+ )
2944
+ ],
2945
+ code_flows=_code_flows(kind_chains),
2946
+ )
2947
+ taint_findings_by_kind[kind] = flow_finding
2948
+ findings.append(flow_finding)
2949
+
2950
+ # Exploitability fusion (#79): attach the best 'exploitable now' score for
2951
+ # each sink kind to its aggregated taint finding, so triage can lead with
2952
+ # the public + no-auth + dangerous-sink combinations.
2953
+ if taint_findings_by_kind:
2954
+ best = best_by_sink_kind(score_exploitability(scan, attack_surfaces))
2955
+ for kind, finding in taint_findings_by_kind.items():
2956
+ scored = best.get(kind)
2957
+ if scored is not None:
2958
+ finding.exploitability = scored.score
2959
+ finding.exploitability_tier = scored.tier
2960
+
2961
+ # Speculative recall findings (#148a). One low-severity, clearly-marked
2962
+ # finding per sink kind reached only via a widened recall knob (deeper hop
2963
+ # or a relaxed gate). Kept at LOW severity so they stay out of the
2964
+ # `--fail-on-new-high` gate — they're leads for `--hunt --verify` to
2965
+ # adjudicate, not asserted flows. Only ever present in `--recall` scans.
2966
+ for kind, spec in _TAINT_FINDING_SPEC.items():
2967
+ kind_chains = speculative_by_kind.get(kind)
2968
+ if not kind_chains:
2969
+ continue
2970
+ top = min(kind_chains, key=lambda c: (c.hops, -c.confidence))
2971
+ label = _TAINT_SINK_LABEL.get(kind, kind)
2972
+ evidence = [
2973
+ "SPECULATIVE (recall mode): surfaced by widened discovery, not confirmed — "
2974
+ "adjudicate with --hunt --verify",
2975
+ f"route {top.route_method} {top.route_path} in {top.route_file}",
2976
+ f"sink at {top.sink_file}:{top.sink_line} ({label}), {top.hops} hop(s)",
2977
+ ]
2978
+ if len(kind_chains) > 1:
2979
+ evidence.append(f"+{len(kind_chains) - 1} more speculative route(s) reach a {label} sink")
2980
+ findings.append(
2981
+ Finding(
2982
+ title=f"Speculative reach to a {label} sink (recall mode)",
2983
+ rule_id=f"speculative-{_rule_slug_kind(kind)}",
2984
+ severity="low",
2985
+ evidence=evidence,
2986
+ locations=_locs(kind_chains, "sink_file", "sink_line") + _locs(kind_chains, "route_file", "route_line"),
2987
+ mitigation=(
2988
+ "Recall mode widened taint discovery to surface this reach; provenance "
2989
+ "is unconfirmed. Verify whether untrusted input actually reaches the sink "
2990
+ f"(run `--hunt --verify`). If it does, treat it as a {spec['title'].lower()}."
2991
+ ),
2992
+ confidence="low",
2993
+ tags=["taint-chain", "speculative", "recall"],
2994
+ attack_techniques=[
2995
+ AttackTechnique(
2996
+ technique_id=spec["technique_id"],
2997
+ name=spec["technique_name"],
2998
+ tactic=spec["tactic"],
2999
+ url=f"https://attack.mitre.org/techniques/{spec['technique_id'].replace('.', '/')}/",
3000
+ )
3001
+ ],
3002
+ )
3003
+ )
3004
+
3005
+ if not findings:
3006
+ findings.append(
3007
+ Finding(
3008
+ title="Heuristic scan found only a limited attack surface",
3009
+ rule_id="limited-surface",
3010
+ severity="low",
3011
+ evidence=["No major route, secret, or integration patterns triggered a stronger finding."],
3012
+ mitigation="Treat this as a weak signal, expand parser coverage, and manually validate the real entry points and trust boundaries.",
3013
+ confidence="low",
3014
+ tags=["weak-signal"],
3015
+ )
3016
+ )
3017
+
3018
+ _fill_missing_locations(findings)
3019
+
3020
+ # #4: attach numeric score for triage ordering, then sort by
3021
+ # (severity, -score, title). Same-severity findings surface in
3022
+ # confidence-weighted order so a HIGH/HIGH beats a HIGH/LOW.
3023
+ for finding in findings:
3024
+ finding.score = compute_finding_score(finding.severity, finding.confidence)
3025
+ return sorted(
3026
+ findings,
3027
+ key=lambda f: (_severity_rank(f.severity), -(f.score or 0), f.title),
3028
+ )
3029
+
3030
+
3031
+ def generate_attack_paths(scan: ScanResult, attack_surfaces: list[AttackSurface] | None = None) -> list[AttackPath]:
3032
+ """
3033
+ Generate plausible attacker-centric paths from recon signals.
3034
+
3035
+ If `attack_surfaces` is provided, reuse it to avoid redundant surface
3036
+ recomputation in callers that already translated recon -> surface.
3037
+ """
3038
+ surfaces_source = attack_surfaces if attack_surfaces is not None else identify_attack_surfaces(scan)
3039
+ surfaces = [surface for surface in surfaces_source if not _is_low_quality_source(surface.file)]
3040
+ atproto_chains = _build_atproto_chains(scan)
3041
+ service_chains = _build_service_chains(scan)
3042
+ chains = _build_probable_chains(scan) if _is_framework_mvc_scan(scan) else []
3043
+
3044
+ if atproto_chains:
3045
+ top_chain = atproto_chains[0]
3046
+ chain_label = (
3047
+ f"{top_chain.entry_service} -> {top_chain.next_service}"
3048
+ if top_chain.next_service
3049
+ else top_chain.entry_service
3050
+ )
3051
+ steps = [
3052
+ _action_step(
3053
+ "Entry",
3054
+ f"Attacker reaches {top_chain.route_method} {top_chain.route_path} in {top_chain.route_file}",
3055
+ ),
3056
+ _action_step("Namespace", f"Endpoint is exposed in AT Protocol namespace `{top_chain.namespace}`"),
3057
+ _action_step("Service entry", f"XRPC request is handled by service `{top_chain.entry_service}`"),
3058
+ ]
3059
+ if top_chain.next_service:
3060
+ steps.append(
3061
+ _action_step(
3062
+ "Propagation",
3063
+ f"Service trust edge allows request influence to reach `{top_chain.next_service}`",
3064
+ )
3065
+ )
3066
+ if top_chain.env_risk:
3067
+ steps.append(
3068
+ _action_step(
3069
+ "Config risk",
3070
+ f"Runtime behavior depends on env-configured endpoint {top_chain.env_risk}, which can widen downstream trust exposure",
3071
+ )
3072
+ )
3073
+ steps.extend(
3074
+ [
3075
+ _action_step("Sink", f"Influence reaches {top_chain.sink} through protocol-driven service flow"),
3076
+ _action_step("Evidence", f"confidence={top_chain.confidence:.2f}; {'; '.join(top_chain.evidence[:4])}"),
3077
+ ]
3078
+ )
3079
+ return [
3080
+ AttackPath(
3081
+ name="AT Protocol namespace trust-chain abuse",
3082
+ steps=steps,
3083
+ impact=(
3084
+ f"An exposed XRPC namespace can be abused to propagate across `{chain_label}` and affect {top_chain.sink} "
3085
+ "when per-namespace authorization and inter-service trust controls are weak."
3086
+ ),
3087
+ )
3088
+ ]
3089
+
3090
+ if service_chains:
3091
+ top_chain = service_chains[0]
3092
+ chain_label = (
3093
+ f"{top_chain.entry_service} -> {top_chain.next_service}"
3094
+ if top_chain.next_service
3095
+ else top_chain.entry_service
3096
+ )
3097
+ steps = [
3098
+ _action_step(
3099
+ "Entry",
3100
+ f"Attacker reaches {top_chain.route_method} {top_chain.route_path} in {top_chain.route_file}",
3101
+ ),
3102
+ _action_step("Service entry", f"Request is handled by service `{top_chain.entry_service}`"),
3103
+ ]
3104
+ if top_chain.next_service:
3105
+ steps.append(
3106
+ _action_step(
3107
+ "Propagation",
3108
+ f"Service-to-service trust edge allows request influence to reach `{top_chain.next_service}`",
3109
+ )
3110
+ )
3111
+ if top_chain.env_risk:
3112
+ steps.append(
3113
+ _action_step(
3114
+ "Config risk",
3115
+ f"Runtime behavior depends on env-configured endpoint {top_chain.env_risk}, which can widen trust assumptions if misconfigured",
3116
+ )
3117
+ )
3118
+ steps.extend(
3119
+ [
3120
+ _action_step(
3121
+ "Sink",
3122
+ f"Influence reaches {top_chain.sink}, creating a plausible unauthorized downstream action path",
3123
+ ),
3124
+ _action_step(
3125
+ "Evidence",
3126
+ f"confidence={top_chain.confidence:.2f}; {'; '.join(top_chain.evidence[:4])}",
3127
+ ),
3128
+ ]
3129
+ )
3130
+ return [
3131
+ AttackPath(
3132
+ name="Distributed service trust-chain abuse",
3133
+ steps=steps,
3134
+ impact=(
3135
+ f"A public API foothold can propagate across `{chain_label}` and affect {top_chain.sink} "
3136
+ "if service boundaries or downstream trust checks are weak."
3137
+ ),
3138
+ )
3139
+ ]
3140
+
3141
+ if chains:
3142
+ top_chain = chains[0]
3143
+ controller_text = top_chain.controller or "framework controller mapping"
3144
+ service_text = top_chain.service or "framework service/factory"
3145
+ action_text = top_chain.action or "application action"
3146
+ sink_text = top_chain.sink
3147
+ evidence_text = "; ".join(top_chain.evidence[:4])
3148
+ return [
3149
+ AttackPath(
3150
+ name="Framework route-to-sink attack chain",
3151
+ steps=[
3152
+ _action_step(
3153
+ "Entry",
3154
+ f"Attacker reaches {top_chain.route_method} {top_chain.route_path} in {top_chain.route_file}",
3155
+ ),
3156
+ _action_step("Routing", f"Framework route config maps request toward {controller_text} ({action_text})"),
3157
+ _action_step("Execution", f"Controller path likely invokes {service_text}"),
3158
+ _action_step(
3159
+ "Sink",
3160
+ f"Request influence reaches {sink_text}, creating an opportunity for unauthorized state change or abuse",
3161
+ ),
3162
+ _action_step(
3163
+ "Evidence",
3164
+ f"confidence={top_chain.confidence:.2f} ({confidence_bucket(top_chain.confidence)}); {evidence_text}",
3165
+ ),
3166
+ ],
3167
+ impact=(
3168
+ "A public request can be chained through framework routing and service execution to sensitive operations "
3169
+ "if boundary validation and authorization checks are weak or misplaced."
3170
+ ),
3171
+ )
3172
+ ]
3173
+
3174
+ # Basic archetypes append rather than early-return. A single scan can
3175
+ # surface multiple distinct attack-path archetypes — see #24. Each
3176
+ # archetype consumes one surface; we dedup by surface identity so the
3177
+ # same route doesn't anchor two paths.
3178
+ # A webhook that verifies its signature doesn't anchor a spoofing path (#243).
3179
+ webhook_surface = next(
3180
+ (surface for surface in surfaces if surface.category == "webhook" and _webhook_state(surface) != "verified"),
3181
+ None,
3182
+ )
3183
+ admin_surface = next((surface for surface in surfaces if surface.category == "admin"), None)
3184
+ auth_surface = next((surface for surface in surfaces if surface.category == "auth"), None)
3185
+ upload_surface = next((surface for surface in surfaces if surface.category == "upload"), None)
3186
+ public_data_surface = next(
3187
+ (surface for surface in surfaces if surface.exposure == "public" and surface.data_store_interaction and surface.category != "health"),
3188
+ None,
3189
+ )
3190
+ integration_surface = next(
3191
+ (surface for surface in surfaces if surface.exposure == "public" and surface.outbound_integration),
3192
+ None,
3193
+ )
3194
+
3195
+ paths: list[AttackPath] = []
3196
+ consumed: set[tuple[str, str, str]] = set()
3197
+
3198
+ def _surface_key(surface: AttackSurface) -> tuple[str, str, str]:
3199
+ return (surface.file, surface.route, surface.method)
3200
+
3201
+ def _claim(surface: AttackSurface | None) -> bool:
3202
+ if surface is None:
3203
+ return False
3204
+ key = _surface_key(surface)
3205
+ if key in consumed:
3206
+ return False
3207
+ consumed.add(key)
3208
+ return True
3209
+
3210
+ def _with_evidence(surface: AttackSurface, base_steps: list[str]) -> list[str]:
3211
+ # If we can cite concrete file-local artifacts (data stores,
3212
+ # external calls, secrets), append an Evidence step so the path
3213
+ # narrative is anchored in specific code, not just archetype-level
3214
+ # generalities. See #15.
3215
+ evidence = _surface_evidence(surface, scan)
3216
+ if evidence:
3217
+ return [*base_steps, _action_step("Evidence", "; ".join(evidence))]
3218
+ return base_steps
3219
+
3220
+ if webhook_surface and (public_data_surface or integration_surface) and _claim(webhook_surface):
3221
+ # Webhooks consume their downstream propagation surface so the
3222
+ # public-data / integration archetypes don't also fire on the
3223
+ # same route.
3224
+ if public_data_surface:
3225
+ _claim(public_data_surface)
3226
+ if integration_surface:
3227
+ _claim(integration_surface)
3228
+ # Enrich the Propagation steps with concrete same-file artifacts
3229
+ # when we can find them — see #5.
3230
+ webhook_db = _same_file_datastore(webhook_surface, scan)
3231
+ webhook_ext = _same_file_external(webhook_surface, scan)
3232
+ steps = [
3233
+ _action_step("Entry", f"An attacker reaches {webhook_surface.method} {webhook_surface.route} in {webhook_surface.file}, a webhook-style endpoint that accepts untrusted inbound events"),
3234
+ _action_step("Weak point", _webhook_weak_point(webhook_surface)),
3235
+ ]
3236
+ if public_data_surface:
3237
+ if webhook_db:
3238
+ steps.append(_action_step(
3239
+ "Propagation",
3240
+ f"Attacker-controlled input reaches a `{webhook_db[0]}` data store in `{webhook_db[1]}`, making unauthorized writes or state changes plausible",
3241
+ ))
3242
+ else:
3243
+ steps.append(_action_step("Propagation", "Attacker-controlled input is processed close to a data store, making unauthorized writes or state changes plausible"))
3244
+ if integration_surface:
3245
+ if webhook_ext:
3246
+ steps.append(_action_step(
3247
+ "Propagation",
3248
+ f"The same request path also influences an outbound call to `{webhook_ext[0]}` from `{webhook_ext[1]}`, widening the blast radius beyond the application itself",
3249
+ ))
3250
+ else:
3251
+ steps.append(_action_step("Propagation", "The same request path can also influence outbound service calls, which widens the blast radius beyond the application itself"))
3252
+ steps.append(_action_step("Impact", "The attacker drives business actions that should only occur after a trusted event or validated request"))
3253
+ paths.append(
3254
+ AttackPath(
3255
+ name="External event spoofing into internal state change",
3256
+ steps=_with_evidence(webhook_surface, steps),
3257
+ impact="Unauthorized state changes can be triggered from the internet and then propagated into internal data or downstream systems.",
3258
+ )
3259
+ )
3260
+
3261
+ if _claim(admin_surface):
3262
+ paths.append(
3263
+ AttackPath(
3264
+ name="Administrative route abuse",
3265
+ steps=_with_evidence(admin_surface, [
3266
+ _action_step("Entry", f"An attacker reaches {admin_surface.method} {admin_surface.route} in {admin_surface.file}, a route associated with privileged behavior"),
3267
+ _action_step("Weak point", "Authentication or authorization around that route is bypassed, reused, or enforced too late"),
3268
+ _action_step("Propagation", "Administrative actions execute with attacker influence and affect higher-value parts of the system"),
3269
+ _action_step("Impact", "Privileged changes, sensitive data access, or configuration abuse follow from a single foothold"),
3270
+ ]),
3271
+ impact="Privilege escalation or destructive administrative actions from a route that should be tightly controlled.",
3272
+ )
3273
+ )
3274
+
3275
+ if _claim(auth_surface):
3276
+ paths.append(
3277
+ AttackPath(
3278
+ name="Authentication boundary bypass",
3279
+ steps=_with_evidence(auth_surface, [
3280
+ _action_step("Entry", f"An attacker targets {auth_surface.method} {auth_surface.route} in {auth_surface.file}, which controls login, tokens, or session state"),
3281
+ _action_step("Weak point", "Credential handling, token validation, or session establishment is weaker than the route implies"),
3282
+ _action_step("Propagation", "The attacker converts that weakness into an authenticated foothold"),
3283
+ _action_step("Impact", "The foothold becomes the starting point for deeper movement into protected application behavior"),
3284
+ ]),
3285
+ impact="Account takeover or a trusted session that opens access to additional internal actions.",
3286
+ )
3287
+ )
3288
+
3289
+ if _claim(upload_surface):
3290
+ paths.append(
3291
+ AttackPath(
3292
+ name="Untrusted file handling abuse",
3293
+ steps=_with_evidence(upload_surface, [
3294
+ _action_step("Entry", f"An attacker submits content to {upload_surface.method} {upload_surface.route} in {upload_surface.file}"),
3295
+ _action_step("Weak point", "The application accepts or parses attacker-controlled files too broadly"),
3296
+ _action_step("Propagation", "Storage, parsing, or downstream consumers treat that content as safer than it is"),
3297
+ _action_step("Impact", "The result is execution, persistence of malicious content, or operational disruption"),
3298
+ ]),
3299
+ impact="Stored malicious content, parser abuse, or denial of service from untrusted file input.",
3300
+ )
3301
+ )
3302
+
3303
+ # Per-file fan-out for public_data. A repo with distinct data-touching
3304
+ # files each becomes its own path (bounded by MAX_ATTACK_PATHS at the
3305
+ # end). See #5.
3306
+ for candidate in _distinct_by_file(
3307
+ surfaces,
3308
+ lambda s: s.exposure == "public" and s.data_store_interaction and s.category != "health",
3309
+ limit=MAX_PER_ARCHETYPE_FANOUT,
3310
+ ):
3311
+ if not _claim(candidate):
3312
+ continue
3313
+ db = _same_file_datastore(candidate, scan)
3314
+ propagation = (
3315
+ f"Attacker-controlled data reaches a `{db[0]}` data store in `{db[1]}`"
3316
+ if db
3317
+ else "Attacker-controlled data reaches code operating close to the data store"
3318
+ )
3319
+ paths.append(
3320
+ AttackPath(
3321
+ name="Public input into sensitive data path",
3322
+ steps=_with_evidence(candidate, [
3323
+ _action_step("Entry", f"An attacker uses {candidate.method} {candidate.route} in {candidate.file} as a public foothold"),
3324
+ _action_step("Weak point", "Input validation or authorization is weaker than the route exposure suggests"),
3325
+ _action_step("Propagation", propagation),
3326
+ _action_step("Impact", "Confidentiality, integrity, or authorization guarantees around application data are weakened"),
3327
+ ]),
3328
+ impact="Unauthorized data access or modification through a public-facing application route.",
3329
+ )
3330
+ )
3331
+
3332
+ for candidate in _distinct_by_file(
3333
+ surfaces,
3334
+ lambda s: s.exposure == "public" and s.outbound_integration,
3335
+ limit=MAX_PER_ARCHETYPE_FANOUT,
3336
+ ):
3337
+ if not _claim(candidate):
3338
+ continue
3339
+ ext = _same_file_external(candidate, scan)
3340
+ propagation = (
3341
+ f"Spoofed, replayed, or attacker-steered interaction with `{ext[0]}` (from `{ext[1]}`) affects internal logic"
3342
+ if ext
3343
+ else "Spoofed, replayed, or attacker-steered third-party interactions affect internal logic"
3344
+ )
3345
+ paths.append(
3346
+ AttackPath(
3347
+ name="Outbound trust boundary abuse",
3348
+ steps=_with_evidence(candidate, [
3349
+ _action_step("Entry", f"An attacker influences {candidate.method} {candidate.route} in {candidate.file}, which sits near an outbound integration"),
3350
+ _action_step("Weak point", "The application assumes too much trust in external calls or responses"),
3351
+ _action_step("Propagation", propagation),
3352
+ _action_step("Impact", "Unsafe business decisions or downstream actions follow from a weak external trust boundary"),
3353
+ ]),
3354
+ impact="Poisoned state or unsafe downstream actions caused by over-trusting an external dependency.",
3355
+ )
3356
+ )
3357
+
3358
+ # BOLA/IDOR archetype (#69): an id-bearing route reaching data with no
3359
+ # ownership check. Independent of the surface-claim machinery above —
3360
+ # it keys off scan.authz_candidates.
3361
+ bola = [c for c in scan.authz_candidates if not _is_low_quality_source(c.route_file)]
3362
+ if bola:
3363
+ _bola_write_methods = {"POST", "PUT", "PATCH", "DELETE", "MUTATION"}
3364
+ # One path per distinct candidate file (#242), writes and authenticated
3365
+ # routes first, bounded so a sprawling app can't crowd out other paths.
3366
+ by_file: dict[str, BolaCandidate] = {}
3367
+ for cand in sorted(
3368
+ bola,
3369
+ key=lambda c: (c.route_method not in _bola_write_methods, c.confidence != "high", c.route_file),
3370
+ ):
3371
+ by_file.setdefault(cand.route_file, cand)
3372
+ for top in list(by_file.values())[:_MAX_BOLA_PATHS]:
3373
+ action = "modify" if top.route_method in _bola_write_methods else "read"
3374
+ paths.append(
3375
+ AttackPath(
3376
+ name="Object-level authorization bypass (BOLA/IDOR)",
3377
+ steps=[
3378
+ _action_step(
3379
+ "Entry",
3380
+ f"{'An authenticated' if top.authn_evidence else 'An'} attacker calls {top.route_method} {top.route_path} in {top.route_file}, which takes a resource id (`{top.id_param}`)",
3381
+ ),
3382
+ _action_step(
3383
+ "Weak point",
3384
+ "No ownership or authorization check is visible in the handler — the resource id from the request is trusted directly",
3385
+ ),
3386
+ _action_step(
3387
+ "Propagation",
3388
+ f"The attacker enumerates or substitutes `{top.id_param}` values; {top.db_evidence}, so the query returns another principal's record",
3389
+ ),
3390
+ _action_step(
3391
+ "Impact",
3392
+ f"Cross-tenant / cross-user ability to {action} records that should be out of scope for the caller",
3393
+ ),
3394
+ ],
3395
+ impact=f"Horizontal privilege escalation — attackers {action} other users' objects by changing an id in the request.",
3396
+ )
3397
+ )
3398
+
3399
+ # Cap to keep report output focused; if more than this fires, the
3400
+ # extras are usually redundant noise that downstream review surfaces
3401
+ # via findings anyway.
3402
+ return paths[:MAX_ATTACK_PATHS]
3403
+
3404
+
3405
+ def rule_catalog() -> list[tuple[str, str, str]]:
3406
+ """Every core rule id with its finding title and severity (#223), for
3407
+ `attackmap rules` and the docs. Family specs are expanded per kind."""
3408
+ rules: list[tuple[str, str, str]] = [
3409
+ ("public-webhook", "Public webhook endpoint may trust attacker-controlled events", "high"),
3410
+ ("webhook-weak-verification", "Webhook signature verification is weak or replayable", "medium"),
3411
+ ("exposed-admin-route", "Administrative routes appear reachable from the main application surface", "high"),
3412
+ ("upload-route", "Upload or import routes expand attacker-controlled input handling", "medium"),
3413
+ ("weak-auth-route", "Authentication routes were detected without strong nearby auth controls", "medium"),
3414
+ ("unauth-outbound-integration", "Public routes appear to influence outbound integrations without clear auth signals", "medium"),
3415
+ ("hardcoded-secret", "Hard-coded secret literals were found in source or config", "high"),
3416
+ ("secret-in-git-history", "Hard-coded secrets were found in git history", "high"),
3417
+ ("secret-env-reference", "Secret-bearing environment variables are referenced in executable paths", "medium"),
3418
+ ("public-data-route", "Public routes likely sit close to sensitive data operations", "medium"),
3419
+ ("atproto-trust-chain", "AT Protocol XRPC surface chains into a downstream trust boundary", "medium"),
3420
+ ("service-trust-chain", "Inter-service trust chain reaches a sensitive downstream sink", "medium"),
3421
+ ("framework-sink-chain", "Framework route-to-service chain reaches a sensitive sink", "medium"),
3422
+ ("vulnerable-dependency", "Vulnerable dependency: <package>@<version> (<ecosystem>)", "varies"),
3423
+ ("limited-surface", "Heuristic scan found only a limited attack surface", "low"),
3424
+ ("bola-modify", "Possible broken object-level authorization (BOLA/IDOR) on modify routes", "high"),
3425
+ ("bola-read", "Possible broken object-level authorization (BOLA/IDOR) on read routes", "medium"),
3426
+ ("unauth-state-change", "State-changing routes are reachable without an authentication control", "high"),
3427
+ ("bfla-privileged-route", "Privileged route checks authentication but not role (BFLA)", "high"),
3428
+ ]
3429
+ for spec_table in (
3430
+ _TAINT_FINDING_SPEC, _CRYPTO_FINDING_SPEC, _WEB_HARDENING_FINDING_SPEC,
3431
+ _CODE_WEAKNESS_FINDING_SPEC, _NATIVE_WEAKNESS_FINDING_SPEC, _WORKFLOW_FINDING_SPEC, _ANOMALY_FINDING_SPEC,
3432
+ _SUPPLY_CHAIN_FINDING_SPEC, _APP_SURFACE_FINDING_SPEC, _CLOUD_MISCONFIG_FINDING_SPEC,
3433
+ ):
3434
+ for kind, spec in spec_table.items():
3435
+ rules.append((_rule_slug_kind(kind), spec["title"], spec.get("severity", "varies")))
3436
+ for spec in _FLOW_FINDING_SPEC.values():
3437
+ rules.append((spec["rule_id"], spec["title"], spec["severity"]))
3438
+ for kind in _TAINT_FINDING_SPEC:
3439
+ label = _TAINT_SINK_LABEL.get(kind, kind)
3440
+ rules.append((f"speculative-{_rule_slug_kind(kind)}", f"Speculative reach to a {label} sink (recall mode)", "low"))
3441
+ return sorted(rules)