attackmap-analyzer-php-laminas 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 PhpLaminasAnalyzer
2
+ from attackmap.sdk import AnalyzerMetadata
3
+
4
+ __all__ = ["AnalyzerMetadata", "PhpLaminasAnalyzer"]
@@ -0,0 +1,367 @@
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 .phparray import PhpArray, TokenList, tokenize
24
+ from .routes import RouteNode, fallback_route_literals, mezzio_routes, route_trees, router_routes
25
+
26
+ # (file, content, tokens, route trees) awaiting the cross-file route index.
27
+ _RouteFile = tuple[str, str, TokenList, list[PhpArray]]
28
+
29
+ # Fallback for a file with no recognizable `'router' => ['routes' => ...]`
30
+ # tree (see `routes.py` for the structural walk): Laminas router
31
+ # `'options' => ['route' => '/album[/:id]']`. Only URL path
32
+ # specs: rooted (`/...`) or an optional child segment (`[/:id]`). The same
33
+ # `'route' =>` key in `navigation` pages and redirects holds a route *name*
34
+ # (`'route' => 'home'`, `'route' => 'admin/site'`), which is not a path
35
+ # (port of mlaify/attackmap-analyzer-php-web#2's config-path fix).
36
+ LAMINAS_ROUTE_PATTERN = re.compile(r"['\"]route['\"]\s*=>\s*['\"]([/\[][^'\"]*)['\"]", re.IGNORECASE)
37
+ # `Foo\\BarController::class` — whole token matched possessively, keyword
38
+ # checked in Python (see the service pattern below; mlaify/AttackMap#236).
39
+ LAMINAS_CONTROLLER_PATTERN = re.compile(r"(?<![A-Za-z0-9_\\])([A-Za-z_\\][A-Za-z0-9_\\]*+)::class")
40
+ # Match the whole `Foo\\BarService::class` token in one possessive pass, then
41
+ # check for a service keyword in Python. The old single regex
42
+ # (`[..]*(?:Service|…)[..]*::class`) backtracked quadratically on a long
43
+ # identifier-like line, enough to stall a scan (mlaify/AttackMap#236).
44
+ LAMINAS_SERVICE_PATTERN = re.compile(r"(?<![A-Za-z0-9_\\])([A-Za-z_\\][A-Za-z0-9_\\]*+)::class")
45
+ _SERVICE_WORD = re.compile(r"Service|Manager|Repository|TableGateway|Adapter", re.IGNORECASE)
46
+
47
+ # Composer packages that make a repo a Laminas (or Zend/Mezzio) app.
48
+ _LAMINAS_PACKAGE_PREFIXES = ("laminas/", "zendframework/", "mezzio/")
49
+
50
+ OUTBOUND_PATTERNS = [
51
+ re.compile(r"curl_init\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
52
+ re.compile(r"file_get_contents\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
53
+ re.compile(r"->request\s*\(\s*['\"](?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)['\"]\s*,\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
54
+ re.compile(r"->(?:get|post|put|patch|delete)\s*\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
55
+ ]
56
+
57
+ DATABASE_PATTERNS = [
58
+ (re.compile(r"\bnew\s+PDO\s*\(", re.IGNORECASE), "sql"),
59
+ (re.compile(r"Doctrine\\DBAL|Doctrine\\ORM|EntityManager", re.IGNORECASE), "sql"),
60
+ (re.compile(r"\bmysqli_connect\s*\(", re.IGNORECASE), "mysql"),
61
+ (re.compile(r"\bnew\s+mysqli\s*\(", re.IGNORECASE), "mysql"),
62
+ (re.compile(r"\bpg_connect\s*\(", re.IGNORECASE), "postgresql"),
63
+ (re.compile(r"\bRedis\s*::|\bnew\s+Redis\s*\(", re.IGNORECASE), "redis"),
64
+ ]
65
+
66
+ AUTH_PATTERNS = [
67
+ (re.compile(r"\bsession_start\s*\(", re.IGNORECASE), "session"),
68
+ (re.compile(r"\$_SESSION\b", re.IGNORECASE), "session"),
69
+ # JWT verification only (AttackMap#325): core credits any auth hint near a
70
+ # route as that route's auth, so `use` imports, `JWT::encode` and building
71
+ # tokens don't count. firebase/php-jwt `JWT::decode` always verifies; tymon
72
+ # facade checks and `jwt.auth` middleware; lcobucci validator / SignedWith;
73
+ # Tuupola JwtAuthentication middleware.
74
+ (
75
+ re.compile(
76
+ r"\bJWT::decode\s*\(|\bJWTAuth::(?:parseToken|authenticate|toUser|getPayload|check)\b"
77
+ r"|['\"]jwt\.(?:auth|verify)['\"]|\bvalidator\s*\(\s*\)\s*->\s*(?:validate|assert)\s*\("
78
+ r"|\bnew\s+SignedWith\s*\(|\bJwtAuthentication\b"
79
+ ),
80
+ "jwt",
81
+ ),
82
+ # Laravel-style `Auth::` facade or the `auth()` / `auth('guard')` helper
83
+ # chained into a guard call, not any function or method named `auth(`.
84
+ (re.compile(r"\bAuth::|(?<![\w$>:\\])auth\s*\(\s*(?:['\"][\w-]*['\"]\s*)?\)\s*->"), "auth"),
85
+ ]
86
+
87
+ # Secret-shaped env var names only. `API`/`DB` on their own matched
88
+ # `DB_HOST`/`API_URL`; `DB_PASSWORD`/`API_KEY`/`API_TOKEN` still match via
89
+ # PASSWORD/KEY/TOKEN. Case-sensitive: env var names are upper-case
90
+ # (port of mlaify/attackmap-analyzer-php-web#2).
91
+ _SECRET_NAME = r"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASSWD)[A-Z0-9_]*)"
92
+ SECRET_PATTERNS = [
93
+ re.compile(r"getenv\s*\(\s*['\"]" + _SECRET_NAME + r"['\"]"),
94
+ re.compile(r"\$_ENV\s*\[\s*['\"]" + _SECRET_NAME + r"['\"]\s*\]"),
95
+ re.compile(r"\$_SERVER\s*\[\s*['\"]" + _SECRET_NAME + r"['\"]\s*\]"),
96
+ ]
97
+
98
+
99
+ class PhpLaminasAnalyzer:
100
+ metadata = AnalyzerMetadata(
101
+ name="php-laminas",
102
+ display_name="PHP Laminas Analyzer",
103
+ version="0.2.0",
104
+ description="Framework-aware Laminas analyzer for route, controller, and service mapping hints.",
105
+ scope="Laminas and Zend Framework MVC projects using module and application config arrays.",
106
+ targets=["php-laminas", "laminas", "zendframework"],
107
+ languages=["php"],
108
+ priority=70,
109
+ experimental=True,
110
+ enabled_by_default=False,
111
+ )
112
+
113
+ @property
114
+ def name(self) -> str:
115
+ return self.metadata.name
116
+
117
+ def detect(self, repo_path: str | Path) -> bool:
118
+ root = Path(repo_path).resolve()
119
+ if not root.exists() or not root.is_dir():
120
+ return False
121
+
122
+ if self._has_laminas_composer_dependencies(root):
123
+ return True
124
+
125
+ if (root / "module").is_dir() and (root / "config" / "application.config.php").exists():
126
+ return True
127
+
128
+ if next(iter_repo_files(root, names={"module.config.php"}), None) is not None:
129
+ return True
130
+
131
+ return False
132
+
133
+ def analyze(self, repo_path: str | Path) -> ScanResult:
134
+ root = Path(repo_path).resolve()
135
+ result = ScanResult(root=str(root))
136
+
137
+ if not root.exists() or not root.is_dir():
138
+ return result
139
+
140
+ self._extract_composer_signals(root, result)
141
+
142
+ # Pruned by repo-relative dir name (vendor, node_modules, .git, ...);
143
+ # symlinks out of the repo are not followed (AttackMap#253).
144
+ pending: list[_RouteFile] = []
145
+ seen: set[tuple[str, str, str]] = set()
146
+ for file_path in iter_repo_files(root, suffixes={".php"}):
147
+ result.files_scanned += 1
148
+ if "php" not in result.languages:
149
+ result.languages.append("php")
150
+
151
+ content = read_source(file_path)
152
+ if content is None:
153
+ continue
154
+
155
+ relative = rel(file_path, root)
156
+ self._extract_routes(content, relative, result, pending, seen)
157
+ self._extract_laminas_controllers(content, relative, result)
158
+ self._extract_laminas_services(content, relative, result)
159
+ self._extract_external_calls(content, relative, result)
160
+ self._extract_datastores(content, relative, result)
161
+ self._extract_auth_hints(content, relative, result)
162
+ self._extract_secret_hints(content, relative, result)
163
+
164
+ self._emit_router_routes(pending, result, seen)
165
+ result.languages.sort()
166
+ return result
167
+
168
+ def _has_laminas_composer_dependencies(self, root: Path) -> bool:
169
+ data = self._load_composer(root)
170
+ if data is None:
171
+ return False
172
+
173
+ requirements = {
174
+ **(data.get("require", {}) if isinstance(data.get("require", {}), dict) else {}),
175
+ **(data.get("require-dev", {}) if isinstance(data.get("require-dev", {}), dict) else {}),
176
+ }
177
+ for package in requirements:
178
+ if package.lower().startswith(_LAMINAS_PACKAGE_PREFIXES):
179
+ return True
180
+ return False
181
+
182
+ def _extract_composer_signals(self, root: Path, result: ScanResult) -> None:
183
+ text = read_source(root / "composer.json", root=root)
184
+ data = self._load_composer(root)
185
+ if data is None or text is None:
186
+ return
187
+
188
+ requirements = {
189
+ **(data.get("require", {}) if isinstance(data.get("require", {}), dict) else {}),
190
+ **(data.get("require-dev", {}) if isinstance(data.get("require-dev", {}), dict) else {}),
191
+ }
192
+
193
+ for package in requirements:
194
+ lowered = package.lower()
195
+ offset = self._composer_offset(text, package)
196
+ if lowered.startswith(_LAMINAS_PACKAGE_PREFIXES):
197
+ self._append_unique_hint(
198
+ result.framework_hints, FrameworkHint, "laminas_dependency", "composer.json", text, offset, 0.9
199
+ )
200
+ if "doctrine" in lowered:
201
+ self._append_unique_database(result, "sql", "composer.json", text, offset)
202
+
203
+ def _extract_routes(
204
+ self, content: str, relative: str, result: ScanResult, pending: list[_RouteFile], seen: set
205
+ ) -> None:
206
+ """Mezzio routes now; router trees go to ``pending`` for
207
+ :meth:`_emit_router_routes`, which needs every file's route names."""
208
+ mezzio = "Mezzio\\" in content or "Zend\\Expressive\\" in content
209
+ # `'route'`, `'routes'`, `'router'` (cheap pre-check before tokenizing).
210
+ if not (mezzio or "'route" in content or '"route' in content):
211
+ return
212
+ tokens = tokenize(content)
213
+ pending.append((relative, content, tokens, route_trees(tokens)))
214
+ if mezzio:
215
+ for route in mezzio_routes(tokens):
216
+ line = line_of(content, route.offset)
217
+ self._append_unique_route(result, seen, route.path, route.method, relative, line)
218
+
219
+ def _emit_router_routes(self, pending: list[_RouteFile], result: ScanResult, seen: set) -> None:
220
+ # Laminas merges all modules' config: index every named route first so
221
+ # a module's `'admin' => ['child_routes' => [...]]` stub joins onto the
222
+ # `/admin` route defined in another module. A few rounds settle stubs
223
+ # of stubs.
224
+ index: dict[str, RouteNode] = {}
225
+ for _ in range(3):
226
+ before = len(index)
227
+ for _relative, _content, _tokens, trees in pending:
228
+ router_routes(trees, known=index, index=index)
229
+ if len(index) == before:
230
+ break
231
+ for relative, content, tokens, trees in pending:
232
+ consumed: set[int] = set()
233
+ for route in router_routes(trees, known=index, consumed=consumed):
234
+ line = line_of(content, route.offset)
235
+ self._append_unique_route(result, seen, route.path, route.method, relative, line)
236
+ # Literals the walk never reached (routes built in a way it can't
237
+ # follow, or past the tokenizer cap) keep the flat match.
238
+ for path, offset in fallback_route_literals(content, tokens, consumed, LAMINAS_ROUTE_PATTERN):
239
+ self._append_unique_route(result, seen, path, "ANY", relative, line_of(content, offset))
240
+
241
+ def _extract_laminas_controllers(self, content: str, relative: str, result: ScanResult) -> None:
242
+ first: int | None = None
243
+ for match in LAMINAS_CONTROLLER_PATTERN.finditer(content):
244
+ if "controller" not in match.group(1).lower():
245
+ continue
246
+ if first is None:
247
+ first = match.start()
248
+ self._append_unique_hint(
249
+ result.framework_hints, FrameworkHint, f"controller:{match.group(1)}", relative, content, match.start(), 0.8
250
+ )
251
+ if first is not None:
252
+ self._append_unique_hint(
253
+ result.framework_hints, FrameworkHint, "laminas_controller_mapping", relative, content, first, 0.8
254
+ )
255
+
256
+ def _extract_laminas_services(self, content: str, relative: str, result: ScanResult) -> None:
257
+ first: int | None = None
258
+ for match in LAMINAS_SERVICE_PATTERN.finditer(content):
259
+ if not _SERVICE_WORD.search(match.group(1)):
260
+ continue
261
+ if first is None:
262
+ first = match.start()
263
+ self._append_unique_hint(
264
+ result.framework_hints, FrameworkHint, f"service:{match.group(1)}", relative, content, match.start(), 0.7
265
+ )
266
+ manager = re.search(r"['\"]service_manager['\"]", content)
267
+ anchor = manager.start() if manager else first
268
+ if anchor is not None:
269
+ self._append_unique_hint(
270
+ result.framework_hints, FrameworkHint, "laminas_service_manager", relative, content, anchor, 0.8
271
+ )
272
+
273
+ def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
274
+ for pattern in OUTBOUND_PATTERNS:
275
+ for match in pattern.finditer(content):
276
+ self._append_unique_external(result, match.group(1), relative, content, match.start())
277
+
278
+ def _extract_datastores(self, content: str, relative: str, result: ScanResult) -> None:
279
+ for pattern, kind in DATABASE_PATTERNS:
280
+ match = pattern.search(content)
281
+ if match:
282
+ self._append_unique_database(result, kind, relative, content, match.start())
283
+
284
+ def _extract_auth_hints(self, content: str, relative: str, result: ScanResult) -> None:
285
+ for pattern, hint in AUTH_PATTERNS:
286
+ match = pattern.search(content)
287
+ if match:
288
+ self._append_unique_hint(result.auth_hints, AuthHint, hint, relative, content, match.start())
289
+
290
+ def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
291
+ for pattern in SECRET_PATTERNS:
292
+ for match in pattern.finditer(content):
293
+ self._append_unique_secret(result, match.group(1), relative, content, match.start())
294
+
295
+ @staticmethod
296
+ def _load_composer(root: Path) -> dict | None:
297
+ text = read_source(root / "composer.json", root=root)
298
+ if text is None:
299
+ return None
300
+ try:
301
+ data = json.loads(text)
302
+ except json.JSONDecodeError:
303
+ return None
304
+ return data if isinstance(data, dict) else None
305
+
306
+ @staticmethod
307
+ def _composer_offset(text: str, package: str) -> int:
308
+ """Offset of a package's `"name": "constraint"` entry in composer.json (0 if not found)."""
309
+ index = text.find(f'"{package}"')
310
+ return index if index >= 0 else 0
311
+
312
+ @staticmethod
313
+ def _append_unique_route(result: ScanResult, seen: set, path: str, method: str, file: str, line: int) -> None:
314
+ key = (path, method, file)
315
+ if key in seen:
316
+ return
317
+ seen.add(key)
318
+ result.routes.append(Route(path=path, method=method, file=file, line=line))
319
+
320
+ @staticmethod
321
+ def _append_unique_external(result: ScanResult, target: str, file: str, content: str, offset: int) -> None:
322
+ key = (target, file)
323
+ if any((item.target, item.file) == key for item in result.external_calls):
324
+ return
325
+ line = line_of(content, offset)
326
+ result.external_calls.append(
327
+ ExternalCall(target=target, file=file, line=line, evidence_text=line_snippet(content, line) or target)
328
+ )
329
+
330
+ @staticmethod
331
+ def _append_unique_database(result: ScanResult, kind: str, file: str, content: str, offset: int) -> None:
332
+ key = (kind, file)
333
+ if any((item.kind, item.file) == key for item in result.databases):
334
+ return
335
+ line = line_of(content, offset)
336
+ result.databases.append(
337
+ DatabaseHint(kind=kind, file=file, line=line, evidence_text=line_snippet(content, line) or kind)
338
+ )
339
+
340
+ @staticmethod
341
+ def _append_unique_hint(
342
+ bucket: list,
343
+ model: type,
344
+ hint: str,
345
+ file: str,
346
+ content: str,
347
+ offset: int,
348
+ confidence: float | None = None,
349
+ ) -> None:
350
+ """Append an AuthHint/FrameworkHint once per (hint, file), located at ``offset``."""
351
+ if any((item.hint, item.file) == (hint, file) for item in bucket):
352
+ return
353
+ line = line_of(content, offset)
354
+ extra = {"confidence": confidence} if confidence is not None else {}
355
+ bucket.append(
356
+ model(hint=hint, file=file, line=line, evidence_text=line_snippet(content, line) or hint, **extra)
357
+ )
358
+
359
+ @staticmethod
360
+ def _append_unique_secret(result: ScanResult, name: str, file: str, content: str, offset: int) -> None:
361
+ key = (name, file)
362
+ if any((item.name, item.file) == key for item in result.secret_hints):
363
+ return
364
+ line = line_of(content, offset)
365
+ result.secret_hints.append(
366
+ SecretHint(name=name, file=file, line=line, evidence_text=line_snippet(content, line) or name)
367
+ )