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,4 @@
1
+ from .analyzer import PhpWebAnalyzer
2
+ from attackmap.sdk import AnalyzerMetadata
3
+
4
+ __all__ = ["AnalyzerMetadata", "PhpWebAnalyzer"]
@@ -0,0 +1,629 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from attackmap.sdk import (
8
+ AnalyzerMetadata,
9
+ AuthHint,
10
+ DatabaseHint,
11
+ ExternalCall,
12
+ FrameworkHint,
13
+ iter_repo_files,
14
+ line_of,
15
+ line_snippet,
16
+ read_source,
17
+ rel,
18
+ Route,
19
+ ScanResult,
20
+ SecretHint,
21
+ )
22
+
23
+ from . import laravel_routes as lr
24
+ from . import route_auth as ra
25
+ from . import symfony_routes as sf
26
+
27
+ HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD")
28
+
29
+ # Slim / FastRoute-style `$app->get('/path', ...)`. The same shape is every
30
+ # `$request->get('id')`, `$cache->get('k')` and `$config->get(...)`, so the
31
+ # path must be rooted (`/...`) and the receiver must be a router: one of
32
+ # SLIM_ROUTER_NAMES, a variable assigned from SLIM_ROUTER_ASSIGN_PATTERN, or a
33
+ # parameter typed by SLIM_ROUTER_PARAM_PATTERN (route-group closures).
34
+ SLIM_ROUTE_PATTERN = re.compile(
35
+ r"\$(\w+)->(get|post|put|patch|delete|options|head|any|map)\s*\(\s*['\"](/[^'\"]*)['\"]",
36
+ re.IGNORECASE,
37
+ )
38
+ # Slim `$app->map(['GET', 'POST'], '/path', ...)`: the methods come first.
39
+ SLIM_MAP_PATTERN = re.compile(
40
+ r"\$(\w+)->map\s*\(\s*\[([^\]]{0,200})\]\s*,\s*['\"](/[^'\"]*)['\"]",
41
+ )
42
+ SLIM_MAP_METHOD_PATTERN = re.compile(r"['\"](GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)['\"]", re.IGNORECASE)
43
+ SLIM_ROUTER_NAMES = frozenset({"app", "router", "route", "group", "r"})
44
+ SLIM_ROUTER_ASSIGN_PATTERN = re.compile(
45
+ r"\$(\w+)\s*=\s*(?:AppFactory::create(?:FromContainer)?\s*\(|Bridge::create\s*\(|"
46
+ r"new\s+\\?(?:Slim\\)?(?:App|RouteCollector)\b|new\s+\\?FastRoute\\RouteCollector\b)",
47
+ )
48
+ SLIM_ROUTER_PARAM_PATTERN = re.compile(
49
+ r"\b(?:RouteCollectorProxy(?:Interface)?|RouteCollector|App)\s+\$(\w+)",
50
+ )
51
+ # Config-array routes (Mezzio-style `'routes' => [['path' => '/x', ...]]`).
52
+ # Only rooted paths in files that declare a `'routes'`/`'router'` key: every
53
+ # other `'path' =>` (Laravel `config/logging.php`, filesystem disks, cache
54
+ # dirs) is a file path, not a route.
55
+ CONFIG_ROUTE_PATH_PATTERN = re.compile(r"['\"]path['\"]\s*=>\s*['\"](/[^'\"]*)['\"]")
56
+ CONFIG_ROUTES_KEY_PATTERN = re.compile(r"['\"](?:routes|router)['\"]\s*=>")
57
+ # Symfony security config whose `access_control` rules guard attribute routes.
58
+ SECURITY_CONFIG_PATHS = (
59
+ "config/packages/security.yaml",
60
+ "config/packages/security.yml",
61
+ "config/security.yaml",
62
+ "app/config/security.yml",
63
+ )
64
+ # `config('file.key')` route prefixes are read from config/<file>.php:
65
+ # `'key' => 'value'` or `'key' => env('NAME', 'default')`.
66
+ CONFIG_VALUE_PATTERN = re.compile(
67
+ r"['\"]([\w-]+)['\"]\s*=>\s*(?:env\s*\(\s*['\"][\w-]*['\"]\s*,\s*)?['\"]([^'\"]*)['\"]"
68
+ )
69
+ CONFIG_NAME_PATTERN = re.compile(r"[\w-]+")
70
+
71
+ OUTBOUND_PATTERNS = [
72
+ re.compile(r"curl_init\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
73
+ re.compile(r"file_get_contents\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
74
+ re.compile(r"->request\s*\(\s*['\"](?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)['\"]\s*,\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
75
+ re.compile(r"->(?:get|post|put|patch|delete)\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
76
+ ]
77
+
78
+ DATABASE_PATTERNS = [
79
+ (re.compile(r"\bnew\s+PDO\s*\(", re.IGNORECASE), "sql"),
80
+ (re.compile(r"\bmysqli_connect\s*\(", re.IGNORECASE), "mysql"),
81
+ (re.compile(r"\bnew\s+mysqli\s*\(", re.IGNORECASE), "mysql"),
82
+ (re.compile(r"Doctrine\\DBAL|Doctrine\\ORM|EntityManager", re.IGNORECASE), "sql"),
83
+ (re.compile(r"\bpg_connect\s*\(", re.IGNORECASE), "postgresql"),
84
+ (re.compile(r"\bRedis\s*::|\bnew\s+Redis\s*\(", re.IGNORECASE), "redis"),
85
+ ]
86
+
87
+ AUTH_PATTERNS = [
88
+ (re.compile(r"\bsession_start\s*\(", re.IGNORECASE), "session"),
89
+ (re.compile(r"\$_SESSION\b", re.IGNORECASE), "session"),
90
+ (re.compile(r"\bpassword_hash\s*\(", re.IGNORECASE), "password_hash"),
91
+ (re.compile(r"\bpassword_verify\s*\(", re.IGNORECASE), "password_verify"),
92
+ # JWT verification only (AttackMap#325): core credits any auth hint near a
93
+ # route as that route's auth, so `use` imports, `JWT::encode` and building
94
+ # tokens don't count. firebase/php-jwt `JWT::decode` always verifies; tymon
95
+ # facade checks and `jwt.auth` middleware; lcobucci validator / SignedWith;
96
+ # Tuupola JwtAuthentication middleware.
97
+ (
98
+ re.compile(
99
+ r"\bJWT::decode\s*\(|\bJWTAuth::(?:parseToken|authenticate|toUser|getPayload|check)\b"
100
+ r"|['\"]jwt\.(?:auth|verify)['\"]|\bvalidator\s*\(\s*\)\s*->\s*(?:validate|assert)\s*\("
101
+ r"|\bnew\s+SignedWith\s*\(|\bJwtAuthentication\b"
102
+ ),
103
+ "jwt",
104
+ ),
105
+ (re.compile(r"\bmiddleware\s*\(\s*['\"]auth(?::[\w,-]+)?['\"]", re.IGNORECASE), "auth_middleware"),
106
+ # Laravel's Auth facade or the `auth()` / `auth('guard')` helper chained
107
+ # into a guard call, not any function or method named `auth(`.
108
+ (re.compile(r"\bAuth::|(?<![\w$>:\\])auth\s*\(\s*(?:['\"][\w-]*['\"]\s*)?\)\s*->"), "auth"),
109
+ ]
110
+
111
+ # Secret-shaped env var names only. `API`/`DB` on their own matched
112
+ # `DB_HOST`/`API_URL`; `DB_PASSWORD`/`API_KEY`/`API_TOKEN` still match via
113
+ # PASSWORD/KEY/TOKEN. Case-sensitive: env var names are upper-case.
114
+ _SECRET_NAME = r"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD)[A-Z0-9_]*)"
115
+ SECRET_PATTERNS = [
116
+ re.compile(r"getenv\s*\(\s*['\"]" + _SECRET_NAME + r"['\"]"),
117
+ re.compile(r"\$_ENV\s*\[\s*['\"]" + _SECRET_NAME + r"['\"]\s*\]"),
118
+ re.compile(r"\$_SERVER\s*\[\s*['\"]" + _SECRET_NAME + r"['\"]\s*\]"),
119
+ ]
120
+
121
+
122
+ class PhpWebAnalyzer:
123
+ metadata = AnalyzerMetadata(
124
+ name="php-web",
125
+ display_name="PHP Web Analyzer",
126
+ version="0.2.0",
127
+ description="Broad PHP web analyzer that emits structured security-relevant scan signals.",
128
+ scope="Generic PHP web projects using route declarations, web controllers, and common HTTP/database/auth patterns.",
129
+ targets=["php-web"],
130
+ languages=["php"],
131
+ priority=40,
132
+ experimental=True,
133
+ enabled_by_default=False,
134
+ )
135
+
136
+ @property
137
+ def name(self) -> str:
138
+ return self.metadata.name
139
+
140
+ def detect(self, repo_path: str | Path) -> bool:
141
+ root = Path(repo_path).resolve()
142
+ if not root.exists() or not root.is_dir():
143
+ return False
144
+
145
+ if (root / "composer.json").exists():
146
+ return True
147
+
148
+ # A `src/`/`app/`/`config/` dir alone says nothing about the language
149
+ # (it matched Python, Go and Java repos): require a PHP source file
150
+ # outside vendor/ (the walker prunes vendor, node_modules, ...).
151
+ return next(iter_repo_files(root, suffixes={".php"}), None) is not None
152
+
153
+ def analyze(self, repo_path: str | Path) -> ScanResult:
154
+ root = Path(repo_path).resolve()
155
+ result = ScanResult(root=str(root))
156
+
157
+ if not root.exists() or not root.is_dir():
158
+ return result
159
+
160
+ self._extract_composer_signals(root, result)
161
+ access_rules = self._load_access_control(root)
162
+ route_config = self._load_route_config(root)
163
+ # Laravel routes are resolved after the walk: a route file's prefix
164
+ # and middleware can come from the provider that mounts it, and a
165
+ # controller's constructor middleware lives in another file.
166
+ laravel_scans: dict[str, tuple[str, lr.FileScan]] = {}
167
+ controllers: dict[str, list[ra.ControllerGuard]] = {}
168
+ seen_routes: dict[tuple[str, str, str], int] = {}
169
+
170
+ # Pruned by repo-relative dir name (vendor, node_modules, .git, ...);
171
+ # symlinks out of the repo are not followed (AttackMap#253).
172
+ for file_path in iter_repo_files(root, suffixes={".php"}):
173
+ result.files_scanned += 1
174
+ if "php" not in result.languages:
175
+ result.languages.append("php")
176
+
177
+ content = read_source(file_path)
178
+ if content is None:
179
+ continue
180
+
181
+ relative = rel(file_path, root)
182
+ laravel = lr.scan_file(content, relative)
183
+ if laravel.routes or laravel.mounts:
184
+ laravel_scans[relative] = (content, laravel)
185
+ self._extract_routes(content, relative, result, access_rules, route_config, seen_routes)
186
+ for cls, guards in ra.laravel_controller_guards(content).items():
187
+ controllers.setdefault(cls, []).extend(guards)
188
+ self._extract_external_calls(content, relative, result)
189
+ self._extract_datastores(content, relative, result)
190
+ self._extract_auth_hints(content, relative, result)
191
+ self._extract_secret_hints(content, relative, result)
192
+
193
+ self._emit_symfony_config_routes(route_config, result, access_rules, seen_routes)
194
+ self._emit_laravel_routes(root, laravel_scans, controllers, result, seen_routes)
195
+
196
+ result.languages.sort()
197
+ return result
198
+
199
+ @staticmethod
200
+ def _load_access_control(root: Path) -> list[ra.AccessRule]:
201
+ for candidate in SECURITY_CONFIG_PATHS:
202
+ if not (root / candidate).is_file():
203
+ continue
204
+ text = read_source(root / candidate, root=root)
205
+ if text is not None:
206
+ return ra.parse_access_control(text)
207
+ return []
208
+
209
+ @staticmethod
210
+ def _load_route_config(root: Path) -> sf.RouteConfig:
211
+ def read(relative: str) -> str | None:
212
+ path = root / relative
213
+ return read_source(path, root=root) if path.is_file() else None
214
+
215
+ def list_dir(relative: str) -> list[str]:
216
+ directory = root / relative
217
+ if not directory.is_dir() or directory.is_symlink():
218
+ return []
219
+ return [f"{relative}/{child.name}" for child in directory.iterdir() if child.is_file()]
220
+
221
+ return sf.load_route_config(read, list_dir)
222
+
223
+ def _emit_symfony_config_routes(
224
+ self,
225
+ config: sf.RouteConfig,
226
+ result: ScanResult,
227
+ access_rules: list[ra.AccessRule],
228
+ seen: dict,
229
+ ) -> None:
230
+ for route in config.routes:
231
+ index = self._append_unique_route(result, route.path, route.method, route.file, route.line, seen=seen)
232
+ if index is not None and access_rules and ra.DYNAMIC_SEGMENT not in route.path:
233
+ rule = ra.resolve_access_control(access_rules, route.path, route.method)
234
+ if rule is not None:
235
+ self._set_route_auth(result, index, ra.combine([rule]))
236
+
237
+ def _emit_laravel_routes(
238
+ self,
239
+ root: Path,
240
+ scans: dict[str, tuple[str, lr.FileScan]],
241
+ controllers: dict[str, list[ra.ControllerGuard]],
242
+ result: ScanResult,
243
+ seen: dict,
244
+ ) -> None:
245
+ sites: dict[str, list] = {}
246
+ for relative, (_, scan) in scans.items():
247
+ for mount in scan.mounts:
248
+ sites.setdefault(mount.target, []).append((relative, mount.groups, mount.once))
249
+ # Framework-loaded route files: the default routes/web.php and
250
+ # routes/api.php, and anything mounted from outside routes/
251
+ # (bootstrap/app.php `withRouting`, a RouteServiceProvider).
252
+ framework_roots = set(lr.FRAMEWORK_ROUTE_FILES)
253
+ for target, target_sites in sites.items():
254
+ if any(not source.startswith("routes/") for source, _, _ in target_sites):
255
+ framework_roots.add(target)
256
+ resolver = lr.MountResolver(sites, (lr.LITERAL, "api"), frozenset(framework_roots))
257
+ config_cache: dict[str, str | None] = {}
258
+ for relative, (content, scan) in scans.items():
259
+ if not scan.routes:
260
+ continue
261
+ chains = resolver.chains(relative)
262
+ # The enclosing groups are fixed by the innermost one, so each
263
+ # (mount chain, innermost group) pair is joined and merged once,
264
+ # not once per route.
265
+ merged: dict[tuple[int, int | None], tuple[str | None, lr.LaravelGroup] | None] = {}
266
+ resolved: dict[tuple, ra.Resolution] = {}
267
+ for route in scan.routes:
268
+ line = line_of(content, route.offset)
269
+ inner = id(route.groups[-1]) if route.groups else (None if route.groups is None else 0)
270
+ for index_chain, chain in enumerate(chains):
271
+ key = (index_chain, inner)
272
+ if key not in merged:
273
+ merged[key] = self._merge_groups(root, chain, route.groups, config_cache)
274
+ joined = merged[key]
275
+ if joined is None:
276
+ # Nested past MAX_NESTING or mounted in a cycle: the
277
+ # prefix and the enclosing middleware are unknown.
278
+ path = lr.join_path([(lr.DYNAMIC, "")], route.uri)
279
+ else:
280
+ path = lr.join_base(joined[0], route.uri)
281
+ for method in route.methods:
282
+ index = self._append_unique_route(result, path, method, relative, line, seen=seen)
283
+ if index is None or joined is None:
284
+ continue
285
+ # Same groups, route-level middleware and controller
286
+ # guard -> same resolution.
287
+ guarded = route.action if route.action and route.action[0] in controllers else None
288
+ rkey = (key, tuple(route.scope.applied), tuple(route.scope.excluded), guarded)
289
+ if rkey not in resolved:
290
+ resolved[rkey] = ra.resolve_laravel(route.scope, [joined[1]], guarded, controllers)
291
+ self._set_route_auth(result, index, resolved[rkey])
292
+
293
+ def _merge_groups(
294
+ self,
295
+ root: Path,
296
+ chain: list | None,
297
+ groups: list | None,
298
+ config_cache: dict[str, str | None],
299
+ ) -> tuple[str | None, lr.LaravelGroup] | None:
300
+ """The joined prefix and the merged middleware of ``chain + groups``."""
301
+ if chain is None or groups is None:
302
+ return None
303
+ outer = chain + groups
304
+ prefixes = [self._resolve_prefix(root, group.prefix, config_cache) for group in outer]
305
+ # One entry per middleware name (the outermost evidence), so the
306
+ # merged scope is as long as the distinct names, not the groups.
307
+ applied: dict[str, tuple[str, str]] = {}
308
+ excluded: dict[str, tuple[str, str]] = {}
309
+ for group in outer:
310
+ for name, evidence in group.scope.applied:
311
+ applied.setdefault(name, (name, evidence))
312
+ for name, evidence in group.scope.excluded:
313
+ excluded.setdefault(name, (name, evidence))
314
+ scope = ra.LaravelScope(list(applied.values()), list(excluded.values()))
315
+ return lr.prefix_base(prefixes), lr.LaravelGroup(-1, -1, scope)
316
+
317
+ @staticmethod
318
+ def _resolve_prefix(root: Path, prefix: lr.Prefix, cache: dict[str, str | None]) -> lr.Prefix:
319
+ """A literal for ``config('file.key')`` when config/<file>.php sets
320
+ the key to a literal (or an ``env()`` default); else dynamic."""
321
+ kind, value = prefix
322
+ if kind != lr.CONFIG:
323
+ return prefix
324
+ if value not in cache:
325
+ cache[value] = None
326
+ name, key = value.split(".", 1)
327
+ if CONFIG_NAME_PATTERN.fullmatch(name) and CONFIG_NAME_PATTERN.fullmatch(key):
328
+ text = read_source(root / "config" / f"{name}.php", root=root)
329
+ if text is not None:
330
+ found = (m.group(2) for m in CONFIG_VALUE_PATTERN.finditer(text) if m.group(1) == key)
331
+ cache[value] = next(found, None)
332
+ literal = cache[value]
333
+ return (lr.LITERAL, literal) if literal is not None else (lr.DYNAMIC, "")
334
+
335
+ def _extract_composer_signals(self, root: Path, result: ScanResult) -> None:
336
+ text = read_source(root / "composer.json", root=root)
337
+ if text is None:
338
+ return
339
+ try:
340
+ data = json.loads(text)
341
+ except json.JSONDecodeError:
342
+ return
343
+ if not isinstance(data, dict):
344
+ return
345
+
346
+ requirements = {
347
+ **(data.get("require", {}) if isinstance(data.get("require", {}), dict) else {}),
348
+ **(data.get("require-dev", {}) if isinstance(data.get("require-dev", {}), dict) else {}),
349
+ }
350
+
351
+ for package in requirements:
352
+ lower = package.lower()
353
+ index = text.find(f'"{package}"')
354
+ offset = index if index >= 0 else 0
355
+ if "doctrine" in lower:
356
+ self._append_unique_database(result, "sql", "composer.json", text, offset)
357
+ if "guzzle" in lower or "symfony/http-client" in lower:
358
+ # An HTTP client *library* is a framework capability, not an
359
+ # outbound call (no target) and not an auth signal (#258).
360
+ self._append_unique_hint(
361
+ result.framework_hints, FrameworkHint, "http_client", "composer.json", text, offset, 0.9
362
+ )
363
+ if "firebase/php-jwt" in lower:
364
+ # A JWT library is a capability, not a check (#13): only
365
+ # verifying calls in code become `jwt` auth hints.
366
+ self._append_unique_hint(
367
+ result.framework_hints, FrameworkHint, "jwt_library", "composer.json", text, offset, 0.9
368
+ )
369
+
370
+ def _extract_routes(
371
+ self,
372
+ content: str,
373
+ relative: str,
374
+ result: ScanResult,
375
+ access_rules: list[ra.AccessRule] | None = None,
376
+ route_config: sf.RouteConfig | None = None,
377
+ seen: dict | None = None,
378
+ ) -> None:
379
+ """Slim, Symfony-attribute and config-array routes (Laravel routes
380
+ are emitted after the walk, see ``_emit_laravel_routes``)."""
381
+ self._extract_slim_routes(content, relative, result, seen)
382
+ if "#[" in content:
383
+ self._extract_attribute_routes(content, relative, result, access_rules or [], route_config, seen)
384
+
385
+ if not CONFIG_ROUTES_KEY_PATTERN.search(content):
386
+ return
387
+ for match in CONFIG_ROUTE_PATH_PATTERN.finditer(content):
388
+ self._append_unique_route(
389
+ result, match.group(1), "ANY", relative, line_of(content, match.start()), seen=seen
390
+ )
391
+
392
+ def _extract_slim_routes(self, content: str, relative: str, result: ScanResult, seen: dict | None) -> None:
393
+ routers = set(SLIM_ROUTER_NAMES)
394
+ routers.update(SLIM_ROUTER_ASSIGN_PATTERN.findall(content))
395
+ routers.update(SLIM_ROUTER_PARAM_PATTERN.findall(content))
396
+ found: list[tuple[int, list[str], str]] = []
397
+ for match in SLIM_ROUTE_PATTERN.finditer(content):
398
+ if match.group(1) in routers:
399
+ method = match.group(2).upper()
400
+ found.append((match.start(), ["ANY" if method in ("MAP", "ANY") else method], match.group(3)))
401
+ for match in SLIM_MAP_PATTERN.finditer(content):
402
+ if match.group(1) in routers:
403
+ methods = list(dict.fromkeys(m.upper() for m in SLIM_MAP_METHOD_PATTERN.findall(match.group(2))))
404
+ if methods:
405
+ found.append((match.start(), methods, match.group(3)))
406
+ if not found:
407
+ return
408
+ found.sort(key=lambda item: item[0])
409
+ groups = ra.slim_groups(content, routers) if "->group" in content else []
410
+ enclosing = ra.enclosing(groups, [offset for offset, _, _ in found])
411
+ for (offset, methods, path), outer in zip(found, enclosing):
412
+ if outer is None or any(group.prefix is None for group in outer):
413
+ full = "/" + lr.DYNAMIC_SEGMENT + path
414
+ else:
415
+ full = "".join(group.prefix for group in outer) + path
416
+ for method in methods:
417
+ index = self._append_unique_route(result, full, method, relative, line_of(content, offset), seen=seen)
418
+ if index is None or outer is None:
419
+ continue
420
+ close = ra.matching_close(content, content.find("(", offset))
421
+ links = ra.parse_chain(content, close + 1) if close > 0 else []
422
+ self._set_route_auth(result, index, ra.resolve_slim(links, outer))
423
+
424
+ def _extract_attribute_routes(
425
+ self,
426
+ content: str,
427
+ relative: str,
428
+ result: ScanResult,
429
+ access_rules: list[ra.AccessRule],
430
+ route_config: sf.RouteConfig | None,
431
+ seen: dict | None,
432
+ ) -> None:
433
+ """Symfony ``#[Route]`` actions, joined onto their class's
434
+ ``#[Route]`` prefix and any route-config import prefix."""
435
+ blocks = ra.attribute_blocks(content)
436
+ routed = [(block, attrs) for block in blocks if (attrs := self._route_attrs(block))]
437
+ if not routed:
438
+ return
439
+ decl_ends = ra.class_decl_ends(content)
440
+ class_blocks = {block.target_offset: block for block in blocks if block.target == "class"}
441
+ import_prefixes = route_config.prefixes_for(relative) if route_config else [""]
442
+ # (offset, path, methods, block, owner class block)
443
+ found: list[tuple[int, str, list[str], ra.AttributeBlock, ra.AttributeBlock | None]] = []
444
+ prefixed_classes: set[int] = set()
445
+ for block, attrs in routed:
446
+ if block.target == "class":
447
+ continue
448
+ owner = None
449
+ class_attr = None
450
+ if block.target == "function":
451
+ owner = ra.owning_class(decl_ends, class_blocks, block.target_offset)
452
+ owner_attrs = self._route_attrs(owner) if owner is not None else []
453
+ if owner_attrs:
454
+ prefixed_classes.add(owner.target_offset)
455
+ class_attr = owner_attrs[0][1]
456
+ for offset, attr in attrs:
457
+ if attr.path is None:
458
+ continue
459
+ if class_attr is None:
460
+ path, methods = attr.path, attr.methods
461
+ else:
462
+ prefix = class_attr.path if class_attr.path is not None else "/" + lr.DYNAMIC_SEGMENT
463
+ path = sf.class_path(prefix, attr.path)
464
+ methods = list(dict.fromkeys(class_attr.methods + attr.methods))
465
+ found.append((offset, path, methods, block, owner))
466
+ for block, attrs in routed:
467
+ # A class-level route is a route itself only for an invokable
468
+ # controller (no routed actions); otherwise it's the prefix.
469
+ if block.target == "class" and block.target_offset not in prefixed_classes:
470
+ found += [(offset, attr.path, attr.methods, block, None) for offset, attr in attrs if attr.path is not None]
471
+ found.sort(key=lambda item: item[0])
472
+ for offset, path, methods, block, owner in found:
473
+ line = line_of(content, offset)
474
+ # One route per live import (imports with different name
475
+ # prefixes all register it).
476
+ for import_prefix in import_prefixes:
477
+ full = sf.import_prefix(import_prefix, path)
478
+ # access_control is only matched against a fully known path.
479
+ rules = access_rules if lr.DYNAMIC_SEGMENT not in full else []
480
+ for method in methods or ["ANY"]:
481
+ index = self._append_unique_route(result, full, method, relative, line, seen=seen)
482
+ if index is not None:
483
+ resolution = self._symfony_resolution(block, owner, full, method, rules)
484
+ self._set_route_auth(result, index, resolution)
485
+
486
+ @staticmethod
487
+ def _route_attrs(block: ra.AttributeBlock) -> list[tuple[int, sf.RouteAttr]]:
488
+ return [(offset, attr) for offset, item in block.attributes if (attr := sf.route_attribute(item))]
489
+
490
+ @staticmethod
491
+ def _symfony_resolution(
492
+ block: ra.AttributeBlock,
493
+ owner: ra.AttributeBlock | None,
494
+ path: str,
495
+ method: str,
496
+ access_rules: list[ra.AccessRule],
497
+ ) -> ra.Resolution:
498
+ states = [g for _, text in block.attributes if (g := ra.symfony_guard(text))]
499
+ if owner is not None:
500
+ states += [g for _, text in owner.attributes if (g := ra.symfony_guard(text))]
501
+ # access_control matches the full path (class and import prefixes
502
+ # joined); callers pass no rules when that path isn't certain.
503
+ if access_rules:
504
+ rule = ra.resolve_access_control(access_rules, path, method)
505
+ if rule is not None:
506
+ states.append(rule)
507
+ return ra.combine(states)
508
+
509
+ @staticmethod
510
+ def _set_route_auth(result: ScanResult, index: int, resolution: ra.Resolution) -> None:
511
+ """Declare a route's auth (AttackMap#256). Older cores ignore the fields."""
512
+ if resolution.auth == ra.UNKNOWN:
513
+ return
514
+ route = result.routes[index]
515
+ if getattr(route, "auth", ra.UNKNOWN) != ra.UNKNOWN:
516
+ return # the first registration of a duplicate route wins
517
+ # Rebuilt rather than mutated so Route's validators (guard_evidence
518
+ # redaction) run.
519
+ result.routes[index] = Route(
520
+ path=route.path,
521
+ method=route.method,
522
+ file=route.file,
523
+ line=route.line,
524
+ auth=resolution.auth,
525
+ guards=list(resolution.guards),
526
+ guard_evidence=resolution.evidence,
527
+ )
528
+
529
+ def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
530
+ for pattern in OUTBOUND_PATTERNS:
531
+ for match in pattern.finditer(content):
532
+ self._append_unique_external(result, match.group(1), relative, content, match.start())
533
+
534
+ def _extract_datastores(self, content: str, relative: str, result: ScanResult) -> None:
535
+ for pattern, kind in DATABASE_PATTERNS:
536
+ match = pattern.search(content)
537
+ if match:
538
+ self._append_unique_database(result, kind, relative, content, match.start())
539
+
540
+ def _extract_auth_hints(self, content: str, relative: str, result: ScanResult) -> None:
541
+ for pattern, hint in AUTH_PATTERNS:
542
+ match = pattern.search(content)
543
+ if match:
544
+ self._append_unique_hint(result.auth_hints, AuthHint, hint, relative, content, match.start())
545
+
546
+ def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
547
+ for pattern in SECRET_PATTERNS:
548
+ for match in pattern.finditer(content):
549
+ self._append_unique_secret(result, match.group(1), relative, content, match.start())
550
+
551
+ @staticmethod
552
+ def _append_unique_route(
553
+ result: ScanResult,
554
+ path: str,
555
+ method: str,
556
+ file: str,
557
+ line: int,
558
+ *,
559
+ auth: str = ra.UNKNOWN,
560
+ guards: list[str] | None = None,
561
+ guard_evidence: str | None = None,
562
+ seen: dict | None = None,
563
+ ) -> int | None:
564
+ """Append a route once per (path, method, file); return its index, or
565
+ None when it was already recorded. ``seen`` indexes the keys already
566
+ in ``result.routes`` so a large repo isn't quadratic."""
567
+ key = (path, method, file)
568
+ if seen is not None:
569
+ if key in seen:
570
+ return None
571
+ seen[key] = len(result.routes)
572
+ elif any((item.path, item.method, item.file) == key for item in result.routes):
573
+ return None
574
+ result.routes.append(
575
+ Route(
576
+ path=path, method=method, file=file, line=line,
577
+ auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
578
+ )
579
+ )
580
+ return len(result.routes) - 1
581
+
582
+ @staticmethod
583
+ def _append_unique_external(result: ScanResult, target: str, file: str, content: str, offset: int) -> None:
584
+ key = (target, file)
585
+ if any((item.target, item.file) == key for item in result.external_calls):
586
+ return
587
+ line = line_of(content, offset)
588
+ result.external_calls.append(
589
+ ExternalCall(target=target, file=file, line=line, evidence_text=line_snippet(content, line) or target)
590
+ )
591
+
592
+ @staticmethod
593
+ def _append_unique_database(result: ScanResult, kind: str, file: str, content: str, offset: int) -> None:
594
+ key = (kind, file)
595
+ if any((item.kind, item.file) == key for item in result.databases):
596
+ return
597
+ line = line_of(content, offset)
598
+ result.databases.append(
599
+ DatabaseHint(kind=kind, file=file, line=line, evidence_text=line_snippet(content, line) or kind)
600
+ )
601
+
602
+ @staticmethod
603
+ def _append_unique_hint(
604
+ bucket: list,
605
+ model: type,
606
+ hint: str,
607
+ file: str,
608
+ content: str,
609
+ offset: int,
610
+ confidence: float | None = None,
611
+ ) -> None:
612
+ """Append an AuthHint/FrameworkHint once per (hint, file), located at ``offset``."""
613
+ if any((item.hint, item.file) == (hint, file) for item in bucket):
614
+ return
615
+ line = line_of(content, offset)
616
+ extra = {"confidence": confidence} if confidence is not None else {}
617
+ bucket.append(
618
+ model(hint=hint, file=file, line=line, evidence_text=line_snippet(content, line) or hint, **extra)
619
+ )
620
+
621
+ @staticmethod
622
+ def _append_unique_secret(result: ScanResult, name: str, file: str, content: str, offset: int) -> None:
623
+ key = (name, file)
624
+ if any((item.name, item.file) == key for item in result.secret_hints):
625
+ return
626
+ line = line_of(content, offset)
627
+ result.secret_hints.append(
628
+ SecretHint(name=name, file=file, line=line, evidence_text=line_snippet(content, line) or name)
629
+ )