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,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
|
+
)
|