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,428 @@
1
+ """Symfony routes from ``#[Route]`` attributes and YAML/XML route config (#3).
2
+
3
+ - ``#[Route('/x', methods: [...])]`` (or ``path: '/x'``) on an action; a
4
+ class-level ``#[Route('/prefix')]`` is joined onto every action's path
5
+ (and its ``methods`` merged), as Symfony's attribute loader does. A class
6
+ route is a route of its own only when no action of the class declares one
7
+ (an invokable controller).
8
+ - ``config/routes.yaml``, ``config/routes/*.yaml`` and ``*.xml`` (and Symfony
9
+ 2/3's ``app/config/routing*.yml``): ``path`` / ``methods`` entries are
10
+ routes, and ``resource`` imports carry a ``prefix`` onto the routes they
11
+ load: attribute controllers under a directory (``../src/Controller/``,
12
+ ``routing.controllers``) or another local route file.
13
+
14
+ The YAML and XML readers are line/tag scanners, not full parsers: they read
15
+ the shapes Symfony's docs and recipes use and skip anything else (localized
16
+ path maps, ``when@dev`` blocks, bundle ``@`` resources). A prefix that can't
17
+ be read (a localized prefix map) becomes a ``{prefix}`` segment rather than a
18
+ wrong path, and access_control isn't resolved for those routes. Symfony keys
19
+ routes by name, so a later import of the same controllers or route file
20
+ replaces the earlier one: each route is emitted once, under the last prefix.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import posixpath
26
+ import re
27
+ from dataclasses import dataclass, field
28
+
29
+ from attackmap.sdk import line_of
30
+
31
+ from . import route_auth as ra
32
+
33
+ HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"})
34
+
35
+ # Route config Symfony loads on its own, in the kernel's order
36
+ # (MicroKernelTrait::configureRoutes): `config/routes/*.yaml` first, sorted,
37
+ # then `config/routes.yaml` last, so the latter's imports win a route-name
38
+ # collision. The kernel's glob is `*.{php,yaml}`: `.yml` / `.xml` files
39
+ # there are only read when something imports them. Symfony 2/3's
40
+ # `app/config/routing.*` comes from the framework config.
41
+ ROUTE_CONFIG_DIR = "config/routes"
42
+ ROUTE_CONFIG_DIR_SUFFIXES = (".yaml",)
43
+ ROUTE_CONFIG_LAST = ("config/routes.yaml",)
44
+ LEGACY_ROUTE_CONFIG_FILES = ("app/config/routing.yml", "app/config/routing.yaml", "app/config/routing.xml")
45
+ ROUTE_CONFIG_SUFFIXES = (".yaml", ".yml", ".xml")
46
+ # Imports followed this deep; files loaded and entries read at most this many.
47
+ MAX_IMPORT_DEPTH = 4
48
+ MAX_CONFIG_FILES = 64
49
+ # Distinct prefixes (different route-name prefixes) per attribute route.
50
+ MAX_PREFIXES = 8
51
+ MAX_ENTRIES = 2000
52
+ MAX_LINES = 20000
53
+
54
+ _ROUTE_ATTR = re.compile(r"\\?(?:[\w\\]+\\)?Route\s*\(")
55
+ _POSITIONAL_PATH = re.compile(r"\s*(?:'([^'\\]*)'|\"([^\"\\]*)\")")
56
+ _NAMED_PATH = re.compile(r"\bpath\s*:\s*(?:'([^'\\]*)'|\"([^\"\\]*)\")")
57
+ _NAMED_METHODS = re.compile(r"\bmethods\s*:\s*(\[[^\]]*\]|'[^']*'|\"[^\"]*\")")
58
+ _METHOD_NAME = re.compile(r"[A-Za-z]+")
59
+
60
+
61
+ @dataclass
62
+ class RouteAttr:
63
+ path: str | None # None: no literal path (localized map, constant, ...)
64
+ methods: list[str]
65
+
66
+
67
+ def route_attribute(item: str) -> RouteAttr | None:
68
+ """A ``Route(...)`` attribute (one item of a ``#[...]`` group), or None."""
69
+ match = _ROUTE_ATTR.match(item)
70
+ if not match:
71
+ return None
72
+ args = item[match.end() :]
73
+ path = None
74
+ positional = _POSITIONAL_PATH.match(args)
75
+ if positional:
76
+ path = positional.group(1) if positional.group(1) is not None else positional.group(2)
77
+ else:
78
+ named = _NAMED_PATH.search(args)
79
+ if named:
80
+ path = named.group(1) if named.group(1) is not None else named.group(2)
81
+ elif not args.lstrip().startswith("["):
82
+ # `#[Route(name: 'x_')]`: no path; a class prefix of ''.
83
+ path = "" if "path" not in args else None
84
+ methods = []
85
+ named_methods = _NAMED_METHODS.search(args)
86
+ if named_methods:
87
+ methods = _methods(named_methods.group(1))
88
+ return RouteAttr(path, methods)
89
+
90
+
91
+ def _methods(text: str) -> list[str]:
92
+ found = [m.upper() for m in _METHOD_NAME.findall(text)]
93
+ return list(dict.fromkeys(m for m in found if m in HTTP_METHODS))
94
+
95
+
96
+ def class_path(prefix: str, path: str) -> str:
97
+ """Symfony's attribute loader concatenates the class and action paths."""
98
+ return prefix + path
99
+
100
+
101
+ def import_prefix(prefix: str, path: str) -> str:
102
+ """``RouteCollection::addPrefix``: ``'/' . trim($prefix, '/') . $path``."""
103
+ prefix = prefix.strip().strip("/")
104
+ if not prefix:
105
+ return path
106
+ return "/" + prefix + path
107
+
108
+
109
+ # ---------- route config ----------
110
+
111
+
112
+ @dataclass
113
+ class ConfigRoute:
114
+ method: str
115
+ path: str
116
+ file: str
117
+ line: int
118
+
119
+
120
+ @dataclass
121
+ class RouteConfig:
122
+ """Routes from config files, and the import prefixes of attribute
123
+ controllers.
124
+
125
+ Symfony keys routes by name: an import that re-adds a route name
126
+ replaces the earlier route. So an import of the same controllers or the
127
+ same route file with the same effective ``name_prefix`` replaces the
128
+ earlier one (the last in load order wins), while imports with different
129
+ name prefixes both stay live.
130
+ """
131
+
132
+ # (config file, entry line, name prefix) -> routes.
133
+ route_sets: dict[tuple[str, int, str], list[ConfigRoute]] = field(default_factory=dict)
134
+ # target (dir ending in "/", or file) -> name prefix -> (load order, path prefix).
135
+ imports_by_target: dict[str, dict[str, tuple[int, str]]] = field(default_factory=dict)
136
+ _order: int = 0
137
+
138
+ @property
139
+ def routes(self) -> list[ConfigRoute]:
140
+ return [route for routes in self.route_sets.values() for route in routes]
141
+
142
+ def add_import(self, target: str, prefix: str, name_prefix: str = "") -> None:
143
+ """Record an import. Each target keeps the MAX_PREFIXES most recent
144
+ name prefixes (dict order is recency), so a lookup stays bounded."""
145
+ self._order += 1
146
+ entries = self.imports_by_target.setdefault(target, {})
147
+ entries.pop(name_prefix, None)
148
+ entries[name_prefix] = (self._order, prefix)
149
+ if len(entries) > MAX_PREFIXES:
150
+ del entries[next(iter(entries))]
151
+
152
+ def prefixes_for(self, file: str) -> list[str]:
153
+ """Import prefixes for attribute routes in ``file``: for each name
154
+ prefix, the last import (in load order) covering the file. A prefix
155
+ that couldn't be read is a ``{prefix}`` segment. Looks up the file
156
+ and each ancestor directory, so it's O(path depth)."""
157
+ if not self.imports_by_target:
158
+ return [""]
159
+ parts = file.split("/")
160
+ candidates = ["./", file] + ["/".join(parts[:i]) + "/" for i in range(1, len(parts))]
161
+ latest: dict[str, tuple[int, str]] = {}
162
+ for target in candidates:
163
+ for name_prefix, (order, prefix) in self.imports_by_target.get(target, {}).items():
164
+ if name_prefix not in latest or order > latest[name_prefix][0]:
165
+ latest[name_prefix] = (order, prefix)
166
+ if not latest:
167
+ return [""]
168
+ ordered = sorted(latest.values())
169
+ # The most recent imports, oldest first.
170
+ return list(dict.fromkeys(prefix for _, prefix in ordered))[-MAX_PREFIXES:]
171
+
172
+
173
+ @dataclass
174
+ class _Entry:
175
+ name: str
176
+ line: int
177
+ values: dict[str, str]
178
+ nested: set[str] # keys whose value is a nested mapping/list
179
+
180
+
181
+ _FLOW_PAIR = re.compile(r"([\w.@-]+)\s*:\s*('[^']*'|\"[^\"]*\"|\[[^\]]*\]|\{|[^,{}\[\]]+)")
182
+ _FLOW_PATH = re.compile(r"\bpath\s*:\s*([^,}]+)")
183
+ _YAML_KEY = re.compile(r"([\w.@-]+|'[^']*'|\"[^\"]*\")\s*:(?:\s+(.*))?$")
184
+
185
+
186
+ def _unquote(value: str) -> str:
187
+ value = value.strip()
188
+ if " #" in value:
189
+ value = value.split(" #", 1)[0].rstrip()
190
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
191
+ return value[1:-1]
192
+ return value
193
+
194
+
195
+ def _flow_entry(entry: _Entry, text: str) -> None:
196
+ """Top-level keys of a one-line flow mapping. A key whose value is a
197
+ nested mapping (``prefix: {en: '', nl: /nl}``) is recorded as nested,
198
+ and that mapping's own keys are skipped."""
199
+ body = text[1:]
200
+ pos = 0
201
+ depth = 0
202
+ parent = None
203
+ for pair in _FLOW_PAIR.finditer(body):
204
+ between = body[pos : pair.start()]
205
+ depth += between.count("{") - between.count("}")
206
+ pos = pair.end()
207
+ name, value = pair.group(1), pair.group(2)
208
+ if depth == 1 and parent is not None and value != "{":
209
+ # `resource: {path: ../src/Controller/, namespace: ...}`.
210
+ entry.values.setdefault(f"{parent}.{name}", value.strip())
211
+ continue
212
+ if depth > 0:
213
+ continue
214
+ if value == "{":
215
+ entry.nested.add(name)
216
+ parent = name
217
+ depth += 1
218
+ elif name not in entry.values and name not in entry.nested:
219
+ entry.values[name] = value.strip()
220
+
221
+
222
+ def parse_yaml_entries(text: str) -> list[_Entry]:
223
+ """Top-level route entries (``name:`` + indented keys) of a routes YAML.
224
+
225
+ ``when@prod:`` blocks are read as top-level; other ``when@<env>`` blocks
226
+ are skipped (dev/test only). A key whose value is a nested mapping is
227
+ recorded as ``key.subkey``."""
228
+ entries: list[_Entry] = []
229
+ entry: _Entry | None = None
230
+ child_indent: int | None = None
231
+ parent_key: str | None = None
232
+ base: int | None = 0 # indent of route names; None: set by the next line
233
+ skipping = False
234
+ for number, raw in enumerate(text.splitlines()[:MAX_LINES], start=1):
235
+ stripped = raw.strip()
236
+ if not stripped or stripped.startswith("#") or stripped == "---":
237
+ continue
238
+ indent = len(raw) - len(raw.lstrip(" "))
239
+ key = _YAML_KEY.match(stripped)
240
+ if indent == 0:
241
+ entry = None
242
+ skipping = False
243
+ base = 0
244
+ if key and key.group(1).startswith("when@"):
245
+ if key.group(1) == "when@prod":
246
+ base = None
247
+ else:
248
+ skipping = True
249
+ continue
250
+ elif skipping:
251
+ continue
252
+ if base is None:
253
+ base = indent
254
+ if indent == base:
255
+ entry = None
256
+ if key and len(entries) < MAX_ENTRIES:
257
+ entry = _Entry(_unquote(key.group(1)), number, {}, set())
258
+ entries.append(entry)
259
+ child_indent = None
260
+ parent_key = None
261
+ inline = (key.group(2) or "").strip()
262
+ if inline.startswith("{"):
263
+ # `name: { path: /x, methods: [GET] }` on one line.
264
+ _flow_entry(entry, inline)
265
+ entry = None
266
+ continue
267
+ if entry is None or not key or indent < base:
268
+ continue
269
+ name = _unquote(key.group(1))
270
+ value = key.group(2)
271
+ if child_indent is None:
272
+ child_indent = indent
273
+ if indent == child_indent:
274
+ parent_key = name
275
+ if value is None or not value.strip() or value.strip().startswith("#"):
276
+ entry.nested.add(name)
277
+ else:
278
+ entry.values[name] = value.strip()
279
+ elif indent > child_indent and parent_key is not None and value is not None:
280
+ entry.values[f"{parent_key}.{name}"] = value.strip()
281
+ return entries
282
+
283
+
284
+ _XML_COMMENT_OPEN = "<!--"
285
+ _XML_TAG = re.compile(r"<(route|import)\b([^<>]{0,4000})>")
286
+ _XML_ATTR = re.compile(r"([\w:-]+)\s*=\s*(?:\"([^\"]*)\"|'([^']*)')")
287
+
288
+
289
+ def _strip_xml_comments(text: str) -> str:
290
+ """Blank out ``<!-- ... -->`` (keeping offsets), one forward scan."""
291
+ out = []
292
+ pos = 0
293
+ while True:
294
+ start = text.find(_XML_COMMENT_OPEN, pos)
295
+ if start < 0:
296
+ out.append(text[pos:])
297
+ break
298
+ end = text.find("-->", start + 4)
299
+ stop = len(text) if end < 0 else end + 3
300
+ out.append(text[pos:start])
301
+ out.append("".join(ch if ch == "\n" else " " for ch in text[start:stop]))
302
+ pos = stop
303
+ if end < 0:
304
+ break
305
+ return "".join(out)
306
+
307
+
308
+ def parse_xml_entries(text: str) -> list[_Entry]:
309
+ text = _strip_xml_comments(text)
310
+ entries = []
311
+ for match in _XML_TAG.finditer(text):
312
+ attrs = {m.group(1): m.group(2) if m.group(2) is not None else m.group(3) for m in _XML_ATTR.finditer(match.group(2))}
313
+ if match.group(1) == "route":
314
+ values = {k: v for k, v in attrs.items() if k in ("path", "methods", "controller")}
315
+ nested = set() if "path" in attrs else {"path"} # <path locale="..."> children
316
+ else:
317
+ values = {k: v for k, v in attrs.items() if k in ("resource", "prefix", "type", "name-prefix")}
318
+ nested = set()
319
+ entries.append(_Entry(attrs.get("id", ""), line_of(text, match.start()), values, nested))
320
+ if len(entries) >= MAX_ENTRIES:
321
+ break
322
+ return entries
323
+
324
+
325
+ def _resolve(base_dir: str, resource: str) -> str | None:
326
+ """A repo-relative path for ``resource`` (relative to the config file's
327
+ directory), or None for bundle (``@``), absolute or out-of-repo paths."""
328
+ resource = resource.strip()
329
+ if not resource or resource.startswith(("@", "/", "%")) or "://" in resource:
330
+ return None
331
+ trailing = resource.endswith("/")
332
+ target = posixpath.normpath(posixpath.join(base_dir, resource))
333
+ if target == ".." or target.startswith("../"):
334
+ return None
335
+ if target == ".":
336
+ return "./"
337
+ return target + "/" if trailing or not posixpath.splitext(target)[1] else target
338
+
339
+
340
+ def load_route_config(read, list_dir) -> RouteConfig:
341
+ """Read the repo's Symfony route config.
342
+
343
+ ``read(relative) -> str | None`` reads a repo file (contained, None when
344
+ missing); ``list_dir(relative) -> list[str]`` lists a directory's files
345
+ (repo-relative). Kept I/O-free so it can be fuzzed directly."""
346
+ config = RouteConfig()
347
+ roots = sorted(f for f in list_dir(ROUTE_CONFIG_DIR) if f.endswith(ROUTE_CONFIG_DIR_SUFFIXES))
348
+ roots += [*ROUTE_CONFIG_LAST, *LEGACY_ROUTE_CONFIG_FILES]
349
+ parsed: dict[str, list[_Entry] | None] = {}
350
+ loads = 0
351
+
352
+ def entries_of(relative: str) -> list[_Entry] | None:
353
+ if relative not in parsed:
354
+ text = read(relative)
355
+ if text is None:
356
+ parsed[relative] = None
357
+ else:
358
+ parsed[relative] = parse_xml_entries(text) if relative.endswith(".xml") else parse_yaml_entries(text)
359
+ return parsed[relative]
360
+
361
+ def load(relative: str, prefix: str, name_prefix: str, depth: int) -> None:
362
+ # Loaded again on a re-import (a later import wins), up to the cap.
363
+ nonlocal loads
364
+ if loads >= MAX_CONFIG_FILES or depth > MAX_IMPORT_DEPTH:
365
+ return
366
+ loads += 1
367
+ entries = entries_of(relative)
368
+ if entries is None:
369
+ return
370
+ base_dir = posixpath.dirname(relative)
371
+ for entry in entries:
372
+ values = entry.values
373
+ if "resource" in values or "resource" in entry.nested:
374
+ _import(entry, base_dir, prefix, name_prefix, depth)
375
+ continue
376
+ if "path" in entry.nested or "path" not in values:
377
+ continue # localized path map, or not a route
378
+ path = _unquote(values["path"])
379
+ if not path.startswith("/"):
380
+ path = "/" + path
381
+ methods = _methods(_unquote(values.get("methods", ""))) or ["ANY"]
382
+ full = import_prefix(prefix, path)
383
+ key = (relative, entry.line, name_prefix)
384
+ config.route_sets.pop(key, None)
385
+ config.route_sets[key] = [ConfigRoute(method, full, relative, entry.line) for method in methods]
386
+
387
+ def _import(entry: _Entry, base_dir: str, prefix: str, name_prefix: str, depth: int) -> None:
388
+ values = entry.values
389
+ resource = values.get("resource") or values.get("resource.path")
390
+ if resource is None:
391
+ return
392
+ resource = _unquote(resource)
393
+ if resource.startswith("{"):
394
+ # Flow mapping: `resource: { path: ../src/Controller/, namespace: App\Controller }`.
395
+ inner = _FLOW_PATH.search(resource)
396
+ if inner is None:
397
+ return
398
+ resource = _unquote(inner.group(1))
399
+ if "prefix" in entry.nested or _unquote(values.get("prefix", "")).startswith("{"):
400
+ # A localized prefix map (`prefix: {en: '', nl: /nl}`): the
401
+ # real path is one of several, so mark the segment unknown.
402
+ own = "/" + ra.DYNAMIC_SEGMENT
403
+ else:
404
+ own = _unquote(values.get("prefix", ""))
405
+ combined = _join_prefix(prefix, own)
406
+ names = name_prefix + _unquote(values.get("name_prefix", values.get("name-prefix", "")))
407
+ kind = _unquote(values.get("type", ""))
408
+ if resource == "routing.controllers":
409
+ # Symfony 7.1+: every attribute-routed controller (src/).
410
+ config.add_import("src/", combined, names)
411
+ return
412
+ target = _resolve(base_dir, resource)
413
+ if target is None:
414
+ return
415
+ if target.endswith(ROUTE_CONFIG_SUFFIXES) and kind not in ("attribute", "annotation"):
416
+ load(target, combined, names, depth + 1)
417
+ elif kind in ("attribute", "annotation") or target.endswith("/") or target.endswith(".php"):
418
+ config.add_import(target, combined, names)
419
+
420
+ for relative in roots:
421
+ load(relative, "", "", 0)
422
+ return config
423
+
424
+
425
+ def _join_prefix(outer: str, inner: str) -> str:
426
+ inner = inner.strip().strip("/")
427
+ outer = outer.strip().rstrip("/")
428
+ return f"{outer}/{inner}" if inner else outer
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: attackmap-analyzer-php-web
3
+ Version: 0.2.0
4
+ Summary: Broad PHP web analyzer plugin for AttackMap
5
+ Author: AttackMap Contributors
6
+ Author-email: Matthew Davis <mlaify@mlaify.io>
7
+ Maintainer-email: Matthew Davis <mlaify@mlaify.io>
8
+ License-Expression: MIT
9
+ Project-URL: Homepage, https://github.com/mlaify/attackmap-analyzer-php-web
10
+ Project-URL: Repository, https://github.com/mlaify/attackmap-analyzer-php-web
11
+ Project-URL: Issues, https://github.com/mlaify/attackmap-analyzer-php-web/issues
12
+ Project-URL: Changelog, https://github.com/mlaify/attackmap-analyzer-php-web/blob/main/CHANGELOG.md
13
+ Project-URL: AttackMap core, https://github.com/mlaify/AttackMap
14
+ Keywords: security,static-analysis,appsec,defensive-security,attackmap,attackmap-plugin
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Information Technology
18
+ Classifier: Intended Audience :: System Administrators
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3 :: Only
25
+ Classifier: Topic :: Security
26
+ Classifier: Topic :: Software Development :: Quality Assurance
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: pydantic>=2.7.0
32
+ Provides-Extra: core
33
+ Requires-Dist: attackmap>=0.6.0; extra == "core"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
36
+ Requires-Dist: build>=1.2.0; extra == "dev"
37
+ Requires-Dist: attackmap>=0.6.0; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # attackmap-analyzer-php-web
41
+
42
+ > [!IMPORTANT]
43
+ > **Active development, slow pace.** AttackMap is under active development, but
44
+ > progress may be slow until more contributors or co-maintainers join. Help is
45
+ > very welcome with the core engine, an analyzer, the macOS app, or the docs —
46
+ > see [CONTRIBUTING.md](CONTRIBUTING.md) or open an issue on
47
+ > [mlaify/AttackMap](https://github.com/mlaify/AttackMap/issues) to say hello.
48
+ > Security reports are still welcome at [security@mlaify.io](mailto:security@mlaify.io).
49
+
50
+ Broad PHP web analyzer for [AttackMap](https://github.com/mlaify/AttackMap).
51
+
52
+ This repository is intentionally separate from AttackMap core. It focuses only on extracting structured signals from PHP repositories:
53
+
54
+ - routes
55
+ - outbound HTTP calls
56
+ - datastore hints
57
+ - auth hints
58
+ - secret and env usage
59
+
60
+ It does not render reports and does not own global severity policy.
61
+
62
+ ## Analyzer identity
63
+
64
+ - `name`: `php-web`
65
+ - `display_name`: `PHP Web Analyzer`
66
+ - `version`: `0.1.0`
67
+ - `experimental`: `true`
68
+ - `enabled_by_default`: `false`
69
+
70
+ ## Scope
71
+
72
+ This analyzer is broad and heuristic. It is meant to map common PHP web application surfaces before framework-specific analyzers (for example `php-laminas` or `omeka-s`) are added.
73
+
74
+ ## Detection strategy
75
+
76
+ `detect(repo_path)` uses lightweight signals:
77
+
78
+ - `composer.json` present, or
79
+ - at least one `.php` file outside `vendor/` (and the other shared skip dirs)
80
+
81
+ A `src/`, `app/` or `config/` directory on its own is not enough, since Python, Go and Java repos have those too.
82
+
83
+ ## Extraction strategy
84
+
85
+ `analyze(repo_path)` scans `.php` files and emits structured signals using regular-expression heuristics.
86
+
87
+ Route extraction currently includes patterns such as:
88
+
89
+ - Laravel `Route::get|post|put|patch|delete|options|head|any(...)`, array-first `Route::match(['get', 'post'], ...)`, and a scope chained before the verb (`Route::middleware('auth')->get(...)`). `Route::resource` / `apiResource` / `resources` / `apiResources` expand to their REST routes (7 / 5 actions; `update` is `PUT` and `PATCH`), honouring `->only()` / `->except()` / `->shallow()`, an `['only' => ...]` options array and nested `photos.comments` names.
90
+ - Laravel groups: `Route::prefix('admin')->middleware(...)->group(function () { ... })` and `Route::group(['prefix' => ..., 'middleware' => ...], fn)` join their prefixes onto the routes inside, at any nesting depth up to 32. `Route::controller(C::class)->group(...)` dispatches bare method names to `C`.
91
+ - Laravel route files take the prefix and middleware of the group that loads them: `->group(base_path('routes/admin.php'))`, `require base_path(...)` / `$this->loadRoutesFrom(__DIR__.'/...')` inside a group, and `withRouting(api: ... | [...], apiPrefix: ...)` in `bootstrap/app.php`. `routes/api.php` defaults to `/api` when nothing outside test code mounts it. A `require_once` that loops back to a file already being loaded is a no-op. A `config('file.key')` prefix is read from `config/file.php` (a literal or an `env()` default), and any other non-literal prefix becomes a `{prefix}` segment.
92
+ - `$app->get('/path', ...)` / `$app->map(['GET', 'POST'], '/path', ...)` Slim calls. The path must start with `/`, and the receiver must be `$app`, `$router`, `$route`, `$group` or `$r`, a variable assigned from `AppFactory::create()` / `new RouteCollector` / `new App`, or a parameter typed `RouteCollectorProxy` / `RouteCollector` / `App`. This keeps `$request->get('id')` and `$cache->get('k')` out. Slim `$app->group('/v1', ...)` and Lumen `$router->group(['prefix' => 'api'], ...)` prefixes are joined.
93
+ - Symfony `#[Route('/path', methods: ['GET'])]` (or `path:`) attributes, joined onto the class-level `#[Route('/prefix')]` (whose `methods` merge in). A class-level route is a route of its own only on a controller with no routed actions (`__invoke`).
94
+ - Symfony route config, loaded in the kernel's order (`config/routes/*.yaml` sorted, then `config/routes.yaml`; plus Symfony 2/3's `app/config/routing.yml`; other files only when imported): `path` / `methods` entries, and `resource` imports with a `prefix`, applied to attribute controllers under the imported directory (or `routing.controllers`, i.e. `src/`) or to an imported local route file. A later import of the same controllers or route file with the same `name_prefix` replaces the earlier one (Symfony keys routes by name), so each route is reported once per distinct name prefix, under the last import's prefix. A localized prefix map becomes a `{prefix}` segment. Localized path maps and `when@dev` / `when@test` blocks are skipped.
95
+ - config-style `"path" => "/..."`, only in files that also declare a `'routes'` / `'router'` key (so Laravel's `config/logging.php` paths aren't routes)
96
+
97
+ Brackets are matched in one string-, comment- and heredoc-aware pass per file, so a commented-out `Route::get(...)` is not a route and a `}` inside a string doesn't end a group.
98
+
99
+ Each route carries `auth`, `guards` and `guard_evidence` (AttackMap#256):
100
+
101
+ | State | When |
102
+ |---|---|
103
+ | `required` | Laravel `->middleware('auth'…)` on the route, an enclosing `Route::middleware([...])->group(...)` / `Route::group(['middleware' => ...], ...)`, or the controller's `$this->middleware('auth')` (honouring `->only()` / `->except()`); Slim `->add($authMiddleware)` / `->add(new JwtAuthentication(...))` on the route or an enclosing `->group(...)`; Symfony `#[IsGranted]` / `#[Security]` on the action or its class, or a matching `security.yaml` `access_control` rule |
104
+ | `anonymous` | Laravel `->withoutMiddleware('auth')` on the route or a group, or a controller `->except([...])` naming the action; Symfony `#[IsGranted('PUBLIC_ACCESS')]` or an `access_control` rule granting `PUBLIC_ACCESS` — when nothing else requires auth |
105
+ | `unknown` | anything else |
106
+
107
+ `access_control` is only applied to rules with literal `^/prefix` paths. A rule with `ips`, `host`, `allow_if` or a regex path stops resolution for the routes after it. Rules are matched against the full path (class and import prefixes joined); attribute routes under an import prefix that can't be read (a localized prefix map) are skipped. Laravel 11 `HasMiddleware::middleware()` controllers, Slim app-wide `$app->add(...)` and `can:` middleware aren't resolved.
108
+
109
+ Outbound calls currently include patterns such as:
110
+
111
+ - `curl_init("https://...")`
112
+ - `file_get_contents("https://...")`
113
+ - common HTTP client calls such as `->request(...)`, `->get(...)`, `->post(...)`
114
+
115
+ Datastore/auth/secret hints are similarly heuristic and intended as first-pass signals. Secret hints are env var names (`getenv`, `$_ENV`, `$_SERVER`) containing `SECRET`, `TOKEN`, `KEY`, `PASSWORD` or `PASSWD`. `DB_HOST`-style connection settings are not secrets. `jwt` needs a JWT library (`Firebase\JWT\JWT`, `JWT::decode/encode`, `Lcobucci\JWT`, `tymon/jwt-auth`), and `auth` needs the `Auth::` facade or a chained `auth()->…` helper.
116
+
117
+ ## Installation
118
+
119
+ ```bash
120
+ uv tool install attackmap --with attackmap-analyzer-php-web \
121
+ --index mlaify=https://pkg.mlaify.io/pypi/simple/ --index-strategy first-index
122
+ # or from source:
123
+ pip install git+https://github.com/mlaify/attackmap-analyzer-php-web.git
124
+ ```
125
+
126
+ For local development:
127
+
128
+ ```bash
129
+ pip install -e .[dev]
130
+ ```
131
+
132
+ ## Contract alignment with AttackMap core
133
+
134
+ This package targets the current AttackMap analyzer contract:
135
+
136
+ - analyzer exposes metadata via `metadata`
137
+ - analyzer implements `detect(repo_path)` and `analyze(repo_path)`
138
+ - `analyze` returns AttackMap-style structured scan data (`ScanResult` shape)
139
+
140
+ The package includes a small compatibility layer so tests can run even if AttackMap core is not installed.
141
+
142
+ ## Future core discovery (documented, not implemented here)
143
+
144
+ AttackMap core can discover this analyzer later via one of these options:
145
+
146
+ 1. entry points (preferred long-term)
147
+ 2. explicit configured analyzer list
148
+ 3. namespace/package scanning in `github.com/mlaify`
149
+
150
+ This repository does not implement core-side discovery logic.
151
+
152
+ ## Limitations
153
+
154
+ - regex-based extraction (not AST)
155
+ - limited config route parsing; the Symfony YAML/XML readers cover the shapes Symfony's docs and recipes use, not full YAML/XML
156
+ - Laravel route names (`->name()` / `'as'`), `Route::view` / `Route::redirect` / `Route::singleton`, and resource `->parameters([...])` renames aren't modelled
157
+ - no framework-specific deep parsing yet
158
+ - no dataflow or reachability modeling inside this analyzer
159
+
160
+ These are deliberate to keep this first external analyzer incremental and maintainable.
@@ -0,0 +1,11 @@
1
+ attackmap_analyzer_php_web/__init__.py,sha256=WoyvHGSSsC39QiMB9Zi8L1G-yxxt7w2rM8pQitRr68E,130
2
+ attackmap_analyzer_php_web/analyzer.py,sha256=BUoT7ilIluaGeaHiqFKyWccyRwmV3LhDusbJHRB59LQ,29588
3
+ attackmap_analyzer_php_web/laravel_routes.py,sha256=xswEG7Y5s5FXAo4C4gNAxEz3TpB5xxd9QxPrXwtimfs,28661
4
+ attackmap_analyzer_php_web/route_auth.py,sha256=nGhtPBJQUSa7Gu8h1CIfx1Aaa2YAFdd66V_kK_oMbZE,25606
5
+ attackmap_analyzer_php_web/symfony_routes.py,sha256=7kcObkPOKpcCeDCJZzPnR3Pg5wsBWr6tTq8kWC7j-vA,17462
6
+ attackmap_analyzer_php_web-0.2.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
7
+ attackmap_analyzer_php_web-0.2.0.dist-info/METADATA,sha256=t5-p4-DCQz8aGTkh1eB6hsLceSPKKccytjn700qyoZc,10236
8
+ attackmap_analyzer_php_web-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ attackmap_analyzer_php_web-0.2.0.dist-info/entry_points.txt,sha256=GCrZaoTapBvwl8DNL0lM7yyvmvLL8qJmqQLGr8ekXMg,74
10
+ attackmap_analyzer_php_web-0.2.0.dist-info/top_level.txt,sha256=2hL5g79YjmLnbFa4mkFsUoMmHqNGlcqLI2rb7d2iUWo,27
11
+ attackmap_analyzer_php_web-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ php_web = attackmap_analyzer_php_web:PhpWebAnalyzer
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Davis and AttackMap Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ attackmap_analyzer_php_web