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