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,474 @@
1
+ """Fuse routes + middleware/guard chains + auth_hints into precise findings (#140).
2
+
3
+ `scan.auth_hints` are raw signals — a large monorepo emits hundreds. They're
4
+ useful evidence, but reviewers want conclusions. This pass resolves, *per
5
+ route*, whether an authentication/authorization control guards it, and emits a
6
+ single precise finding for the state-changing routes that have none — each
7
+ carrying the resolved middleware/guard chain as evidence.
8
+
9
+ Why not reuse `AttackSurface.auth_signals`? That attribution is file-windowed
10
+ (±40 lines), so in a compact file every route inherits a neighbor's auth code.
11
+ This module resolves the control on the route's *own* registration chain:
12
+
13
+ - **Express / Koa / Fastify (JS/TS)** — an auth middleware in the route's
14
+ argument list (`app.post('/x', requireAuth, handler)`) or a global
15
+ `app.use(requireAuth)` / `router.use(...)`.
16
+ - **FastAPI / Flask (Python)** — an auth decorator (`@login_required`,
17
+ `@jwt_required`, …), a `dependencies=[Depends(...)]` on the route decorator,
18
+ `Depends(<auth>)` / `Security(...)` in the handler signature, or a
19
+ router-level dependency.
20
+ - **Spring (Java)** — `@PreAuthorize` / `@PostAuthorize` / `@Secured` /
21
+ `@RolesAllowed` on the method or controller, or a global security config that
22
+ requires authentication for every request.
23
+
24
+ Scope: public, state-changing (`POST/PUT/PATCH/DELETE`) `public_api` routes,
25
+ plus privileged routes — `admin`, and `internal` routes whose own path says
26
+ `/internal` or `/debug` (#242). The admin/internal category findings are
27
+ file-windowed advisories; this pass gives those routes the same precise
28
+ per-route answer. Webhook, upload and auth routes keep their dedicated
29
+ findings. `auth_hints` emission is untouched; this only consumes them.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from pathlib import Path
36
+
37
+ from .handler_auth import handler_body, mask, session_gate
38
+ from .safe_fs import read_repo_text, walk_repo
39
+ from .srcpaths import JS_TS_SUFFIXES, split_lines
40
+ from .models import AttackSurface, AttackTechnique, Finding, FindingLocation, Route, ScanResult
41
+
42
+ _MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
43
+ # An `internal` surface counts only when the route's own path says so; one
44
+ # classified internal from worker/event-handler metadata is not HTTP-reachable.
45
+ _INTERNAL_PATH = re.compile(r"/(?:internal|debug)(?:/|$)", re.IGNORECASE)
46
+ _JS_SUFFIXES = JS_TS_SUFFIXES
47
+ _PY_SUFFIXES = {".py"}
48
+ _JAVA_SUFFIXES = {".java", ".kt"}
49
+
50
+ # Identifiers whose presence in a route's chain means it is guarded. Broad
51
+ # enough to catch common conventions, specific enough not to match ordinary
52
+ # handler code.
53
+ _JS_AUTH_TOKEN = re.compile(
54
+ r"\b(?:require(?:Auth|Login|User|Token|Admin)|requiresAuth|isAuthenticated|"
55
+ r"ensureAuth\w*|ensureLoggedIn|ensureAuthenticated|authenticat\w*|authGuard|"
56
+ r"authMiddleware|authenticateJWT|checkAuth|checkJwt|verify(?:Token|Jwt|Auth)|"
57
+ r"jwtMiddleware|passport|protect(?:ed)?Route|withAuth|guard)\b",
58
+ re.IGNORECASE,
59
+ )
60
+ _PY_AUTH_DECORATOR = re.compile(
61
+ r"@(?:\w+\.)*(?:login_required|jwt_required|token_required|auth_required|"
62
+ r"requires_auth|require_auth|authenticated|permission_required|"
63
+ r"require_(?:oauth|api_key|token)|protected)\b",
64
+ re.IGNORECASE,
65
+ )
66
+ _PY_DEPENDS_AUTH = re.compile(
67
+ r"(?:Security|Depends)\(\s*(?:[\w.]*)?(?:oauth2_scheme|get_current_user|"
68
+ r"current_user|current_active_user|verify_token|require_\w+|auth\w*|"
69
+ r"authenticate\w*|HTTPBearer|HTTPBasic|JWTBearer|get_user|require_user)",
70
+ re.IGNORECASE,
71
+ )
72
+ _PY_SECURITY_CALL = re.compile(r"\bSecurity\s*\(") # fastapi.Security is always auth
73
+ _JAVA_AUTH_ANNOTATION = re.compile(
74
+ r"@(?:PreAuthorize|PostAuthorize|Secured|RolesAllowed|RoleAllowed|"
75
+ r"Authenticated|HasRole|HasAuthority)\b"
76
+ )
77
+ # A quoted string literal. A backslash is consumed only by the escape branch,
78
+ # so an unterminated quote followed by many backslashes can't backtrack
79
+ # exponentially (CodeQL py/redos).
80
+ _STRING_LITERAL_RE = re.compile(r"""(['"`])(?:\\.|(?!\1)[^\\])*\1""")
81
+ _JAVA_GLOBAL_AUTH = re.compile(
82
+ r"\.(?:anyRequest|anyExchange)\s*\(\s*\)\s*\.(?:authenticated|hasRole|hasAuthority|hasAnyRole)"
83
+ r"|\.authorize(?:HttpRequests|Requests|Exchange)\b",
84
+ )
85
+
86
+
87
+ def unauthenticated_routes(
88
+ scan: ScanResult, surfaces: list[AttackSurface]
89
+ ) -> list[tuple[Route, str]]:
90
+ """The in-scope state-changing routes with no resolved authentication
91
+ control, each with its control-chain evidence."""
92
+ root = Path(scan.root)
93
+ surface_by_key: dict[tuple[str, str, str], AttackSurface] = {}
94
+ for s in surfaces:
95
+ surface_by_key.setdefault((s.method, s.route, s.file), s)
96
+
97
+ content_cache: dict[str, str | None] = {}
98
+ # Only pay the Spring-config scan when there are Java routes to attribute —
99
+ # otherwise this is a no-op that would needlessly walk the whole tree.
100
+ has_java_routes = any(
101
+ Path(r.file).suffix.lower() in _JAVA_SUFFIXES for r in scan.routes
102
+ )
103
+ java_global_auth = (
104
+ repo_has_java_global_auth(scan, root, content_cache) if has_java_routes else False
105
+ )
106
+
107
+ line_starts: dict[str, tuple[list[int], str]] = {}
108
+ hop_box: list = []
109
+
110
+ flagged: list[tuple[Route, str]] = []
111
+ for route in scan.routes:
112
+ if route.method.upper() not in _MUTATING:
113
+ continue
114
+ surface = surface_by_key.get((route.method, route.path, route.file))
115
+ if surface is None or not _in_scope(surface, route):
116
+ continue
117
+ # Trust a plugin-declared state (#256); resolve ourselves only when the
118
+ # plugin couldn't tell.
119
+ if route.auth == "required":
120
+ continue
121
+ if route.auth == "anonymous":
122
+ flagged.append((route, _declared_anonymous_evidence(route)))
123
+ continue
124
+ content = _read(root, route.file, content_cache)
125
+ if content is None:
126
+ continue
127
+ control = resolve_control(route, content, java_global_auth)
128
+ if control is not None:
129
+ continue # guarded — not a finding
130
+ if Path(route.file).suffix.lower() in _JS_SUFFIXES and _handler_session_gate(
131
+ route, content, root, content_cache, line_starts, hop_box
132
+ ):
133
+ continue # the handler rejects a request without a session (#348)
134
+ flagged.append((route, _chain_evidence(route, content)))
135
+ return flagged
136
+
137
+
138
+ _REGISTRATION_CALL = re.compile(r"\.\s*(?P<verb>get|post|put|patch|delete|all|route|on)\s*\(", re.IGNORECASE)
139
+ _NEWLINE = re.compile(r"\n")
140
+ _READ_ONLY_MIDDLEWARE = re.compile(
141
+ r"(?:[A-Za-z_$][\w$]{0,40}\.)?"
142
+ r"(?:(?:[a-z]{1,3}V|v)alidat(?:or|e|ion)(?:Body|Query|Params|Request|Input|Schema|Json|Middleware)?"
143
+ r"|json|urlencoded|text|raw|cors|helmet|rateLimit(?:er)?|[A-Za-z_$]{0,20}[Ll]imiter)"
144
+ r"\s*\([^;]{0,400}\)"
145
+ )
146
+ _CHAIN_CONTINUES = re.compile(r"[ \t\r\n]{0,200}\.")
147
+ _PATH_LITERAL = re.compile(r"(?<!\s)\s*+['\"`/]")
148
+ _SOLE_REF = re.compile(r"([A-Za-z_$][\w$]{0,80}(?:\.[A-Za-z_$][\w$]{0,80})?)")
149
+
150
+
151
+ def _top_level_args(text: str) -> list[tuple[int, str]]:
152
+ """``text`` split on commas outside brackets, as ``(offset, argument)``;
153
+ a trailing comma adds no empty argument."""
154
+ args: list[tuple[int, str]] = []
155
+ depth = 0
156
+ start = 0
157
+ for i, ch in enumerate(text):
158
+ if ch in "([{":
159
+ depth += 1
160
+ elif ch in ")]}":
161
+ depth -= 1
162
+ elif ch == "," and depth == 0:
163
+ args.append((start, text[start:i]))
164
+ start = i + 1
165
+ if text[start:].strip():
166
+ args.append((start, text[start:]))
167
+ return args
168
+
169
+
170
+ def _handler_session_gate(
171
+ route: Route,
172
+ content: str,
173
+ root: Path,
174
+ cache: dict[str, str | None],
175
+ line_starts: dict[str, tuple[list[int], str]],
176
+ hop_box: list,
177
+ ) -> str | None:
178
+ """A session check that rejects (see :mod:`handler_auth`) in the route's
179
+ own handler: the inline handler, clamped to its closing brace, or the one
180
+ function the registration passes by reference (one hop)."""
181
+ if route.line is None:
182
+ return None
183
+ # Route.line counts `\n` only; `splitlines()` would also split on
184
+ # U+2028, `\f` or a lone `\r` and shift the handler to another route.
185
+ # The file is masked once and shared by all its routes.
186
+ if route.file not in line_starts:
187
+ line_starts[route.file] = ([0] + [m.end() for m in _NEWLINE.finditer(content)], mask(content))
188
+ starts, file_code = line_starts[route.file]
189
+ if not 1 <= route.line <= len(starts):
190
+ return None
191
+ offset = starts[route.line - 1]
192
+ text = content[offset : offset + 20_000]
193
+ code = file_code[offset : offset + 20_000]
194
+ first_line = code[: code.find("\n") + 1 if "\n" in code else len(code)]
195
+ calls = list(_REGISTRATION_CALL.finditer(first_line))
196
+ if not calls:
197
+ if _CHAIN_CONTINUES.match(code, len(first_line)):
198
+ return None # `router\n .route('/x')\n .post(mw, h)`: a chain we don't follow
199
+ # Not a call registration (a decorator, a function header): the
200
+ # handler body is the first function body that follows.
201
+ return session_gate(text, code)
202
+ # Several registrations on one line (`.get(a).post(b)`, `app.get(…); app.post(…)`):
203
+ # only the one for this route's method, and only if it is unambiguous.
204
+ same_method = [c for c in calls if c.group("verb").lower() == route.method.lower()]
205
+ if len(same_method) == 1:
206
+ call = same_method[0]
207
+ elif len(calls) == 1 and not same_method and calls[0].group("verb").lower() in ("all", "route", "on"):
208
+ call = calls[0]
209
+ else:
210
+ return None
211
+ open_paren = call.end() - 1
212
+ depth = 0
213
+ reg_close = None
214
+ for i in range(open_paren, len(code)):
215
+ if code[i] == "(":
216
+ depth += 1
217
+ elif code[i] == ")":
218
+ depth -= 1
219
+ if depth == 0:
220
+ reg_close = i
221
+ break
222
+ if reg_close is None:
223
+ return None
224
+ args = _top_level_args(code[open_paren + 1 : reg_close])
225
+ # Middleware: every argument between the path (when the first argument is
226
+ # one; `.route('/x').post(mw, h)` has none) and the handler. A gate in the
227
+ # handler runs after it (multer, a writer) has already acted. Validators,
228
+ # body parsers, cors and rate limiters only read the request.
229
+ has_path = bool(args) and _PATH_LITERAL.match(args[0][1]) is not None
230
+ middleware = args[1 if has_path else 0 : -1]
231
+ if not all(_READ_ONLY_MIDDLEWARE.fullmatch(text[open_paren + 1 + o : open_paren + 1 + o + len(a)].strip()) for o, a in middleware):
232
+ return None
233
+ # An inline handler: clamp to the registration, so a one-line route costs
234
+ # its own length. The body must open in the last argument, not in a
235
+ # callback passed to middleware (`validate({ custom: (req) => { … } })`).
236
+ last_start = open_paren + 1 + args[-1][0] if args else reg_close
237
+ inline_text, inline_code = text[: reg_close + 1], code[: reg_close + 1]
238
+ body = handler_body(inline_text[last_start:], inline_code[last_start:])
239
+ if body is not None:
240
+ return session_gate(inline_text[last_start:], inline_code[last_start:])
241
+ ref = _SOLE_REF.fullmatch(args[-1][1].strip()) if args else None
242
+ if ref is None:
243
+ return None
244
+ if not hop_box:
245
+ from .authz import _HopResolver # local import: authz imports this module
246
+
247
+ # The resolver, and (file, function) → gate shared by every route using it.
248
+ hop_box.extend([_HopResolver(root, cache), {}])
249
+ hop, gates = hop_box
250
+ owner, _, func = ref.group(1).rpartition(".")
251
+ imports = hop._import_map(route.file, content)
252
+ if owner and owner not in imports:
253
+ return None # `ctl.create` with `ctl` not imported: don't guess a local `create`
254
+ target = imports.get(owner or func, route.file)
255
+ key = (target, func)
256
+ if key not in gates:
257
+ definition = hop._definition(target, func)
258
+ gates[key] = session_gate(definition) if definition else None
259
+ return gates[key]
260
+
261
+
262
+ def _in_scope(surface: AttackSurface, route: Route) -> bool:
263
+ if surface.category == "public_api":
264
+ return surface.exposure == "public"
265
+ if surface.category == "admin":
266
+ return True
267
+ return surface.category == "internal" and bool(_INTERNAL_PATH.search(route.path))
268
+
269
+
270
+ def synthesize_unauthenticated_routes(
271
+ scan: ScanResult,
272
+ surfaces: list[AttackSurface],
273
+ flagged: list[tuple[Route, str]] | None = None,
274
+ ) -> list[Finding]:
275
+ """Return at most one finding aggregating unauthenticated state-changing
276
+ routes, with the resolved control chain per route as evidence. Pass
277
+ ``flagged`` (from :func:`unauthenticated_routes`) to reuse a resolution."""
278
+ if flagged is None:
279
+ flagged = unauthenticated_routes(scan, surfaces)
280
+ if not flagged:
281
+ return []
282
+
283
+ evidence = [chain for _, chain in flagged[:10]]
284
+ if len(flagged) > 10:
285
+ evidence.append(f"+{len(flagged) - 10} more state-changing route(s) with no resolved auth control")
286
+ return [
287
+ Finding(
288
+ title="State-changing routes are reachable without an authentication control",
289
+ rule_id="unauth-state-change",
290
+ severity="high",
291
+ evidence=evidence,
292
+ locations=[FindingLocation(file=route.file, line=route.line) for route, _ in flagged],
293
+ mitigation=(
294
+ "Require authentication (and server-side authorization) on every "
295
+ "state-changing endpoint — attach an auth middleware/guard to the "
296
+ "route or its router, a dependency/decorator on the handler, or a "
297
+ "global policy that defaults to deny. Confirm the control actually "
298
+ "runs before the handler mutates state."
299
+ ),
300
+ confidence="medium",
301
+ tags=["exposed-endpoint", "auth-missing", "state-changing"],
302
+ attack_techniques=[
303
+ AttackTechnique(
304
+ technique_id="T1190",
305
+ name="Exploit Public-Facing Application",
306
+ tactic="Initial Access",
307
+ url="https://attack.mitre.org/techniques/T1190/",
308
+ )
309
+ ],
310
+ )
311
+ ]
312
+
313
+
314
+ def _declared_anonymous_evidence(route: Route) -> str:
315
+ where = f"{route.file}:{route.line}" if route.line else route.file
316
+ source = f" by {route.source_analyzer}" if route.source_analyzer else ""
317
+ basis = f" ({route.guard_evidence})" if route.guard_evidence else ""
318
+ return f"{route.method} {route.path} ({where}) — declared anonymous{source}{basis}"
319
+
320
+
321
+ def _read(root: Path, rel: str, cache: dict[str, str | None]) -> str | None:
322
+ if rel not in cache:
323
+ try:
324
+ cache[rel] = read_repo_text(root, rel, errors="replace")
325
+ except OSError:
326
+ cache[rel] = None
327
+ return cache[rel]
328
+
329
+
330
+ def resolve_control(route: Route, content: str, java_global_auth: bool) -> str | None:
331
+ """Return a short description of the control guarding `route`, or None."""
332
+ suffix = Path(route.file).suffix.lower()
333
+ lines = split_lines(content)
334
+ if suffix in _JS_SUFFIXES:
335
+ return _resolve_js(route, content, lines)
336
+ if suffix in _PY_SUFFIXES:
337
+ return _resolve_py(route, lines)
338
+ if suffix in _JAVA_SUFFIXES:
339
+ return _resolve_java(route, lines, java_global_auth)
340
+ return None
341
+
342
+
343
+ def _line_index(route: Route) -> int | None:
344
+ return (route.line - 1) if route.line else None
345
+
346
+
347
+ def _resolve_js(route: Route, content: str, lines: list[str]) -> str | None:
348
+ # Global middleware: app.use(requireAuth) / router.use(passport...).
349
+ for m in re.finditer(r"\b(?:app|router|server)\s*\.\s*use\s*\(([^)]*)\)", content):
350
+ if _JS_AUTH_TOKEN.search(m.group(1)):
351
+ return f"global middleware: {m.group(0).strip()[:80]}"
352
+
353
+ idx = _line_index(route)
354
+ if idx is None:
355
+ return None
356
+ # The route registration may span a few lines; take up to the start of the
357
+ # handler body so we inspect the middleware list, not the handler code.
358
+ region = "\n".join(lines[idx : idx + 4])
359
+ handler_start = re.search(r"=>|function\b|\(\s*req\b|\(\s*request\b", region)
360
+ mw_region = region[: handler_start.start()] if handler_start else region
361
+ # Drop the path string literal so a path like '/authenticate' can't match.
362
+ mw_region = _STRING_LITERAL_RE.sub("", mw_region, count=1)
363
+ if _JS_AUTH_TOKEN.search(mw_region):
364
+ return f"route middleware on {route.path}"
365
+ return None
366
+
367
+
368
+ def _resolve_py(route: Route, lines: list[str]) -> str | None:
369
+ idx = _line_index(route)
370
+ if idx is None:
371
+ return None
372
+ # Decorators can sit above or below the route decorator; scan a small
373
+ # window above, then the handler signature below.
374
+ start = max(0, idx - 4)
375
+ # Find the handler `def`/`async def` at or after the route decorator, then
376
+ # read through the end of its signature (the line with the closing `):`).
377
+ def_line = None
378
+ for i in range(idx, min(len(lines), idx + 6)):
379
+ if re.match(r"\s*(?:async\s+)?def\s+\w+\s*\(", lines[i]):
380
+ def_line = i
381
+ break
382
+ sig_end = def_line if def_line is not None else idx
383
+ if def_line is not None:
384
+ depth = 0
385
+ for i in range(def_line, min(len(lines), def_line + 30)):
386
+ depth += lines[i].count("(") - lines[i].count(")")
387
+ sig_end = i
388
+ if depth <= 0 and "(" in "".join(lines[def_line : i + 1]):
389
+ break
390
+ window = "\n".join(lines[start : sig_end + 1])
391
+ if _PY_AUTH_DECORATOR.search(window):
392
+ return f"auth decorator on {route.path}"
393
+ if _PY_DEPENDS_AUTH.search(window) or _PY_SECURITY_CALL.search(window):
394
+ return f"auth dependency on {route.path} handler"
395
+ # Router-level dependency (applies to every route on the router).
396
+ return _py_router_dependency(lines)
397
+
398
+
399
+ def _py_router_dependency(lines: list[str]) -> str | None:
400
+ text = "\n".join(lines)
401
+ for m in re.finditer(r"(?:APIRouter|include_router)\s*\(([^)]*)\)", text, re.DOTALL):
402
+ if "dependencies" in m.group(1) and _PY_DEPENDS_AUTH.search(m.group(1)):
403
+ return "router-level auth dependency"
404
+ return None
405
+
406
+
407
+ def _resolve_java(route: Route, lines: list[str], java_global_auth: bool) -> str | None:
408
+ if java_global_auth:
409
+ return "global Spring Security policy requires authentication"
410
+ idx = _line_index(route)
411
+ if idx is None:
412
+ return None
413
+ # Method-level annotations sit just above the mapping (after any preceding
414
+ # method's closing brace). Only scan up to that boundary so a *different*
415
+ # method's @PreAuthorize doesn't leak onto this route.
416
+ method_start = 0
417
+ for i in range(idx - 1, -1, -1):
418
+ if "}" in lines[i] or re.search(r"\b(?:public|private|protected)\b.*\)", lines[i]):
419
+ method_start = i + 1
420
+ break
421
+ window = "\n".join(lines[method_start : idx + 1])
422
+ if _JAVA_AUTH_ANNOTATION.search(window):
423
+ return f"method security annotation on {route.path}"
424
+ # Class-level annotations: only those directly above the class declaration.
425
+ for i, line in enumerate(lines):
426
+ if re.search(r"\b(?:class|interface)\s+\w+", line):
427
+ class_head = "\n".join(lines[max(0, i - 6) : i])
428
+ if _JAVA_AUTH_ANNOTATION.search(class_head):
429
+ return "class-level security annotation"
430
+ break
431
+ return None
432
+
433
+
434
+ def repo_has_java_global_auth(
435
+ scan: ScanResult, root: Path, cache: dict[str, str | None]
436
+ ) -> bool:
437
+ """True when a Spring Security config requires auth for every request —
438
+ then per-route method annotations aren't needed and routes aren't flagged."""
439
+ java_files = {r.file for r in scan.routes if Path(r.file).suffix.lower() in _JAVA_SUFFIXES}
440
+ # Config usually lives elsewhere; check route files plus any *Security*.java.
441
+ candidates = set(java_files)
442
+ for path in (p for p in walk_repo(root) if p.suffix == ".java" and "Security" in p.name):
443
+ try:
444
+ candidates.add(str(path.relative_to(root)))
445
+ except ValueError:
446
+ continue
447
+ for rel in candidates:
448
+ content = _read(root, rel, cache)
449
+ if content and "anyRequest" in content and _JAVA_GLOBAL_AUTH.search(content):
450
+ # Require an explicit authenticated()/hasRole() to avoid matching a
451
+ # config that permits all.
452
+ if re.search(r"\.(?:authenticated|hasRole|hasAuthority|hasAnyRole|fullyAuthenticated)\s*\(", content):
453
+ return True
454
+ return False
455
+
456
+
457
+ def _chain_evidence(route: Route, content: str) -> str:
458
+ """Describe what the control-chain resolution found (i.e. nothing)."""
459
+ loc = f"{route.file}:{route.line}" if route.line else route.file
460
+ suffix = Path(route.file).suffix.lower()
461
+ if suffix in _JS_SUFFIXES:
462
+ has_any = bool(_JS_AUTH_TOKEN.search(content))
463
+ elif suffix in _PY_SUFFIXES:
464
+ has_any = bool(_PY_AUTH_DECORATOR.search(content) or _PY_DEPENDS_AUTH.search(content))
465
+ elif suffix in _JAVA_SUFFIXES:
466
+ has_any = bool(_JAVA_AUTH_ANNOTATION.search(content))
467
+ else:
468
+ has_any = False
469
+ tail = (
470
+ "auth controls exist in the file but none guard this route"
471
+ if has_any
472
+ else "no authentication/authorization control found on the route's chain"
473
+ )
474
+ return f"{route.method} {route.path} in {loc} — {tail}"