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.
- attackmap_analyzer_java_spring/__init__.py +3 -0
- attackmap_analyzer_java_spring/analyzer.py +1187 -0
- attackmap_analyzer_java_spring/annotations.py +464 -0
- attackmap_analyzer_java_spring/constants.py +278 -0
- attackmap_analyzer_java_spring/functional.py +171 -0
- attackmap_analyzer_java_spring/security_dsl.py +1119 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/METADATA +118 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/RECORD +12 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|