attackmap-analyzer-php-web 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_php_web/__init__.py +4 -0
- attackmap_analyzer_php_web/analyzer.py +629 -0
- attackmap_analyzer_php_web/laravel_routes.py +694 -0
- attackmap_analyzer_php_web/route_auth.py +668 -0
- attackmap_analyzer_php_web/symfony_routes.py +428 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/METADATA +160 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/RECORD +11 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_php_web-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""Route-level auth resolution for the AttackMap#256 contract.
|
|
2
|
+
|
|
3
|
+
Each emitted ``Route`` carries ``auth`` (``required`` / ``anonymous`` /
|
|
4
|
+
``unknown``), the ``guards`` that apply and the ``guard_evidence`` source text
|
|
5
|
+
that established the state. Core trusts a declared state over its own regex
|
|
6
|
+
resolution, so this module only declares what the source verifiably says:
|
|
7
|
+
|
|
8
|
+
- **Laravel** — ``->middleware('auth')`` on the route,
|
|
9
|
+
``Route::middleware([...])->group(...)`` / ``Route::group(['middleware' =>
|
|
10
|
+
...], ...)`` around it, and a controller constructor's
|
|
11
|
+
``$this->middleware('auth')`` (honouring ``->only()`` / ``->except()``).
|
|
12
|
+
``->withoutMiddleware('auth')`` on the route or a group, and a controller
|
|
13
|
+
``->except([...])`` naming the action, are explicit opt-outs (anonymous).
|
|
14
|
+
- **Slim** — ``->add($authMiddleware)`` on the route or an enclosing
|
|
15
|
+
``->group(...)``.
|
|
16
|
+
- **Symfony** — ``#[IsGranted]`` / ``#[Security]`` on the action or its class,
|
|
17
|
+
and ``access_control`` rules in ``security.yaml`` with literal path prefixes.
|
|
18
|
+
``PUBLIC_ACCESS`` / ``IS_AUTHENTICATED_ANONYMOUSLY`` are explicitly public.
|
|
19
|
+
|
|
20
|
+
Anything else stays ``unknown``. Every pattern here is linear: brackets are
|
|
21
|
+
matched by a single forward scan and no regex nests quantifiers over
|
|
22
|
+
overlapping classes.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import bisect
|
|
28
|
+
import functools
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
REQUIRED = "required"
|
|
33
|
+
ANONYMOUS = "anonymous"
|
|
34
|
+
UNKNOWN = "unknown"
|
|
35
|
+
|
|
36
|
+
# Stands in for a path prefix that can't be read statically.
|
|
37
|
+
DYNAMIC_SEGMENT = "{prefix}"
|
|
38
|
+
|
|
39
|
+
_OPEN = "([{"
|
|
40
|
+
_CLOSE = ")]}"
|
|
41
|
+
_EVIDENCE_MAX = 200
|
|
42
|
+
# Guard names reported per route (the evidence is clipped too).
|
|
43
|
+
MAX_GUARDS = 16
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Resolution:
|
|
48
|
+
auth: str = UNKNOWN
|
|
49
|
+
guards: list[str] = field(default_factory=list)
|
|
50
|
+
evidence: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clip(text: str) -> str:
|
|
54
|
+
text = " ".join(text.split())
|
|
55
|
+
return text if len(text) <= _EVIDENCE_MAX else text[: _EVIDENCE_MAX - 1] + "…"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# One token per step: a string opener, a heredoc/nowdoc opener, a comment
|
|
59
|
+
# opener or a bracket.
|
|
60
|
+
_SCAN_TOKEN = re.compile(r"['\"]|<<<|//|/\*|#(?!\[)|[()\[\]{}]")
|
|
61
|
+
_HEREDOC_OPEN = re.compile(r"[ \t]*(['\"]?)([A-Za-z_]\w*)\1\r?\n")
|
|
62
|
+
_STRING_BODY = {
|
|
63
|
+
"'": re.compile(r"[^'\\]*(?:\\.[^'\\]*)*'", re.DOTALL),
|
|
64
|
+
'"': re.compile(r'[^"\\]*(?:\\.[^"\\]*)*"', re.DOTALL),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@functools.lru_cache(maxsize=4)
|
|
69
|
+
def bracket_pairs(content: str) -> dict[int, int]:
|
|
70
|
+
"""Map each opening bracket's offset to its closing bracket's offset.
|
|
71
|
+
|
|
72
|
+
One forward pass over the file (strings, heredocs/nowdocs and ``//`` /
|
|
73
|
+
``#`` / ``/* */`` comments skipped; ``#[`` is an attribute), so every
|
|
74
|
+
later lookup is O(1)
|
|
75
|
+
and a file full of unclosed brackets stays linear.
|
|
76
|
+
"""
|
|
77
|
+
pairs: dict[int, int] = {}
|
|
78
|
+
stack: list[int] = []
|
|
79
|
+
pos = 0
|
|
80
|
+
n = len(content)
|
|
81
|
+
while pos < n:
|
|
82
|
+
match = _SCAN_TOKEN.search(content, pos)
|
|
83
|
+
if not match:
|
|
84
|
+
break
|
|
85
|
+
token = match.group()
|
|
86
|
+
pos = match.end()
|
|
87
|
+
if token in _STRING_BODY:
|
|
88
|
+
body = _STRING_BODY[token].match(content, pos)
|
|
89
|
+
pos = body.end() if body else n
|
|
90
|
+
elif token == "<<<":
|
|
91
|
+
# Heredoc / nowdoc: skip to the closing identifier, which starts
|
|
92
|
+
# its own line (PHP 7.3+ allows indenting it).
|
|
93
|
+
opener = _HEREDOC_OPEN.match(content, pos)
|
|
94
|
+
if opener:
|
|
95
|
+
pos = _heredoc_end(content, opener.end(), opener.group(2))
|
|
96
|
+
elif token == "//" or token == "#":
|
|
97
|
+
nl = content.find("\n", pos)
|
|
98
|
+
pos = n if nl < 0 else nl
|
|
99
|
+
elif token == "/*":
|
|
100
|
+
end = content.find("*/", pos)
|
|
101
|
+
pos = n if end < 0 else end + 2
|
|
102
|
+
elif token in _OPEN:
|
|
103
|
+
stack.append(match.start())
|
|
104
|
+
elif stack:
|
|
105
|
+
pairs[stack.pop()] = match.start()
|
|
106
|
+
return pairs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _heredoc_end(content: str, pos: int, label: str) -> int:
|
|
110
|
+
"""Offset just past the line-leading ``label`` that closes a heredoc
|
|
111
|
+
opened before ``pos``, or the end of the file. One forward scan."""
|
|
112
|
+
n = len(content)
|
|
113
|
+
while pos < n:
|
|
114
|
+
nl = content.find("\n", pos)
|
|
115
|
+
line_end = n if nl < 0 else nl
|
|
116
|
+
stripped = content[pos:line_end].lstrip(" \t")
|
|
117
|
+
after = stripped[len(label) : len(label) + 1]
|
|
118
|
+
if stripped.startswith(label) and not (after.isalnum() or after == "_"):
|
|
119
|
+
return line_end - len(stripped) + len(label)
|
|
120
|
+
pos = line_end + 1
|
|
121
|
+
return n
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def matching_close(content: str, open_idx: int) -> int:
|
|
125
|
+
"""Index of the bracket closing ``content[open_idx]``, or -1."""
|
|
126
|
+
if open_idx < 0:
|
|
127
|
+
return -1
|
|
128
|
+
return bracket_pairs(content).get(open_idx, -1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_CHAIN_LINK = re.compile(r"\s*->\s*(\w+)\s*\(")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Argument lists are only read this far. Middleware lists are short; a
|
|
135
|
+
# bound keeps nested registrations (a group's closure holding thousands of
|
|
136
|
+
# routes) from being rescanned once per enclosing call.
|
|
137
|
+
_ARG_SCAN = 2000
|
|
138
|
+
# Deeper group nesting than this is not resolved (the route stays unknown).
|
|
139
|
+
MAX_NESTING = 32
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class Link:
|
|
144
|
+
"""One ``name(args)`` call; offsets into ``content``, sliced on demand."""
|
|
145
|
+
|
|
146
|
+
name: str
|
|
147
|
+
content: str = field(repr=False)
|
|
148
|
+
start: int
|
|
149
|
+
open: int # offset of the `(`
|
|
150
|
+
close: int # offset of the matching `)`
|
|
151
|
+
|
|
152
|
+
def head(self, limit: int = _ARG_SCAN) -> str:
|
|
153
|
+
return self.content[self.open + 1 : min(self.close, self.open + 1 + limit)]
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def text(self) -> str:
|
|
157
|
+
return clip(self.content[self.start : min(self.close + 1, self.start + 2 * _EVIDENCE_MAX)])
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_TRIVIA = re.compile(r"\s+|//[^\n]*|#(?!\[)[^\n]*|/\*")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def skip_trivia(content: str, idx: int) -> int:
|
|
164
|
+
"""Offset of the first character at or after ``idx`` that isn't
|
|
165
|
+
whitespace or a comment."""
|
|
166
|
+
while True:
|
|
167
|
+
match = _TRIVIA.match(content, idx)
|
|
168
|
+
if not match:
|
|
169
|
+
return idx
|
|
170
|
+
if match.group() == "/*":
|
|
171
|
+
end = content.find("*/", match.end())
|
|
172
|
+
if end < 0:
|
|
173
|
+
return len(content)
|
|
174
|
+
idx = end + 2
|
|
175
|
+
else:
|
|
176
|
+
idx = match.end()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse_chain(content: str, idx: int) -> list[Link]:
|
|
180
|
+
"""Method calls chained after ``idx`` (``->name(args)->name(args)...``),
|
|
181
|
+
with comments allowed between the links."""
|
|
182
|
+
links: list[Link] = []
|
|
183
|
+
while True:
|
|
184
|
+
idx = skip_trivia(content, idx)
|
|
185
|
+
match = _CHAIN_LINK.match(content, idx)
|
|
186
|
+
if not match:
|
|
187
|
+
return links
|
|
188
|
+
open_idx = match.end() - 1
|
|
189
|
+
close = matching_close(content, open_idx)
|
|
190
|
+
if close < 0:
|
|
191
|
+
return links
|
|
192
|
+
links.append(Link(match.group(1), content, match.start(), open_idx, close))
|
|
193
|
+
idx = close + 1
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def enclosing(spans: list, offsets: list[int]) -> list[list | None]:
|
|
197
|
+
"""For each offset (ascending), the spans (``.start``/``.end``, properly
|
|
198
|
+
nested as bracket pairs are) that contain it, outermost first. None when
|
|
199
|
+
nesting exceeds MAX_NESTING. Each span is pushed and popped once."""
|
|
200
|
+
ordered = sorted(spans, key=lambda span: span.start)
|
|
201
|
+
out: list[list | None] = []
|
|
202
|
+
stack: list = []
|
|
203
|
+
i = 0
|
|
204
|
+
for offset in offsets:
|
|
205
|
+
while i < len(ordered) and ordered[i].start < offset:
|
|
206
|
+
span = ordered[i]
|
|
207
|
+
i += 1
|
|
208
|
+
while stack and stack[-1].end < span.start:
|
|
209
|
+
stack.pop()
|
|
210
|
+
stack.append(span)
|
|
211
|
+
while stack and stack[-1].end < offset:
|
|
212
|
+
stack.pop()
|
|
213
|
+
out.append(None if len(stack) > MAX_NESTING else list(stack))
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------- Laravel ----------
|
|
218
|
+
|
|
219
|
+
_STRING_OR_CLASS = re.compile(r"['\"]([^'\"]*)['\"]|\\?([\w\\]+)::class")
|
|
220
|
+
# Middleware aliases / classes that authenticate the caller. Authorization-only
|
|
221
|
+
# middleware (`can:`) is left out: a policy can admit guests.
|
|
222
|
+
_LARAVEL_AUTH_ALIASES = frozenset({"auth", "auth.basic", "auth.basic.once"})
|
|
223
|
+
_LARAVEL_AUTH_CLASSES = frozenset({"Authenticate", "AuthenticateWithBasicAuth"})
|
|
224
|
+
_LARAVEL_GROUP_ATTR_MIDDLEWARE = re.compile(
|
|
225
|
+
r"['\"](excluded_middleware|middleware)['\"]\s*=>\s*(\[[^\]]*\]|['\"][^'\"]*['\"])"
|
|
226
|
+
)
|
|
227
|
+
_CONTROLLER_MIDDLEWARE = re.compile(r"\$this\s*->\s*middleware\s*\(")
|
|
228
|
+
_CLASS_DECL = re.compile(r"\bclass\s+(\w+)")
|
|
229
|
+
_ACTION_ARRAY = re.compile(r"\[\s*\\?([\w\\]+)::class\s*,\s*['\"](\w+)['\"]\s*\]")
|
|
230
|
+
_ACTION_STRING = re.compile(r"['\"]\\?([\w\\]+)@(\w+)['\"]")
|
|
231
|
+
_ACTION_INVOKABLE = re.compile(r",\s*\\?([\w\\]+)::class\s*$")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def laravel_auth_names(args: str) -> list[str]:
|
|
235
|
+
"""Auth middleware named in a ``middleware(...)`` argument list."""
|
|
236
|
+
names = []
|
|
237
|
+
for match in _STRING_OR_CLASS.finditer(args):
|
|
238
|
+
if match.group(1) is not None:
|
|
239
|
+
value = match.group(1).strip()
|
|
240
|
+
if value.split(":", 1)[0] in _LARAVEL_AUTH_ALIASES:
|
|
241
|
+
names.append(value)
|
|
242
|
+
elif match.group(2).rsplit("\\", 1)[-1] in _LARAVEL_AUTH_CLASSES:
|
|
243
|
+
names.append(match.group(2).rsplit("\\", 1)[-1] + "::class")
|
|
244
|
+
return names
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _base(name: str) -> str:
|
|
248
|
+
return name.split(":", 1)[0]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class LaravelScope:
|
|
253
|
+
"""Middleware a route registration (or an enclosing group) applies/excludes."""
|
|
254
|
+
|
|
255
|
+
applied: list[tuple[str, str]] = field(default_factory=list) # (name, evidence)
|
|
256
|
+
excluded: list[tuple[str, str]] = field(default_factory=list)
|
|
257
|
+
|
|
258
|
+
def add_links(self, links: list[Link]) -> None:
|
|
259
|
+
for link in links:
|
|
260
|
+
if link.name == "middleware":
|
|
261
|
+
self.applied += [(name, link.text) for name in laravel_auth_names(link.head())]
|
|
262
|
+
elif link.name == "withoutMiddleware":
|
|
263
|
+
self.excluded += [(name, link.text) for name in laravel_auth_names(link.head())]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def add_group_attr_middleware(scope: LaravelScope, attrs: str) -> None:
|
|
267
|
+
"""``'middleware' => ...`` / ``'excluded_middleware' => ...`` in a legacy
|
|
268
|
+
``Route::group([...], fn)`` attribute array."""
|
|
269
|
+
for attr in _LARAVEL_GROUP_ATTR_MIDDLEWARE.finditer(attrs):
|
|
270
|
+
names = laravel_auth_names(attr.group(2))
|
|
271
|
+
evidence = clip(attr.group(0))
|
|
272
|
+
target = scope.excluded if attr.group(1) == "excluded_middleware" else scope.applied
|
|
273
|
+
target += [(name, evidence) for name in names]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class ControllerGuard:
|
|
278
|
+
names: list[str]
|
|
279
|
+
evidence: str
|
|
280
|
+
only: set[str] | None
|
|
281
|
+
except_: set[str]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def laravel_controller_guards(content: str) -> dict[str, list[ControllerGuard]]:
|
|
285
|
+
"""``class X { __construct() { $this->middleware('auth')->except([...]); } }``."""
|
|
286
|
+
found: dict[str, list[ControllerGuard]] = {}
|
|
287
|
+
class_decls: list[tuple[int, str]] | None = None
|
|
288
|
+
for match in _CONTROLLER_MIDDLEWARE.finditer(content):
|
|
289
|
+
open_idx = match.end() - 1
|
|
290
|
+
close = matching_close(content, open_idx)
|
|
291
|
+
if close < 0:
|
|
292
|
+
continue
|
|
293
|
+
names = laravel_auth_names(content[open_idx + 1 : min(close, open_idx + 1 + _ARG_SCAN)])
|
|
294
|
+
if not names:
|
|
295
|
+
continue
|
|
296
|
+
if class_decls is None:
|
|
297
|
+
class_decls = [(m.start(), m.group(1)) for m in _CLASS_DECL.finditer(content)]
|
|
298
|
+
owner = bisect.bisect_left(class_decls, (match.start(),)) - 1
|
|
299
|
+
if owner < 0:
|
|
300
|
+
continue
|
|
301
|
+
only: set[str] | None = None
|
|
302
|
+
except_: set[str] = set()
|
|
303
|
+
links = parse_chain(content, close + 1)
|
|
304
|
+
for link in links:
|
|
305
|
+
methods = {m.group(1) for m in _STRING_OR_CLASS.finditer(link.head()) if m.group(1)}
|
|
306
|
+
if link.name == "only":
|
|
307
|
+
only = methods
|
|
308
|
+
elif link.name == "except":
|
|
309
|
+
except_ |= methods
|
|
310
|
+
head = content[match.start() : min(close + 1, match.start() + _EVIDENCE_MAX)]
|
|
311
|
+
evidence = clip(head + "".join(link.text for link in links[:3]))
|
|
312
|
+
found.setdefault(class_decls[owner][1], []).append(ControllerGuard(names, evidence, only, except_))
|
|
313
|
+
return found
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def laravel_action(content: str, open_idx: int, close: int) -> tuple[str, str] | None:
|
|
317
|
+
"""``(ControllerShortName, method)`` a Laravel route call dispatches to.
|
|
318
|
+
|
|
319
|
+
The action is the second argument (array / ``'C@m'``), or a trailing
|
|
320
|
+
``C::class`` for an invokable controller; only the ends are read.
|
|
321
|
+
"""
|
|
322
|
+
head = content[open_idx + 1 : min(close, open_idx + 1 + 500)]
|
|
323
|
+
match = _ACTION_ARRAY.search(head) or _ACTION_STRING.search(head)
|
|
324
|
+
if match:
|
|
325
|
+
return match.group(1).rsplit("\\", 1)[-1], match.group(2)
|
|
326
|
+
match = _ACTION_INVOKABLE.search(content[max(open_idx + 1, close - 200) : close])
|
|
327
|
+
if match:
|
|
328
|
+
return match.group(1).rsplit("\\", 1)[-1], "__invoke"
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def resolve_laravel(
|
|
333
|
+
route_scope: LaravelScope,
|
|
334
|
+
groups: list,
|
|
335
|
+
action: tuple[str, str] | None,
|
|
336
|
+
controllers: dict[str, list[ControllerGuard]],
|
|
337
|
+
) -> Resolution:
|
|
338
|
+
applied = list(route_scope.applied)
|
|
339
|
+
excluded = list(route_scope.excluded)
|
|
340
|
+
for group in groups:
|
|
341
|
+
applied += group.scope.applied
|
|
342
|
+
excluded += group.scope.excluded
|
|
343
|
+
controller_excepted: list[str] = []
|
|
344
|
+
if action is not None:
|
|
345
|
+
cls, method = action
|
|
346
|
+
for guard in controllers.get(cls, []):
|
|
347
|
+
if method in guard.except_:
|
|
348
|
+
controller_excepted.append(guard.evidence)
|
|
349
|
+
elif guard.only is None or method in guard.only:
|
|
350
|
+
applied += [(name, guard.evidence) for name in guard.names]
|
|
351
|
+
# Laravel removes excluded middleware wherever it was added (route, group
|
|
352
|
+
# or controller), matched by alias.
|
|
353
|
+
excluded_bases = {_base(name) for name, _ in excluded}
|
|
354
|
+
remaining = [(name, ev) for name, ev in applied if _base(name) not in excluded_bases]
|
|
355
|
+
if remaining:
|
|
356
|
+
guards = list(dict.fromkeys(name for name, _ in remaining))[:MAX_GUARDS]
|
|
357
|
+
return Resolution(REQUIRED, guards, clip("; ".join(dict.fromkeys(ev for _, ev in remaining))))
|
|
358
|
+
if excluded or controller_excepted:
|
|
359
|
+
evidence = [ev for _, ev in excluded] + controller_excepted
|
|
360
|
+
return Resolution(ANONYMOUS, [], clip("; ".join(dict.fromkeys(evidence))))
|
|
361
|
+
return Resolution()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------- Slim ----------
|
|
365
|
+
|
|
366
|
+
# A middleware whose name says it authenticates: `$authMiddleware`,
|
|
367
|
+
# `new JwtAuthentication(...)`, `HttpBasicAuthentication`. Not "author".
|
|
368
|
+
_SLIM_AUTH_MIDDLEWARE = re.compile(r"auth(?!or)|jwt|bearer", re.IGNORECASE)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_SLIM_NEW = re.compile(r"\s*new\s+\\?(?:[\w\\]+\\)?(\w+)")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def slim_auth_adds(links: list[Link]) -> list[tuple[str, str]]:
|
|
375
|
+
"""``(guard name, evidence)`` for each auth ``->add(...)`` in a chain."""
|
|
376
|
+
found = []
|
|
377
|
+
for link in links:
|
|
378
|
+
if link.name != "add" or not _SLIM_AUTH_MIDDLEWARE.search(link.head()):
|
|
379
|
+
continue
|
|
380
|
+
new = _SLIM_NEW.match(link.head())
|
|
381
|
+
found.append((new.group(1) if new else clip(link.head(_EVIDENCE_MAX)), link.text))
|
|
382
|
+
return found
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass
|
|
386
|
+
class SlimGroup:
|
|
387
|
+
start: int
|
|
388
|
+
end: int
|
|
389
|
+
guards: list[tuple[str, str]]
|
|
390
|
+
# Joined onto the routes inside: Slim's `$app->group('/v1', ...)`
|
|
391
|
+
# pattern, or Lumen's `$router->group(['prefix' => 'api'], ...)` as
|
|
392
|
+
# `/api`. None when it isn't a literal.
|
|
393
|
+
prefix: str | None = ""
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_SLIM_GROUP = re.compile(r"\$(\w+)\s*->\s*group\s*\(")
|
|
397
|
+
_SLIM_GROUP_PATTERN = re.compile(r"\s*(?:'([^'\\]*)'|\"([^\"\\]*)\")\s*,")
|
|
398
|
+
_SLIM_GROUP_CLOSURE = re.compile(r"\s*(?:static\s+)?(?:function|fn)\b")
|
|
399
|
+
_LUMEN_GROUP_ATTRS = re.compile(r"\s*\[")
|
|
400
|
+
_LUMEN_PREFIX = re.compile(r"['\"]prefix['\"]\s*=>\s*(?:'([^'\\]*)'|\"([^\"\\]*)\")?")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _slim_group_prefix(content: str, open_idx: int, close: int) -> str | None:
|
|
404
|
+
pattern = _SLIM_GROUP_PATTERN.match(content, open_idx + 1, close)
|
|
405
|
+
if pattern:
|
|
406
|
+
return pattern.group(1) if pattern.group(1) is not None else pattern.group(2)
|
|
407
|
+
attrs = _LUMEN_GROUP_ATTRS.match(content, open_idx + 1, close)
|
|
408
|
+
if attrs:
|
|
409
|
+
attrs_close = matching_close(content, attrs.end() - 1)
|
|
410
|
+
if attrs_close < 0:
|
|
411
|
+
return None
|
|
412
|
+
prefix = _LUMEN_PREFIX.search(content, attrs.end(), min(attrs_close, attrs.end() + _ARG_SCAN))
|
|
413
|
+
if prefix is None:
|
|
414
|
+
return ""
|
|
415
|
+
value = prefix.group(1) if prefix.group(1) is not None else prefix.group(2)
|
|
416
|
+
if value is None:
|
|
417
|
+
return None
|
|
418
|
+
return "/" + value.strip("/") if value.strip("/") else ""
|
|
419
|
+
if _SLIM_GROUP_CLOSURE.match(content, open_idx + 1, close):
|
|
420
|
+
return ""
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def slim_groups(content: str, routers: set[str]) -> list[SlimGroup]:
|
|
425
|
+
groups = []
|
|
426
|
+
for match in _SLIM_GROUP.finditer(content):
|
|
427
|
+
if match.group(1) not in routers:
|
|
428
|
+
continue
|
|
429
|
+
open_idx = match.end() - 1
|
|
430
|
+
close = matching_close(content, open_idx)
|
|
431
|
+
if close < 0:
|
|
432
|
+
continue
|
|
433
|
+
guards = slim_auth_adds(parse_chain(content, close + 1))
|
|
434
|
+
prefix = _slim_group_prefix(content, open_idx, close)
|
|
435
|
+
if guards or prefix != "":
|
|
436
|
+
groups.append(SlimGroup(open_idx, close, guards, prefix))
|
|
437
|
+
return groups
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def resolve_slim(route_links: list[Link], groups: list[SlimGroup]) -> Resolution:
|
|
441
|
+
guards = slim_auth_adds(route_links)
|
|
442
|
+
for group in groups:
|
|
443
|
+
guards += group.guards
|
|
444
|
+
if guards:
|
|
445
|
+
names = list(dict.fromkeys(name for name, _ in guards))[:MAX_GUARDS]
|
|
446
|
+
return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(ev for _, ev in guards))))
|
|
447
|
+
return Resolution()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------- Symfony ----------
|
|
451
|
+
|
|
452
|
+
_ATTR_START = re.compile(r"#\[")
|
|
453
|
+
_WS = re.compile(r"\s*")
|
|
454
|
+
_ATTR_TARGET = re.compile(
|
|
455
|
+
r"\s*(?:(?:public|protected|private|static|final|abstract|readonly)\s+)*(function|class|enum|interface|trait)\b"
|
|
456
|
+
)
|
|
457
|
+
_PUBLIC_ROLES = frozenset({"PUBLIC_ACCESS", "IS_AUTHENTICATED_ANONYMOUSLY"})
|
|
458
|
+
_GUARD_ATTR = re.compile(r"\s*\\?(?:[\w\\]+\\)?(IsGranted|Security)\s*\(")
|
|
459
|
+
_FIRST_STRING = re.compile(r"['\"]([^'\"]*)['\"]")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# Attribute groups longer than this are read only this far.
|
|
463
|
+
_ATTR_GROUP_SCAN = 8000
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def split_attribute_group(content: str, start: int, end: int) -> list[str]:
|
|
467
|
+
"""The comma-separated attributes in ``content[start:end]`` (the inside
|
|
468
|
+
of one ``#[...]``), split at top-level commas via the bracket table."""
|
|
469
|
+
pairs = bracket_pairs(content)
|
|
470
|
+
items: list[str] = []
|
|
471
|
+
end = min(end, start + _ATTR_GROUP_SCAN)
|
|
472
|
+
item_start = pos = start
|
|
473
|
+
while pos < end:
|
|
474
|
+
ch = content[pos]
|
|
475
|
+
if ch in _OPEN:
|
|
476
|
+
close = pairs.get(pos, -1)
|
|
477
|
+
pos = close + 1 if close > pos else pos + 1
|
|
478
|
+
continue
|
|
479
|
+
if ch in _STRING_BODY:
|
|
480
|
+
body = _STRING_BODY[ch].match(content, pos + 1, end)
|
|
481
|
+
pos = body.end() if body else end
|
|
482
|
+
continue
|
|
483
|
+
if ch == ",":
|
|
484
|
+
items.append(content[item_start:pos].strip())
|
|
485
|
+
item_start = pos + 1
|
|
486
|
+
pos += 1
|
|
487
|
+
items.append(content[item_start:end].strip())
|
|
488
|
+
return [item for item in items if item]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@dataclass
|
|
492
|
+
class AttributeBlock:
|
|
493
|
+
attributes: list[tuple[int, str]] # (offset of `#[`, text inside the brackets)
|
|
494
|
+
target: str | None # function / class / ...
|
|
495
|
+
target_offset: int
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def attribute_blocks(content: str) -> list[AttributeBlock]:
|
|
499
|
+
"""Consecutive ``#[...]`` attributes and the declaration they decorate."""
|
|
500
|
+
blocks: list[AttributeBlock] = []
|
|
501
|
+
pos = 0
|
|
502
|
+
current: list[tuple[int, str]] = []
|
|
503
|
+
while True:
|
|
504
|
+
match = _ATTR_START.search(content, pos)
|
|
505
|
+
if not match:
|
|
506
|
+
break
|
|
507
|
+
close = matching_close(content, match.start() + 1)
|
|
508
|
+
if close < 0:
|
|
509
|
+
# A `#[` in a comment or string (the bracket scan skipped it).
|
|
510
|
+
pos = match.end()
|
|
511
|
+
continue
|
|
512
|
+
# `#[Route('/x'), IsGranted('ROLE_USER')]` holds several attributes.
|
|
513
|
+
current += [(match.start(), item) for item in split_attribute_group(content, match.start() + 2, close)]
|
|
514
|
+
pos = close + 1
|
|
515
|
+
if content.startswith("#[", _WS.match(content, pos).end()):
|
|
516
|
+
continue
|
|
517
|
+
target = _ATTR_TARGET.match(content, pos)
|
|
518
|
+
blocks.append(AttributeBlock(current, target.group(1) if target else None, target.end() if target else pos))
|
|
519
|
+
current = []
|
|
520
|
+
return blocks
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# A declaration starts its line (after any modifiers), so prose like
|
|
524
|
+
# "checks class and method attributes" in a comment doesn't count.
|
|
525
|
+
_CLASS_KEYWORD = re.compile(
|
|
526
|
+
r"^[ \t]*(?:(?:final|abstract|readonly)[ \t]+)*(class)[ \t]+\w", re.MULTILINE
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def class_decl_ends(content: str) -> list[int]:
|
|
531
|
+
"""Offsets just past every ``class`` keyword that declares a class."""
|
|
532
|
+
return [match.end(1) for match in _CLASS_KEYWORD.finditer(content)]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def owning_class(
|
|
536
|
+
decl_ends: list[int], class_blocks: dict[int, AttributeBlock], offset: int
|
|
537
|
+
) -> AttributeBlock | None:
|
|
538
|
+
"""The attribute block of the class declared last before ``offset``, or
|
|
539
|
+
None when that class has no attributes. ``class_blocks`` is keyed by
|
|
540
|
+
the offset just past the ``class`` keyword."""
|
|
541
|
+
index = bisect.bisect_left(decl_ends, offset) - 1
|
|
542
|
+
if index < 0:
|
|
543
|
+
return None
|
|
544
|
+
return class_blocks.get(decl_ends[index])
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def symfony_guard(attribute: str) -> tuple[str, str, str] | None:
|
|
548
|
+
"""``(state, guard, evidence)`` for ``#[IsGranted(...)]`` / ``#[Security(...)]``."""
|
|
549
|
+
match = _GUARD_ATTR.match(attribute)
|
|
550
|
+
if not match:
|
|
551
|
+
return None
|
|
552
|
+
evidence = clip(f"#[{attribute}]")
|
|
553
|
+
if match.group(1) == "IsGranted":
|
|
554
|
+
role = _FIRST_STRING.search(attribute, match.end())
|
|
555
|
+
if role and role.group(1).strip() in _PUBLIC_ROLES:
|
|
556
|
+
return ANONYMOUS, "", evidence
|
|
557
|
+
return REQUIRED, clip(attribute), evidence
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@dataclass
|
|
561
|
+
class AccessRule:
|
|
562
|
+
prefix: str | None # None: unparseable (stop resolving)
|
|
563
|
+
exact: bool
|
|
564
|
+
methods: set[str] | None
|
|
565
|
+
roles: set[str]
|
|
566
|
+
evidence: str
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
_YAML_PAIR = re.compile(r"(\w+)\s*:\s*(\[[^\]]*\]|'[^']*'|\"[^\"]*\"|[^,}\[\]]+)")
|
|
570
|
+
_LITERAL_PREFIX = re.compile(r"\^(/[\w/-]*)(\$?)")
|
|
571
|
+
_MAX_ACCESS_RULES = 256
|
|
572
|
+
_ALLOWED_KEYS = frozenset({"path", "roles", "methods", "requires_channel"})
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _yaml_list(value: str) -> set[str]:
|
|
576
|
+
value = value.strip().strip("[]")
|
|
577
|
+
return {item.strip().strip("'\"") for item in value.split(",") if item.strip()}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _access_rule(pairs: dict[str, str], evidence: str) -> AccessRule:
|
|
581
|
+
unparseable = AccessRule(None, False, None, set(), evidence)
|
|
582
|
+
if set(pairs) - _ALLOWED_KEYS or "roles" not in pairs:
|
|
583
|
+
return unparseable
|
|
584
|
+
path = pairs.get("path", "^/").strip().strip("'\"")
|
|
585
|
+
literal = _LITERAL_PREFIX.fullmatch(path)
|
|
586
|
+
if not literal:
|
|
587
|
+
return unparseable
|
|
588
|
+
methods = {m.upper() for m in _yaml_list(pairs["methods"])} if "methods" in pairs else None
|
|
589
|
+
return AccessRule(literal.group(1), bool(literal.group(2)), methods, _yaml_list(pairs["roles"]), evidence)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def parse_access_control(text: str) -> list[AccessRule]:
|
|
593
|
+
"""``security.access_control`` rules, in order (flow or block mappings)."""
|
|
594
|
+
lines = text.splitlines()
|
|
595
|
+
rules: list[AccessRule] = []
|
|
596
|
+
section_indent: int | None = None
|
|
597
|
+
entry: dict[str, str] | None = None
|
|
598
|
+
entry_text: list[str] = []
|
|
599
|
+
|
|
600
|
+
def flush() -> None:
|
|
601
|
+
if entry is not None:
|
|
602
|
+
rules.append(_access_rule(entry, clip(" ".join(entry_text))))
|
|
603
|
+
|
|
604
|
+
for raw in lines:
|
|
605
|
+
stripped = raw.strip()
|
|
606
|
+
if not stripped or stripped.startswith("#"):
|
|
607
|
+
continue
|
|
608
|
+
indent = len(raw) - len(raw.lstrip())
|
|
609
|
+
if section_indent is None:
|
|
610
|
+
if stripped.startswith("access_control:"):
|
|
611
|
+
section_indent = indent
|
|
612
|
+
continue
|
|
613
|
+
if indent <= section_indent and not stripped.startswith("-"):
|
|
614
|
+
break
|
|
615
|
+
if stripped.startswith("-"):
|
|
616
|
+
flush()
|
|
617
|
+
body = stripped[1:].strip()
|
|
618
|
+
entry = {}
|
|
619
|
+
entry_text = [stripped]
|
|
620
|
+
elif entry is not None:
|
|
621
|
+
body = stripped
|
|
622
|
+
entry_text.append(stripped)
|
|
623
|
+
else:
|
|
624
|
+
continue
|
|
625
|
+
for key, value in _YAML_PAIR.findall(body.strip("{}")):
|
|
626
|
+
entry[key] = value.strip()
|
|
627
|
+
flush()
|
|
628
|
+
if len(rules) > _MAX_ACCESS_RULES:
|
|
629
|
+
# Past the cap, stop resolving rather than trust a partial list.
|
|
630
|
+
rules = rules[:_MAX_ACCESS_RULES] + [AccessRule(None, False, None, set(), "")]
|
|
631
|
+
return rules
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def resolve_access_control(rules: list[AccessRule], path: str, method: str) -> tuple[str, str, str] | None:
|
|
635
|
+
"""First matching rule's ``(state, guard, evidence)``; None when none
|
|
636
|
+
matches or a rule can't be evaluated statically."""
|
|
637
|
+
for rule in rules:
|
|
638
|
+
if rule.prefix is None:
|
|
639
|
+
return None
|
|
640
|
+
if rule.methods is not None:
|
|
641
|
+
if method == "ANY":
|
|
642
|
+
return None
|
|
643
|
+
if method not in rule.methods:
|
|
644
|
+
continue
|
|
645
|
+
brace = path.find("{")
|
|
646
|
+
if 0 <= brace < len(rule.prefix):
|
|
647
|
+
return None # a placeholder where the literal prefix is compared
|
|
648
|
+
matched = path == rule.prefix if rule.exact else path.startswith(rule.prefix)
|
|
649
|
+
if not matched:
|
|
650
|
+
continue
|
|
651
|
+
if rule.roles <= _PUBLIC_ROLES:
|
|
652
|
+
return ANONYMOUS, "", rule.evidence
|
|
653
|
+
roles = ", ".join(sorted(rule.roles))
|
|
654
|
+
return REQUIRED, f"access_control ^{rule.prefix}: {roles}", rule.evidence
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def combine(states: list[tuple[str, str, str]]) -> Resolution:
|
|
659
|
+
"""Every Symfony check must pass: one requiring auth makes the route
|
|
660
|
+
required; explicitly public only when nothing requires auth."""
|
|
661
|
+
required = [(guard, ev) for state, guard, ev in states if state == REQUIRED]
|
|
662
|
+
if required:
|
|
663
|
+
guards = list(dict.fromkeys(guard for guard, _ in required))[:MAX_GUARDS]
|
|
664
|
+
return Resolution(REQUIRED, guards, clip("; ".join(dict.fromkeys(ev for _, ev in required))))
|
|
665
|
+
anonymous = list(dict.fromkeys(ev for state, _, ev in states if state == ANONYMOUS))
|
|
666
|
+
if anonymous:
|
|
667
|
+
return Resolution(ANONYMOUS, [], clip("; ".join(anonymous)))
|
|
668
|
+
return Resolution()
|