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,358 @@
|
|
|
1
|
+
"""Litestar / Starlite route handlers, controllers and routers (#2).
|
|
2
|
+
|
|
3
|
+
- ``@get("/x")`` / ``@post()`` (no path → ``/``) / ``@get(path=["/a", "/b"])``
|
|
4
|
+
/ ``@route("/x", http_method=["GET", "POST"])`` / ``@websocket("/ws")``,
|
|
5
|
+
at any indentation — module level, inside a ``Controller``, or inside a
|
|
6
|
+
factory function.
|
|
7
|
+
- ``class UserController(Controller): path = "/users"`` joins the controller
|
|
8
|
+
path onto each handler path.
|
|
9
|
+
- ``Router(path="/api", route_handlers=[UserController, health])`` joins the
|
|
10
|
+
router path onto what it lists; nested routers chain.
|
|
11
|
+
- Route auth (#256): ``guards=[...]`` on the handler, controller or router →
|
|
12
|
+
``required``; ``exclude_from_auth=True`` (or ``opt={"exclude_from_auth":
|
|
13
|
+
True}``) with no guard → ``anonymous``. Anything else stays ``unknown``.
|
|
14
|
+
|
|
15
|
+
Decorators only count when their name is imported from ``litestar``/``starlite``
|
|
16
|
+
(so a project's own ``get`` decorator never fires).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from ._pyast import call_name, dotted, expr_names, keyword, safe_parse, str_const, str_list
|
|
25
|
+
|
|
26
|
+
_HANDLER_KINDS = {
|
|
27
|
+
"get": ["GET"],
|
|
28
|
+
"post": ["POST"],
|
|
29
|
+
"put": ["PUT"],
|
|
30
|
+
"patch": ["PATCH"],
|
|
31
|
+
"delete": ["DELETE"],
|
|
32
|
+
"head": ["HEAD"],
|
|
33
|
+
"route": None, # methods come from http_method=
|
|
34
|
+
"websocket": ["WS"],
|
|
35
|
+
"websocket_listener": ["WS"],
|
|
36
|
+
}
|
|
37
|
+
_LITESTAR_MODULES = ("litestar", "starlite")
|
|
38
|
+
_MAX_NEST = 32
|
|
39
|
+
_MAX_ROUTER_DEPTH = 8
|
|
40
|
+
_MAX_PREFIXES = 32
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Handler:
|
|
45
|
+
file: str
|
|
46
|
+
line: int
|
|
47
|
+
name: str
|
|
48
|
+
paths: list[str]
|
|
49
|
+
methods: list[str]
|
|
50
|
+
module_level: bool
|
|
51
|
+
guards: list[str] = field(default_factory=list)
|
|
52
|
+
excluded: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Controller:
|
|
57
|
+
file: str
|
|
58
|
+
name: str
|
|
59
|
+
path: str
|
|
60
|
+
guards: list[str]
|
|
61
|
+
handlers: list[Handler] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Router:
|
|
66
|
+
file: str
|
|
67
|
+
var: str | None
|
|
68
|
+
path: str
|
|
69
|
+
members: list[str]
|
|
70
|
+
guards: list[str]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class LitestarFile:
|
|
75
|
+
rel: str
|
|
76
|
+
handlers: list[Handler] = field(default_factory=list) # not inside a controller
|
|
77
|
+
controllers: list[Controller] = field(default_factory=list)
|
|
78
|
+
routers: list[Router] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class LitestarRoute:
|
|
83
|
+
path: str
|
|
84
|
+
method: str
|
|
85
|
+
file: str
|
|
86
|
+
line: int
|
|
87
|
+
auth: str = "unknown"
|
|
88
|
+
guards: list[str] = field(default_factory=list)
|
|
89
|
+
evidence: str | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_litestar_module(module: str | None) -> bool:
|
|
93
|
+
return bool(module) and module.split(".")[0] in _LITESTAR_MODULES
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_litestar(content: str, rel: str) -> LitestarFile | None:
|
|
97
|
+
"""The file's handlers/controllers/routers; ``None`` only when it can't be
|
|
98
|
+
parsed (the caller then falls back to the decorator regex)."""
|
|
99
|
+
tree = safe_parse(content)
|
|
100
|
+
if tree is None:
|
|
101
|
+
return None
|
|
102
|
+
aliases: dict[str, str] = {}
|
|
103
|
+
module_aliases: set[str] = set()
|
|
104
|
+
router_names: set[str] = set()
|
|
105
|
+
for node in ast.walk(tree):
|
|
106
|
+
if isinstance(node, ast.ImportFrom) and _is_litestar_module(node.module):
|
|
107
|
+
for alias in node.names:
|
|
108
|
+
bound = alias.asname or alias.name
|
|
109
|
+
if alias.name in _HANDLER_KINDS:
|
|
110
|
+
aliases[bound] = alias.name
|
|
111
|
+
elif alias.name in {"Router", "Litestar", "Starlite"}:
|
|
112
|
+
router_names.add(bound)
|
|
113
|
+
elif isinstance(node, ast.Import):
|
|
114
|
+
for alias in node.names:
|
|
115
|
+
if _is_litestar_module(alias.name):
|
|
116
|
+
module_aliases.add(alias.asname or alias.name.split(".")[0])
|
|
117
|
+
out = LitestarFile(rel=rel)
|
|
118
|
+
if not aliases and not module_aliases and not router_names:
|
|
119
|
+
# Mentions litestar but imports no handler decorator from it — e.g. a
|
|
120
|
+
# test using `unittest.mock.patch("...")`, which is not a route.
|
|
121
|
+
return out
|
|
122
|
+
try:
|
|
123
|
+
_visit(tree.body, None, True, out, aliases, module_aliases, 0)
|
|
124
|
+
_collect_routers(tree, out, router_names, module_aliases)
|
|
125
|
+
except RecursionError:
|
|
126
|
+
return None
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _decorator_kind(dec: ast.expr, aliases: dict[str, str], module_aliases: set[str]) -> str | None:
|
|
131
|
+
if not isinstance(dec, ast.Call):
|
|
132
|
+
return None
|
|
133
|
+
func = dec.func
|
|
134
|
+
if isinstance(func, ast.Name):
|
|
135
|
+
return aliases.get(func.id)
|
|
136
|
+
if isinstance(func, ast.Attribute) and func.attr in _HANDLER_KINDS:
|
|
137
|
+
base = dotted(func.value)
|
|
138
|
+
if base and base.split(".")[0] in module_aliases:
|
|
139
|
+
return func.attr
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _handler_from(fn: ast.FunctionDef | ast.AsyncFunctionDef, dec: ast.Call, kind: str, rel: str, module_level: bool) -> Handler | None:
|
|
144
|
+
path_node = dec.args[0] if dec.args else keyword(dec, "path")
|
|
145
|
+
if path_node is None:
|
|
146
|
+
paths = ["/"]
|
|
147
|
+
else:
|
|
148
|
+
single = str_const(path_node)
|
|
149
|
+
paths = [single] if single is not None else (str_list(path_node) or [])
|
|
150
|
+
if not paths:
|
|
151
|
+
return None # a computed path: nothing reliable to report
|
|
152
|
+
methods = _HANDLER_KINDS[kind]
|
|
153
|
+
if methods is None:
|
|
154
|
+
methods = _http_methods(keyword(dec, "http_method")) or ["ANY"]
|
|
155
|
+
guards = expr_names(keyword(dec, "guards"))
|
|
156
|
+
return Handler(
|
|
157
|
+
file=rel,
|
|
158
|
+
line=dec.lineno,
|
|
159
|
+
name=fn.name,
|
|
160
|
+
paths=paths,
|
|
161
|
+
methods=list(methods),
|
|
162
|
+
module_level=module_level,
|
|
163
|
+
guards=guards,
|
|
164
|
+
excluded=_excluded_from_auth(dec),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _http_methods(node: ast.expr | None) -> list[str]:
|
|
169
|
+
if node is None:
|
|
170
|
+
return []
|
|
171
|
+
elts = node.elts if isinstance(node, (ast.List, ast.Tuple, ast.Set)) else [node]
|
|
172
|
+
out: list[str] = []
|
|
173
|
+
for elt in elts[:16]:
|
|
174
|
+
value = str_const(elt)
|
|
175
|
+
if value is None and isinstance(elt, ast.Attribute):
|
|
176
|
+
value = elt.attr # HttpMethod.GET
|
|
177
|
+
if value and value.isalpha():
|
|
178
|
+
out.append(value.upper())
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _excluded_from_auth(dec: ast.Call) -> bool:
|
|
183
|
+
flag = keyword(dec, "exclude_from_auth")
|
|
184
|
+
if isinstance(flag, ast.Constant) and flag.value is True:
|
|
185
|
+
return True
|
|
186
|
+
opt = keyword(dec, "opt")
|
|
187
|
+
if isinstance(opt, ast.Dict):
|
|
188
|
+
for key, value in zip(opt.keys, opt.values):
|
|
189
|
+
if str_const(key) == "exclude_from_auth" and isinstance(value, ast.Constant) and value.value is True:
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _visit(body, controller: Controller | None, module_level: bool, out: LitestarFile, aliases, module_aliases, depth: int) -> None:
|
|
195
|
+
if depth > _MAX_NEST:
|
|
196
|
+
return
|
|
197
|
+
for stmt in body:
|
|
198
|
+
if isinstance(stmt, ast.ClassDef):
|
|
199
|
+
ctrl = None
|
|
200
|
+
if any((dotted(base) or "").endswith("Controller") for base in stmt.bases):
|
|
201
|
+
ctrl = Controller(file=out.rel, name=stmt.name, path="", guards=[])
|
|
202
|
+
for item in stmt.body:
|
|
203
|
+
if isinstance(item, (ast.Assign, ast.AnnAssign)):
|
|
204
|
+
targets = item.targets if isinstance(item, ast.Assign) else [item.target]
|
|
205
|
+
for target in targets:
|
|
206
|
+
if isinstance(target, ast.Name) and target.id == "path":
|
|
207
|
+
ctrl.path = str_const(item.value) or ""
|
|
208
|
+
elif isinstance(target, ast.Name) and target.id == "guards":
|
|
209
|
+
ctrl.guards = expr_names(item.value)
|
|
210
|
+
out.controllers.append(ctrl)
|
|
211
|
+
_visit(stmt.body, ctrl, False, out, aliases, module_aliases, depth + 1)
|
|
212
|
+
elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
213
|
+
for dec in stmt.decorator_list:
|
|
214
|
+
kind = _decorator_kind(dec, aliases, module_aliases)
|
|
215
|
+
if kind is None:
|
|
216
|
+
continue
|
|
217
|
+
handler = _handler_from(stmt, dec, kind, out.rel, module_level)
|
|
218
|
+
if handler is None:
|
|
219
|
+
continue
|
|
220
|
+
if controller is not None:
|
|
221
|
+
controller.handlers.append(handler)
|
|
222
|
+
else:
|
|
223
|
+
out.handlers.append(handler)
|
|
224
|
+
_visit(stmt.body, None, False, out, aliases, module_aliases, depth + 1)
|
|
225
|
+
elif isinstance(stmt, (ast.If, ast.With, ast.Try, ast.For, ast.While)) or type(stmt).__name__ == "TryStar":
|
|
226
|
+
for block in ("body", "orelse", "finalbody"):
|
|
227
|
+
_visit(getattr(stmt, block, []) or [], controller, module_level, out, aliases, module_aliases, depth + 1)
|
|
228
|
+
for handler in getattr(stmt, "handlers", []) or []:
|
|
229
|
+
_visit(handler.body, controller, module_level, out, aliases, module_aliases, depth + 1)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _collect_routers(tree: ast.Module, out: LitestarFile, router_names: set[str], module_aliases: set[str]) -> None:
|
|
233
|
+
assigned: dict[int, str] = {}
|
|
234
|
+
for node in ast.walk(tree):
|
|
235
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
|
236
|
+
for target in node.targets:
|
|
237
|
+
if isinstance(target, ast.Name):
|
|
238
|
+
assigned[id(node.value)] = target.id
|
|
239
|
+
for node in ast.walk(tree):
|
|
240
|
+
if not isinstance(node, ast.Call):
|
|
241
|
+
continue
|
|
242
|
+
func = node.func
|
|
243
|
+
if isinstance(func, ast.Name) and func.id in router_names:
|
|
244
|
+
pass
|
|
245
|
+
elif isinstance(func, ast.Attribute) and func.attr in {"Router", "Litestar", "Starlite"} and (dotted(func.value) or "").split(".")[0] in module_aliases:
|
|
246
|
+
pass
|
|
247
|
+
else:
|
|
248
|
+
continue
|
|
249
|
+
is_app = call_name(node) in {"Litestar", "Starlite"}
|
|
250
|
+
path_node = None if is_app else (node.args[0] if node.args else keyword(node, "path"))
|
|
251
|
+
path = str_const(path_node) or "" if path_node is not None else ""
|
|
252
|
+
members_node = keyword(node, "route_handlers")
|
|
253
|
+
if members_node is None and not is_app and len(node.args) > 1:
|
|
254
|
+
members_node = node.args[1]
|
|
255
|
+
if members_node is None and is_app and node.args:
|
|
256
|
+
members_node = node.args[0]
|
|
257
|
+
members = [name.split(".")[-1] for name in expr_names(members_node, limit=256)]
|
|
258
|
+
out.routers.append(
|
|
259
|
+
Router(file=out.rel, var=assigned.get(id(node)), path=path, members=members, guards=expr_names(keyword(node, "guards")))
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _join(*parts: str) -> str:
|
|
264
|
+
pieces = [p.strip("/") for p in parts if p and p.strip("/")]
|
|
265
|
+
return "/" + "/".join(pieces) if pieces else "/"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def resolve_litestar(files: list[LitestarFile]) -> list[LitestarRoute]:
|
|
269
|
+
"""Join router and controller paths onto every handler, repo-wide."""
|
|
270
|
+
routers = [r for f in files for r in f.routers]
|
|
271
|
+
# member name → routers that list it
|
|
272
|
+
listed_by: dict[str, list[Router]] = {}
|
|
273
|
+
for router in routers:
|
|
274
|
+
for member in router.members:
|
|
275
|
+
listed_by.setdefault(member, []).append(router)
|
|
276
|
+
defined: dict[str, set[str]] = {}
|
|
277
|
+
for f in files:
|
|
278
|
+
for ctrl in f.controllers:
|
|
279
|
+
defined.setdefault(ctrl.name, set()).add(f.rel)
|
|
280
|
+
for handler in f.handlers:
|
|
281
|
+
defined.setdefault(handler.name, set()).add(f.rel)
|
|
282
|
+
for router in f.routers:
|
|
283
|
+
if router.var:
|
|
284
|
+
defined.setdefault(router.var, set()).add(f.rel)
|
|
285
|
+
|
|
286
|
+
def owners(name: str, file: str) -> list[Router]:
|
|
287
|
+
# A router listing `name` counts when it's in the same file or the name
|
|
288
|
+
# is defined exactly once in the repo (an unambiguous import).
|
|
289
|
+
unique = len(defined.get(name, ())) == 1
|
|
290
|
+
return [r for r in listed_by.get(name, []) if r.file == file or unique]
|
|
291
|
+
|
|
292
|
+
memo: dict[tuple[str, str], list[tuple[str, list[str]]]] = {}
|
|
293
|
+
|
|
294
|
+
def mounts(name: str, file: str, stack: frozenset = frozenset()) -> list[tuple[str, list[str]]]:
|
|
295
|
+
"""(prefix, inherited guards) for each way `name` is mounted. Memoized;
|
|
296
|
+
a router cycle or a chain deeper than _MAX_ROUTER_DEPTH stops there."""
|
|
297
|
+
key = (name, file)
|
|
298
|
+
if key in memo:
|
|
299
|
+
return memo[key]
|
|
300
|
+
if key in stack or len(stack) >= _MAX_ROUTER_DEPTH:
|
|
301
|
+
return [("", [])]
|
|
302
|
+
result: list[tuple[str, list[str]]] = []
|
|
303
|
+
for router in owners(name, file):
|
|
304
|
+
parents = mounts(router.var, router.file, stack | {key}) if router.var else [("", [])]
|
|
305
|
+
for parent_prefix, parent_guards in parents:
|
|
306
|
+
result.append((_join(parent_prefix, router.path), parent_guards + router.guards))
|
|
307
|
+
if len(result) >= _MAX_PREFIXES:
|
|
308
|
+
break
|
|
309
|
+
if len(result) >= _MAX_PREFIXES:
|
|
310
|
+
break
|
|
311
|
+
memo[key] = result = result or [("", [])]
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
routes: list[LitestarRoute] = []
|
|
315
|
+
|
|
316
|
+
def emit(handler: Handler, prefix: str, ctrl_path: str, inherited: list[str]) -> None:
|
|
317
|
+
guards = inherited + handler.guards
|
|
318
|
+
if guards:
|
|
319
|
+
auth, evidence = "required", f"guards=[{', '.join(guards)}]"
|
|
320
|
+
elif handler.excluded:
|
|
321
|
+
auth, evidence = "anonymous", "exclude_from_auth=True"
|
|
322
|
+
else:
|
|
323
|
+
auth, evidence = "unknown", None
|
|
324
|
+
for hpath in handler.paths:
|
|
325
|
+
for method in handler.methods:
|
|
326
|
+
routes.append(
|
|
327
|
+
LitestarRoute(
|
|
328
|
+
path=_join(prefix, ctrl_path, hpath),
|
|
329
|
+
method=method,
|
|
330
|
+
file=handler.file,
|
|
331
|
+
line=handler.line,
|
|
332
|
+
auth=auth,
|
|
333
|
+
guards=list(guards),
|
|
334
|
+
evidence=evidence,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
for f in files:
|
|
339
|
+
for ctrl in f.controllers:
|
|
340
|
+
for prefix, inherited in mounts(ctrl.name, f.rel):
|
|
341
|
+
for handler in ctrl.handlers:
|
|
342
|
+
emit(handler, prefix, ctrl.path, inherited + ctrl.guards)
|
|
343
|
+
for handler in f.handlers:
|
|
344
|
+
for prefix, inherited in (mounts(handler.name, f.rel) if handler.module_level else [("", [])]):
|
|
345
|
+
emit(handler, prefix, "", inherited)
|
|
346
|
+
# One handler mounted twice at the same path with different auth (a
|
|
347
|
+
# guarded and an unguarded router) collapses to one route downstream, so
|
|
348
|
+
# its auth isn't known.
|
|
349
|
+
auth_by_key: dict[tuple[str, str, str], set[str]] = {}
|
|
350
|
+
for route in routes:
|
|
351
|
+
auth_by_key.setdefault((route.path, route.method, route.file), set()).add(route.auth)
|
|
352
|
+
for route in routes:
|
|
353
|
+
if len(auth_by_key[(route.path, route.method, route.file)]) > 1:
|
|
354
|
+
route.auth, route.guards, route.evidence = "unknown", [], None
|
|
355
|
+
return routes
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
__all__ = ["LitestarFile", "LitestarRoute", "parse_litestar", "resolve_litestar"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Sanic ``@app.route(...)`` / ``@bp.websocket(...)`` decorators (#2).
|
|
2
|
+
|
|
3
|
+
The verb decorators (``@app.get``) stay with the regex extractor. ``route``
|
|
4
|
+
takes its verbs from ``methods=[...]`` and defaults to ``GET`` (Sanic's own
|
|
5
|
+
default, and what core's ``@x.route`` pattern assumes, so the two analyzers'
|
|
6
|
+
routes merge instead of duplicating). The receiver must be ``app``/``bp``/
|
|
7
|
+
``blueprint`` or a name assigned from ``Sanic(...)`` / ``Blueprint(...)``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
|
|
14
|
+
from ._pyast import call_name, keyword, safe_parse, str_const
|
|
15
|
+
|
|
16
|
+
_RECEIVERS = {"app", "bp", "blueprint"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize(path: str) -> str:
|
|
20
|
+
"""Leading slash, no `//` runs: the shape core gives the same decorator."""
|
|
21
|
+
path = "/" + path.strip().lstrip("/")
|
|
22
|
+
while "//" in path:
|
|
23
|
+
path = path.replace("//", "/")
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _methods(node: ast.expr | None) -> list[str]:
|
|
28
|
+
"""`methods=["GET", "post"]`, a tuple/set, or `HTTPMethod.DELETE` enums."""
|
|
29
|
+
if node is None:
|
|
30
|
+
return []
|
|
31
|
+
elts = node.elts if isinstance(node, (ast.List, ast.Tuple, ast.Set)) else [node]
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
for elt in elts[:16]:
|
|
34
|
+
value = str_const(elt)
|
|
35
|
+
if value is None and isinstance(elt, ast.Attribute):
|
|
36
|
+
value = elt.attr # HTTPMethod.DELETE
|
|
37
|
+
if value and value.isalpha():
|
|
38
|
+
out.append(value.upper())
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sanic_routes(content: str) -> list[tuple[str, str, int]] | None:
|
|
43
|
+
"""[(path, method, line)], or ``None`` when the file can't be parsed."""
|
|
44
|
+
tree = safe_parse(content)
|
|
45
|
+
if tree is None:
|
|
46
|
+
return None
|
|
47
|
+
receivers = set(_RECEIVERS)
|
|
48
|
+
for node in ast.walk(tree):
|
|
49
|
+
if isinstance(node, ast.Assign) and call_name(node.value) in {"Sanic", "Blueprint"}:
|
|
50
|
+
receivers.update(t.id for t in node.targets if isinstance(t, ast.Name))
|
|
51
|
+
out: list[tuple[str, str, int]] = []
|
|
52
|
+
for node in ast.walk(tree):
|
|
53
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
54
|
+
continue
|
|
55
|
+
for dec in node.decorator_list:
|
|
56
|
+
if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)):
|
|
57
|
+
continue
|
|
58
|
+
owner = dec.func.value
|
|
59
|
+
if not (isinstance(owner, ast.Name) and owner.id in receivers):
|
|
60
|
+
continue
|
|
61
|
+
kind = dec.func.attr
|
|
62
|
+
if kind not in {"route", "websocket"}:
|
|
63
|
+
continue
|
|
64
|
+
path = str_const(dec.args[0] if dec.args else keyword(dec, "uri"))
|
|
65
|
+
if path is None:
|
|
66
|
+
continue
|
|
67
|
+
path = _normalize(path)
|
|
68
|
+
if kind == "websocket":
|
|
69
|
+
out.append((path, "WS", dec.lineno))
|
|
70
|
+
continue
|
|
71
|
+
methods = _methods(keyword(dec, "methods")) or ["GET"]
|
|
72
|
+
for method in dict.fromkeys(methods):
|
|
73
|
+
out.append((path, method, dec.lineno))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["sanic_routes"]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Tornado URLSpec routing paired with RequestHandler methods (#2).
|
|
2
|
+
|
|
3
|
+
``tornado.web.Application([(r"/users/(\\d+)", UserHandler), url(r"/x", X)])``
|
|
4
|
+
(or a ``handlers = [...]`` list handed to it, or ``add_handlers``) maps a
|
|
5
|
+
regex to a handler class; the HTTP methods are the ``get``/``post``/... the
|
|
6
|
+
class defines. A ``WebSocketHandler`` subclass is a ``WS`` route. A handler
|
|
7
|
+
that defines no verb itself (inherits them) is ``ANY``.
|
|
8
|
+
|
|
9
|
+
A URL spec counts only when it sits in a list/tuple literal, its pattern
|
|
10
|
+
starts with ``/``, and its handler is a known ``*Handler`` class (anywhere in
|
|
11
|
+
the repo) or at least named like one. Route auth (#256): a verb method
|
|
12
|
+
decorated with ``@tornado.web.authenticated`` is ``required``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from ._pyast import call_name, dotted, safe_parse, str_const
|
|
21
|
+
|
|
22
|
+
_VERBS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
23
|
+
_MAX_INHERIT = 3
|
|
24
|
+
_MAX_SPECS = 5_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class HandlerClass:
|
|
29
|
+
file: str
|
|
30
|
+
name: str
|
|
31
|
+
bases: list[str]
|
|
32
|
+
methods: dict[str, bool] = field(default_factory=dict) # VERB → @authenticated
|
|
33
|
+
websocket: bool = False
|
|
34
|
+
direct: bool = False # a base is named *Handler
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class UrlSpec:
|
|
39
|
+
file: str
|
|
40
|
+
line: int
|
|
41
|
+
pattern: str
|
|
42
|
+
handler: str # last dotted component of the handler expression
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TornadoFile:
|
|
47
|
+
rel: str
|
|
48
|
+
classes: list[HandlerClass] = field(default_factory=list)
|
|
49
|
+
specs: list[UrlSpec] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TornadoRoute:
|
|
54
|
+
path: str
|
|
55
|
+
method: str
|
|
56
|
+
file: str
|
|
57
|
+
line: int
|
|
58
|
+
auth: str = "unknown"
|
|
59
|
+
guards: list[str] = field(default_factory=list)
|
|
60
|
+
evidence: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_tornado(content: str, rel: str) -> TornadoFile | None:
|
|
64
|
+
tree = safe_parse(content)
|
|
65
|
+
if tree is None:
|
|
66
|
+
return None
|
|
67
|
+
out = TornadoFile(rel=rel)
|
|
68
|
+
for node in ast.walk(tree):
|
|
69
|
+
if isinstance(node, ast.ClassDef):
|
|
70
|
+
bases = [dotted(b) or "" for b in node.bases]
|
|
71
|
+
# Every class is recorded: one that subclasses RequestHandler only
|
|
72
|
+
# through a project base (`class Api(BaseView)`) is resolved later.
|
|
73
|
+
cls = HandlerClass(
|
|
74
|
+
file=rel,
|
|
75
|
+
name=node.name,
|
|
76
|
+
bases=[b.split(".")[-1] for b in bases if b],
|
|
77
|
+
websocket=any("WebSocket" in b for b in bases),
|
|
78
|
+
direct=any(b.endswith("Handler") for b in bases),
|
|
79
|
+
)
|
|
80
|
+
for item in node.body:
|
|
81
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name in _VERBS:
|
|
82
|
+
cls.methods[item.name.upper()] = any(
|
|
83
|
+
(dotted(d) or "").split(".")[-1] == "authenticated" for d in item.decorator_list
|
|
84
|
+
)
|
|
85
|
+
out.classes.append(cls)
|
|
86
|
+
elif isinstance(node, (ast.List, ast.Tuple)) and len(out.specs) < _MAX_SPECS:
|
|
87
|
+
for elt in node.elts:
|
|
88
|
+
spec = _spec(elt, rel)
|
|
89
|
+
if spec is not None:
|
|
90
|
+
out.specs.append(spec)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _spec(node: ast.expr, rel: str) -> UrlSpec | None:
|
|
95
|
+
if isinstance(node, ast.Tuple) and len(node.elts) >= 2:
|
|
96
|
+
pattern, handler = node.elts[0], node.elts[1]
|
|
97
|
+
elif isinstance(node, ast.Call) and call_name(node) in {"url", "URLSpec"} and len(node.args) >= 2:
|
|
98
|
+
pattern, handler = node.args[0], node.args[1]
|
|
99
|
+
else:
|
|
100
|
+
return None
|
|
101
|
+
text = str_const(pattern)
|
|
102
|
+
name = dotted(handler)
|
|
103
|
+
if text is None or name is None or not text.lstrip("^").startswith("/"):
|
|
104
|
+
return None
|
|
105
|
+
return UrlSpec(file=rel, line=node.lineno, pattern=text, handler=name.split(".")[-1])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _path(pattern: str) -> str:
|
|
109
|
+
path = pattern.lstrip("^")
|
|
110
|
+
while path.endswith("$") and not path.endswith("\\$"):
|
|
111
|
+
path = path[:-1]
|
|
112
|
+
return path or "/"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_tornado(files: list[TornadoFile]) -> list[TornadoRoute]:
|
|
116
|
+
by_name: dict[str, list[HandlerClass]] = {}
|
|
117
|
+
for f in files:
|
|
118
|
+
for cls in f.classes:
|
|
119
|
+
by_name.setdefault(cls.name, []).append(cls)
|
|
120
|
+
|
|
121
|
+
def lookup(name: str, file: str) -> HandlerClass | None:
|
|
122
|
+
found = by_name.get(name, [])
|
|
123
|
+
same = [c for c in found if c.file == file]
|
|
124
|
+
if same:
|
|
125
|
+
return same[0]
|
|
126
|
+
return found[0] if len(found) == 1 else None
|
|
127
|
+
|
|
128
|
+
def verbs(cls: HandlerClass) -> tuple[dict[str, bool], bool, bool]:
|
|
129
|
+
"""(verb → authenticated, websocket, is a handler) through up to
|
|
130
|
+
_MAX_INHERIT levels of bases."""
|
|
131
|
+
methods: dict[str, bool] = {}
|
|
132
|
+
websocket = cls.websocket
|
|
133
|
+
handler = cls.direct
|
|
134
|
+
seen: set[int] = set()
|
|
135
|
+
current: list[HandlerClass] = [cls]
|
|
136
|
+
for _ in range(_MAX_INHERIT + 1):
|
|
137
|
+
nxt: list[HandlerClass] = []
|
|
138
|
+
for c in current:
|
|
139
|
+
if id(c) in seen:
|
|
140
|
+
continue
|
|
141
|
+
seen.add(id(c))
|
|
142
|
+
websocket = websocket or c.websocket
|
|
143
|
+
handler = handler or c.direct
|
|
144
|
+
for verb, authed in c.methods.items():
|
|
145
|
+
methods.setdefault(verb, authed)
|
|
146
|
+
for base in c.bases:
|
|
147
|
+
parent = lookup(base, c.file)
|
|
148
|
+
if parent is not None:
|
|
149
|
+
nxt.append(parent)
|
|
150
|
+
current = nxt
|
|
151
|
+
return methods, websocket, handler
|
|
152
|
+
|
|
153
|
+
routes: list[TornadoRoute] = []
|
|
154
|
+
for f in files:
|
|
155
|
+
for spec in f.specs:
|
|
156
|
+
cls = lookup(spec.handler, spec.file)
|
|
157
|
+
methods, websocket, handler = verbs(cls) if cls is not None else ({}, False, False)
|
|
158
|
+
if not handler and not spec.handler.endswith("Handler"):
|
|
159
|
+
continue
|
|
160
|
+
path = _path(spec.pattern)
|
|
161
|
+
if websocket:
|
|
162
|
+
routes.append(TornadoRoute(path, "WS", spec.file, spec.line))
|
|
163
|
+
continue
|
|
164
|
+
if not methods:
|
|
165
|
+
routes.append(TornadoRoute(path, "ANY", spec.file, spec.line))
|
|
166
|
+
continue
|
|
167
|
+
for verb in sorted(methods):
|
|
168
|
+
if methods[verb]:
|
|
169
|
+
routes.append(
|
|
170
|
+
TornadoRoute(
|
|
171
|
+
path, verb, spec.file, spec.line,
|
|
172
|
+
auth="required",
|
|
173
|
+
guards=["@tornado.web.authenticated"],
|
|
174
|
+
evidence=f"@tornado.web.authenticated on {spec.handler}.{verb.lower()}",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
routes.append(TornadoRoute(path, verb, spec.file, spec.line))
|
|
179
|
+
return routes
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["TornadoFile", "TornadoRoute", "parse_tornado", "resolve_tornado"]
|