attackmap-analyzer-python 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_python/__init__.py +3 -0
- attackmap_analyzer_python/_pyast.py +90 -0
- attackmap_analyzer_python/analyzer.py +783 -0
- attackmap_analyzer_python/django_urls.py +667 -0
- attackmap_analyzer_python/litestar_routes.py +358 -0
- attackmap_analyzer_python/sanic_routes.py +77 -0
- attackmap_analyzer_python/tornado_routes.py +182 -0
- attackmap_analyzer_python-0.2.0.dist-info/METADATA +134 -0
- attackmap_analyzer_python-0.2.0.dist-info/RECORD +13 -0
- attackmap_analyzer_python-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_python-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_python-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_python-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Django URLconf include() graph (mlaify/attackmap-analyzer-python#2).
|
|
2
|
+
|
|
3
|
+
Django mounts one URLconf inside another with ``include()``; the child's
|
|
4
|
+
patterns only exist under the parent's prefix. This module reads every URLconf
|
|
5
|
+
(and every DRF router module) with :mod:`ast`, records what each module-level
|
|
6
|
+
pattern list contains, and then walks the include graph from the root
|
|
7
|
+
URLconfs so each leaf route carries its full prefix:
|
|
8
|
+
|
|
9
|
+
- ``path("api/", include("shop.api_urls"))`` — a dotted module path
|
|
10
|
+
- ``include(api_urls)`` / ``include(views.urlpatterns)`` — an imported module
|
|
11
|
+
- ``include(router.urls)`` / ``urlpatterns = router.urls`` — a DRF router
|
|
12
|
+
- ``include((patterns, "app"))`` — the ``(patterns, app_name)`` tuple form
|
|
13
|
+
- ``include([path(...), ...])`` and ``include(local_list)`` — inline lists
|
|
14
|
+
- ``re_path`` / legacy ``url`` regex segments (anchors are stripped on join)
|
|
15
|
+
|
|
16
|
+
A resolved ``include()`` line is a prefix, not an endpoint, so it is no
|
|
17
|
+
longer emitted as a route. An include that can't be resolved inside the repo
|
|
18
|
+
(``include("allauth.urls")``, ``include(get_model_urls(...))``) is still
|
|
19
|
+
emitted as its prefix so the mounted surface stays visible.
|
|
20
|
+
|
|
21
|
+
Within the bounds below, no route the regex extractor used to report is
|
|
22
|
+
lost:
|
|
23
|
+
|
|
24
|
+
- A pattern list is the *union* of everything assigned to it. Reassignments,
|
|
25
|
+
if/else and try/except alternatives, and wrappers
|
|
26
|
+
(``urlpatterns = decorate_urlpatterns(urlpatterns, ...)``) all add; nothing
|
|
27
|
+
replaces. A route that every branch replaces is still reported, which trades
|
|
28
|
+
a little precision for recall.
|
|
29
|
+
- A list, router or module that is never reached from a root URLconf is still
|
|
30
|
+
emitted, unprefixed (the pre-#2 behaviour), so a dynamic mount the graph
|
|
31
|
+
can't follow keeps its routes.
|
|
32
|
+
|
|
33
|
+
Everything is bounded: entries per list and per file, registrations per
|
|
34
|
+
router, include depth, mounts per list, steps (router registrations included)
|
|
35
|
+
and routes. Hitting a bound truncates instead of hanging. Nothing here touches
|
|
36
|
+
the filesystem: module references resolve only against the files the analyzer
|
|
37
|
+
already read.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
import posixpath
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
|
|
46
|
+
from ._pyast import call_name, dotted, safe_parse, str_const
|
|
47
|
+
|
|
48
|
+
# name → is the first argument a regex?
|
|
49
|
+
_URL_FUNCS = {"path": False, "re_path": True, "url": True}
|
|
50
|
+
# Calls that wrap a pattern list and return it (or a superset of it).
|
|
51
|
+
# `patterns("view.prefix", url(...), ...)` is the pre-1.10 form.
|
|
52
|
+
_PASSTHROUGH_FUNCS = {"i18n_patterns", "format_suffix_patterns", "patterns", "list", "tuple"}
|
|
53
|
+
|
|
54
|
+
# Auth wrappers applied to a view right in the URLconf (#256):
|
|
55
|
+
# `path("x/", login_required(views.x))`, `permission_required("p")(View.as_view())`.
|
|
56
|
+
# Django 5.1's `login_not_required` marks a view explicitly public.
|
|
57
|
+
_AUTH_WRAPPERS = {"login_required", "permission_required", "staff_member_required", "user_passes_test"}
|
|
58
|
+
_PUBLIC_WRAPPERS = {"login_not_required"}
|
|
59
|
+
_MAX_WRAP_DEPTH = 6
|
|
60
|
+
|
|
61
|
+
MAX_DEPTH = 12
|
|
62
|
+
# Per-file model bounds: entries in one pattern list, entries across a file's
|
|
63
|
+
# lists, and registrations on one router. A real URLconf is far below these.
|
|
64
|
+
MAX_LIST_ENTRIES = 5_000
|
|
65
|
+
MAX_FILE_ENTRIES = 20_000
|
|
66
|
+
MAX_ROUTER_REGISTRATIONS = 5_000
|
|
67
|
+
MAX_PREFIXES_PER_LIST = 64
|
|
68
|
+
MAX_STEPS = 200_000
|
|
69
|
+
MAX_ROUTES = 50_000
|
|
70
|
+
_MAX_EXPR_DEPTH = 64
|
|
71
|
+
_ORPHANS = "<orphans>"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Target:
|
|
76
|
+
"""What an ``include()`` (or a bare list/attribute mount) points at."""
|
|
77
|
+
|
|
78
|
+
kind: str # "module" | "name" | "attr" | "inline" | "unknown"
|
|
79
|
+
value: str = "" # dotted module, local/imported name, or attribute base
|
|
80
|
+
attr: str = "" # for kind="attr": the attribute (`urls`, `urlpatterns`)
|
|
81
|
+
entries: list["Entry"] = field(default_factory=list) # kind="inline"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Entry:
|
|
86
|
+
kind: str # "leaf" | "include" | "mount"
|
|
87
|
+
line: int
|
|
88
|
+
segment: str | None = None # None: a non-literal route (e.g. settings.BASE_PATH)
|
|
89
|
+
regex: bool = False
|
|
90
|
+
target: Target | None = None
|
|
91
|
+
auth: str = "unknown"
|
|
92
|
+
guard: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class UrlConf:
|
|
97
|
+
rel: str
|
|
98
|
+
is_urlconf: bool
|
|
99
|
+
emit_unmounted_routers: bool
|
|
100
|
+
lists: dict[str, list[Entry]] = field(default_factory=dict)
|
|
101
|
+
# router variable → [(prefix, line)] from `router.register("prefix", ViewSet)`
|
|
102
|
+
routers: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
|
|
103
|
+
# bound name → (relative-import level, module, imported attribute or "")
|
|
104
|
+
imports: dict[str, tuple[int, str, str]] = field(default_factory=dict)
|
|
105
|
+
truncated: bool = False # a per-file cap was hit
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class DjangoRoute:
|
|
110
|
+
path: str
|
|
111
|
+
file: str
|
|
112
|
+
line: int
|
|
113
|
+
auth: str = "unknown"
|
|
114
|
+
guards: list[str] = field(default_factory=list)
|
|
115
|
+
evidence: str | None = None
|
|
116
|
+
method: str = "ANY" # a URLconf doesn't say which verbs the view takes
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------- Parsing ----------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_urlconf(content: str, rel: str, *, is_urlconf: bool, drf: bool) -> UrlConf | None:
|
|
123
|
+
"""Build the URLconf model for one file, or ``None`` when it can't be
|
|
124
|
+
parsed (the caller then falls back to the regex extractor)."""
|
|
125
|
+
tree = safe_parse(content)
|
|
126
|
+
if tree is None:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
return _Builder(rel, is_urlconf=is_urlconf, drf=drf).build(tree)
|
|
130
|
+
except RecursionError:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _Builder:
|
|
135
|
+
def __init__(self, rel: str, *, is_urlconf: bool, drf: bool):
|
|
136
|
+
self.conf = UrlConf(rel=rel, is_urlconf=is_urlconf, emit_unmounted_routers=drf or is_urlconf)
|
|
137
|
+
self.consumed: set[int] = set()
|
|
138
|
+
self._list_ids: dict[str, set[int]] = {}
|
|
139
|
+
self._total = 0
|
|
140
|
+
|
|
141
|
+
def build(self, tree: ast.Module) -> UrlConf:
|
|
142
|
+
for stmt in _module_statements(tree.body):
|
|
143
|
+
self._statement(stmt)
|
|
144
|
+
# path()/re_path()/url() calls the list model didn't account for (inside
|
|
145
|
+
# a function, a comprehension, a dynamic container): keep emitting them
|
|
146
|
+
# unprefixed, as the regex extractor always did.
|
|
147
|
+
orphans: list[Entry] = []
|
|
148
|
+
for node in ast.walk(tree):
|
|
149
|
+
if isinstance(node, ast.Call) and id(node) not in self.consumed and self._is_url_call(node):
|
|
150
|
+
entry = self._url_entry(node, 0)
|
|
151
|
+
# An empty route here (e.g. inside an AppConfig.get_urls()) sits at
|
|
152
|
+
# a mount point we can't see; claiming "/" would be wrong.
|
|
153
|
+
if entry is not None and entry.segment:
|
|
154
|
+
orphans.append(entry)
|
|
155
|
+
if orphans and self.conf.is_urlconf:
|
|
156
|
+
self._extend(_ORPHANS, orphans)
|
|
157
|
+
return self.conf
|
|
158
|
+
|
|
159
|
+
# -- statements --
|
|
160
|
+
|
|
161
|
+
def _statement(self, stmt: ast.stmt) -> None:
|
|
162
|
+
conf = self.conf
|
|
163
|
+
if isinstance(stmt, ast.ImportFrom):
|
|
164
|
+
for alias in stmt.names:
|
|
165
|
+
if alias.name != "*":
|
|
166
|
+
conf.imports[alias.asname or alias.name] = (stmt.level or 0, stmt.module or "", alias.name)
|
|
167
|
+
elif isinstance(stmt, ast.Import):
|
|
168
|
+
for alias in stmt.names:
|
|
169
|
+
if alias.asname:
|
|
170
|
+
conf.imports[alias.asname] = (0, alias.name, "")
|
|
171
|
+
else:
|
|
172
|
+
head = alias.name.split(".")[0]
|
|
173
|
+
conf.imports[head] = (0, head, "")
|
|
174
|
+
elif isinstance(stmt, (ast.Assign, ast.AnnAssign)):
|
|
175
|
+
targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
|
|
176
|
+
value = stmt.value
|
|
177
|
+
if value is None:
|
|
178
|
+
return
|
|
179
|
+
for target in targets:
|
|
180
|
+
if isinstance(target, ast.Name):
|
|
181
|
+
self._assign(target.id, value)
|
|
182
|
+
elif isinstance(stmt, ast.AugAssign) and isinstance(stmt.op, ast.Add) and isinstance(stmt.target, ast.Name):
|
|
183
|
+
name = stmt.target.id
|
|
184
|
+
entries = self._entries(stmt.value, name, 0)
|
|
185
|
+
if entries or name in conf.lists:
|
|
186
|
+
self._extend(name, entries)
|
|
187
|
+
elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
188
|
+
self._call_statement(stmt.value)
|
|
189
|
+
|
|
190
|
+
def _assign(self, name: str, value: ast.expr) -> None:
|
|
191
|
+
conf = self.conf
|
|
192
|
+
callee = call_name(value) if isinstance(value, ast.Call) else None
|
|
193
|
+
if callee and callee.endswith("Router") and callee != "Router":
|
|
194
|
+
conf.routers.setdefault(name, [])
|
|
195
|
+
return
|
|
196
|
+
entries = self._entries(value, name, 0)
|
|
197
|
+
if entries or name == "urlpatterns" or (isinstance(value, (ast.List, ast.Tuple)) and not value.elts):
|
|
198
|
+
# Union, never replace: a reassignment, an if/else or try/except
|
|
199
|
+
# alternative, or `urlpatterns = wrap(urlpatterns)` keeps every
|
|
200
|
+
# route any branch defines (precision trade-off: a route replaced
|
|
201
|
+
# on every path is still reported).
|
|
202
|
+
self._extend(name, entries)
|
|
203
|
+
|
|
204
|
+
def _extend(self, name: str, entries: list[Entry]) -> None:
|
|
205
|
+
"""Add entries to a list once each, within the per-list/per-file caps."""
|
|
206
|
+
target = self.conf.lists.setdefault(name, [])
|
|
207
|
+
ids = self._list_ids.setdefault(name, set())
|
|
208
|
+
for entry in entries:
|
|
209
|
+
if id(entry) in ids:
|
|
210
|
+
continue
|
|
211
|
+
if len(target) >= MAX_LIST_ENTRIES or self._total >= MAX_FILE_ENTRIES:
|
|
212
|
+
self.conf.truncated = True
|
|
213
|
+
return
|
|
214
|
+
ids.add(id(entry))
|
|
215
|
+
target.append(entry)
|
|
216
|
+
self._total += 1
|
|
217
|
+
|
|
218
|
+
def _call_statement(self, call: ast.Call) -> None:
|
|
219
|
+
func = call.func
|
|
220
|
+
if not isinstance(func, ast.Attribute) or not isinstance(func.value, ast.Name):
|
|
221
|
+
return
|
|
222
|
+
owner, method = func.value.id, func.attr
|
|
223
|
+
conf = self.conf
|
|
224
|
+
if method == "register" and call.args:
|
|
225
|
+
prefix = str_const(call.args[0])
|
|
226
|
+
if prefix is None or len(call.args) + len(call.keywords) < 2:
|
|
227
|
+
return
|
|
228
|
+
# A locally built router, or one named like the conventional `router`.
|
|
229
|
+
if owner in conf.routers or owner.lower().endswith("router"):
|
|
230
|
+
regs = conf.routers.setdefault(owner, [])
|
|
231
|
+
if len(regs) < MAX_ROUTER_REGISTRATIONS:
|
|
232
|
+
regs.append((prefix, call.lineno))
|
|
233
|
+
else:
|
|
234
|
+
conf.truncated = True
|
|
235
|
+
return
|
|
236
|
+
if method in {"append", "extend", "insert"} and call.args:
|
|
237
|
+
arg = call.args[-1]
|
|
238
|
+
if method == "extend":
|
|
239
|
+
entries = self._entries(arg, owner, 0)
|
|
240
|
+
else:
|
|
241
|
+
entries = self._entries(ast.List(elts=[arg], ctx=ast.Load()), owner, 0)
|
|
242
|
+
if entries or owner in conf.lists:
|
|
243
|
+
self._extend(owner, entries)
|
|
244
|
+
|
|
245
|
+
# -- expressions --
|
|
246
|
+
|
|
247
|
+
def _entries(self, node: ast.expr, self_name: str, depth: int) -> list[Entry]:
|
|
248
|
+
"""The URL entries an expression contributes to a pattern list."""
|
|
249
|
+
if depth > _MAX_EXPR_DEPTH:
|
|
250
|
+
return []
|
|
251
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
252
|
+
out: list[Entry] = []
|
|
253
|
+
for elt in node.elts:
|
|
254
|
+
if isinstance(elt, ast.Starred):
|
|
255
|
+
out.extend(self._entries(elt.value, self_name, depth + 1))
|
|
256
|
+
elif isinstance(elt, ast.Call) and self._is_url_call(elt):
|
|
257
|
+
entry = self._url_entry(elt, depth + 1)
|
|
258
|
+
if entry is not None:
|
|
259
|
+
out.append(entry)
|
|
260
|
+
else:
|
|
261
|
+
out.extend(self._entries(elt, self_name, depth + 1))
|
|
262
|
+
return out
|
|
263
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
264
|
+
return self._entries(node.left, self_name, depth + 1) + self._entries(node.right, self_name, depth + 1)
|
|
265
|
+
if isinstance(node, ast.Name):
|
|
266
|
+
if node.id == self_name:
|
|
267
|
+
# `urlpatterns = format_suffix_patterns(urlpatterns)` / `x = x + x`:
|
|
268
|
+
# the list already holds these (lists are unions), add nothing.
|
|
269
|
+
return []
|
|
270
|
+
return [Entry("mount", getattr(node, "lineno", 0), segment="", target=Target("name", node.id))]
|
|
271
|
+
if isinstance(node, ast.Attribute) and node.attr in {"urls", "urlpatterns"}:
|
|
272
|
+
base = dotted(node.value)
|
|
273
|
+
if base:
|
|
274
|
+
return [Entry("mount", node.lineno, segment="", target=Target("attr", base, node.attr))]
|
|
275
|
+
return []
|
|
276
|
+
if isinstance(node, ast.Call):
|
|
277
|
+
if self._is_url_call(node):
|
|
278
|
+
entry = self._url_entry(node, depth + 1)
|
|
279
|
+
return [entry] if entry is not None else []
|
|
280
|
+
# Known wrappers (`i18n_patterns`, `format_suffix_patterns`, ...) and
|
|
281
|
+
# unknown ones (`decorate_urlpatterns(api, login_required)`) alike:
|
|
282
|
+
# whatever pattern lists they're handed stay mounted. Non-pattern
|
|
283
|
+
# arguments resolve to nothing.
|
|
284
|
+
out = []
|
|
285
|
+
for arg in node.args:
|
|
286
|
+
inner = arg.value if isinstance(arg, ast.Starred) else arg
|
|
287
|
+
out.extend(self._entries(inner, self_name, depth + 1))
|
|
288
|
+
return out
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _is_url_call(node: ast.Call) -> bool:
|
|
293
|
+
func = node.func
|
|
294
|
+
if isinstance(func, ast.Name):
|
|
295
|
+
name = func.id
|
|
296
|
+
elif isinstance(func, ast.Attribute) and not (isinstance(func.value, ast.Name) and func.value.id == "os"):
|
|
297
|
+
name = func.attr
|
|
298
|
+
else:
|
|
299
|
+
return False
|
|
300
|
+
return name in _URL_FUNCS and len(node.args) + len(node.keywords) >= 2
|
|
301
|
+
|
|
302
|
+
def _url_entry(self, call: ast.Call, depth: int) -> Entry | None:
|
|
303
|
+
self.consumed.add(id(call))
|
|
304
|
+
regex = _URL_FUNCS[call_name(call) or "path"]
|
|
305
|
+
route = call.args[0] if call.args else _kw(call, "route", "regex")
|
|
306
|
+
view = call.args[1] if len(call.args) > 1 else _kw(call, "view")
|
|
307
|
+
if route is None or view is None:
|
|
308
|
+
return None
|
|
309
|
+
segment = str_const(route)
|
|
310
|
+
if isinstance(view, ast.Call) and call_name(view) == "include":
|
|
311
|
+
self.consumed.add(id(view))
|
|
312
|
+
target_node = view.args[0] if view.args else _kw(view, "arg")
|
|
313
|
+
target = self._include_target(target_node, depth + 1) if target_node is not None else Target("unknown")
|
|
314
|
+
return Entry("include", call.lineno, segment=segment, regex=regex, target=target)
|
|
315
|
+
auth, guard = _wrapper_auth(view)
|
|
316
|
+
return Entry("leaf", call.lineno, segment=segment, regex=regex, auth=auth, guard=guard)
|
|
317
|
+
|
|
318
|
+
def _include_target(self, node: ast.expr, depth: int) -> Target:
|
|
319
|
+
if depth > _MAX_EXPR_DEPTH:
|
|
320
|
+
return Target("unknown")
|
|
321
|
+
module = str_const(node)
|
|
322
|
+
if module is not None:
|
|
323
|
+
return Target("module", module)
|
|
324
|
+
if isinstance(node, ast.Tuple) and 2 <= len(node.elts) <= 3 and str_const(node.elts[1]) is not None:
|
|
325
|
+
# include((patterns, "app_name")) / include(("mod.urls", "app"), namespace=...)
|
|
326
|
+
return self._include_target(node.elts[0], depth + 1)
|
|
327
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
328
|
+
return Target("inline", entries=self._entries(node, "", depth + 1))
|
|
329
|
+
if isinstance(node, ast.Name):
|
|
330
|
+
return Target("name", node.id)
|
|
331
|
+
if isinstance(node, ast.Attribute):
|
|
332
|
+
base = dotted(node.value)
|
|
333
|
+
if base:
|
|
334
|
+
return Target("attr", base, node.attr)
|
|
335
|
+
if isinstance(node, ast.Call) and call_name(node) in _PASSTHROUGH_FUNCS:
|
|
336
|
+
return Target("inline", entries=self._entries(node, "", depth + 1))
|
|
337
|
+
return Target("unknown")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _wrapper_auth(view: ast.expr) -> tuple[str, str | None]:
|
|
341
|
+
"""`login_required(v)` / `permission_required("p")(v)` / nested wrappers
|
|
342
|
+
around the view in the URLconf → ("required" | "anonymous", wrapper)."""
|
|
343
|
+
node = view
|
|
344
|
+
for _ in range(_MAX_WRAP_DEPTH):
|
|
345
|
+
if not isinstance(node, ast.Call):
|
|
346
|
+
break
|
|
347
|
+
func = node.func
|
|
348
|
+
# `permission_required("p")(view)`: the decorator factory is func.func.
|
|
349
|
+
name = call_name(func.func) if isinstance(func, ast.Call) else call_name(func)
|
|
350
|
+
if name in _AUTH_WRAPPERS:
|
|
351
|
+
return "required", name
|
|
352
|
+
if name in _PUBLIC_WRAPPERS:
|
|
353
|
+
return "anonymous", name
|
|
354
|
+
if not node.args:
|
|
355
|
+
break
|
|
356
|
+
node = node.args[0]
|
|
357
|
+
return "unknown", None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _kw(call: ast.Call, *names: str) -> ast.expr | None:
|
|
361
|
+
for kw in call.keywords:
|
|
362
|
+
if kw.arg in names:
|
|
363
|
+
return kw.value
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _module_statements(body: list[ast.stmt], depth: int = 0):
|
|
368
|
+
"""Module-level statements, descending into `if settings.DEBUG:` /
|
|
369
|
+
`try:` / `with` / `for` blocks but not into functions or classes."""
|
|
370
|
+
if depth > 16:
|
|
371
|
+
return
|
|
372
|
+
for stmt in body:
|
|
373
|
+
yield stmt
|
|
374
|
+
if isinstance(stmt, (ast.If, ast.For, ast.While)):
|
|
375
|
+
yield from _module_statements(stmt.body, depth + 1)
|
|
376
|
+
yield from _module_statements(stmt.orelse, depth + 1)
|
|
377
|
+
elif isinstance(stmt, ast.With):
|
|
378
|
+
yield from _module_statements(stmt.body, depth + 1)
|
|
379
|
+
elif isinstance(stmt, ast.Try) or type(stmt).__name__ == "TryStar":
|
|
380
|
+
yield from _module_statements(stmt.body, depth + 1)
|
|
381
|
+
for handler in stmt.handlers:
|
|
382
|
+
yield from _module_statements(handler.body, depth + 1)
|
|
383
|
+
yield from _module_statements(stmt.orelse, depth + 1)
|
|
384
|
+
yield from _module_statements(stmt.finalbody, depth + 1)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------- Path joining ----------
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _strip_anchors(segment: str) -> str:
|
|
391
|
+
if segment.startswith("\\A"):
|
|
392
|
+
segment = segment[2:]
|
|
393
|
+
segment = segment.lstrip("^")
|
|
394
|
+
if segment.endswith("\\Z"):
|
|
395
|
+
segment = segment[:-2]
|
|
396
|
+
while segment.endswith("$") and not segment.endswith("\\$"):
|
|
397
|
+
segment = segment[:-1]
|
|
398
|
+
return segment
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def join_segments(segments: list[tuple[str, bool]]) -> str:
|
|
402
|
+
"""Concatenate URLconf segments the way Django does (no separator added),
|
|
403
|
+
stripping regex anchors, and return a single-leading-slash path."""
|
|
404
|
+
joined = "".join(_strip_anchors(text) if regex else text for text, regex in segments)
|
|
405
|
+
path = "/" + joined.lstrip("/")
|
|
406
|
+
while "//" in path:
|
|
407
|
+
path = path.replace("//", "/")
|
|
408
|
+
return path
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------- Graph ----------
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class UrlGraph:
|
|
415
|
+
def __init__(self, confs: dict[str, UrlConf], project_dirs: list[str]):
|
|
416
|
+
self.confs = confs
|
|
417
|
+
# Directories holding manage.py: Django's sys.path roots, tried first
|
|
418
|
+
# when resolving an absolute dotted module.
|
|
419
|
+
self._project_dirs = set(project_dirs)
|
|
420
|
+
self._anchor_cache: dict[str, list[str]] = {}
|
|
421
|
+
self.routes: list[DjangoRoute] = []
|
|
422
|
+
self._seen: set[tuple[str, str, int]] = set()
|
|
423
|
+
self._expanded: set[tuple[str, str]] = set()
|
|
424
|
+
self._prefix_count: dict[tuple[str, str], int] = {}
|
|
425
|
+
self._mounted_routers: set[tuple[str, str]] = set()
|
|
426
|
+
self._router_mounts: set[tuple[str, str, str]] = set()
|
|
427
|
+
self._steps = 0
|
|
428
|
+
self.truncated = False
|
|
429
|
+
|
|
430
|
+
# -- resolution --
|
|
431
|
+
|
|
432
|
+
def _module_rel(self, from_rel: str, level: int, module: str) -> str | None:
|
|
433
|
+
parts = [p for p in module.split(".") if p]
|
|
434
|
+
if level:
|
|
435
|
+
base = posixpath.dirname(from_rel)
|
|
436
|
+
for _ in range(level - 1):
|
|
437
|
+
base = posixpath.dirname(base)
|
|
438
|
+
anchors = [base]
|
|
439
|
+
else:
|
|
440
|
+
if not parts:
|
|
441
|
+
return None
|
|
442
|
+
anchors = self._absolute_anchors(from_rel)
|
|
443
|
+
for anchor in anchors:
|
|
444
|
+
stem = posixpath.join(anchor, *parts) if parts else anchor
|
|
445
|
+
if not stem:
|
|
446
|
+
continue
|
|
447
|
+
for cand in (stem + ".py", posixpath.join(stem, "__init__.py")):
|
|
448
|
+
if cand in self.confs:
|
|
449
|
+
return cand
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
def _absolute_anchors(self, from_rel: str) -> list[str]:
|
|
453
|
+
"""manage.py directories above the file (deepest first), the repo
|
|
454
|
+
root, then the file's ancestors. Indexed, cached per directory."""
|
|
455
|
+
directory = posixpath.dirname(from_rel)
|
|
456
|
+
cached = self._anchor_cache.get(directory)
|
|
457
|
+
if cached is not None:
|
|
458
|
+
return cached
|
|
459
|
+
ancestors: list[str] = []
|
|
460
|
+
d = directory
|
|
461
|
+
while d:
|
|
462
|
+
ancestors.append(d)
|
|
463
|
+
d = posixpath.dirname(d)
|
|
464
|
+
project = [a for a in ancestors if a in self._project_dirs]
|
|
465
|
+
if "" in self._project_dirs:
|
|
466
|
+
project.append("")
|
|
467
|
+
anchors = list(dict.fromkeys(project + [""] + ancestors))
|
|
468
|
+
self._anchor_cache[directory] = anchors
|
|
469
|
+
return anchors
|
|
470
|
+
|
|
471
|
+
def _import_module(self, conf: UrlConf, name: str) -> tuple[str | None, str]:
|
|
472
|
+
"""For a bound name: (module file it names or comes from, imported attr)."""
|
|
473
|
+
level, module, attr = conf.imports[name]
|
|
474
|
+
if attr:
|
|
475
|
+
sub = f"{module}.{attr}" if module else attr
|
|
476
|
+
rel = self._module_rel(conf.rel, level, sub)
|
|
477
|
+
if rel is not None:
|
|
478
|
+
return rel, "" # `from pkg import api_urls` binds a module
|
|
479
|
+
return self._module_rel(conf.rel, level, module), attr
|
|
480
|
+
|
|
481
|
+
def _resolve(self, conf: UrlConf, target: Target | None):
|
|
482
|
+
"""→ ("list", conf, name) | ("router", conf, name) | ("inline", conf, entries) | None"""
|
|
483
|
+
if target is None:
|
|
484
|
+
return None
|
|
485
|
+
kind = target.kind
|
|
486
|
+
if kind == "inline":
|
|
487
|
+
return ("inline", conf, target.entries)
|
|
488
|
+
if kind == "module":
|
|
489
|
+
rel = self._module_rel(conf.rel, 0, target.value)
|
|
490
|
+
return self._urlpatterns_of(rel)
|
|
491
|
+
if kind == "name":
|
|
492
|
+
name = target.value
|
|
493
|
+
if name in conf.lists:
|
|
494
|
+
return ("list", conf, name)
|
|
495
|
+
if name in conf.imports:
|
|
496
|
+
rel, attr = self._import_module(conf, name)
|
|
497
|
+
if rel is None:
|
|
498
|
+
return None
|
|
499
|
+
if not attr:
|
|
500
|
+
return self._urlpatterns_of(rel)
|
|
501
|
+
other = self.confs[rel]
|
|
502
|
+
if attr in other.lists:
|
|
503
|
+
return ("list", other, attr)
|
|
504
|
+
if attr in other.routers:
|
|
505
|
+
return None # include(router) without .urls isn't valid Django
|
|
506
|
+
return None
|
|
507
|
+
if kind == "attr":
|
|
508
|
+
base, attr = target.value, target.attr
|
|
509
|
+
if "." in base:
|
|
510
|
+
return None
|
|
511
|
+
if attr == "urls" and base in conf.routers:
|
|
512
|
+
return ("router", conf, base)
|
|
513
|
+
if base in conf.imports:
|
|
514
|
+
rel, imported = self._import_module(conf, base)
|
|
515
|
+
if rel is None:
|
|
516
|
+
return None
|
|
517
|
+
other = self.confs[rel]
|
|
518
|
+
if not imported:
|
|
519
|
+
# `from . import api` + include(api.urlpatterns)
|
|
520
|
+
return self._urlpatterns_of(rel) if attr == "urlpatterns" else None
|
|
521
|
+
if attr == "urls" and imported in other.routers:
|
|
522
|
+
return ("router", other, imported)
|
|
523
|
+
if attr == "urlpatterns" and imported in other.lists:
|
|
524
|
+
return ("list", other, imported)
|
|
525
|
+
return None
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
def _urlpatterns_of(self, rel: str | None):
|
|
529
|
+
if rel is None:
|
|
530
|
+
return None
|
|
531
|
+
conf = self.confs[rel]
|
|
532
|
+
if conf.is_urlconf and "urlpatterns" in conf.lists:
|
|
533
|
+
return ("list", conf, "urlpatterns")
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
# -- expansion --
|
|
537
|
+
|
|
538
|
+
def _emit(self, path: str, file: str, line: int, entry: Entry | None = None) -> None:
|
|
539
|
+
if len(self.routes) >= MAX_ROUTES:
|
|
540
|
+
self.truncated = True
|
|
541
|
+
return
|
|
542
|
+
key = (path, file, line)
|
|
543
|
+
if key in self._seen:
|
|
544
|
+
return
|
|
545
|
+
self._seen.add(key)
|
|
546
|
+
route = DjangoRoute(path, file, line)
|
|
547
|
+
if entry is not None and entry.guard:
|
|
548
|
+
route.auth = entry.auth
|
|
549
|
+
route.guards = [entry.guard] if entry.auth == "required" else []
|
|
550
|
+
route.evidence = f"{entry.guard}(...) in {file}:{line}"
|
|
551
|
+
self.routes.append(route)
|
|
552
|
+
|
|
553
|
+
def _expand_list(self, conf: UrlConf, name: str, prefix: list[tuple[str, bool]], depth: int, stack: frozenset) -> None:
|
|
554
|
+
key = (conf.rel, name)
|
|
555
|
+
if key in stack or depth > MAX_DEPTH:
|
|
556
|
+
return
|
|
557
|
+
count = self._prefix_count.get(key, 0)
|
|
558
|
+
if count >= MAX_PREFIXES_PER_LIST:
|
|
559
|
+
self.truncated = True
|
|
560
|
+
return
|
|
561
|
+
self._prefix_count[key] = count + 1
|
|
562
|
+
self._expanded.add(key)
|
|
563
|
+
stack = stack | {key}
|
|
564
|
+
for entry in conf.lists.get(name, []):
|
|
565
|
+
self._expand_entry(conf, entry, prefix, depth, stack)
|
|
566
|
+
|
|
567
|
+
def _expand_entry(self, conf: UrlConf, entry: Entry, prefix: list[tuple[str, bool]], depth: int, stack: frozenset) -> None:
|
|
568
|
+
self._steps += 1
|
|
569
|
+
if self._steps > MAX_STEPS or len(self.routes) >= MAX_ROUTES:
|
|
570
|
+
self.truncated = True
|
|
571
|
+
return
|
|
572
|
+
if entry.kind == "leaf":
|
|
573
|
+
if entry.segment is not None:
|
|
574
|
+
self._emit(join_segments(prefix + [(entry.segment, entry.regex)]), conf.rel, entry.line, entry)
|
|
575
|
+
return
|
|
576
|
+
resolved = self._resolve(conf, entry.target)
|
|
577
|
+
if entry.kind == "mount":
|
|
578
|
+
if resolved is not None:
|
|
579
|
+
self._expand_resolved(resolved, prefix, depth + 1, stack)
|
|
580
|
+
return
|
|
581
|
+
# include(): a dynamic route (`settings.BASE_PATH`) joins as "".
|
|
582
|
+
child_prefix = prefix + [(entry.segment or "", entry.regex)]
|
|
583
|
+
if resolved is not None:
|
|
584
|
+
self._expand_resolved(resolved, child_prefix, depth + 1, stack)
|
|
585
|
+
elif entry.segment is not None:
|
|
586
|
+
path = join_segments(child_prefix)
|
|
587
|
+
if path != "/":
|
|
588
|
+
self._emit(path, conf.rel, entry.line)
|
|
589
|
+
|
|
590
|
+
def _expand_resolved(self, resolved, prefix: list[tuple[str, bool]], depth: int, stack: frozenset) -> None:
|
|
591
|
+
kind, conf, ref = resolved
|
|
592
|
+
if depth > MAX_DEPTH:
|
|
593
|
+
self.truncated = True
|
|
594
|
+
return
|
|
595
|
+
if kind == "list":
|
|
596
|
+
self._expand_list(conf, ref, prefix, depth, stack)
|
|
597
|
+
elif kind == "inline":
|
|
598
|
+
for entry in ref:
|
|
599
|
+
self._expand_entry(conf, entry, prefix, depth, stack)
|
|
600
|
+
elif kind == "router":
|
|
601
|
+
self._mounted_routers.add((conf.rel, ref))
|
|
602
|
+
mount = (conf.rel, ref, join_segments(prefix))
|
|
603
|
+
if mount in self._router_mounts:
|
|
604
|
+
return # the same router mounted at the same prefix again
|
|
605
|
+
self._router_mounts.add(mount)
|
|
606
|
+
for segment, line in conf.routers.get(ref, []):
|
|
607
|
+
self._steps += 1
|
|
608
|
+
if self._steps > MAX_STEPS or len(self.routes) >= MAX_ROUTES:
|
|
609
|
+
self.truncated = True
|
|
610
|
+
return
|
|
611
|
+
self._emit(join_segments(prefix + [(segment, False)]), conf.rel, line)
|
|
612
|
+
|
|
613
|
+
def _references(self) -> set[tuple[str, str]]:
|
|
614
|
+
"""Every (file, list) some include/mount resolves to."""
|
|
615
|
+
refs: set[tuple[str, str]] = set()
|
|
616
|
+
budget = MAX_STEPS
|
|
617
|
+
for conf in self.confs.values():
|
|
618
|
+
stack = [e for entries in conf.lists.values() for e in entries]
|
|
619
|
+
while stack and budget > 0:
|
|
620
|
+
budget -= 1
|
|
621
|
+
entry = stack.pop()
|
|
622
|
+
resolved = self._resolve(conf, entry.target)
|
|
623
|
+
if resolved is None:
|
|
624
|
+
continue
|
|
625
|
+
kind, other, ref = resolved
|
|
626
|
+
if kind == "list" and other is not conf:
|
|
627
|
+
refs.add((other.rel, ref))
|
|
628
|
+
elif kind == "list" and ref != "urlpatterns":
|
|
629
|
+
refs.add((other.rel, ref))
|
|
630
|
+
elif kind == "inline":
|
|
631
|
+
stack.extend(ref)
|
|
632
|
+
return refs
|
|
633
|
+
|
|
634
|
+
def expand(self) -> list[DjangoRoute]:
|
|
635
|
+
refs = self._references()
|
|
636
|
+
ordered = sorted(self.confs.values(), key=lambda c: c.rel)
|
|
637
|
+
|
|
638
|
+
def list_order(conf: UrlConf) -> list[str]:
|
|
639
|
+
names = sorted(conf.lists, key=lambda n: (n != "urlpatterns", n == _ORPHANS, n))
|
|
640
|
+
return names
|
|
641
|
+
|
|
642
|
+
# 1. Root URLconfs: urlpatterns no other URLconf includes.
|
|
643
|
+
for conf in ordered:
|
|
644
|
+
if conf.is_urlconf and "urlpatterns" in conf.lists and (conf.rel, "urlpatterns") not in refs:
|
|
645
|
+
self._expand_list(conf, "urlpatterns", [], 0, frozenset())
|
|
646
|
+
# 2. Anything not reached yet: unreferenced lists first (they may mount
|
|
647
|
+
# the rest under a prefix), then the remainder (include cycles).
|
|
648
|
+
for want_referenced in (False, True):
|
|
649
|
+
for conf in ordered:
|
|
650
|
+
for name in list_order(conf):
|
|
651
|
+
key = (conf.rel, name)
|
|
652
|
+
if key in self._expanded or ((key in refs) != want_referenced):
|
|
653
|
+
continue
|
|
654
|
+
self._expand_list(conf, name, [], 0, frozenset())
|
|
655
|
+
# 3. Routers nobody mounted keep the pre-#2 unprefixed emission.
|
|
656
|
+
for conf in ordered:
|
|
657
|
+
if not conf.emit_unmounted_routers:
|
|
658
|
+
continue
|
|
659
|
+
for name in sorted(conf.routers):
|
|
660
|
+
if (conf.rel, name) in self._mounted_routers:
|
|
661
|
+
continue
|
|
662
|
+
for segment, line in conf.routers[name]:
|
|
663
|
+
self._emit(join_segments([(segment, False)]), conf.rel, line)
|
|
664
|
+
return self.routes
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
__all__ = ["DjangoRoute", "UrlConf", "UrlGraph", "join_segments", "parse_urlconf"]
|