attackmap-analyzer-java-spring 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1119 @@
1
+ """Spring Security `HttpSecurity` / `ServerHttpSecurity` rules -> route auth (#3).
2
+
3
+ Reads the authorization DSL of a security filter chain:
4
+
5
+ - `authorizeHttpRequests` / `authorizeRequests` / `authorizeExchange`, in the
6
+ lambda form (`auth -> auth.requestMatchers("/a").permitAll()...`, block
7
+ lambdas with one statement per rule), the pre-lambda chain form
8
+ (`.authorizeRequests().antMatchers("/a").permitAll().and()`), and the Kotlin
9
+ DSL (`authorizeHttpRequests { authorize("/a", permitAll) }`);
10
+ - matchers `requestMatchers` / `antMatchers` / `mvcMatchers` / `pathMatchers`
11
+ (with an optional `HttpMethod.X`), `anyRequest()` / `anyExchange()`, the
12
+ wrappers `antMatcher("/x")`, `mvc.pattern("/x")`, `new AntPathRequestMatcher(...)`,
13
+ and `PathRequest.toStaticResources().atCommonLocations()`;
14
+ - access decisions `permitAll()` / `anonymous()` (anonymous), `authenticated()` /
15
+ `fullyAuthenticated()` / `rememberMe()` / `hasRole(..)` / `hasAnyRole(..)` /
16
+ `hasAuthority(..)` / `hasAnyAuthority(..)` / `denyAll()` (required), and
17
+ `access(..)` when its expression is readable;
18
+ - `web.ignoring().requestMatchers(...)` (requests that bypass every chain) and a
19
+ chain's `securityMatcher(...)` scope.
20
+
21
+ Rules are applied to a route the way Spring applies them: the first matcher that
22
+ matches decides. Matching is Ant/PathPattern style (`/**`, `/*`, `?`, `{var}`,
23
+ `{*var}`) on path segments. A matcher that *might* match the route (a
24
+ `{var}` route segment against a literal, a method-specific matcher against a
25
+ method-less `@RequestMapping`, or a matcher we can't read such as a constant or
26
+ `regexMatchers`) doesn't decide on its own: the route gets the first full match's
27
+ decision only when every such possible match before it agrees, else it stays
28
+ unknown. Everything here is a bounded, linear scan over comment-masked text, so
29
+ it never raises on hostile or truncated input.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from bisect import bisect_right
36
+ from contextvars import ContextVar
37
+ from dataclasses import dataclass, field
38
+ from typing import NamedTuple
39
+
40
+ from .annotations import BracketIndex, bracket_end, split_top_level
41
+ from .constants import EMPTY, ConstantTable, resolve_expression
42
+
43
+ # Results of matching a route against a matcher.
44
+ NONE, PARTIAL, FULL = 0, 1, 2
45
+
46
+ MATCH_FULL, MATCH_NONE, MATCH_METHOD, MATCH_VALUE, MATCH_OPAQUE = "full", "none", "method", "value", "opaque"
47
+
48
+ ANONYMOUS = "anonymous"
49
+ REQUIRED = "required"
50
+ UNKNOWN = "unknown"
51
+
52
+ # Bounds: hostile repos can contain anything.
53
+ MAX_BODY_CHARS = 50_000
54
+ # Arguments of one matcher or decision call.
55
+ MAX_CALL_CHARS = 5_000
56
+ MAX_RULES_PER_CHAIN = 500
57
+ MAX_RULES_PER_FILE = 1000
58
+ MAX_SCOPE_CALLS = 64
59
+ MAX_IGNORING_CALLS = 64
60
+ MAX_CHAINS = 64
61
+ MAX_AUTHORIZE_CALLS = 256
62
+ MAX_ENTRIES_PER_MATCHER = 200
63
+ MAX_PATTERN_CHARS = 512
64
+ MAX_SEGMENTS = 32
65
+ MAX_SEGMENT_CHARS = 128
66
+ MAX_DOUBLE_WILDCARDS = 4
67
+ # Work allowed per repo when applying rules to routes, in cost units: each
68
+ # matcher entry costs ENTRY_COST, each path match the size of its segment table,
69
+ # each glob comparison pattern x text characters. Cost grows as routes x chains
70
+ # x rules x entries x pattern size, so a hostile repo (large config files, long
71
+ # glob patterns, many routes) is cut off here and its remaining routes stay unknown.
72
+ MAX_MATCH_COST = 5_000_000
73
+ ENTRY_COST = 20
74
+ # Wildcards (`*`, `?`, `{..}`) in one pattern segment; more is unreadable.
75
+ MAX_SEGMENT_WILDCARDS = 8
76
+ _EVIDENCE_CHARS = 160
77
+
78
+ HTTP_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"})
79
+
80
+ # ---------- Patterns (module level: they run on hostile input) ----------
81
+
82
+ # Cheap per-file gate before any parsing.
83
+ SECURITY_GATE_RE = re.compile(r"\bauthorize(?:HttpRequests|Requests|Exchange)\b|\bignoring\s*\(")
84
+ _AUTHORIZE_RE = re.compile(r"\bauthorize(?:HttpRequests|Requests|Exchange)\b")
85
+ _IGNORING_RE = re.compile(r"\bignoring\s*\(\s*\)")
86
+ # One rule's matcher call inside an authorize body. `authorize(` is the Kotlin DSL.
87
+ _RULE_CALL_RE = re.compile(
88
+ r"(?<![\w$])(requestMatchers|antMatchers|mvcMatchers|pathMatchers|regexMatchers|"
89
+ r"dispatcherTypeMatchers|anyRequest|anyExchange|authorize)\s*\("
90
+ )
91
+ _CHAINED_CALL_RE = re.compile(r"\s*\.\s*([A-Za-z_]\w*)\s*\(")
92
+ _MATCHER_NAMES = frozenset({"requestMatchers", "antMatchers", "mvcMatchers", "pathMatchers", "regexMatchers"})
93
+ # A chain's own scope: `http.securityMatcher("/api/**")`, the pre-6 singular
94
+ # `http.antMatcher("/api/**")`, and `http.requestMatchers().antMatchers(...)` /
95
+ # `securityMatchers(m -> ...)` outside the authorize body.
96
+ _SCOPE_CALL_RE = re.compile(
97
+ r"(?<![\w$])(securityMatchers?|requestMatchers)\s*\(|\.\s*(antMatcher|mvcMatcher|regexMatcher|requestMatcher)\s*\("
98
+ )
99
+ _STRING_LITERAL_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
100
+ _HTTP_METHOD_ARG_RE = re.compile(r"(?:[A-Za-z_][\w.]*\.)?HttpMethod\.([A-Z]+)")
101
+ # Matcher wrappers whose arguments are a pattern (and maybe a method).
102
+ _WRAPPER_RE = re.compile(
103
+ r"(?:new\s+)?(?:[A-Za-z_][\w.]*\.)?"
104
+ r"(antMatcher|pattern|pathMatchers|AntPathRequestMatcher|PathPatternRequestMatcher|"
105
+ r"PathPatternParserServerWebExchangeMatcher|MvcRequestMatcher)\s*\(",
106
+ )
107
+ _STATIC_RESOURCES_RE = re.compile(r"(?:[A-Za-z_][\w.]*\.)?toStaticResources\s*\(\s*\)\s*\.\s*atCommonLocations\s*\(\s*\)")
108
+ _ANY_REQUEST_ARG_RE = re.compile(r"(?:[A-Za-z_][\w.]*\.)?(?:anyRequest|anyExchange)(?:\s*\(\s*\))?")
109
+ _ACCESS_REQUIRED_RE = re.compile(
110
+ r"\b(?:hasRole|hasAnyRole|hasAuthority|hasAnyAuthority|isAuthenticated|isFullyAuthenticated|"
111
+ r"authenticated|fullyAuthenticated|denyAll|hasPermission)\b"
112
+ )
113
+ _ACCESS_PERMIT_RE = re.compile(r"\b(?:permitAll|isAnonymous|anonymous)\b")
114
+ # An authority anonymous users hold (`ROLE_ANONYMOUS`, `hasAnyRole("ANONYMOUS", ...)`,
115
+ # `IS_AUTHENTICATED_ANONYMOUSLY`, jhipster's `AuthoritiesConstants.ANONYMOUS`).
116
+ ANONYMOUS_AUTHORITY_RE = re.compile(r"ANONYMOUS")
117
+ _SPEL_OR_RE = re.compile(r"\bor\b|\|\|")
118
+ _SPEL_AND_RE = re.compile(r"\band\b|&&")
119
+ # One SpEL term we can read: a bare name or a call whose arguments are all
120
+ # string literals (`hasRole('A')`, `isAuthenticated()`, `permitAll`). Anything
121
+ # else (`==`, `!`, `not`, ternaries, `#param`, `@bean`, grouping) is unreadable.
122
+ _SPEL_LITERAL = r"""(?:'[^'\\\n]*'|"[^"\\\n]*")"""
123
+ _SPEL_TERM_RE = re.compile(
124
+ r"\s*([A-Za-z_]\w*)\s*(?:\(\s*(" + _SPEL_LITERAL + r"(?:\s*,\s*" + _SPEL_LITERAL + r")*)?\s*\))?\s*"
125
+ )
126
+ _SPEL_PERMIT_NAMES = frozenset({"permitAll", "isAnonymous", "anonymous"})
127
+ _SPEL_REQUIRED_NAMES = frozenset(
128
+ {"isAuthenticated", "isFullyAuthenticated", "authenticated", "fullyAuthenticated", "denyAll", "isRememberMe"}
129
+ )
130
+ _SPEL_ROLE_NAMES = frozenset({"hasRole", "hasAnyRole", "hasAuthority", "hasAnyAuthority"})
131
+ # `access(...)` arguments we can read: a SpEL string (Spring Security 5), the
132
+ # same wrapped in a WebExpressionAuthorizationManager, or a stock manager call.
133
+ _SPEL_ARG_RE = re.compile(r'(?:new\s+WebExpressionAuthorizationManager\s*\(\s*)?"((?:[^"\\\n]|\\.)*)"\s*\)?')
134
+ _MANAGER_ARG_RE = re.compile(
135
+ r"(?:[A-Za-z_][\w.]*\.)?(?:authenticated|fullyAuthenticated|rememberMe|hasRole|hasAnyRole|"
136
+ r"hasAuthority|hasAnyAuthority)\s*\([^()]*\)"
137
+ )
138
+ _AND_CALL_RE = re.compile(r"\.\s*and\s*\(\s*\)")
139
+ _IDENT_RE = re.compile(r"[A-Za-z_]\w*")
140
+ _TRAILING_IDENT_RE = re.compile(r"([A-Za-z_]\w*)\s*\Z")
141
+ _NEWLINE_RE = re.compile(r"\n")
142
+ _CLASS_HEADER_RE = re.compile(r"(?<![\w.$])(?:class|interface|object|enum)\b")
143
+
144
+ # Spring Boot's StaticResourceLocation patterns.
145
+ STATIC_RESOURCE_PATTERNS = ("/css/**", "/js/**", "/images/**", "/webjars/**", "/favicon.*", "/*/icon-*")
146
+
147
+ _ANON_DECISIONS = frozenset({"permitAll", "anonymous"})
148
+ _REQUIRED_DECISIONS = frozenset(
149
+ {
150
+ "authenticated", "fullyAuthenticated", "rememberMe", "hasRole", "hasAnyRole",
151
+ "hasAuthority", "hasAnyAuthority", "denyAll",
152
+ }
153
+ )
154
+
155
+
156
+ # ---------- Model ----------
157
+
158
+
159
+ class Entry(NamedTuple):
160
+ method: str | None # None: any method
161
+ pattern: str | None # None: any path
162
+ # True when an exact pattern also covers the trailing-slash form of a path
163
+ # (`/admin` matching `/admin/`): MVC-mirroring matchers (`mvcMatchers`,
164
+ # `mvc.pattern`) and WebFlux `pathMatchers`. Ant matchers (`antMatchers`,
165
+ # and `requestMatchers` / the Kotlin DSL, which fall back to Ant) don't.
166
+ slash_safe: bool = False
167
+
168
+
169
+ class Budget:
170
+ """A per-repo allowance of matching work (see MAX_MATCH_COST)."""
171
+
172
+ def __init__(self, cost: int = MAX_MATCH_COST) -> None:
173
+ self.remaining = cost
174
+
175
+ @property
176
+ def exhausted(self) -> bool:
177
+ return self.remaining < 0
178
+
179
+ def spend(self, amount: int = 1) -> None:
180
+ self.remaining -= amount
181
+ if self.remaining < 0:
182
+ raise BudgetExceeded
183
+
184
+
185
+ class BudgetExceeded(Exception):
186
+ pass
187
+
188
+
189
+ @dataclass
190
+ class Matcher:
191
+ entries: list[Entry] = field(default_factory=list)
192
+ # Part of the matcher couldn't be read (a constant, regexMatchers, a custom
193
+ # RequestMatcher): it may match any route.
194
+ opaque: bool = False
195
+
196
+ def match(self, path: str, method: str, *, trailing: bool = False, budget: Budget | None = None) -> str:
197
+ """How this matcher covers requests to a route.
198
+
199
+ `full`: every request; `none`: none; `method`: the requests of one
200
+ method of a method-less route; `value`: some values of the route's
201
+ path variables (`/a/me` against `/a/{id}`); `opaque`: unknown, since
202
+ part of the matcher couldn't be read.
203
+ """
204
+ kinds: set[str] = set()
205
+ for entry_method, pattern, slash_safe in self.entries:
206
+ if budget is not None:
207
+ budget.spend(ENTRY_COST)
208
+ path_result = (
209
+ FULL
210
+ if pattern is None
211
+ else match_path(pattern, path, trailing=trailing, slash_safe=slash_safe, budget=budget)
212
+ )
213
+ if path_result == NONE:
214
+ continue
215
+ method_subset = entry_method is not None and entry_method != method
216
+ if method_subset and method != "ANY":
217
+ continue
218
+ if path_result == FULL and not method_subset:
219
+ return MATCH_FULL
220
+ kinds.add(MATCH_VALUE if path_result == PARTIAL else MATCH_METHOD)
221
+ if self.opaque:
222
+ return MATCH_OPAQUE
223
+ if MATCH_VALUE in kinds:
224
+ return MATCH_VALUE
225
+ if MATCH_METHOD in kinds:
226
+ return MATCH_METHOD
227
+ return MATCH_NONE
228
+
229
+
230
+ @dataclass
231
+ class Rule:
232
+ matcher: Matcher
233
+ decision: str # ANONYMOUS / REQUIRED / UNKNOWN
234
+ guard: str # e.g. `hasAuthority("ADMIN")`
235
+ evidence: str # `file:line <source text>`
236
+
237
+
238
+ @dataclass
239
+ class SecurityChain:
240
+ file: str
241
+ rules: list[Rule]
242
+ # The chain's securityMatcher; None when the chain matches every request.
243
+ scope: Matcher | None = None
244
+
245
+
246
+ @dataclass
247
+ class FileSecurity:
248
+ chains: list[SecurityChain] = field(default_factory=list)
249
+ # `web.ignoring()` rules: these requests skip every chain.
250
+ ignored: list[Rule] = field(default_factory=list)
251
+
252
+
253
+ # ---------- Path matching ----------
254
+
255
+
256
+ def _segments(path: str) -> list[str] | None:
257
+ segs = [s for s in path.split("/") if s]
258
+ if len(segs) > MAX_SEGMENTS or any(len(s) > MAX_SEGMENT_CHARS for s in segs):
259
+ return None
260
+ return segs
261
+
262
+
263
+ def _is_multi(seg: str) -> bool:
264
+ """`**` or `{*rest}`: zero or more path segments."""
265
+ return seg == "**" or (seg.startswith("{*") and seg.endswith("}"))
266
+
267
+
268
+ def _is_variable(seg: str) -> bool:
269
+ """A route segment that stands for many values (`{id}`, `{id}.json`, `*`)."""
270
+ return "{" in seg or "*" in seg
271
+
272
+
273
+ def _glob_match(pattern: str, text: str, budget: Budget | None = None) -> bool:
274
+ """`*`/`?` wildcard match of one segment (`{var}` already turned into `*`).
275
+
276
+ Dynamic programming, O(len(pattern) * len(text)); no regex backtracking.
277
+ """
278
+ if budget is not None:
279
+ budget.spend(len(pattern) * (len(text) + 1))
280
+ prev = [False] * (len(text) + 1)
281
+ prev[0] = True
282
+ for p in pattern:
283
+ cur = [False] * (len(text) + 1)
284
+ if p == "*":
285
+ cur[0] = prev[0]
286
+ for j in range(1, len(text) + 1):
287
+ cur[j] = prev[j] or cur[j - 1]
288
+ else:
289
+ for j in range(1, len(text) + 1):
290
+ cur[j] = prev[j - 1] and (p == "?" or p == text[j - 1])
291
+ prev = cur
292
+ return prev[len(text)]
293
+
294
+
295
+ def _template_to_glob(seg: str) -> tuple[str, bool]:
296
+ """Replace `{var}` / `{var:regex}` with `*`. Returns (glob, had_regex)."""
297
+ out: list[str] = []
298
+ had_regex = False
299
+ i = 0
300
+ while i < len(seg):
301
+ if seg[i] == "{":
302
+ close = seg.find("}", i)
303
+ if close == -1:
304
+ out.append(seg[i:])
305
+ break
306
+ if ":" in seg[i:close]:
307
+ had_regex = True
308
+ out.append("*")
309
+ i = close + 1
310
+ else:
311
+ out.append(seg[i])
312
+ i += 1
313
+ return "".join(out), had_regex
314
+
315
+
316
+ def _match_segment(pattern_seg: str, route_seg: str, budget: Budget | None = None) -> int:
317
+ if pattern_seg == "*":
318
+ return FULL
319
+ if pattern_seg.startswith("{") and pattern_seg.endswith("}") and pattern_seg.count("{") == 1:
320
+ # `{var}` matches any one segment; `{var:regex}` only some of them.
321
+ return PARTIAL if ":" in pattern_seg else FULL
322
+ is_glob = any(c in pattern_seg for c in "*?{")
323
+ if _is_variable(route_seg):
324
+ if is_glob:
325
+ return PARTIAL
326
+ # Some values of the route variable equal the literal, unless the
327
+ # route segment's own literal parts rule it out (`*.lesson` vs `favicon.ico`).
328
+ route_glob, _ = _template_to_glob(route_seg)
329
+ return PARTIAL if _glob_match(route_glob, pattern_seg, budget) else NONE
330
+ if not is_glob:
331
+ return FULL if pattern_seg == route_seg else NONE
332
+ glob, had_regex = _template_to_glob(pattern_seg)
333
+ if not _glob_match(glob, route_seg, budget):
334
+ return NONE
335
+ return PARTIAL if had_regex else FULL
336
+
337
+
338
+ def match_path(
339
+ pattern: str, path: str, *, trailing: bool = False, slash_safe: bool = False, budget: Budget | None = None
340
+ ) -> int:
341
+ """Match an Ant/PathPattern ``pattern`` against a route path template.
342
+
343
+ FULL when every request to the route matches, PARTIAL when some might,
344
+ NONE when none can. `/api/**` matches `/api` too, as in Spring.
345
+
346
+ ``trailing`` asks about the trailing-slash form of the route's requests
347
+ (`/admin/` for `/admin`), which Spring MVC 5 (trailing-slash matching on by
348
+ default) routes to the same handler. An Ant pattern only matches it when the
349
+ pattern ends with `/` or `/**`; ``slash_safe`` matchers match it like the
350
+ plain path. A pattern that ends with `/` never matches a path without one.
351
+
352
+ A multi-segment route wildcard (`/files/**`, `/files/{*rest}`) stands for
353
+ any number of segments, so only a pattern `**` covers it fully; anything
354
+ narrower is at most PARTIAL.
355
+ """
356
+ if len(pattern) > MAX_PATTERN_CHARS:
357
+ return PARTIAL
358
+ psegs = _segments(pattern)
359
+ rsegs = _segments(path)
360
+ if (
361
+ psegs is None
362
+ or rsegs is None
363
+ or sum(_is_multi(s) for s in psegs) > MAX_DOUBLE_WILDCARDS
364
+ or any(sum(s.count(c) for c in "*?{") > MAX_SEGMENT_WILDCARDS for s in psegs)
365
+ ):
366
+ return PARTIAL
367
+ if budget is not None:
368
+ budget.spend((len(psegs) + 1) * (len(rsegs) + 1))
369
+ request_trailing = trailing or (len(path) > 1 and path.endswith("/"))
370
+ pattern_trailing = len(pattern) > 1 and pattern.endswith("/")
371
+ if pattern_trailing and not request_trailing:
372
+ return NONE
373
+ if request_trailing and not pattern_trailing and not slash_safe and not (psegs and _is_multi(psegs[-1])):
374
+ return NONE
375
+ memo: dict[tuple[int, int], int] = {}
376
+
377
+ def walk(i: int, j: int) -> int:
378
+ key = (i, j)
379
+ if key in memo:
380
+ return memo[key]
381
+ if i == len(psegs):
382
+ if j == len(rsegs):
383
+ result = FULL
384
+ else:
385
+ # Leftover route wildcards can expand to zero segments.
386
+ result = PARTIAL if all(_is_multi(r) for r in rsegs[j:]) else NONE
387
+ else:
388
+ p = psegs[i]
389
+ if _is_multi(p):
390
+ result = walk(i + 1, j)
391
+ k = j
392
+ while result != FULL and k < len(rsegs):
393
+ result = max(result, walk(i + 1, k + 1))
394
+ k += 1
395
+ elif j == len(rsegs):
396
+ result = NONE
397
+ elif _is_multi(rsegs[j]):
398
+ # A narrower pattern covers only some expansions of `**`.
399
+ result = PARTIAL
400
+ else:
401
+ seg = _match_segment(p, rsegs[j], budget)
402
+ result = NONE if seg == NONE else min(seg, walk(i + 1, j + 1))
403
+ memo[key] = result
404
+ return result
405
+
406
+ return walk(0, 0)
407
+
408
+
409
+ # ---------- Argument readers ----------
410
+
411
+
412
+ # The repo's string constants while a file's rules are parsed (#11).
413
+ _CONSTANTS: ContextVar[ConstantTable] = ContextVar("java_spring_constants", default=EMPTY)
414
+
415
+
416
+ def _string_literal(text: str) -> str | None:
417
+ """The string an argument evaluates to: a literal, or literals and same-repo
418
+ `Class.CONSTANT`s joined by `+`. None when it can't be read (a Kotlin
419
+ `$template`, an unknown or ambiguous constant, a call)."""
420
+ return resolve_expression(text, _CONSTANTS.get())
421
+
422
+
423
+ def read_matcher_args(args: str, *, slash_safe: bool = False, depth: int = 0) -> Matcher:
424
+ """Read `requestMatchers(...)`-style arguments into a Matcher.
425
+
426
+ Arguments are an optional `HttpMethod.X` followed by path patterns or
427
+ matcher objects. Anything unreadable makes the matcher opaque, and so does
428
+ a pattern without a leading `/`: Ant never matches it, other matchers may.
429
+ """
430
+ matcher = Matcher()
431
+ method: str | None = None
432
+ patterns: list[str] = []
433
+ parts = split_top_level(args)
434
+ for part in parts[:MAX_ENTRIES_PER_MATCHER]:
435
+ literal = _string_literal(part)
436
+ if literal is not None:
437
+ if literal.startswith("/"):
438
+ patterns.append(literal)
439
+ else:
440
+ matcher.opaque = True
441
+ continue
442
+ m = _HTTP_METHOD_ARG_RE.fullmatch(part)
443
+ if m:
444
+ if m.group(1) in HTTP_METHODS:
445
+ method = m.group(1)
446
+ else:
447
+ matcher.opaque = True
448
+ continue
449
+ if _STATIC_RESOURCES_RE.fullmatch(part):
450
+ matcher.entries.extend(Entry(None, p, slash_safe) for p in STATIC_RESOURCE_PATTERNS)
451
+ continue
452
+ if _ANY_REQUEST_ARG_RE.fullmatch(part):
453
+ matcher.entries.append(Entry(None, None, True))
454
+ continue
455
+ wrapper = _WRAPPER_RE.match(part)
456
+ if wrapper and depth < 2:
457
+ close = bracket_end(part, wrapper.end() - 1)
458
+ if close == len(part):
459
+ inner = _read_wrapper(wrapper.group(1), part[wrapper.end() : close - 1], depth)
460
+ matcher.entries.extend(inner.entries)
461
+ matcher.opaque = matcher.opaque or inner.opaque
462
+ continue
463
+ matcher.opaque = True
464
+ if len(parts) > MAX_ENTRIES_PER_MATCHER:
465
+ matcher.opaque = True
466
+ if patterns:
467
+ matcher.entries.extend(Entry(method, p, slash_safe) for p in patterns)
468
+ elif method is not None and not matcher.entries and not matcher.opaque:
469
+ # `antMatchers(HttpMethod.OPTIONS)`: every path for that method.
470
+ matcher.entries.append(Entry(method, None, True))
471
+ elif method is not None:
472
+ matcher.entries = [Entry(method, e.pattern, e.slash_safe) for e in matcher.entries]
473
+ if not parts:
474
+ matcher.opaque = True
475
+ return matcher
476
+
477
+
478
+ # Wrappers whose exact patterns also match the trailing-slash form of a path.
479
+ _SLASH_SAFE_WRAPPERS = frozenset(
480
+ {"pattern", "MvcRequestMatcher", "pathMatchers", "PathPatternParserServerWebExchangeMatcher"}
481
+ )
482
+
483
+
484
+ def _read_wrapper(name: str, args: str, depth: int) -> Matcher:
485
+ parts = split_top_level(args)
486
+ if name == "MvcRequestMatcher":
487
+ # new MvcRequestMatcher(introspector, "/x")
488
+ parts = parts[1:]
489
+ if name == "AntPathRequestMatcher" and len(parts) >= 2:
490
+ # new AntPathRequestMatcher("/x", "POST"[, caseSensitive])
491
+ pattern = _string_literal(parts[0])
492
+ method = _string_literal(parts[1])
493
+ if pattern is not None and pattern.startswith("/") and method is not None and method.upper() in HTTP_METHODS:
494
+ return Matcher(entries=[Entry(method.upper(), pattern, False)])
495
+ return Matcher(opaque=True)
496
+ return read_matcher_args(", ".join(parts), slash_safe=name in _SLASH_SAFE_WRAPPERS, depth=depth + 1)
497
+
498
+
499
+ _ROLE_DECISIONS = frozenset({"hasRole", "hasAnyRole", "hasAuthority", "hasAnyAuthority"})
500
+
501
+
502
+ def literal_args(args: str) -> list[str] | None:
503
+ """The string-literal arguments of a call, or None if any argument isn't one
504
+ (a constant such as `AuthoritiesConstants.ADMIN`, a concatenation)."""
505
+ parts = split_top_level(args)
506
+ values = [_string_literal(part) for part in parts]
507
+ if not parts or any(v is None for v in values):
508
+ return None
509
+ return [v for v in values if v is not None]
510
+
511
+
512
+ def _role_decision(args: str | None) -> str:
513
+ """`hasRole(..)`-style arguments: literal roles only; anonymous ones let anyone in."""
514
+ roles = literal_args(args or "")
515
+ if roles is None:
516
+ return UNKNOWN
517
+ return ANONYMOUS if any(ANONYMOUS_AUTHORITY_RE.search(r) for r in roles) else REQUIRED
518
+
519
+
520
+ def _decision(name: str, args: str | None) -> str:
521
+ if name in _ANON_DECISIONS:
522
+ return ANONYMOUS
523
+ if name in _ROLE_DECISIONS:
524
+ return _role_decision(args)
525
+ if name in _REQUIRED_DECISIONS:
526
+ return REQUIRED
527
+ if name == "access" and args is not None:
528
+ args = args.strip()
529
+ spel = _SPEL_ARG_RE.fullmatch(args)
530
+ if spel is not None:
531
+ return spel_decision(spel.group(1))
532
+ expression = _string_literal(args)
533
+ if expression is not None:
534
+ return spel_decision(expression)
535
+ manager = _MANAGER_ARG_RE.fullmatch(args)
536
+ if manager is not None:
537
+ inner = args[args.index("(") + 1 : args.rindex(")")]
538
+ return _role_decision(inner) if inner.strip() else REQUIRED
539
+ return UNKNOWN
540
+
541
+
542
+ def spel_decision(expr: str) -> str:
543
+ """Decision of a Spring Security SpEL expression (`access("...")`, `@PreAuthorize`).
544
+
545
+ Only plain terms are read (see `_SPEL_TERM_RE`), joined by `and` / `or`.
546
+ A branch is REQUIRED when one of its `and` terms requires an authenticated,
547
+ non-anonymous caller; ANONYMOUS when every term only permits (`permitAll`,
548
+ `isAnonymous()`, an ANONYMOUS authority). The expression is REQUIRED only when
549
+ every `or` branch is; a single ANONYMOUS branch is ANONYMOUS; anything else,
550
+ including any operator or argument we can't read, is UNKNOWN.
551
+ """
552
+ if len(expr) > MAX_CALL_CHARS:
553
+ return UNKNOWN
554
+ results = []
555
+ for branch in _SPEL_OR_RE.split(expr):
556
+ kinds = set()
557
+ for term in _SPEL_AND_RE.split(branch):
558
+ m = _SPEL_TERM_RE.fullmatch(term)
559
+ if m is None:
560
+ return UNKNOWN
561
+ name, args = m.group(1), m.group(2)
562
+ if name in _SPEL_PERMIT_NAMES:
563
+ kinds.add(ANONYMOUS)
564
+ elif name in _SPEL_ROLE_NAMES and args:
565
+ kinds.add(ANONYMOUS if ANONYMOUS_AUTHORITY_RE.search(args) else REQUIRED)
566
+ elif name in _SPEL_REQUIRED_NAMES:
567
+ kinds.add(REQUIRED)
568
+ else:
569
+ kinds.add(UNKNOWN)
570
+ if REQUIRED in kinds:
571
+ results.append(REQUIRED)
572
+ elif kinds == {ANONYMOUS}:
573
+ results.append(ANONYMOUS)
574
+ else:
575
+ results.append(UNKNOWN)
576
+ if all(r == REQUIRED for r in results):
577
+ return REQUIRED
578
+ if len(results) == 1:
579
+ return results[0]
580
+ return UNKNOWN
581
+
582
+
583
+ def _kotlin_decision(expr: str) -> tuple[str, str]:
584
+ """Decision of a Kotlin DSL `authorize(..., <expr>)` last argument."""
585
+ expr = expr.strip()
586
+ m = _IDENT_RE.match(expr)
587
+ if m is None:
588
+ return UNKNOWN, expr
589
+ name = m.group(0)
590
+ rest = expr[m.end():].strip()
591
+ if rest and not (rest.startswith("(") and rest.endswith(")")):
592
+ return UNKNOWN, expr
593
+ return _decision(name, rest[1:-1] if rest.startswith("(") else None), expr
594
+
595
+
596
+ def _clip(text: str) -> str:
597
+ text = " ".join(text.split())
598
+ return text if len(text) <= _EVIDENCE_CHARS else text[: _EVIDENCE_CHARS - 1] + "…"
599
+
600
+
601
+ # ---------- Parsing ----------
602
+
603
+
604
+ def _skip_ws(text: str, i: int) -> int:
605
+ n = len(text)
606
+ while i < n and text[i].isspace():
607
+ i += 1
608
+ return i
609
+
610
+
611
+ def _chain_end(masked: str, start: int, brackets: BracketIndex) -> int:
612
+ """End of a pre-lambda `.authorizeRequests()` chain: `.and()`, `;`, or the
613
+ bracket that closes the enclosing expression."""
614
+ limit = min(len(masked), start + MAX_BODY_CHARS)
615
+ i = start
616
+ while i < limit:
617
+ c = masked[i]
618
+ if c in "\"'":
619
+ close = _skip_quoted(masked, i)
620
+ i = close
621
+ continue
622
+ if c in "({[":
623
+ close = brackets.close(i)
624
+ if close is None:
625
+ return limit
626
+ i = close
627
+ continue
628
+ if c in ")}];":
629
+ return i
630
+ if c == "." and _AND_CALL_RE.match(masked, i, min(limit, i + 40)):
631
+ return i
632
+ i += 1
633
+ return limit
634
+
635
+
636
+ def _skip_quoted(text: str, i: int) -> int:
637
+ quote = text[i]
638
+ j = i + 1
639
+ while j < len(text):
640
+ if text[j] == "\\":
641
+ j += 2
642
+ continue
643
+ if text[j] == quote or text[j] == "\n":
644
+ return j + 1
645
+ j += 1
646
+ return len(text)
647
+
648
+
649
+ def _authorize_body(masked: str, name_end: int, brackets: BracketIndex) -> tuple[int, int, int] | None:
650
+ """(body_start, body_end, call_end) of the authorize call ending its name at ``name_end``."""
651
+ i = _skip_ws(masked, name_end)
652
+ if i >= len(masked):
653
+ return None
654
+ if masked[i] == "{":
655
+ close = brackets.close(i)
656
+ if close is None:
657
+ return None
658
+ return i + 1, close - 1, close
659
+ if masked[i] != "(":
660
+ return None
661
+ close = brackets.close(i)
662
+ if close is None:
663
+ return None
664
+ if masked[i + 1 : close - 1].strip():
665
+ return i + 1, close - 1, close
666
+ # Pre-lambda chain: `.authorizeRequests().antMatchers(...)...and()`; or a
667
+ # Kotlin trailing lambda after empty parentheses.
668
+ j = _skip_ws(masked, close)
669
+ if j < len(masked) and masked[j] == "{":
670
+ brace = brackets.close(j)
671
+ if brace is None:
672
+ return None
673
+ return j + 1, brace - 1, brace
674
+ end = _chain_end(masked, close, brackets)
675
+ return close, end, end
676
+
677
+
678
+ class _Lines:
679
+ """1-based line numbers by offset, from one pass over the file."""
680
+
681
+ def __init__(self, text: str) -> None:
682
+ self._newlines = [m.start() for m in _NEWLINE_RE.finditer(text)]
683
+
684
+ def __call__(self, offset: int) -> int:
685
+ return bisect_right(self._newlines, offset - 1) + 1
686
+
687
+
688
+ def _parse_rules(
689
+ masked: str,
690
+ start: int,
691
+ end: int,
692
+ file: str,
693
+ rules: list[Rule],
694
+ line_at: _Lines,
695
+ budget: list[int],
696
+ brackets: BracketIndex,
697
+ ) -> None:
698
+ """Append the rules found in ``masked[start:end]`` in source order.
699
+
700
+ ``budget`` is the file's remaining rule allowance (shared across chains)."""
701
+ pos = start
702
+ while pos < end and len(rules) < MAX_RULES_PER_CHAIN and budget[0] > 0:
703
+ m = _RULE_CALL_RE.search(masked, pos, end)
704
+ if m is None:
705
+ return
706
+ # Every matcher call costs budget, not just the ones that become rules.
707
+ budget[0] -= 1
708
+ name = m.group(1)
709
+ open_at = m.end() - 1
710
+ close = brackets.close(open_at)
711
+ if close is None or close > end or close - open_at > MAX_CALL_CHARS:
712
+ return
713
+ args = masked[open_at + 1 : close - 1]
714
+ if name == "authorize":
715
+ _kotlin_rule(masked, m.start(), close, args, file, rules, line_at)
716
+ pos = close
717
+ continue
718
+ if name == "dispatcherTypeMatchers":
719
+ # Forward/error dispatches, not requests to a route.
720
+ pos = close
721
+ continue
722
+ if name in ("anyRequest", "anyExchange"):
723
+ matcher = Matcher(entries=[Entry(None, None, True)])
724
+ elif name == "regexMatchers":
725
+ matcher = Matcher(opaque=True)
726
+ else:
727
+ matcher = read_matcher_args(args, slash_safe=name in ("mvcMatchers", "pathMatchers"))
728
+ decision_call = _CHAINED_CALL_RE.match(masked, close, end)
729
+ if decision_call is None:
730
+ pos = close
731
+ continue
732
+ d_open = decision_call.end() - 1
733
+ d_close = brackets.close(d_open)
734
+ if d_close is None or d_close > end or d_close - d_open > MAX_CALL_CHARS:
735
+ return
736
+ d_name = decision_call.group(1)
737
+ d_args = masked[d_open + 1 : d_close - 1]
738
+ if d_name in _MATCHER_NAMES:
739
+ # `antMatchers("/a").antMatchers(...)` isn't a rule.
740
+ pos = close
741
+ continue
742
+ decision = _decision(d_name, d_args)
743
+ guard = _clip(f"{d_name}({d_args.strip()})")
744
+ evidence = _clip(f"{file}:{line_at(m.start())} {masked[m.start():d_close]}")
745
+ rules.append(Rule(matcher, decision, guard, evidence))
746
+ pos = d_close
747
+
748
+
749
+ def _kotlin_rule(
750
+ masked: str, start: int, close: int, args: str, file: str, rules: list[Rule], line_at: _Lines
751
+ ) -> None:
752
+ parts = split_top_level(args)
753
+ if len(parts) < 2:
754
+ return
755
+ decision, guard = _kotlin_decision(parts[-1])
756
+ matcher = read_matcher_args(", ".join(parts[:-1]))
757
+ evidence = _clip(f"{file}:{line_at(start)} {masked[start:close]}")
758
+ rules.append(Rule(matcher, decision, _clip(guard), evidence))
759
+
760
+
761
+ class _BraceIndex:
762
+ """Every `{...}` pair of a file from one linear pass, with parent links.
763
+
764
+ Used to find the method body (the "unit") an `authorize*` call sits in:
765
+ the outermost block below a class body, or the outermost block when there
766
+ is no class (Kotlin top-level functions and `beans {}`).
767
+ """
768
+
769
+ def __init__(self, masked: str, brackets: BracketIndex) -> None:
770
+ self.masked = masked
771
+ self.starts = brackets.brace_starts
772
+ self.ends = brackets.brace_ends
773
+ self.parents = brackets.brace_parents
774
+ self._class_body: dict[int, bool] = {}
775
+
776
+ def _is_class_body(self, idx: int) -> bool:
777
+ if idx not in self._class_body:
778
+ start = self.starts[idx]
779
+ header_start = max(0, start - 200)
780
+ header = self.masked[header_start:start]
781
+ cut = max(header.rfind(";"), header.rfind("{"), header.rfind("}"))
782
+ self._class_body[idx] = _CLASS_HEADER_RE.search(header, cut + 1) is not None
783
+ return self._class_body[idx]
784
+
785
+ def unit(self, offset: int) -> tuple[int, int]:
786
+ idx = bisect_right(self.starts, offset) - 1
787
+ while idx >= 0 and self.ends[idx] <= offset:
788
+ idx = self.parents[idx]
789
+ if idx < 0:
790
+ return 0, len(self.masked)
791
+ while self.parents[idx] != -1 and not self._is_class_body(self.parents[idx]):
792
+ idx = self.parents[idx]
793
+ return self.starts[idx], self.ends[idx]
794
+
795
+
796
+ def _scope_matcher(
797
+ masked: str, start: int, end: int, excluded: list[tuple[int, int]], brackets: BracketIndex
798
+ ) -> Matcher | None:
799
+ """A chain's securityMatcher scope inside ``masked[start:end]``, or None."""
800
+ scope: Matcher | None = None
801
+ pos = start
802
+ for _ in range(MAX_SCOPE_CALLS):
803
+ m = _SCOPE_CALL_RE.search(masked, pos, end)
804
+ if m is None:
805
+ break
806
+ if any(a <= m.start() < b for a, b in excluded) or _static_call(masked, m):
807
+ pos = m.end()
808
+ continue
809
+ open_at = m.end() - 1
810
+ close = brackets.close(open_at)
811
+ if close is None or close > end or close - open_at > MAX_CALL_CHARS:
812
+ break
813
+ args = masked[open_at + 1 : close - 1]
814
+ if scope is None:
815
+ scope = Matcher()
816
+ name = m.group(1) or m.group(2)
817
+ if name == "regexMatcher":
818
+ scope.opaque = True
819
+ elif not args.strip():
820
+ # `http.requestMatchers().antMatchers("/api/**")...`
821
+ chained = _read_chained_matchers(masked, close, end, brackets)
822
+ scope.entries.extend(chained.entries)
823
+ scope.opaque = scope.opaque or chained.opaque or not chained.entries
824
+ elif "->" in args or "{" in args:
825
+ # `securityMatchers(m -> m.requestMatchers("/api/**"))`: literals only.
826
+ literals = _STRING_LITERAL_RE.findall(args)
827
+ usable = [v for v in literals if v.startswith("/") and "$" not in v]
828
+ scope.entries.extend(Entry(None, p, False) for p in usable[:MAX_ENTRIES_PER_MATCHER])
829
+ # A method (`HttpMethod.POST`) narrows the scope in a way we don't read.
830
+ scope.opaque = (
831
+ scope.opaque or not usable or len(usable) != len(literals) or "HttpMethod" in args
832
+ )
833
+ else:
834
+ inner = read_matcher_args(args)
835
+ scope.entries.extend(inner.entries)
836
+ scope.opaque = scope.opaque or inner.opaque
837
+ pos = close
838
+ return scope
839
+
840
+
841
+ def _static_call(masked: str, m: re.Match[str]) -> bool:
842
+ """`AntPathRequestMatcher.antMatcher("/x")` builds a matcher for something
843
+ else (CSRF, an entry point); only `http.antMatcher(...)` scopes the chain."""
844
+ if m.group(2) is None:
845
+ return False
846
+ before = _TRAILING_IDENT_RE.search(masked, max(0, m.start() - 80), m.start())
847
+ return before is not None and before.group(1)[0].isupper()
848
+
849
+
850
+ def _read_chained_matchers(masked: str, pos: int, end: int, brackets: BracketIndex) -> Matcher:
851
+ """`.antMatchers("/a").mvcMatchers("/b")...` calls chained at ``pos``."""
852
+ matcher = Matcher()
853
+ for _ in range(MAX_ENTRIES_PER_MATCHER):
854
+ call = _CHAINED_CALL_RE.match(masked, pos, end)
855
+ if call is None or call.group(1) not in _MATCHER_NAMES:
856
+ break
857
+ open_at = call.end() - 1
858
+ close = brackets.close(open_at)
859
+ if close is None or close > end or close - open_at > MAX_CALL_CHARS:
860
+ break
861
+ if call.group(1) == "regexMatchers":
862
+ matcher.opaque = True
863
+ else:
864
+ inner = read_matcher_args(
865
+ masked[open_at + 1 : close - 1], slash_safe=call.group(1) in ("mvcMatchers", "pathMatchers")
866
+ )
867
+ matcher.entries.extend(inner.entries)
868
+ matcher.opaque = matcher.opaque or inner.opaque
869
+ pos = close
870
+ return matcher
871
+
872
+
873
+ def parse_security(masked: str, file: str, constants: ConstantTable | None = None) -> FileSecurity:
874
+ """Every security chain and `web.ignoring()` rule in one comment-masked file.
875
+
876
+ ``constants`` resolves class-qualified string constants in arguments."""
877
+ token = _CONSTANTS.set(constants or EMPTY)
878
+ try:
879
+ return _parse_security(masked, file)
880
+ finally:
881
+ _CONSTANTS.reset(token)
882
+
883
+
884
+ def _parse_security(masked: str, file: str) -> FileSecurity:
885
+ found = FileSecurity()
886
+ if not SECURITY_GATE_RE.search(masked):
887
+ return found
888
+ brackets = BracketIndex(masked)
889
+ braces = _BraceIndex(masked, brackets)
890
+ line_at = _Lines(masked)
891
+ budget = [MAX_RULES_PER_FILE]
892
+ calls = 0
893
+ units: dict[tuple[int, int], tuple[list[Rule], list[tuple[int, int]]]] = {}
894
+ for m in _AUTHORIZE_RE.finditer(masked):
895
+ calls += 1
896
+ if len(units) >= MAX_CHAINS or calls > MAX_AUTHORIZE_CALLS:
897
+ break
898
+ body = _authorize_body(masked, m.end(), brackets)
899
+ if body is None:
900
+ continue
901
+ body_start, body_end, call_end = body
902
+ unit = braces.unit(m.start())
903
+ rules, spans = units.setdefault(unit, ([], []))
904
+ spans.append((m.start(), call_end))
905
+ _parse_rules(masked, body_start, body_end, file, rules, line_at, budget, brackets)
906
+ for (unit_start, unit_end), (rules, spans) in units.items():
907
+ scope = _scope_matcher(masked, unit_start, unit_end, spans, brackets)
908
+ found.chains.append(SecurityChain(file=file, rules=rules, scope=scope))
909
+
910
+ for count, m in enumerate(_IGNORING_RE.finditer(masked)):
911
+ if count >= MAX_IGNORING_CALLS:
912
+ break
913
+ call = _CHAINED_CALL_RE.match(masked, m.end())
914
+ if call is None or call.group(1) not in _MATCHER_NAMES:
915
+ continue
916
+ matcher = _read_chained_matchers(masked, m.end(), min(len(masked), m.end() + MAX_BODY_CHARS), brackets)
917
+ if not matcher.entries and not matcher.opaque:
918
+ continue
919
+ evidence = _clip(f"{file}:{line_at(m.start())} web.{masked[m.start():m.end()]}…")
920
+ found.ignored.append(Rule(matcher, ANONYMOUS, "web.ignoring()", evidence))
921
+ if len(found.ignored) >= MAX_RULES_PER_CHAIN:
922
+ break
923
+ return found
924
+
925
+
926
+ # ---------- Resolution ----------
927
+
928
+
929
+ @dataclass
930
+ class Resolution:
931
+ decision: str # ANONYMOUS / REQUIRED
932
+ guards: list[str]
933
+ evidence: str
934
+
935
+
936
+ def evaluate_rules(
937
+ rules: list[Rule], path: str, method: str, *, trailing: bool = False, budget: Budget | None = None
938
+ ) -> Rule | None | str:
939
+ """First-match evaluation of ``rules`` for one route.
940
+
941
+ Returns the deciding Rule, None (no rule can match), or UNKNOWN. A rule
942
+ that only *might* match changes the outcome as follows:
943
+
944
+ - `opaque` (unreadable matcher): the first full match decides only if the
945
+ opaque rules before it agree with it;
946
+ - `value` (a literal such as `/articles/feed` against `/articles/{slug}`):
947
+ those requests may even go to another handler, so the generic remainder
948
+ decides when it is anonymous; a `required` remainder needs every such
949
+ rule to be `required` too;
950
+ - `method` (`POST /users` against a method-less `/users` mapping): an
951
+ anonymous one makes the handler reachable without auth, so it decides
952
+ unless an earlier possible match could have caught those requests first.
953
+ """
954
+ opaque: list[Rule] = []
955
+ value: list[Rule] = []
956
+ method_rules: list[Rule] = []
957
+ reachable_by_method: Rule | None = None
958
+ for rule in rules:
959
+ if budget is not None:
960
+ budget.spend()
961
+ kind = rule.matcher.match(path, method, trailing=trailing, budget=budget)
962
+ if kind == MATCH_NONE:
963
+ continue
964
+ if kind == MATCH_FULL:
965
+ if rule.decision == UNKNOWN or any(r.decision != rule.decision for r in opaque):
966
+ return UNKNOWN
967
+ if rule.decision == ANONYMOUS:
968
+ return rule
969
+ if reachable_by_method is not None:
970
+ return reachable_by_method
971
+ if any(r.decision != REQUIRED for r in (*value, *method_rules)):
972
+ return UNKNOWN
973
+ return rule
974
+ if kind == MATCH_OPAQUE:
975
+ opaque.append(rule)
976
+ elif kind == MATCH_VALUE:
977
+ value.append(rule)
978
+ else:
979
+ if (
980
+ rule.decision == ANONYMOUS
981
+ and reachable_by_method is None
982
+ and all(r.decision == ANONYMOUS for r in (*opaque, *method_rules))
983
+ ):
984
+ reachable_by_method = rule
985
+ method_rules.append(rule)
986
+ if reachable_by_method is not None:
987
+ return reachable_by_method
988
+ return UNKNOWN if (opaque or value or method_rules) else None
989
+
990
+
991
+ def _decide(
992
+ chains: list[SecurityChain], ignored: list[Rule], path: str, method: str, trailing: bool, budget: Budget | None
993
+ ) -> Resolution | None:
994
+ """The decision every chain in ``chains`` agrees on for one request shape."""
995
+ decided: list[Rule] = []
996
+ for chain in chains:
997
+ outcome = evaluate_rules([*ignored, *chain.rules], path, method, trailing=trailing, budget=budget)
998
+ if not isinstance(outcome, Rule):
999
+ return None
1000
+ decided.append(outcome)
1001
+ decisions = {rule.decision for rule in decided}
1002
+ if len(decisions) != 1:
1003
+ return None
1004
+ decision = decisions.pop()
1005
+ guards = list(dict.fromkeys(rule.guard for rule in decided)) if decision == REQUIRED else []
1006
+ return Resolution(decision, guards, decided[0].evidence)
1007
+
1008
+
1009
+ def resolve_route(
1010
+ chains: list[SecurityChain],
1011
+ ignored: list[Rule],
1012
+ path: str,
1013
+ method: str,
1014
+ route_dir: str,
1015
+ *,
1016
+ trailing_slash_reaches_handler: bool = True,
1017
+ budget: Budget | None = None,
1018
+ ) -> Resolution | None:
1019
+ """The auth state the filter chains give a route, or None when unknown.
1020
+
1021
+ A chain whose securityMatcher fully matches the route is preferred over
1022
+ catch-all chains (Spring requires the catch-all chain to come last). When
1023
+ several chains could apply, they must agree; chains whose config file sits
1024
+ in a parent package of the route are preferred (Spring Boot component scan),
1025
+ which separates two applications living in one module.
1026
+
1027
+ A `required` answer must also hold for the other requests that reach the
1028
+ same handler, or the route is unknown:
1029
+
1030
+ - the trailing-slash form (`POST /admin/`), when
1031
+ ``trailing_slash_reaches_handler`` (Spring MVC 5 / Boot 2 matches it by
1032
+ default; an Ant pattern `/admin` doesn't);
1033
+ - `HEAD` for a `GET` route (Spring MVC runs the GET handler for HEAD, and a
1034
+ `HttpMethod.GET` rule doesn't cover it).
1035
+
1036
+ Raises BudgetExceeded when ``budget`` runs out.
1037
+ """
1038
+ if not chains:
1039
+ return None
1040
+ resolution = _resolve_form(chains, ignored, path, method, False, route_dir, budget)
1041
+ if resolution is None or resolution.decision != REQUIRED:
1042
+ return resolution
1043
+ variants: list[tuple[str, bool]] = []
1044
+ segments = [s for s in path.split("/") if s]
1045
+ if trailing_slash_reaches_handler and not path.endswith("/") and not (segments and _is_multi(segments[-1])):
1046
+ variants.append((method, True))
1047
+ if method == "GET":
1048
+ variants.append(("HEAD", False))
1049
+ for variant_method, trailing in variants:
1050
+ # Each request form picks its chain afresh: a securityMatcher can match
1051
+ # `POST /x` but not `POST /x/` or `HEAD /x`.
1052
+ other = _resolve_form(chains, ignored, path, variant_method, trailing, route_dir, budget)
1053
+ if other is None or other.decision != REQUIRED:
1054
+ return None
1055
+ return resolution
1056
+
1057
+
1058
+ def _resolve_form(
1059
+ chains: list[SecurityChain],
1060
+ ignored: list[Rule],
1061
+ path: str,
1062
+ method: str,
1063
+ trailing: bool,
1064
+ route_dir: str,
1065
+ budget: Budget | None,
1066
+ ) -> Resolution | None:
1067
+ """Chain selection and decision for one request form of a route."""
1068
+ full: list[SecurityChain] = []
1069
+ partial: list[SecurityChain] = []
1070
+ catch_all: list[SecurityChain] = []
1071
+ for chain in chains:
1072
+ if chain.scope is None:
1073
+ catch_all.append(chain)
1074
+ continue
1075
+ result = chain.scope.match(path, method, trailing=trailing, budget=budget)
1076
+ if result == MATCH_FULL:
1077
+ full.append(chain)
1078
+ elif result != MATCH_NONE:
1079
+ partial.append(chain)
1080
+ considered = full + partial if full else partial + catch_all
1081
+ if not considered:
1082
+ return None
1083
+ if len(considered) > 1:
1084
+ nearby = [c for c in considered if _is_ancestor_dir(_dirname(c.file), route_dir)]
1085
+ if nearby and len(nearby) < len(considered) and not partial:
1086
+ considered = nearby
1087
+ return _decide(considered, ignored, path, method, trailing, budget)
1088
+
1089
+
1090
+ def _dirname(path: str) -> str:
1091
+ return path.rsplit("/", 1)[0] if "/" in path else ""
1092
+
1093
+
1094
+ def _is_ancestor_dir(ancestor: str, path_dir: str) -> bool:
1095
+ if not ancestor:
1096
+ return True
1097
+ return path_dir == ancestor or path_dir.startswith(ancestor + "/")
1098
+
1099
+
1100
+ __all__ = [
1101
+ "ANONYMOUS",
1102
+ "Budget",
1103
+ "BudgetExceeded",
1104
+ "Entry",
1105
+ "FileSecurity",
1106
+ "Matcher",
1107
+ "REQUIRED",
1108
+ "Resolution",
1109
+ "Rule",
1110
+ "SecurityChain",
1111
+ "UNKNOWN",
1112
+ "evaluate_rules",
1113
+ "literal_args",
1114
+ "match_path",
1115
+ "parse_security",
1116
+ "read_matcher_args",
1117
+ "resolve_route",
1118
+ "spel_decision",
1119
+ ]