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.
@@ -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()