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
attackmap/anomalies.py ADDED
@@ -0,0 +1,612 @@
1
+ """Anomaly / outlier detection — surface the odd-one-out (#78).
2
+
3
+ The closest honest thing to "find the unknown": instead of matching a
4
+ known-bad signature, flag a route that deviates from the norm its own
5
+ siblings establish. If a cohort of routes under the same resource prefix
6
+ all carry an auth signal and one doesn't, that one is worth a human's
7
+ eyes — no CVE, no rule, just an internal inconsistency that often marks a
8
+ forgotten decorator or a copy-paste that dropped a guard.
9
+
10
+ ## Heuristics (all peer-group relative)
11
+
12
+ - **auth_outlier**: siblings carry an auth/authorization signal near the
13
+ handler; this route doesn't. (This is the scan-level, route-cohort
14
+ realization of the layered engine's `asymmetric_protection` /
15
+ `control_strength_mismatch` insight — divergent control strength across
16
+ similar surfaces, promoted to an evidence-cited finding.)
17
+ - **validation_outlier**: among a cohort's state-changing handlers, peers
18
+ validate their input and this one shows no validation marker.
19
+ - **method_outlier**: a lone state-changing method exposed in a cohort
20
+ that is otherwise read-only.
21
+ - **invariant_violation** (#149a): the *mined-invariant* pass. Its cohort is
22
+ not a route-path prefix but the set of handlers that reach the same
23
+ dangerous sink kind (from the taint pass). When a strong majority guard the
24
+ request — an auth or validation check *before* the sink — the pass mines
25
+ that as an implicit invariant and flags the handler that reaches the same
26
+ sink kind with no guard before it, citing the mined rule as evidence. This
27
+ is signature-free: it compares the code against itself, so it can surface a
28
+ forgotten guard on a sink pattern no rule anticipates.
29
+
30
+ ## Precision
31
+
32
+ Everything is relative to a *consistent* peer group, and confidence
33
+ scales with how consistent it is: a lone deviation among many agreeing
34
+ siblings is more likely a mistake than the same deviation in a group
35
+ that's split. Outliers are only flagged when they're a strict minority,
36
+ so a genuinely 50/50 surface isn't nagged. Per-route signal detection
37
+ reads a small window around the handler (decorators / middleware /
38
+ early-body checks), the same imprecise-but-useful proxy the rest of the
39
+ analyzers use — findings are evidence, not proof.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import re
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import TYPE_CHECKING
48
+
49
+ from .handler_span import span_bounds
50
+ from .safe_fs import contained_file
51
+ from .taint_sinks import kind_label
52
+ from .models import Anomaly, Route, ScanResult
53
+ from .srcpaths import is_test_file
54
+
55
+ if TYPE_CHECKING:
56
+ from .progress import JsonScanProgress, ScanProgress
57
+
58
+ _Progress = ScanProgress | JsonScanProgress
59
+
60
+
61
+ @dataclass
62
+ class _SignalCtx:
63
+ """Shared state for per-route signal lookups within one scan."""
64
+
65
+ root: Path
66
+ line_cache: dict[str, list[str]]
67
+ file_route_lines: dict[str, list[int]]
68
+
69
+ # Auth / authorization signal near a handler. Intentionally broad: a false
70
+ # *presence* only suppresses an outlier (fewer findings), so recall here
71
+ # keeps false positives down. Mirrors authz.py's ownership markers plus
72
+ # framework auth decorators / middleware.
73
+ _AUTH_MARKERS = re.compile(
74
+ r"""
75
+ login_required | @authenticated | ensureAuthenticated | requireAuth | requiresAuth
76
+ | require[s]?_permission | permission_required | permission_classes | IsAuthenticated
77
+ | current_user | currentUser | \brequest\.user\b | \breq\.user\b | \bctx\.user\b
78
+ | authorize | authoriz | \bpolicy\b | \bpolicies\b | \bguard\b | can_access | canAccess
79
+ | has_access | hasAccess | check_access | verify_owner | assert_owner | ensure_owner
80
+ | is_authorized | isAuthorized | @roles? | requireRole | hasRole | @PreAuthorize
81
+ | @Secured | @RolesAllowed | authenticate\s*\( | ensureLoggedIn | verifyToken
82
+ | verify_jwt | jwt_required | @jwt_required | \bmiddleware\b.*auth | auth.*\bmiddleware\b
83
+ | passport\.authenticate | before_action\s*:?\s*:authenticate | check_authentication
84
+ """,
85
+ re.IGNORECASE | re.VERBOSE,
86
+ )
87
+
88
+ # Input-validation signal near a handler.
89
+ _VALIDATION_MARKERS = re.compile(
90
+ r"""
91
+ validate | validation | \bschema\b | pydantic | BaseModel | \bjoi\b | \bzod\b | \byup\b
92
+ | z\.object | Joi\. | \.parse\s*\( | safeParse | serializer | marshmallow | cerberus
93
+ | is_valid\s*\( | \.clean\s*\( | clean_data | sanitize | check_arg | assert_ | expect\s*\(
94
+ | @validate | @Valid | class-validator | express-validator | \bcheck\s*\( | \bbody\s*\(
95
+ """,
96
+ re.IGNORECASE | re.VERBOSE,
97
+ )
98
+
99
+ _READ_METHODS = {"GET", "HEAD", "OPTIONS", "ANY"}
100
+ _WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
101
+
102
+ # Path-parameter forms across frameworks (same as authz.py): {id} :id <id>.
103
+ _PARAM_SEG_RE = re.compile(r"[{:<]|>$|}$")
104
+
105
+ _MAX_FILE_BYTES = 1_000_000
106
+ # A cohort must have at least this many routes to reason about a "norm".
107
+ _MIN_GROUP = 3
108
+
109
+ # Invariant mining (#149a). A sink cohort must have at least this many distinct
110
+ # handlers before a "guard the request before the sink" invariant is credible —
111
+ # higher than `_MIN_GROUP` because a wrong call here points at a dangerous sink.
112
+ _MIN_INVARIANT_COHORT = 4
113
+
114
+ # Human labels for the taint sink kinds an invariant is mined over (mirrors
115
+ # threat_model._TAINT_SINK_LABEL; kept local so anomalies.py stays standalone).
116
+ _SINK_LABELS: dict[str, str] = {
117
+ "sql_execute": "database (SQL execute)",
118
+ "subprocess_shell": "command execution",
119
+ "eval": "code eval",
120
+ "exec": "code exec",
121
+ "dynamic_open": "filesystem open",
122
+ "unsafe_deserialization": "unsafe deserialization",
123
+ "ssti": "template injection",
124
+ "ssrf": "outbound request (SSRF)",
125
+ "nosql_injection": "NoSQL query",
126
+ "open_redirect": "redirect target",
127
+ }
128
+
129
+ # Leading segments that are transport scaffolding, not the resource itself
130
+ # (`/api`, `/v1`, `/rest`). Stripped so the cohort key lands on the resource
131
+ # root — /api/users, /api/users/<id> and /api/users/export all group as
132
+ # "api/users" — while versioned resources still separate (api/v1/users vs
133
+ # api/v1/orders). `admin`/`internal`/`public` are deliberately NOT here:
134
+ # they mark real trust boundaries worth grouping on.
135
+ _SCAFFOLD_RE = re.compile(r"^(?:api|rest|graphql|gql|v\d+(?:beta\d*|alpha\d*)?)$", re.IGNORECASE)
136
+
137
+
138
+ def find_anomalies(
139
+ scan: ScanResult,
140
+ root: str | Path | None = None,
141
+ progress: "_Progress | None" = None,
142
+ ) -> list[Anomaly]:
143
+ """Return within-repo consistency outliers for the routes in ``scan``.
144
+
145
+ On a large route surface the per-cohort source-window reads dominate this
146
+ pass, so when a ``progress`` reporter is supplied it drives a determinate
147
+ bar over the cohorts (rather than an open-ended spinner).
148
+ """
149
+ root_path = Path(root or scan.root).resolve()
150
+
151
+ routes = [r for r in scan.routes if not is_test_file(r.file)]
152
+ groups: dict[str, list[Route]] = {}
153
+ for route in routes:
154
+ key = _peer_key(route.path)
155
+ if key:
156
+ groups.setdefault(key, []).append(route)
157
+
158
+ # Per-file line cache (read each source file at most once) and the sorted
159
+ # set of route lines per file, used to bound each handler's signal span.
160
+ line_cache: dict[str, list[str]] = {}
161
+ file_route_lines: dict[str, list[int]] = {}
162
+ for route in routes:
163
+ if route.line is not None:
164
+ file_route_lines.setdefault(route.file, []).append(route.line)
165
+ for lines in file_route_lines.values():
166
+ lines.sort()
167
+
168
+ ctx = _SignalCtx(root_path, line_cache, file_route_lines)
169
+
170
+ if progress is not None:
171
+ progress.begin(len(groups), "Anomaly / outlier detection")
172
+
173
+ out: list[Anomaly] = []
174
+ for key, members in groups.items():
175
+ if progress is not None:
176
+ progress.advance(key)
177
+ if len(members) < _MIN_GROUP:
178
+ continue
179
+ if not _is_route_like_cohort(key, members):
180
+ continue
181
+ out.extend(_auth_outliers(key, members, ctx))
182
+ out.extend(_method_outliers(key, members))
183
+ out.extend(_validation_outliers(key, members, ctx))
184
+
185
+ # Mined-invariant pass (#149a): cohorts of handlers reaching the same
186
+ # dangerous sink, flagged when the majority guard the request before it.
187
+ out.extend(_invariant_violations(scan, ctx))
188
+
189
+ out.sort(key=lambda a: (-a.confidence, a.route_file, a.route_path))
190
+ return out
191
+
192
+
193
+ def route_auth_signals(
194
+ scan: ScanResult, root: str | Path | None = None
195
+ ) -> dict[tuple[str, str, str], bool]:
196
+ """Per-route authentication/authorization presence for one repo.
197
+
198
+ Maps ``(file, method, path)`` → whether an auth/authorization marker appears
199
+ within that route handler's own span (the same span-bounded detection the
200
+ within-repo `auth_outlier` pass uses). Exposed for cross-repo trust analysis
201
+ (#146d): the fleet layer runs it per repo — each with its own root — to spot
202
+ an unauthenticated route reached across a service boundary, and the sibling
203
+ service that omits an auth control its peers enforce."""
204
+ root_path = Path(root or scan.root).resolve()
205
+ routes = [r for r in scan.routes if not is_test_file(r.file)]
206
+ line_cache: dict[str, list[str]] = {}
207
+ file_route_lines: dict[str, list[int]] = {}
208
+ for route in routes:
209
+ if route.line is not None:
210
+ file_route_lines.setdefault(route.file, []).append(route.line)
211
+ for lines in file_route_lines.values():
212
+ lines.sort()
213
+ ctx = _SignalCtx(root_path, line_cache, file_route_lines)
214
+ return {
215
+ (r.file, r.method, r.path): _route_has_signal(r, ctx, _AUTH_MARKERS) for r in routes
216
+ }
217
+
218
+
219
+ # --- Heuristics ------------------------------------------------------------
220
+
221
+
222
+ def _auth_outliers(key: str, members: list[Route], ctx: _SignalCtx) -> list[Anomaly]:
223
+ with_auth: list[Route] = []
224
+ without_auth: list[Route] = []
225
+ for route in members:
226
+ if _route_has_signal(route, ctx, _AUTH_MARKERS):
227
+ with_auth.append(route)
228
+ else:
229
+ without_auth.append(route)
230
+
231
+ if not _is_outlier_split(len(with_auth), len(without_auth), len(members)):
232
+ return []
233
+
234
+ examples = _peer_examples(with_auth)
235
+ return [
236
+ Anomaly(
237
+ kind="auth_outlier",
238
+ route_path=r.path,
239
+ route_method=r.method,
240
+ route_file=r.file,
241
+ route_line=r.line,
242
+ peer_group=key,
243
+ peer_group_size=len(members),
244
+ consistent_peers=len(with_auth),
245
+ deviation=(
246
+ f"{len(with_auth)} of {len(members)} routes under '{key}' carry an "
247
+ f"auth/authorization signal near the handler; this one does not"
248
+ ),
249
+ peer_examples=examples,
250
+ severity="high",
251
+ confidence=_confidence(len(with_auth)),
252
+ source_analyzer="anomalies",
253
+ )
254
+ for r in without_auth
255
+ ]
256
+
257
+
258
+ def _validation_outliers(key: str, members: list[Route], ctx: _SignalCtx) -> list[Anomaly]:
259
+ # Validation only meaningfully applies to state-changing handlers.
260
+ writers = [r for r in members if r.method.upper() in _WRITE_METHODS]
261
+ if len(writers) < _MIN_GROUP:
262
+ return []
263
+ with_val: list[Route] = []
264
+ without_val: list[Route] = []
265
+ for route in writers:
266
+ if _route_has_signal(route, ctx, _VALIDATION_MARKERS):
267
+ with_val.append(route)
268
+ else:
269
+ without_val.append(route)
270
+
271
+ if not _is_outlier_split(len(with_val), len(without_val), len(writers)):
272
+ return []
273
+
274
+ examples = _peer_examples(with_val)
275
+ return [
276
+ Anomaly(
277
+ kind="validation_outlier",
278
+ route_path=r.path,
279
+ route_method=r.method,
280
+ route_file=r.file,
281
+ route_line=r.line,
282
+ peer_group=key,
283
+ peer_group_size=len(writers),
284
+ consistent_peers=len(with_val),
285
+ deviation=(
286
+ f"{len(with_val)} of {len(writers)} state-changing routes under '{key}' "
287
+ f"validate their input; this one shows no validation marker"
288
+ ),
289
+ peer_examples=examples,
290
+ severity="low",
291
+ # Validation detection is noisier than auth — cap the ceiling.
292
+ confidence=min(0.7, _confidence(len(with_val))),
293
+ source_analyzer="anomalies",
294
+ )
295
+ for r in without_val
296
+ ]
297
+
298
+
299
+ def _method_outliers(key: str, members: list[Route]) -> list[Anomaly]:
300
+ readers = [r for r in members if r.method.upper() in _READ_METHODS]
301
+ writers = [r for r in members if r.method.upper() in _WRITE_METHODS]
302
+ # A lone writer in a cohort that is otherwise a solid read-only cluster.
303
+ # Require a large read-only majority — one POST among a few GETs is just
304
+ # ordinary CRUD, so only a dominant read cluster makes the writer odd.
305
+ if len(writers) != 1 or len(readers) < 4:
306
+ return []
307
+ r = writers[0]
308
+ return [
309
+ Anomaly(
310
+ kind="method_outlier",
311
+ route_path=r.path,
312
+ route_method=r.method,
313
+ route_file=r.file,
314
+ route_line=r.line,
315
+ peer_group=key,
316
+ peer_group_size=len(members),
317
+ consistent_peers=len(readers),
318
+ deviation=(
319
+ f"{len(readers)} of {len(members)} routes under '{key}' are read-only; "
320
+ f"this {r.method} is the only state-changing method in the cohort"
321
+ ),
322
+ peer_examples=_peer_examples(readers),
323
+ severity="medium",
324
+ confidence=min(0.7, _confidence(len(readers))),
325
+ source_analyzer="anomalies",
326
+ )
327
+ ]
328
+
329
+
330
+ # --- Mined-invariant pass (#149a) ------------------------------------------
331
+
332
+
333
+ @dataclass
334
+ class _InvHandler:
335
+ """One handler in a sink cohort: its analysis file + declaration line, the
336
+ verbs it is registered under, and the same-file chains it reaches."""
337
+
338
+ file: str
339
+ line: int | None
340
+ path: str
341
+ methods: set[str]
342
+ chains: list
343
+
344
+
345
+ def _invariant_violations(scan: ScanResult, ctx: _SignalCtx) -> list[Anomaly]:
346
+ """Mine a "guard the request before the sink" invariant per sink kind and
347
+ flag the handler that violates it.
348
+
349
+ The cohort is not a route-path prefix but the set of handlers that reach
350
+ the *same dangerous sink kind* (from ``scan.taint_chains``). When a strong
351
+ majority of those handlers apply an auth/validation guard before the sink,
352
+ that pattern is mined as an implicit invariant and every cohort member that
353
+ reaches the same sink kind with no preceding guard is flagged, citing the
354
+ mined rule. Signature-free: the code is measured against its own norm.
355
+
356
+ Scope decisions that keep the high-severity signal precise:
357
+
358
+ - **Same-file flows only** (``hops == 0``). "Guard before the sink" is a
359
+ statement about ordering, and ordering is only verifiable when the guard
360
+ and the sink live in one file. A downstream (cross-file) sink could be
361
+ called before an ``authorize(...)`` that textually follows it, so we
362
+ don't guess — cross-file chains are simply out of scope here.
363
+ - **Anchored on the chain's own file.** The taint pass reasons from the
364
+ module that *defines* the handler (#107), which for a central-registration
365
+ Node app is not the route's registration file. Analysis therefore anchors
366
+ on ``chain.route_file``; a matching ``Route`` supplies the declaration line
367
+ for a precise per-handler span, and a resolved handler module with no such
368
+ match falls back to a whole-file span.
369
+ - **Per handler, not per verb.** One handler registered for several methods
370
+ emits one ``Route`` per verb; cohort identity is the handler (file +
371
+ declaration line), so multi-verb handlers count once.
372
+ - **Sanitized chains excluded** (#137) — a sink-local neutralizer is a
373
+ different control from a handler guard, so dropping them keeps the cohort
374
+ to genuinely undefended flows.
375
+ """
376
+ # (file, method, path) -> declaration line, for handlers whose defining file
377
+ # is the file the chain is anchored on (inline / decorator handlers).
378
+ line_by_key: dict[tuple[str, str, str], int] = {}
379
+ for route in scan.routes:
380
+ if is_test_file(route.file) or route.line is None:
381
+ continue
382
+ line_by_key.setdefault((route.file, route.method, route.path), route.line)
383
+
384
+ # sink_kind -> { handler_id -> _InvHandler }
385
+ per_kind: dict[str, dict[tuple[str, object], _InvHandler]] = {}
386
+ for chain in scan.taint_chains:
387
+ # Same-file, undefended flows only (see the scope notes above); for
388
+ # hops == 0 the sink file is the anchor file, so one test covers both.
389
+ # Speculative recall chains (#148a) are unconfirmed leads — don't mine
390
+ # an asserted invariant violation from them.
391
+ if chain.sanitized or chain.speculative or chain.hops != 0:
392
+ continue
393
+ anchor_file = chain.route_file
394
+ if is_test_file(anchor_file):
395
+ continue
396
+ line = line_by_key.get((anchor_file, chain.route_method, chain.route_path))
397
+ # Handler identity: (file, decl line) when known, else (file, path) so a
398
+ # resolved handler module's several verbs still collapse to one handler.
399
+ handler_id: tuple[str, object] = (
400
+ (anchor_file, line) if line is not None else (anchor_file, chain.route_path)
401
+ )
402
+ cohort = per_kind.setdefault(chain.sink_kind, {})
403
+ handler = cohort.get(handler_id)
404
+ if handler is None:
405
+ handler = _InvHandler(
406
+ file=anchor_file, line=line, path=chain.route_path, methods=set(), chains=[]
407
+ )
408
+ cohort[handler_id] = handler
409
+ handler.methods.add(chain.route_method)
410
+ handler.chains.append(chain)
411
+
412
+ out: list[Anomaly] = []
413
+ for kind, cohort in per_kind.items():
414
+ handlers = list(cohort.values())
415
+ if len(handlers) < _MIN_INVARIANT_COHORT:
416
+ continue
417
+ guarded: list[_InvHandler] = []
418
+ unguarded: list[_InvHandler] = []
419
+ for handler in handlers:
420
+ if all(
421
+ _guard_before_sink(handler.file, handler.line, c.sink_file, c.sink_line, ctx)
422
+ for c in handler.chains
423
+ ):
424
+ guarded.append(handler)
425
+ else:
426
+ unguarded.append(handler)
427
+ if not _is_outlier_split(len(guarded), len(unguarded), len(handlers)):
428
+ continue
429
+ label = _SINK_LABELS.get(kind) or kind_label(kind)
430
+ invariant = (
431
+ f"{len(guarded)} of {len(handlers)} handlers that reach a {label} sink "
432
+ f"apply an auth/validation guard before it"
433
+ )
434
+ examples = _handler_examples(guarded)
435
+ for handler in unguarded:
436
+ out.append(
437
+ Anomaly(
438
+ kind="invariant_violation",
439
+ route_path=handler.path,
440
+ route_method="/".join(sorted(handler.methods)),
441
+ route_file=handler.file,
442
+ route_line=handler.line,
443
+ peer_group=f"sink:{kind}",
444
+ peer_group_size=len(handlers),
445
+ consistent_peers=len(guarded),
446
+ deviation=(
447
+ f"reaches a {label} sink with no auth/validation guard "
448
+ f"before it, unlike its sibling handlers"
449
+ ),
450
+ invariant=invariant,
451
+ peer_examples=examples,
452
+ severity="high",
453
+ confidence=_confidence(len(guarded)),
454
+ source_analyzer="anomalies",
455
+ )
456
+ )
457
+ return out
458
+
459
+
460
+ def _handler_examples(handlers: list[_InvHandler], limit: int = 3) -> list[str]:
461
+ seen: list[str] = []
462
+ for h in handlers:
463
+ label = f"{'/'.join(sorted(h.methods))} {h.path}"
464
+ if label not in seen:
465
+ seen.append(label)
466
+ if len(seen) >= limit:
467
+ break
468
+ return seen
469
+
470
+
471
+ def _guard_before_sink(
472
+ file: str, line: int | None, sink_file: str, sink_line: int | None, ctx: _SignalCtx
473
+ ) -> bool:
474
+ """True if an auth/validation marker guards the handler *before* the sink.
475
+
476
+ The handler span is its declaration line down to the next route (``line``
477
+ known), or the whole file when the handler is a resolved module with no
478
+ matching route declaration. The sink is same-file (this pass only reasons
479
+ about ``hops == 0`` flows): when it sits inside the span the window is
480
+ tightened to strictly above the sink line — a guard placed *after* the sink
481
+ doesn't defend it — otherwise (a sink in another handler's body, reached via
482
+ same-file import fan-out) the whole span counts.
483
+ """
484
+ lines = _file_lines(file, ctx)
485
+ if not lines:
486
+ return False
487
+ if line is None:
488
+ start, end = 0, len(lines)
489
+ else:
490
+ start, end = span_bounds(line, lines, ctx.file_route_lines.get(file, []))
491
+ # sink_line is 1-based; index sink_line-1 is the sink itself. Only tighten
492
+ # the window to "before the sink" when the sink sits within this span.
493
+ if sink_file == file and sink_line is not None and start < sink_line - 1 < end:
494
+ end = sink_line - 1
495
+ if end <= start:
496
+ return False
497
+ window = "\n".join(lines[start:end])
498
+ return (
499
+ _AUTH_MARKERS.search(window) is not None
500
+ or _VALIDATION_MARKERS.search(window) is not None
501
+ )
502
+
503
+
504
+ # --- Internals -------------------------------------------------------------
505
+
506
+
507
+ def _is_route_like_cohort(key: str, members: list[Route]) -> bool:
508
+ """Reject cohorts that are route-extractor noise rather than a real
509
+ resource surface.
510
+
511
+ Method-call strings the extractor mistakes for routes (``headers.delete
512
+ ('content-type')`` → ``DELETE /content-type``, ``params.get('request_uri')``
513
+ → ``GET /request_uri``) collapse to a single bare path repeated across
514
+ files. A genuine resource cohort instead has *structural variety*: more
515
+ than one distinct path, and at least one member that carries a path
516
+ parameter or descends below the cohort key. Requiring both keeps real
517
+ surfaces (``/api/users`` + ``/api/users/{id}``) while dropping the noise.
518
+ """
519
+ distinct_paths = {r.path for r in members}
520
+ if len(distinct_paths) < 2:
521
+ return False
522
+ key_depth = key.count("/") + 1
523
+ for path in distinct_paths:
524
+ if _PARAM_SEG_RE.search(path):
525
+ return True
526
+ if len([s for s in path.split("/") if s]) > key_depth:
527
+ return True
528
+ return False
529
+
530
+
531
+ def _is_outlier_split(consistent: int, deviating: int, total: int) -> bool:
532
+ """True when ``deviating`` routes are a strict, small minority against a
533
+ consistent majority — the shape of a real odd-one-out."""
534
+ if consistent < 2 or deviating < 1:
535
+ return False
536
+ # Outliers may be at most a quarter of the cohort (min 1), so a split
537
+ # group isn't nagged and a large consistent group can tolerate a couple.
538
+ return deviating <= max(1, total // 4)
539
+
540
+
541
+ def _confidence(consistent_peers: int) -> float:
542
+ """Scale confidence with the size of the agreeing peer group."""
543
+ return round(min(0.9, 0.45 + 0.09 * consistent_peers), 2)
544
+
545
+
546
+ def _peer_key(path: str) -> str | None:
547
+ """Cohort key: scaffolding prefix (``api``/version) plus the resource
548
+ root — the first static segment past the scaffolding. ``None`` if there
549
+ is no static prefix (e.g. ``/{id}``), which has no resource cohort.
550
+
551
+ Examples: ``/api/users/{id}`` → ``api/users``; ``/api/users/export`` →
552
+ ``api/users``; ``/api/v1/orders/{id}`` → ``api/v1/orders``."""
553
+ static: list[str] = []
554
+ for seg in path.split("/"):
555
+ if not seg:
556
+ continue
557
+ if _PARAM_SEG_RE.search(seg):
558
+ break
559
+ static.append(seg)
560
+ if not static:
561
+ return None
562
+ i = 0
563
+ while i < len(static) and _SCAFFOLD_RE.match(static[i]):
564
+ i += 1
565
+ # scaffolding + the resource root (or just the scaffolding if the whole
566
+ # static prefix was scaffolding, e.g. a bare `/api`).
567
+ key_parts = static[: i + 1] if i < len(static) else static
568
+ return "/".join(key_parts) or None
569
+
570
+
571
+ def _peer_examples(routes: list[Route], limit: int = 3) -> list[str]:
572
+ seen: list[str] = []
573
+ for r in routes:
574
+ label = f"{r.method} {r.path}"
575
+ if label not in seen:
576
+ seen.append(label)
577
+ if len(seen) >= limit:
578
+ break
579
+ return seen
580
+
581
+
582
+ def _route_has_signal(route: Route, ctx: _SignalCtx, marker: re.Pattern[str]) -> bool:
583
+ """True if ``marker`` appears within the route handler's own span.
584
+
585
+ The span is the handler's line range — from its declaration (extended
586
+ upward over contiguous ``@decorator`` lines) down to just before the
587
+ next route in the same file — so a sibling handler's guard is never
588
+ mistaken for this one's. With no line info, fall back to the whole file."""
589
+ lines = _file_lines(route.file, ctx)
590
+ if not lines:
591
+ return False
592
+ if route.line is None:
593
+ return marker.search("\n".join(lines)) is not None
594
+ start, end = span_bounds(route.line, lines, ctx.file_route_lines.get(route.file, []))
595
+ return marker.search("\n".join(lines[start:end])) is not None
596
+
597
+
598
+ def _file_lines(rel_file: str, ctx: _SignalCtx) -> list[str]:
599
+ if rel_file in ctx.line_cache:
600
+ return ctx.line_cache[rel_file]
601
+ path = contained_file(ctx.root, rel_file)
602
+ lines: list[str] = []
603
+ try:
604
+ if path is not None and path.stat().st_size <= _MAX_FILE_BYTES:
605
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
606
+ except (OSError, ValueError):
607
+ lines = []
608
+ ctx.line_cache[rel_file] = lines
609
+ return lines
610
+
611
+
612
+ __all__ = ["find_anomalies", "route_auth_signals"]