attackmap-analyzer-node-service 0.3.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_node_service/__init__.py +3 -0
- attackmap_analyzer_node_service/analyzer.py +1149 -0
- attackmap_analyzer_node_service/modules.py +366 -0
- attackmap_analyzer_node_service/route_auth.py +1553 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/METADATA +104 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/RECORD +10 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
"""Route-level auth and mount paths for the AttackMap#256 contract (#4).
|
|
2
|
+
|
|
3
|
+
Each emitted ``Route`` carries ``auth`` (``required`` / ``anonymous`` /
|
|
4
|
+
``unknown``), the ``guards`` that apply and the ``guard_evidence`` source text
|
|
5
|
+
that established the state. Core trusts a declared state over its own
|
|
6
|
+
same-file resolution, so this only declares what the source verifiably says.
|
|
7
|
+
Routes also get the prefixes of every router mount above them, across files.
|
|
8
|
+
|
|
9
|
+
**Express / Koa / Hono / Elysia / Fastify** — routers are tracked by variable:
|
|
10
|
+
|
|
11
|
+
- roots (``express()``, ``new Koa()``, ``new Hono()``, ``new Elysia()``,
|
|
12
|
+
``fastify()``) and routers (``express.Router()``, ``new Router({ prefix })``,
|
|
13
|
+
``new Elysia({ prefix })``, Hono ``app.basePath('/api')``);
|
|
14
|
+
- ``X.use([path,] mw...)``, ``X.all('*', mw...)``, Fastify
|
|
15
|
+
``X.addHook('onRequest' | 'preHandler' | ..., mw)`` and Elysia
|
|
16
|
+
``X.onBeforeHandle(mw)`` / ``X.guard({ beforeHandle })`` guard the routes
|
|
17
|
+
registered on ``X`` *after* them (in order, as the frameworks run them),
|
|
18
|
+
under ``path`` when one is given;
|
|
19
|
+
- ``X.use([path,] mw..., router)`` / ``X.use(router.routes())`` /
|
|
20
|
+
``X.route(path, sub)`` mount a router, Fastify ``X.register(plugin,
|
|
21
|
+
{ prefix })`` a plugin (``fastify-plugin`` wrapped plugins ignore
|
|
22
|
+
``prefix``), Elysia ``X.group(path, (g) => ...)`` a callback, and
|
|
23
|
+
``plugin(X)`` / ``require('./routes')(X)`` an exported route function: the
|
|
24
|
+
mount's prefix and middleware, and ``X``'s earlier middleware, apply to
|
|
25
|
+
every route below it;
|
|
26
|
+
- calls chain: ``new Hono().use(mw).get('/x', h).route('/y', sub)`` is the
|
|
27
|
+
same as one statement per call;
|
|
28
|
+
- a router, plugin or route function imported from another file is followed
|
|
29
|
+
to that file's export (see ``modules``);
|
|
30
|
+
- route-local middleware: ``router.post('/x', requireAuth, h)`` (arrays
|
|
31
|
+
flattened), ``router.route('/x').post(mw, h)``, and route options
|
|
32
|
+
``{ preHandler | onRequest | preValidation | beforeHandle: [...] }``.
|
|
33
|
+
|
|
34
|
+
A middleware is a guard when its name says it authenticates (``requireAuth``,
|
|
35
|
+
``isAuthenticated``, ``ensureLoggedIn``, ``passport.authenticate('jwt')``,
|
|
36
|
+
``expressjwt(...)``, ``jwt(...)``, ``verifyToken``, ``bearerAuth(...)``) or
|
|
37
|
+
checks a role (``requireRole('admin')``, ``isAdmin``, ``authorize('admin')``,
|
|
38
|
+
``guard.check([...])``), or when an inline hook calls ``jwtVerify()``.
|
|
39
|
+
``optionalAuth``-style middleware, ``passport.authenticate('anonymous')``,
|
|
40
|
+
rate limiters, body parsers, CORS / CSRF / helmet and trackers such as
|
|
41
|
+
``updateAuthenticatedUsers`` are not guards.
|
|
42
|
+
|
|
43
|
+
``guard.unless({ path: ['/login', ...] })`` (express-unless) is an explicit
|
|
44
|
+
opt-out: a route whose full path is listed is ``anonymous`` when no other
|
|
45
|
+
guard applies. A list with regexes or other keys makes the guard uncertain.
|
|
46
|
+
|
|
47
|
+
**NestJS** — ``@UseGuards(...)`` on the method or controller, and a global
|
|
48
|
+
guard (``app.useGlobalGuards(...)`` / ``{ provide: APP_GUARD, useClass }``)
|
|
49
|
+
make a route ``required``; ``Optional...`` guards don't. A metadata-driven
|
|
50
|
+
role guard (``RolesGuard``, ``PermissionsGuard``) counts only next to its
|
|
51
|
+
``@Roles(...)`` / ``@Permissions(...)`` metadata. ``@Public()`` /
|
|
52
|
+
``@SkipAuth()`` / ``@AllowAnonymous()`` / ``@SetMetadata('isPublic', true)``
|
|
53
|
+
make a route ``anonymous``, unless a guard is Passport's own
|
|
54
|
+
``AuthGuard('...')``, which ignores that metadata.
|
|
55
|
+
|
|
56
|
+
Anything else stays ``unknown``. Brackets are matched in one cached pass per
|
|
57
|
+
file, argument lists are split by jumping over nested functions, and chains,
|
|
58
|
+
lookups, mounts and paths are bounded, so every scan is linear.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import bisect
|
|
64
|
+
import functools
|
|
65
|
+
import re
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
|
|
68
|
+
from .modules import ImportRef, ModuleResolver, Reexports, file_candidates, parse_imports, parse_reexports
|
|
69
|
+
|
|
70
|
+
REQUIRED = "required"
|
|
71
|
+
ANONYMOUS = "anonymous"
|
|
72
|
+
UNKNOWN = "unknown"
|
|
73
|
+
|
|
74
|
+
AUTHN = "authn"
|
|
75
|
+
ROLE = "role"
|
|
76
|
+
|
|
77
|
+
_EVIDENCE_MAX = 200
|
|
78
|
+
_ARG_SCAN = 4000
|
|
79
|
+
_LOOKUP_LIMIT = 64 # bindings of one name searched
|
|
80
|
+
_MAX_DEPTH = 16 # mount levels followed
|
|
81
|
+
_MAX_PATHS = 64 # mount paths explored per route
|
|
82
|
+
_MAX_VARIANTS = 8 # distinct mounted paths emitted per route
|
|
83
|
+
_MAX_USES = 32 # guarding use() calls kept per router
|
|
84
|
+
_MAX_GUARDS = 8 # guards kept per use() / mount / route registration
|
|
85
|
+
_MAX_MIDDLEWARE_ARGS = 64 # middleware arguments inspected per call
|
|
86
|
+
_MAX_CHAIN = 256 # calls followed in one chain
|
|
87
|
+
_MAX_REEXPORT_DEPTH = 6
|
|
88
|
+
_MAX_LOOKUP_STEPS = 256 # modules expanded per export lookup
|
|
89
|
+
_MAX_STAR_EXPORTS = 16
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clip(text: str) -> str:
|
|
93
|
+
text = " ".join(text.split())
|
|
94
|
+
return text if len(text) <= _EVIDENCE_MAX else text[: _EVIDENCE_MAX - 1] + "…"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------- Brackets ----------
|
|
98
|
+
|
|
99
|
+
_SCAN_TOKEN = re.compile(r"[\"'`]|//|/\*|[()\[\]{}]")
|
|
100
|
+
_ARG_TOKEN = re.compile(r"[\"'`,]|//|/\*|[(\[{]")
|
|
101
|
+
_STRING_BODY = {
|
|
102
|
+
'"': re.compile(r'[^"\\\n]*(?:\\.[^"\\\n]*)*"'),
|
|
103
|
+
"'": re.compile(r"[^'\\\n]*(?:\\.[^'\\\n]*)*'"),
|
|
104
|
+
"`": re.compile(r"[^`\\]*(?:\\.[^`\\]*)*`", re.DOTALL),
|
|
105
|
+
}
|
|
106
|
+
_OPEN = "([{"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _skip(content: str, token: str, pos: int, limit: int) -> int:
|
|
110
|
+
if token in _STRING_BODY:
|
|
111
|
+
body = _STRING_BODY[token].match(content, pos, limit)
|
|
112
|
+
return body.end() if body else pos
|
|
113
|
+
if token == "//":
|
|
114
|
+
nl = content.find("\n", pos, limit)
|
|
115
|
+
return limit if nl < 0 else nl
|
|
116
|
+
if token == "/*":
|
|
117
|
+
end = content.find("*/", pos, limit)
|
|
118
|
+
return limit if end < 0 else end + 2
|
|
119
|
+
return pos
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@functools.lru_cache(maxsize=4)
|
|
123
|
+
def bracket_pairs(content: str) -> dict[int, int]:
|
|
124
|
+
"""Opening-bracket offset -> closing-bracket offset, in one forward pass
|
|
125
|
+
(strings, template literals and comments skipped)."""
|
|
126
|
+
pairs: dict[int, int] = {}
|
|
127
|
+
stack: list[int] = []
|
|
128
|
+
pos = 0
|
|
129
|
+
n = len(content)
|
|
130
|
+
while pos < n:
|
|
131
|
+
match = _SCAN_TOKEN.search(content, pos)
|
|
132
|
+
if not match:
|
|
133
|
+
break
|
|
134
|
+
token = match.group()
|
|
135
|
+
pos = match.end()
|
|
136
|
+
if token in _OPEN:
|
|
137
|
+
stack.append(match.start())
|
|
138
|
+
elif token in ")]}":
|
|
139
|
+
if stack:
|
|
140
|
+
pairs[stack.pop()] = match.start()
|
|
141
|
+
else:
|
|
142
|
+
pos = _skip(content, token, pos, n)
|
|
143
|
+
return pairs
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def matching_close(content: str, open_idx: int) -> int:
|
|
147
|
+
if open_idx < 0:
|
|
148
|
+
return -1
|
|
149
|
+
return bracket_pairs(content).get(open_idx, -1)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def split_args(content: str, open_idx: int, close: int) -> list[tuple[int, int]]:
|
|
153
|
+
"""``(start, end)`` of each top-level element inside the bracket at
|
|
154
|
+
``open_idx``; nested brackets and strings are jumped over."""
|
|
155
|
+
pairs = bracket_pairs(content)
|
|
156
|
+
args: list[tuple[int, int]] = []
|
|
157
|
+
start = pos = open_idx + 1
|
|
158
|
+
while pos < close:
|
|
159
|
+
match = _ARG_TOKEN.search(content, pos, close)
|
|
160
|
+
if not match:
|
|
161
|
+
break
|
|
162
|
+
token = match.group()
|
|
163
|
+
pos = match.end()
|
|
164
|
+
if token == ",":
|
|
165
|
+
args.append((start, match.start()))
|
|
166
|
+
start = pos
|
|
167
|
+
elif token in _OPEN:
|
|
168
|
+
pos = max(pos, pairs.get(match.start(), close - 1) + 1)
|
|
169
|
+
else:
|
|
170
|
+
pos = _skip(content, token, pos, close)
|
|
171
|
+
if content[start:close].strip():
|
|
172
|
+
args.append((start, close))
|
|
173
|
+
return args
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _strip_span(content: str, start: int, end: int) -> tuple[int, int]:
|
|
177
|
+
while start < end and content[start].isspace():
|
|
178
|
+
start += 1
|
|
179
|
+
while end > start and content[end - 1].isspace():
|
|
180
|
+
end -= 1
|
|
181
|
+
return start, end
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_STRING_LITERAL = re.compile(r"""\s*(['"`])([^'"`\\$]*)\1\s*$""")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def string_literal(text: str) -> str | None:
|
|
188
|
+
match = _STRING_LITERAL.match(text)
|
|
189
|
+
return match.group(2) if match else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------- Guards ----------
|
|
193
|
+
|
|
194
|
+
_ARG_NAME = re.compile(r"\s*(?:await\s+)?(?:new\s+)?([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)")
|
|
195
|
+
# Names are compared lower-cased with `_` removed, one dotted segment at a time.
|
|
196
|
+
_AUTHN_NAME = re.compile(
|
|
197
|
+
# requireAuth, isAuthenticated, authenticate, authenticateJWT, authMiddleware,
|
|
198
|
+
# withAuth, isAuthorized, checkAuth
|
|
199
|
+
r"(?:require|requires|ensure|check|verify|is|has|must|assert|enforce|with|need|needs)?"
|
|
200
|
+
r"(?:auth|authn|authed|authenticate|authenticated|authentication|authorized|authorised)"
|
|
201
|
+
r"(?:user|token|jwt|request|req|session|middleware|mw|guard|handler|check|required|only)?"
|
|
202
|
+
# isLoggedIn, ensureLoggedIn, requireLogin, verifyToken, checkJwt, requireSession
|
|
203
|
+
r"|(?:require|requires|ensure|check|verify|is|has|must|assert|enforce|need|needs)"
|
|
204
|
+
r"(?:loggedin|login|signedin|signin|session|token|jwt|accesstoken|idtoken|bearer|bearertoken|apikey|apitoken"
|
|
205
|
+
r"|authtoken|jwttoken|validtoken|validjwt|validsession|firebasetoken)"
|
|
206
|
+
r"(?:middleware|mw|guard|handler|check|required)?"
|
|
207
|
+
r"|validate(?:session|token|jwt|accesstoken|bearertoken|apikey|apitoken)"
|
|
208
|
+
r"|(?:require|ensure)(?:user|currentuser|signedinuser)"
|
|
209
|
+
r"|jwt|expressjwt|ejwt|jwt(?:middleware|guard|check|verify|required|authentication)|passportjwt"
|
|
210
|
+
r"|loginrequired|requiresauth|protect|protected|protectroute|protectedroute|clerkexpressrequireauth"
|
|
211
|
+
r"|isloggedin|loggedin"
|
|
212
|
+
)
|
|
213
|
+
# Ends in an auth word: webhookAuth, basicAuth, bearerAuth, apiKeyAuth, jwtAuthGuard.
|
|
214
|
+
_AUTHN_SUFFIX = ("auth", "authguard", "authmiddleware", "authrequired", "authcheck", "authhandler")
|
|
215
|
+
_ROLE_NAME = re.compile(
|
|
216
|
+
# isAdmin, requireRole, hasAnyRole, checkPermissions, requireScopes, adminOnly
|
|
217
|
+
r"(?:require|requires|ensure|check|verify|is|has|must|assert|only|restrict|allow|need|needs)(?:any)?"
|
|
218
|
+
r"(?:role|roles|admin|admins|administrator|superuser|superadmin|staff|permission|permissions|perm|perms"
|
|
219
|
+
r"|scope|scopes|privilege|privileges)"
|
|
220
|
+
r"(?:middleware|mw|guard|check|required|only|access|user)?"
|
|
221
|
+
r"|(?:admin|administrator|superuser|superadmin|staff|role|roles|permission|permissions|scope|scopes)"
|
|
222
|
+
r"(?:only|required|guard|middleware|mw|check|access)"
|
|
223
|
+
r"|authori[sz]e\w*|permit|restrictto|requiredscopes|rbac|acl|onlyadmins?"
|
|
224
|
+
)
|
|
225
|
+
# Objects whose generic method is an auth check: `auth.required`, `keycloak.protect()`.
|
|
226
|
+
_AUTH_OBJECTS = frozenset({"auth", "authn", "authentication", "authmiddleware", "authmw", "jwt", "passport", "keycloak"})
|
|
227
|
+
_AUTH_OBJECT_METHODS = frozenset(
|
|
228
|
+
{"required", "require", "verify", "check", "protect", "ensure", "authenticate", "authenticated", "jwt", "user",
|
|
229
|
+
"isauthenticated", "loggedin", "isloggedin", "token", "guard"}
|
|
230
|
+
)
|
|
231
|
+
_NOT_GUARD_NAME = re.compile(
|
|
232
|
+
r"optional|maybe|soft|tryauth|noauth|skipauth|withoutauth|bypass|public|anonymous|guest|unauth|notauth"
|
|
233
|
+
r"|ratelimit|throttl|cors|csrf|xsrf|helmet|logout|signout|callback|clerkexpresswithauth"
|
|
234
|
+
)
|
|
235
|
+
_INLINE_VERIFY = re.compile(r"\.\s*jwtVerify\s*\(")
|
|
236
|
+
_FUNCTION_START = re.compile(r"\s*(?:async\s+)?(?:function\b|\([^()]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)")
|
|
237
|
+
_UNLESS = re.compile(r"\.\s*unless\s*\(")
|
|
238
|
+
_ANONYMOUS_STRATEGY = re.compile(r"""['"]anonymous['"]""")
|
|
239
|
+
_SPACES = re.compile(r"\s+")
|
|
240
|
+
_NEW = re.compile(r"^new\s+")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def guard_kind(name: str) -> str | None:
|
|
244
|
+
"""``authn`` / ``role`` when a middleware's (dotted) name says it
|
|
245
|
+
authenticates or checks a role; None otherwise."""
|
|
246
|
+
norm = name.lower().replace("_", "").replace(" ", "")
|
|
247
|
+
if not norm or _NOT_GUARD_NAME.search(norm):
|
|
248
|
+
return None
|
|
249
|
+
segments = norm.split(".")
|
|
250
|
+
final = segments[-1]
|
|
251
|
+
if _ROLE_NAME.fullmatch(final) or (len(segments) > 1 and segments[-2] == "guard" and final == "check"):
|
|
252
|
+
return ROLE
|
|
253
|
+
if _AUTHN_NAME.fullmatch(final):
|
|
254
|
+
return AUTHN
|
|
255
|
+
if final.endswith(_AUTHN_SUFFIX) and not final.endswith("oauth") and final != "oauth":
|
|
256
|
+
return AUTHN
|
|
257
|
+
if len(segments) > 1 and segments[-2] in _AUTH_OBJECTS and final in _AUTH_OBJECT_METHODS:
|
|
258
|
+
return AUTHN
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class Skip:
|
|
264
|
+
"""An ``.unless({ path: [...] })`` opt-out list."""
|
|
265
|
+
|
|
266
|
+
exact: set[str]
|
|
267
|
+
characterized: bool
|
|
268
|
+
evidence: str
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass
|
|
272
|
+
class Guard:
|
|
273
|
+
name: str
|
|
274
|
+
evidence: str
|
|
275
|
+
skip: Skip | None = None
|
|
276
|
+
uncertain: bool = False
|
|
277
|
+
prefix: str | None = None # `use('/api', mw)`: only under this path
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _unless(content: str, start: int, end: int) -> Skip | None:
|
|
281
|
+
match = _UNLESS.search(content, start, end)
|
|
282
|
+
if not match:
|
|
283
|
+
return None
|
|
284
|
+
close = matching_close(content, match.end() - 1)
|
|
285
|
+
if close < 0:
|
|
286
|
+
return Skip(set(), False, "")
|
|
287
|
+
evidence = clip(content[match.start() + 1 : min(close + 1, match.start() + _EVIDENCE_MAX)])
|
|
288
|
+
args = split_args(content, match.end() - 1, close)
|
|
289
|
+
if len(args) != 1 or content[_strip_span(content, *args[0])[0]] != "{":
|
|
290
|
+
return Skip(set(), False, evidence)
|
|
291
|
+
obj_open = _strip_span(content, *args[0])[0]
|
|
292
|
+
obj_close = matching_close(content, obj_open)
|
|
293
|
+
exact: set[str] = set()
|
|
294
|
+
characterized = obj_close > 0
|
|
295
|
+
for s, e in split_args(content, obj_open, obj_close) if obj_close > 0 else []:
|
|
296
|
+
key, _, value = content[s:e].partition(":")
|
|
297
|
+
if key.strip().strip("'\"") != "path":
|
|
298
|
+
characterized = False
|
|
299
|
+
continue
|
|
300
|
+
value_start = s + len(key) + 1
|
|
301
|
+
vs, ve = _strip_span(content, value_start, e)
|
|
302
|
+
items = [(vs, ve)]
|
|
303
|
+
if content[vs:vs + 1] == "[":
|
|
304
|
+
vclose = matching_close(content, vs)
|
|
305
|
+
items = split_args(content, vs, vclose) if vclose > 0 else []
|
|
306
|
+
characterized = characterized and vclose > 0
|
|
307
|
+
for item_s, item_e in items:
|
|
308
|
+
literal = string_literal(content[item_s:item_e])
|
|
309
|
+
if literal is None:
|
|
310
|
+
characterized = False # a regex, an object, a variable
|
|
311
|
+
else:
|
|
312
|
+
exact.add(literal)
|
|
313
|
+
return Skip(exact, characterized and bool(exact), evidence)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def guard_from_arg(content: str, start: int, end: int, context: str | None = None) -> Guard | None:
|
|
317
|
+
"""A Guard when the middleware argument ``content[start:end]`` authenticates
|
|
318
|
+
or checks a role."""
|
|
319
|
+
end = min(end, start + _ARG_SCAN)
|
|
320
|
+
text = content[start:end]
|
|
321
|
+
evidence = clip(context if context is not None else text)
|
|
322
|
+
if _FUNCTION_START.match(text):
|
|
323
|
+
# An inline hook / middleware: a guard when it verifies a JWT.
|
|
324
|
+
return Guard("jwtVerify", evidence) if _INLINE_VERIFY.search(text) else None
|
|
325
|
+
match = _ARG_NAME.match(content, start, end)
|
|
326
|
+
if not match:
|
|
327
|
+
return None
|
|
328
|
+
name = _SPACES.sub("", match.group(1))
|
|
329
|
+
if guard_kind(name) is None:
|
|
330
|
+
return None
|
|
331
|
+
if name.endswith("passport.authenticate"):
|
|
332
|
+
# `passport.authenticate('anonymous')` / `(['jwt', 'anonymous'])` admit
|
|
333
|
+
# callers without credentials.
|
|
334
|
+
paren = content.find("(", match.end(), end)
|
|
335
|
+
close = matching_close(content, paren) if paren >= 0 else -1
|
|
336
|
+
args = split_args(content, paren, close) if 0 < close <= end else []
|
|
337
|
+
if args and _ANONYMOUS_STRATEGY.search(content, args[0][0], args[0][1]):
|
|
338
|
+
return None
|
|
339
|
+
return Guard(name, evidence, _unless(content, start, end))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
_LOGIN_STRATEGY = re.compile(r"""\s*['"]local['"]""")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def guard_only_registration(content: str, spans: list[tuple[int, int]], context: str) -> list[Guard]:
|
|
346
|
+
"""The guards of a route registration whose every argument is a guard,
|
|
347
|
+
else []. Passport's ``local`` strategy there is the login handler itself."""
|
|
348
|
+
guards: list[Guard] = []
|
|
349
|
+
for start, end in flatten(content, spans)[:_MAX_MIDDLEWARE_ARGS]:
|
|
350
|
+
guard = guard_from_arg(content, start, end, context)
|
|
351
|
+
if guard is None:
|
|
352
|
+
return []
|
|
353
|
+
if guard.name.endswith("passport.authenticate"):
|
|
354
|
+
paren = content.find("(", start, end)
|
|
355
|
+
if paren >= 0 and _LOGIN_STRATEGY.match(content, paren + 1, end):
|
|
356
|
+
return []
|
|
357
|
+
guards.append(guard)
|
|
358
|
+
return guards[:_MAX_GUARDS]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def flatten(content: str, spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
362
|
+
"""Expand array arguments (``[requireAuth, validate]``) into their elements."""
|
|
363
|
+
out: list[tuple[int, int]] = []
|
|
364
|
+
for start, end in spans:
|
|
365
|
+
s, e = _strip_span(content, start, end)
|
|
366
|
+
if content[s:s + 1] == "[":
|
|
367
|
+
close = matching_close(content, s)
|
|
368
|
+
if close > 0:
|
|
369
|
+
out += split_args(content, s, close)
|
|
370
|
+
continue
|
|
371
|
+
out.append((start, end))
|
|
372
|
+
return out
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# Used with .match(content, pos): `^` would only match at the string start.
|
|
376
|
+
_HOOK_KEYS = re.compile(r"""\s*['"]?(preHandler|onRequest|preValidation|preParsing|beforeHandle)['"]?\s*:""")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def route_option_guards(content: str, start: int, end: int, context: str) -> list[Guard]:
|
|
380
|
+
"""Route options ``{ preHandler: [...], onRequest: fn, beforeHandle: mw }``."""
|
|
381
|
+
s, e = _strip_span(content, start, end)
|
|
382
|
+
if content[s:s + 1] != "{":
|
|
383
|
+
return []
|
|
384
|
+
close = matching_close(content, s)
|
|
385
|
+
if close < 0:
|
|
386
|
+
return []
|
|
387
|
+
guards: list[Guard] = []
|
|
388
|
+
for ps, pe in split_args(content, s, close):
|
|
389
|
+
key = _HOOK_KEYS.match(content, ps, pe)
|
|
390
|
+
if not key:
|
|
391
|
+
continue
|
|
392
|
+
for vs, ve in flatten(content, [(key.end(), pe)])[:_MAX_MIDDLEWARE_ARGS]:
|
|
393
|
+
guard = guard_from_arg(content, vs, ve, context)
|
|
394
|
+
if guard:
|
|
395
|
+
guards.append(guard)
|
|
396
|
+
return guards[:_MAX_GUARDS]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------- Functions ----------
|
|
400
|
+
|
|
401
|
+
_FN_KEYWORD = re.compile(r"\s*(?:async\s+)?function\b\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*(?:<[^()]{0,200}>)?\s*\(")
|
|
402
|
+
_FN_ARROW_PARAMS = re.compile(r"\s*(?:async\s*)?(?:<[^()]{0,200}>\s*)?\(")
|
|
403
|
+
_FN_SINGLE = re.compile(r"\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>")
|
|
404
|
+
_FIRST_PARAM = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*[?]?\s*[:,)=]")
|
|
405
|
+
_ARROW = re.compile(r"\s*(?::\s*[^=;{}()]{1,200})?=>")
|
|
406
|
+
_BLOCK_OPEN = re.compile(r"\s*(?::\s*[^{};=]{1,200})?\{")
|
|
407
|
+
_EXPR_BODY_OPEN = re.compile(r"\s*\{")
|
|
408
|
+
# A request handler / middleware, not a route function taking the app.
|
|
409
|
+
_NOT_ROUTER_PARAMS = frozenset(
|
|
410
|
+
{"req", "request", "res", "response", "reply", "ctx", "c", "context", "next", "err", "error", "e", "event",
|
|
411
|
+
"_", "args", "options", "opts", "props", "config", "env"}
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def function_at(content: str, start: int, end: int, *, block_only: bool = False) -> tuple[str, int, int] | None:
|
|
416
|
+
"""``(first parameter, body start, body end)`` of the function expression
|
|
417
|
+
at ``content[start:end]``; an arrow with an expression body ends at
|
|
418
|
+
``end`` (not with ``block_only``)."""
|
|
419
|
+
keyword = _FN_KEYWORD.match(content, start, end)
|
|
420
|
+
head = keyword or _FN_ARROW_PARAMS.match(content, start, end)
|
|
421
|
+
if head:
|
|
422
|
+
paren = head.end() - 1
|
|
423
|
+
close = matching_close(content, paren)
|
|
424
|
+
if close < 0 or close >= end:
|
|
425
|
+
return None
|
|
426
|
+
param = _FIRST_PARAM.match(content, paren + 1, close + 1)
|
|
427
|
+
if not param:
|
|
428
|
+
return None
|
|
429
|
+
after = close + 1
|
|
430
|
+
if keyword is None:
|
|
431
|
+
arrow = _ARROW.match(content, after, end)
|
|
432
|
+
if not arrow:
|
|
433
|
+
return None
|
|
434
|
+
after = arrow.end()
|
|
435
|
+
name = param.group(1)
|
|
436
|
+
else:
|
|
437
|
+
single = _FN_SINGLE.match(content, start, end)
|
|
438
|
+
if not single:
|
|
439
|
+
return None
|
|
440
|
+
name, after = single.group(1), single.end()
|
|
441
|
+
keyword = None
|
|
442
|
+
if name in _NOT_ROUTER_PARAMS:
|
|
443
|
+
return None
|
|
444
|
+
body = (_BLOCK_OPEN if keyword else _EXPR_BODY_OPEN).match(content, after, end)
|
|
445
|
+
if body:
|
|
446
|
+
open_idx = body.end() - 1
|
|
447
|
+
close = matching_close(content, open_idx)
|
|
448
|
+
return (name, open_idx, close) if 0 < close <= end else None
|
|
449
|
+
if keyword or block_only:
|
|
450
|
+
return None
|
|
451
|
+
return name, after, end
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------- Router bindings ----------
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@dataclass
|
|
458
|
+
class Mount:
|
|
459
|
+
owner: "Binding"
|
|
460
|
+
offset: int
|
|
461
|
+
prefix: str
|
|
462
|
+
guards: list[Guard]
|
|
463
|
+
routers: "FileRouters"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass
|
|
467
|
+
class Binding:
|
|
468
|
+
name: str
|
|
469
|
+
offset: int
|
|
470
|
+
scope: tuple[int, int]
|
|
471
|
+
root: bool = False # express() / new Koa() / new Hono() / fastify()
|
|
472
|
+
prefix: str = "" # koa `new Router({ prefix })`, elysia `{ prefix }`, hono basePath, register `{ prefix }`
|
|
473
|
+
uses: list[tuple[int, list[Guard]]] = field(default_factory=list)
|
|
474
|
+
mounts: list[Mount] = field(default_factory=list)
|
|
475
|
+
overflow: bool = False
|
|
476
|
+
skip_prefix: bool = False # a fastify-plugin wrapped plugin ignores `register(..., { prefix })`
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@dataclass
|
|
480
|
+
class ChainRoute:
|
|
481
|
+
"""``.get('/x', ...)`` called on the result of another router call."""
|
|
482
|
+
|
|
483
|
+
binding: Binding
|
|
484
|
+
method: str
|
|
485
|
+
path: str
|
|
486
|
+
offset: int
|
|
487
|
+
paren: int
|
|
488
|
+
close: int
|
|
489
|
+
head: str | None # receiver name of the chain's first call; None after a constructor
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
_IDENT = r"[A-Za-z_$][\w$]*"
|
|
493
|
+
_SPEC = r"""['"]([^'"\n]{1,300})['"]"""
|
|
494
|
+
_CTOR_NAMES = (
|
|
495
|
+
r"express\s*\.\s*Router|require\s*\(\s*['\"]express['\"]\s*\)\s*\.\s*Router|express|Router|fastify|Fastify|polka"
|
|
496
|
+
r"|new\s+(?:Koa|Hono|OpenAPIHono|Elysia|Router|KoaRouter)\b"
|
|
497
|
+
)
|
|
498
|
+
_PREFIXED_CTORS = re.compile(r"Router|Elysia") # `new Router({ prefix })`, `new Elysia({ prefix })`
|
|
499
|
+
_IDENT_RE = re.compile(_IDENT)
|
|
500
|
+
_VALUE_IDENT = re.compile(rf"\s*({_IDENT})\s*(?:[;,)\n]|$)")
|
|
501
|
+
_ROOT_CTORS = re.compile(r"express|fastify|Fastify|polka|new\s+(?:Koa|Hono|OpenAPIHono|Elysia)")
|
|
502
|
+
_ASSIGN_CTOR = re.compile(
|
|
503
|
+
rf"\b({_IDENT})\s*(?::\s*[^=;\n]{{1,200}})?=\s*(?:await\s+)?(?P<ctor>{_CTOR_NAMES})"
|
|
504
|
+
)
|
|
505
|
+
_EXPORT_CTOR = re.compile(rf"(?:\bmodule\s*\.\s*exports\s*=(?!=)|\bexport\s+default\b)\s*(?P<ctor>{_CTOR_NAMES})")
|
|
506
|
+
# `(` after a constructor name, past TypeScript generics: `new Hono<{ Bindings: Env }>()`.
|
|
507
|
+
_CTOR_PAREN = re.compile(r"\s*(?:<[^()<>;]{0,300}(?:<[^()<>;]{0,300}>[^()<>;]{0,300})*>)?\s*\(")
|
|
508
|
+
_BASEPATH_ASSIGN = re.compile(
|
|
509
|
+
rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*({_IDENT})\s*\.\s*basePath\s*\(\s*['\"]([^'\"\n]*)['\"]\s*\)"
|
|
510
|
+
)
|
|
511
|
+
_VERBS = frozenset({"get", "post", "put", "patch", "delete", "options", "head", "all"})
|
|
512
|
+
_CALL_HEAD = re.compile(
|
|
513
|
+
rf"\b({_IDENT})\s*\.\s*(get|post|put|patch|delete|options|head|all|use|route|group|guard|basePath|addHook"
|
|
514
|
+
rf"|register|onBeforeHandle)\s*\("
|
|
515
|
+
)
|
|
516
|
+
_WS = re.compile(r"(?:\s|//[^\n]{0,500}\n|/\*(?:[^*]|\*(?!/)){0,500}\*/)*")
|
|
517
|
+
def skip_space(content: str, pos: int) -> int:
|
|
518
|
+
"""The offset after whitespace and comments at ``pos`` (bounded)."""
|
|
519
|
+
return _WS.match(content, pos).end()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
_LINK = re.compile(rf"\.\s*({_IDENT})\s*(?:<[^()<>;]{{0,200}}>\s*)?\(")
|
|
523
|
+
# Chainable calls that return the same app (Hono / Elysia / Express / Fastify).
|
|
524
|
+
_PASSTHROUGH = frozenset(
|
|
525
|
+
{"onError", "notFound", "decorate", "state", "derive", "resolve", "model", "onRequest", "onAfterHandle",
|
|
526
|
+
"onParse", "onTransform", "onResponse", "onAfterResponse", "mapResponse", "error", "setErrorHandler",
|
|
527
|
+
"setNotFoundHandler", "decorateRequest", "decorateReply", "mount", "doc", "openapi", "param", "set",
|
|
528
|
+
"engine", "disable", "enable", "macro", "headers", "onStart", "onStop", "onError"}
|
|
529
|
+
)
|
|
530
|
+
_HOOK_NAME = re.compile(r"""\s*['"](?:onRequest|preHandler|preValidation|preParsing)['"]\s*,""")
|
|
531
|
+
_PREFIX_OPTION = re.compile(r"""\bprefix\s*:\s*['"]([^'"]*)['"]""")
|
|
532
|
+
_ROUTES_CALL = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*\.\s*(?:routes|middleware)\s*\(\s*\)\s*$")
|
|
533
|
+
_IDENT_ONLY = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*$")
|
|
534
|
+
_MEMBER = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*$")
|
|
535
|
+
_INLINE_IMPORT = re.compile(
|
|
536
|
+
rf"\s*(?:\(\s*)?(?:await\s+)?(?:require|import)\s*\(\s*{_SPEC}\s*\)\s*\)?(?:\s*\.\s*({_IDENT}))?\s*$"
|
|
537
|
+
)
|
|
538
|
+
_PLUGIN_WRAP = re.compile(r"\s*(?:fp|fastifyPlugin)\s*\(")
|
|
539
|
+
_CALL_MOUNT = re.compile(rf"(?<![.\w$])({_IDENT})\s*\(\s*({_IDENT})\s*[,)]")
|
|
540
|
+
# `function name(app) {`, `name(app) {` (a method): definitions, not calls.
|
|
541
|
+
_DEFINITION_AFTER = re.compile(r"\s*(?::[^{};=]{1,200})?(?:\{|=>)")
|
|
542
|
+
_REQUIRE_CALL_MOUNT = re.compile(rf"\brequire\s*\(\s*{_SPEC}\s*\)(?:\s*\.\s*({_IDENT}))?\s*\(\s*({_IDENT})\s*[,)]")
|
|
543
|
+
_FN_DECL = re.compile(rf"\b(?:async\s+)?function\s*\*?\s*({_IDENT})\s*(?:<[^()]{{0,200}}>)?\s*\(")
|
|
544
|
+
_FN_ASSIGN = re.compile(rf"\b(?:const|let|var)\s+({_IDENT})\s*(?::\s*[^=;\n]{{1,200}})?=(?!=)")
|
|
545
|
+
_EXPORT_DEFAULT = re.compile(r"(?:\bmodule\s*\.\s*exports\s*=(?!=)|\bexport\s+default\b)")
|
|
546
|
+
_EXPORT_DECL = re.compile(rf"\bexport\s+(?:(?:const|let|var)\s+({_IDENT})|(?:async\s+)?function\s*\*?\s*({_IDENT}))")
|
|
547
|
+
_EXPORT_LIST = re.compile(r"\bexport\s*\{([^{}]{0,1000})\}(?!\s*from)")
|
|
548
|
+
_CJS_EXPORT = re.compile(rf"\b(?:module\s*\.\s*)?exports\s*\.\s*({_IDENT})\s*=(?!=)")
|
|
549
|
+
_EXPORT_AS = re.compile(rf"^\s*({_IDENT})(?:\s+as\s+({_IDENT}))?\s*$")
|
|
550
|
+
_CONVENTIONAL = frozenset({"app", "router", "server", "fastify"})
|
|
551
|
+
_PATH_VARIABLE = re.compile(rf"{_IDENT}(?:\s*\.\s*{_IDENT})*")
|
|
552
|
+
_KEYWORDS = frozenset(
|
|
553
|
+
{"if", "for", "while", "switch", "catch", "function", "return", "typeof", "await", "new", "require", "import",
|
|
554
|
+
"super", "with", "void", "delete", "in", "of", "do", "else", "case", "throw", "yield"}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def join_path(prefix: str, path: str) -> str:
|
|
559
|
+
if not prefix:
|
|
560
|
+
return path
|
|
561
|
+
if not path or path == "/":
|
|
562
|
+
return "/" + prefix.strip("/") if prefix.strip("/") else "/"
|
|
563
|
+
return "/" + "/".join(p for p in (prefix.strip("/"), path.strip("/")) if p)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _under(path: str, prefix: str) -> bool:
|
|
567
|
+
"""Express/Hono path-prefix match (`/api` covers `/api` and `/api/x`;
|
|
568
|
+
`/api/*` covers `/api/x` but not `/api`, as in Express 4)."""
|
|
569
|
+
if prefix.endswith("/*"):
|
|
570
|
+
return path.startswith(prefix[:-1])
|
|
571
|
+
prefix = prefix.rstrip("*")
|
|
572
|
+
if prefix in ("", "/"):
|
|
573
|
+
return True
|
|
574
|
+
if prefix.endswith("/"):
|
|
575
|
+
return path.startswith(prefix) or path == prefix.rstrip("/")
|
|
576
|
+
return path == prefix or path.startswith(prefix + "/")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
_FN_BODY_ARROW = re.compile(r"=>\s*$")
|
|
580
|
+
_FN_BODY_AFTER_PARAMS = re.compile(r"\)\s*(?::[^{};()=]{1,150})?\s*$")
|
|
581
|
+
_ARROW_PARAM = re.compile(rf"(?<![\w$.])({_IDENT})\s*$")
|
|
582
|
+
_PARAM_NAME = re.compile(rf"(?:^|,)\s*(?:\.\.\.)?({_IDENT})")
|
|
583
|
+
_CONTROL_KEYWORD = re.compile(r"(?:\b(?:if|for|while|switch|catch|with)|\bfor\s+await)\s*$")
|
|
584
|
+
_MAX_PARAMS_TEXT = 400
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _function_params(content: str, open_idx: int, opens: dict[int, int]) -> list[str] | None:
|
|
588
|
+
"""The parameter names of the function / method / arrow whose body the
|
|
589
|
+
``{`` at ``open_idx`` opens, or None when it opens something else (a
|
|
590
|
+
class, an object literal, an ``if`` / ``for`` block)."""
|
|
591
|
+
head = content[max(0, open_idx - 200) : open_idx]
|
|
592
|
+
arrow = _FN_BODY_ARROW.search(head)
|
|
593
|
+
if arrow:
|
|
594
|
+
head = head[: arrow.start()]
|
|
595
|
+
single = _ARROW_PARAM.search(head)
|
|
596
|
+
if single and not _FN_BODY_AFTER_PARAMS.search(head):
|
|
597
|
+
return [single.group(1)]
|
|
598
|
+
params = _FN_BODY_AFTER_PARAMS.search(head)
|
|
599
|
+
if not params:
|
|
600
|
+
return [] if arrow else None
|
|
601
|
+
close = open_idx - (open_idx - max(0, open_idx - 200)) + params.start()
|
|
602
|
+
paren = opens.get(close)
|
|
603
|
+
if paren is None:
|
|
604
|
+
return [] if arrow else None
|
|
605
|
+
if not arrow and _CONTROL_KEYWORD.search(content, max(0, paren - 20), paren):
|
|
606
|
+
return None
|
|
607
|
+
text = content[paren + 1 : min(close, paren + 1 + _MAX_PARAMS_TEXT)]
|
|
608
|
+
names: list[str] = []
|
|
609
|
+
depth = 0
|
|
610
|
+
start = 0
|
|
611
|
+
# Top-level parameter names only: `(router: Router, { a, b } = {}, ...rest)`.
|
|
612
|
+
for i, ch in enumerate(text):
|
|
613
|
+
if ch in "([{<":
|
|
614
|
+
depth += 1
|
|
615
|
+
elif ch in ")]}>":
|
|
616
|
+
depth = max(0, depth - 1)
|
|
617
|
+
elif ch == "," and depth == 0:
|
|
618
|
+
match = _PARAM_NAME.match(text, start)
|
|
619
|
+
if match:
|
|
620
|
+
names.append(match.group(1))
|
|
621
|
+
start = i
|
|
622
|
+
match = _PARAM_NAME.match(text, start)
|
|
623
|
+
if match:
|
|
624
|
+
names.append(match.group(1))
|
|
625
|
+
return names
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _is_function_body(content: str, open_idx: int, opens: dict[int, int]) -> bool:
|
|
629
|
+
return _function_params(content, open_idx, opens) is not None
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class FileRouters:
|
|
633
|
+
"""Router variables, their middleware, mounts, imports and exports in one file."""
|
|
634
|
+
|
|
635
|
+
def __init__(self, content: str, file: str) -> None:
|
|
636
|
+
self.content = content
|
|
637
|
+
self.file = file
|
|
638
|
+
self._length = len(content)
|
|
639
|
+
pairs = bracket_pairs(content)
|
|
640
|
+
blocks = sorted((o, c) for o, c in pairs.items() if content[o] == "{")
|
|
641
|
+
# A router passed in as a parameter is bound per function: the
|
|
642
|
+
# outermost function body around a call (a class or object literal
|
|
643
|
+
# isn't one, so each method gets its own).
|
|
644
|
+
opens = {c: o for o, c in pairs.items() if content[o] == "("}
|
|
645
|
+
self._top: list[tuple[int, int]] = []
|
|
646
|
+
# Function bodies by parameter name: a router passed in as a
|
|
647
|
+
# parameter belongs to its own function, not to a sibling's.
|
|
648
|
+
self._param_scopes: dict[str, list[tuple[int, int]]] = {}
|
|
649
|
+
for block in blocks:
|
|
650
|
+
params = _function_params(content, block[0], opens)
|
|
651
|
+
if params is None:
|
|
652
|
+
continue
|
|
653
|
+
if not self._top or block[0] > self._top[-1][1]:
|
|
654
|
+
self._top.append(block)
|
|
655
|
+
for param in params[:16]:
|
|
656
|
+
self._param_scopes.setdefault(param, []).append(block)
|
|
657
|
+
self._top_starts = [s for s, _ in self._top]
|
|
658
|
+
self._blocks = blocks
|
|
659
|
+
self._stack: list[tuple[int, int]] = []
|
|
660
|
+
self._next_block = 0
|
|
661
|
+
self._by_name: dict[str, list[Binding]] = {}
|
|
662
|
+
self._offsets: dict[str, list[int]] = {}
|
|
663
|
+
self._free: dict[tuple[str, int], Binding] = {}
|
|
664
|
+
self._anonymous: dict[int, Binding] = {} # export-default constructor offset -> binding
|
|
665
|
+
self._fn_defs: dict[str, int] | None = None
|
|
666
|
+
self.imports: dict[str, ImportRef] = parse_imports(content)
|
|
667
|
+
self.reexports: Reexports = parse_reexports(content)
|
|
668
|
+
self.import_mounts: list[tuple[ImportRef, Mount]] = []
|
|
669
|
+
self.exports: dict[str, Binding] = {}
|
|
670
|
+
self.chain_routes: list[ChainRoute] = []
|
|
671
|
+
|
|
672
|
+
self._fn_params: dict[str, Binding] = {}
|
|
673
|
+
# Router variables first, then the functions whose parameter shadows
|
|
674
|
+
# one (`async function routes(fastify) {...}` next to `const fastify =
|
|
675
|
+
# Fastify()`), then the calls, so each call sees the right binding.
|
|
676
|
+
declarations: list[tuple[int, int, re.Match]] = []
|
|
677
|
+
for kind, pattern in enumerate((_ASSIGN_CTOR, _EXPORT_CTOR, _BASEPATH_ASSIGN)):
|
|
678
|
+
declarations += [(m.start(), kind, m) for m in pattern.finditer(content)]
|
|
679
|
+
declarations.sort(key=lambda e: (e[0], e[1]))
|
|
680
|
+
calls: list[tuple[int, int, re.Match]] = []
|
|
681
|
+
for kind, pattern in enumerate((_CALL_HEAD, _CALL_MOUNT, _REQUIRE_CALL_MOUNT), start=3):
|
|
682
|
+
calls += [(m.start(), kind, m) for m in pattern.finditer(content)]
|
|
683
|
+
calls.sort(key=lambda e: (e[0], e[1]))
|
|
684
|
+
for _, kind, match in declarations:
|
|
685
|
+
if kind <= 1:
|
|
686
|
+
self._constructor(match, kind == 1)
|
|
687
|
+
else:
|
|
688
|
+
self._base_path(match)
|
|
689
|
+
self._shadowing_parameters()
|
|
690
|
+
for _, kind, match in calls:
|
|
691
|
+
if kind == 3:
|
|
692
|
+
self._call_head(match)
|
|
693
|
+
elif kind == 4:
|
|
694
|
+
self._call_mount(match)
|
|
695
|
+
else:
|
|
696
|
+
self._require_call_mount(match)
|
|
697
|
+
self._parse_exports()
|
|
698
|
+
# Only bindings are needed from here on; don't keep every file's source.
|
|
699
|
+
self.content = ""
|
|
700
|
+
self._fn_defs = None
|
|
701
|
+
|
|
702
|
+
# -- scopes / bindings --
|
|
703
|
+
|
|
704
|
+
def _advance(self, offset: int) -> tuple[int, int]:
|
|
705
|
+
while self._next_block < len(self._blocks) and self._blocks[self._next_block][0] < offset:
|
|
706
|
+
block = self._blocks[self._next_block]
|
|
707
|
+
self._next_block += 1
|
|
708
|
+
while self._stack and self._stack[-1][1] < block[0]:
|
|
709
|
+
self._stack.pop()
|
|
710
|
+
self._stack.append(block)
|
|
711
|
+
while self._stack and self._stack[-1][1] < offset:
|
|
712
|
+
self._stack.pop()
|
|
713
|
+
# Module scope for top-level `const app = express()`.
|
|
714
|
+
return self._stack[-1] if self._stack else (0, self._length)
|
|
715
|
+
|
|
716
|
+
def _top_level(self, offset: int) -> tuple[int, int]:
|
|
717
|
+
index = bisect.bisect_right(self._top_starts, offset) - 1
|
|
718
|
+
if index >= 0 and self._top[index][1] >= offset:
|
|
719
|
+
return self._top[index]
|
|
720
|
+
return (0, self._length)
|
|
721
|
+
|
|
722
|
+
def _param_scope(self, name: str, offset: int) -> tuple[int, int] | None:
|
|
723
|
+
"""The innermost function body around ``offset`` that takes ``name``
|
|
724
|
+
as a parameter."""
|
|
725
|
+
scopes = self._param_scopes.get(name)
|
|
726
|
+
if not scopes:
|
|
727
|
+
return None
|
|
728
|
+
index = bisect.bisect_right(scopes, (offset, self._length + 1)) - 1
|
|
729
|
+
for _ in range(_LOOKUP_LIMIT):
|
|
730
|
+
if index < 0:
|
|
731
|
+
break
|
|
732
|
+
if scopes[index][0] <= offset <= scopes[index][1]:
|
|
733
|
+
return scopes[index]
|
|
734
|
+
index -= 1
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
def _bind(self, binding: Binding) -> None:
|
|
738
|
+
offsets = self._offsets.setdefault(binding.name, [])
|
|
739
|
+
index = bisect.bisect_right(offsets, binding.offset)
|
|
740
|
+
offsets.insert(index, binding.offset)
|
|
741
|
+
self._by_name.setdefault(binding.name, []).insert(index, binding)
|
|
742
|
+
|
|
743
|
+
def names(self) -> set[str]:
|
|
744
|
+
"""Names bound to a router somewhere in the file."""
|
|
745
|
+
return {name for name in self._by_name if name}
|
|
746
|
+
|
|
747
|
+
def resolve(self, name: str, offset: int, *, create: bool = True) -> Binding | None:
|
|
748
|
+
"""The binding of ``name`` visible at ``offset``; else a free binding
|
|
749
|
+
for the enclosing top-level function (a router passed in)."""
|
|
750
|
+
bindings = self._by_name.get(name, [])
|
|
751
|
+
index = bisect.bisect_left(self._offsets.get(name, []), offset) - 1
|
|
752
|
+
for _ in range(_LOOKUP_LIMIT):
|
|
753
|
+
if index < 0:
|
|
754
|
+
break
|
|
755
|
+
binding = bindings[index]
|
|
756
|
+
if binding.scope[0] <= offset <= binding.scope[1]:
|
|
757
|
+
return binding
|
|
758
|
+
index -= 1
|
|
759
|
+
else:
|
|
760
|
+
if index >= 0:
|
|
761
|
+
return None
|
|
762
|
+
if not create:
|
|
763
|
+
return None
|
|
764
|
+
scope = self._param_scope(name, offset) or self._top_level(offset)
|
|
765
|
+
free = self._free.get((name, scope[0]))
|
|
766
|
+
if free is None:
|
|
767
|
+
free = self._free[(name, scope[0])] = Binding(name, scope[0], scope)
|
|
768
|
+
return free
|
|
769
|
+
|
|
770
|
+
def _context(self, start: int, close: int) -> str:
|
|
771
|
+
return self.content[start : min(close + 1, start + 2 * _EVIDENCE_MAX)]
|
|
772
|
+
|
|
773
|
+
def _param_binding(self, fn: tuple[str, int, int]) -> Binding:
|
|
774
|
+
"""The router a route function receives as its first parameter (the
|
|
775
|
+
same free binding its body's routes resolve to)."""
|
|
776
|
+
name, body_start, _ = fn
|
|
777
|
+
return self.resolve(name, body_start + 1) # type: ignore[return-value]
|
|
778
|
+
|
|
779
|
+
def _definitions(self) -> dict[str, int]:
|
|
780
|
+
"""Same-file ``function name(...)`` / ``const name = (...) =>`` offsets."""
|
|
781
|
+
if self._fn_defs is None:
|
|
782
|
+
self._fn_defs = {}
|
|
783
|
+
for match in _FN_DECL.finditer(self.content):
|
|
784
|
+
self._fn_defs.setdefault(match.group(1), match.start())
|
|
785
|
+
for match in _FN_ASSIGN.finditer(self.content):
|
|
786
|
+
self._fn_defs.setdefault(match.group(1), match.end())
|
|
787
|
+
return self._fn_defs
|
|
788
|
+
|
|
789
|
+
def _function_at(self, start: int) -> tuple[str, int, int] | None:
|
|
790
|
+
return function_at(self.content, start, min(len(self.content), start + _ARG_SCAN * 50), block_only=True)
|
|
791
|
+
|
|
792
|
+
def _shadowing_parameters(self) -> None:
|
|
793
|
+
"""A named function whose first parameter has the name of a router
|
|
794
|
+
variable gets its own binding for its body."""
|
|
795
|
+
if not self._by_name:
|
|
796
|
+
return
|
|
797
|
+
for name, start in self._definitions().items():
|
|
798
|
+
fn = self._function_at(start)
|
|
799
|
+
if fn is not None and fn[0] in self._by_name:
|
|
800
|
+
binding = Binding(fn[0], fn[1], (fn[1], fn[2]))
|
|
801
|
+
self._bind(binding)
|
|
802
|
+
self._fn_params[name] = binding
|
|
803
|
+
|
|
804
|
+
def _function_binding(self, name: str) -> Binding | None:
|
|
805
|
+
"""The parameter binding of a same-file ``function name(app)`` /
|
|
806
|
+
``const name = (app) => {...}``."""
|
|
807
|
+
if name in self._fn_params:
|
|
808
|
+
return self._fn_params[name]
|
|
809
|
+
start = self._definitions().get(name)
|
|
810
|
+
fn = self._function_at(start) if start is not None else None
|
|
811
|
+
return self._param_binding(fn) if fn else None
|
|
812
|
+
|
|
813
|
+
def _import_ref(self, start: int, end: int) -> ImportRef | None:
|
|
814
|
+
"""An imported module named by an argument: ``require('./x')``,
|
|
815
|
+
``import('./x')``, ``imported``, ``ns.member``."""
|
|
816
|
+
end = min(end, start + 400)
|
|
817
|
+
inline = _INLINE_IMPORT.match(self.content, start, end)
|
|
818
|
+
if inline:
|
|
819
|
+
member = inline.group(2)
|
|
820
|
+
return ImportRef(inline.group(1), member if member and member != "default" else "default")
|
|
821
|
+
member = _MEMBER.match(self.content, start, end)
|
|
822
|
+
if member and member.group(1) in self.imports:
|
|
823
|
+
ref = self.imports[member.group(1)]
|
|
824
|
+
if ref.name in ("*", "default"):
|
|
825
|
+
return ImportRef(ref.spec, member.group(2))
|
|
826
|
+
return None
|
|
827
|
+
ident = _IDENT_ONLY.match(self.content, start, end)
|
|
828
|
+
if ident and ident.group(1) in self.imports:
|
|
829
|
+
ref = self.imports[ident.group(1)]
|
|
830
|
+
return ImportRef(ref.spec, "default") if ref.name == "*" else ref
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
# -- events --
|
|
834
|
+
|
|
835
|
+
def _constructor(self, match: re.Match, exported: bool) -> None:
|
|
836
|
+
ctor = match.group("ctor")
|
|
837
|
+
paren_match = _CTOR_PAREN.match(self.content, match.end("ctor"))
|
|
838
|
+
if paren_match is None and not ctor.startswith("new"):
|
|
839
|
+
return # `express.static(...)`, a property named `Router`, ...
|
|
840
|
+
name = "" if exported else match.group(1)
|
|
841
|
+
if name == "exports":
|
|
842
|
+
return
|
|
843
|
+
scope = self._advance(match.start())
|
|
844
|
+
binding = Binding(name, match.start(), scope, root=bool(_ROOT_CTORS.fullmatch(ctor)))
|
|
845
|
+
close = -1
|
|
846
|
+
if paren_match is not None:
|
|
847
|
+
paren = paren_match.end() - 1
|
|
848
|
+
close = matching_close(self.content, paren)
|
|
849
|
+
if close > 0 and _PREFIXED_CTORS.search(ctor):
|
|
850
|
+
option = _PREFIX_OPTION.search(self.content, paren, min(close, paren + 400))
|
|
851
|
+
binding.prefix = option.group(1) if option else ""
|
|
852
|
+
if exported:
|
|
853
|
+
self._anonymous[match.start()] = binding
|
|
854
|
+
else:
|
|
855
|
+
self._bind(binding)
|
|
856
|
+
if close > 0:
|
|
857
|
+
self._walk_chain(binding, close, None, binding)
|
|
858
|
+
|
|
859
|
+
def _base_path(self, match: re.Match) -> None:
|
|
860
|
+
"""``const api = app.basePath('/api')`` (Hono)."""
|
|
861
|
+
parent = self.resolve(match.group(2), match.start())
|
|
862
|
+
if parent is None:
|
|
863
|
+
return
|
|
864
|
+
child = Binding(match.group(1), match.start(), self._advance(match.start()), prefix=match.group(3))
|
|
865
|
+
child.mounts.append(Mount(parent, match.start(), "", [], self))
|
|
866
|
+
self._bind(child)
|
|
867
|
+
|
|
868
|
+
def _call_head(self, match: re.Match) -> None:
|
|
869
|
+
name, method = match.group(1), match.group(2)
|
|
870
|
+
paren = match.end() - 1
|
|
871
|
+
close = matching_close(self.content, paren)
|
|
872
|
+
owner = self.resolve(name, match.start())
|
|
873
|
+
if owner is None or close < 0:
|
|
874
|
+
return
|
|
875
|
+
# `app\n .get('/x', h)`: the analyzer's route pattern wants `app.get(`.
|
|
876
|
+
spaced = self.content[match.end(1)] != "."
|
|
877
|
+
following = self._call(owner, method, match.start(), paren, close, head=name, link=spaced, ctor=None)
|
|
878
|
+
if following is not None:
|
|
879
|
+
self._walk_chain(following, close, name, None)
|
|
880
|
+
|
|
881
|
+
def _walk_chain(self, owner: Binding, close: int, head: str | None, ctor: Binding | None) -> None:
|
|
882
|
+
pos = close + 1
|
|
883
|
+
for _ in range(_MAX_CHAIN):
|
|
884
|
+
link = _LINK.match(self.content, _WS.match(self.content, pos).end())
|
|
885
|
+
if not link:
|
|
886
|
+
return
|
|
887
|
+
paren = link.end() - 1
|
|
888
|
+
link_close = matching_close(self.content, paren)
|
|
889
|
+
if link_close < 0:
|
|
890
|
+
return
|
|
891
|
+
following = self._call(owner, link.group(1), link.start(1), paren, link_close, head=head, link=True, ctor=ctor)
|
|
892
|
+
if following is None:
|
|
893
|
+
return
|
|
894
|
+
owner, pos = following, link_close + 1
|
|
895
|
+
|
|
896
|
+
def _call(
|
|
897
|
+
self, owner: Binding, method: str, offset: int, paren: int, close: int,
|
|
898
|
+
*, head: str | None, link: bool, ctor: Binding | None,
|
|
899
|
+
) -> Binding | None:
|
|
900
|
+
"""Apply one router call; return the binding a chained call continues
|
|
901
|
+
on, or None when the result isn't the router any more. ``link``
|
|
902
|
+
records a verb call as a route here (the analyzer finds the others)."""
|
|
903
|
+
if method in _VERBS:
|
|
904
|
+
if link:
|
|
905
|
+
args = split_args(self.content, paren, close)
|
|
906
|
+
path = string_literal(self.content[args[0][0] : args[0][1]]) if args else None
|
|
907
|
+
if path is not None:
|
|
908
|
+
self.chain_routes.append(ChainRoute(owner, method.upper(), path, offset, paren, close, head))
|
|
909
|
+
if method == "all":
|
|
910
|
+
self._use(owner, offset, paren, close, "all")
|
|
911
|
+
return owner
|
|
912
|
+
if method in ("use", "route"):
|
|
913
|
+
return owner if self._use(owner, offset, paren, close, method) else None
|
|
914
|
+
if method == "addHook":
|
|
915
|
+
if _HOOK_NAME.match(self.content, paren + 1, close):
|
|
916
|
+
self._hooks(owner, offset, paren, close, 1)
|
|
917
|
+
return owner
|
|
918
|
+
if method == "onBeforeHandle":
|
|
919
|
+
self._hooks(owner, offset, paren, close, 0)
|
|
920
|
+
return owner
|
|
921
|
+
if method == "register":
|
|
922
|
+
self._register(owner, offset, paren, close)
|
|
923
|
+
return owner
|
|
924
|
+
if method in ("group", "guard"):
|
|
925
|
+
self._group(owner, offset, paren, close, method == "group")
|
|
926
|
+
return owner
|
|
927
|
+
if method == "basePath":
|
|
928
|
+
args = split_args(self.content, paren, close)
|
|
929
|
+
literal = string_literal(self.content[args[0][0] : args[0][1]]) if len(args) == 1 else None
|
|
930
|
+
if literal is None:
|
|
931
|
+
return owner
|
|
932
|
+
if ctor is owner:
|
|
933
|
+
owner.prefix = join_path(owner.prefix, literal)
|
|
934
|
+
return owner
|
|
935
|
+
child = Binding("", offset, owner.scope, prefix=literal)
|
|
936
|
+
child.mounts.append(Mount(owner, offset, "", [], self))
|
|
937
|
+
return child
|
|
938
|
+
return owner if method in _PASSTHROUGH else None
|
|
939
|
+
|
|
940
|
+
def _mount_target(self, start: int, end: int, verb: str) -> Binding | ImportRef | None:
|
|
941
|
+
"""A router mounted by a use()/route() argument: a binding in this
|
|
942
|
+
file, or an imported module's export. Hono's ``route(path, sub)``
|
|
943
|
+
always takes a sub-app; a ``use()`` argument named like auth
|
|
944
|
+
middleware is middleware."""
|
|
945
|
+
text = self.content[start : min(end, start + 300)]
|
|
946
|
+
routes_call = _ROUTES_CALL.match(text)
|
|
947
|
+
ident = routes_call or _IDENT_ONLY.match(text)
|
|
948
|
+
if ident:
|
|
949
|
+
binding = self.resolve(ident.group(1), start, create=False)
|
|
950
|
+
if binding is not None:
|
|
951
|
+
return binding
|
|
952
|
+
if verb == "use" and not routes_call and guard_kind(ident.group(1)) is not None:
|
|
953
|
+
return None # imported auth middleware
|
|
954
|
+
if routes_call:
|
|
955
|
+
return self._import_ref(start + routes_call.start(1), start + routes_call.end(1))
|
|
956
|
+
return self._import_ref(start, end)
|
|
957
|
+
|
|
958
|
+
def _use(self, owner: Binding, offset: int, paren: int, close: int, verb: str) -> bool:
|
|
959
|
+
"""``use()`` / ``all('*')`` / ``route()``; False for Express's
|
|
960
|
+
one-argument ``router.route('/x')``, whose result is a route."""
|
|
961
|
+
args = split_args(self.content, paren, close)
|
|
962
|
+
if not args:
|
|
963
|
+
return verb != "route"
|
|
964
|
+
prefix: str | None = None
|
|
965
|
+
prefixes: list[str] | None = None # `use(['/a', '/b'], mw)`
|
|
966
|
+
unknown_path = False # `use(API_PREFIX, mw)`, `use(/^\/api/, mw)`
|
|
967
|
+
first = string_literal(self.content[args[0][0] : args[0][1]])
|
|
968
|
+
if first is not None:
|
|
969
|
+
prefix, args = first, args[1:]
|
|
970
|
+
elif verb in ("all", "route"):
|
|
971
|
+
return True # `.all(handler)`, Fastify `.route({ ... })`
|
|
972
|
+
elif len(args) > 1:
|
|
973
|
+
kind = self._path_argument(*args[0])
|
|
974
|
+
if kind is not None:
|
|
975
|
+
args = args[1:]
|
|
976
|
+
if isinstance(kind, list):
|
|
977
|
+
prefixes = kind
|
|
978
|
+
else:
|
|
979
|
+
unknown_path = True
|
|
980
|
+
if verb == "route" and not args:
|
|
981
|
+
return False
|
|
982
|
+
if verb == "all" and not prefix.endswith("*"):
|
|
983
|
+
return True # a real `.all('/x', h)` route, not a catch-all guard
|
|
984
|
+
context = self._context(offset, close)
|
|
985
|
+
args = flatten(self.content, args)[:_MAX_MIDDLEWARE_ARGS]
|
|
986
|
+
target = self._mount_target(*args[-1], verb) if args and verb in ("use", "route") else None
|
|
987
|
+
middleware = args[:-1] if target is not None else args
|
|
988
|
+
guards = [] if verb == "route" else [
|
|
989
|
+
g for s, e in middleware if (g := guard_from_arg(self.content, s, e, context))
|
|
990
|
+
][:_MAX_GUARDS]
|
|
991
|
+
if unknown_path:
|
|
992
|
+
# A path this file can't read: the guard may not cover the route.
|
|
993
|
+
guards = [Guard(g.name, g.evidence, uncertain=True) for g in guards]
|
|
994
|
+
if target is not None:
|
|
995
|
+
for mount_prefix in (prefixes or [prefix or ""])[:_MAX_GUARDS]:
|
|
996
|
+
mount = Mount(owner, offset, mount_prefix, guards, self)
|
|
997
|
+
if isinstance(target, Binding):
|
|
998
|
+
if target is not owner:
|
|
999
|
+
target.mounts.append(mount)
|
|
1000
|
+
else:
|
|
1001
|
+
self.import_mounts.append((target, mount))
|
|
1002
|
+
return True
|
|
1003
|
+
if not guards:
|
|
1004
|
+
return True
|
|
1005
|
+
if prefixes is not None:
|
|
1006
|
+
# One copy per literal prefix of `use(['/a', '/b'], mw)`.
|
|
1007
|
+
guards = [
|
|
1008
|
+
Guard(g.name, g.evidence, g.skip, g.uncertain, p) for p in prefixes[:_MAX_GUARDS] for g in guards
|
|
1009
|
+
][:_MAX_GUARDS * 2]
|
|
1010
|
+
else:
|
|
1011
|
+
for guard in guards:
|
|
1012
|
+
guard.prefix = prefix
|
|
1013
|
+
self._add_use(owner, offset, guards)
|
|
1014
|
+
return True
|
|
1015
|
+
|
|
1016
|
+
def _path_argument(self, start: int, end: int) -> list[str] | bool | None:
|
|
1017
|
+
"""What a non-string first ``use()`` argument is: a list of literal
|
|
1018
|
+
paths, True for a path this file can't read (a regex, a template, a
|
|
1019
|
+
variable such as ``API_PREFIX``), or None for middleware."""
|
|
1020
|
+
s, e = _strip_span(self.content, start, end)
|
|
1021
|
+
text = self.content[s:e]
|
|
1022
|
+
if text.startswith("["):
|
|
1023
|
+
close = matching_close(self.content, s)
|
|
1024
|
+
items = split_args(self.content, s, close) if close > 0 else []
|
|
1025
|
+
literals = [string_literal(self.content[a:b]) for a, b in items]
|
|
1026
|
+
if items and all(lit is not None for lit in literals):
|
|
1027
|
+
return literals # type: ignore[return-value]
|
|
1028
|
+
return True
|
|
1029
|
+
if text.startswith(("/", "`")):
|
|
1030
|
+
return True # a regex or template literal
|
|
1031
|
+
if _PATH_VARIABLE.fullmatch(text) and guard_kind(text) is None and self.resolve(text, s, create=False) is None:
|
|
1032
|
+
return True # a bare identifier / member that isn't middleware we know
|
|
1033
|
+
return None
|
|
1034
|
+
|
|
1035
|
+
def _add_use(self, owner: Binding, offset: int, guards: list[Guard]) -> None:
|
|
1036
|
+
if len(owner.uses) >= _MAX_USES:
|
|
1037
|
+
owner.overflow = True
|
|
1038
|
+
else:
|
|
1039
|
+
owner.uses.append((offset, guards))
|
|
1040
|
+
|
|
1041
|
+
def _hooks(self, owner: Binding, offset: int, paren: int, close: int, first: int) -> None:
|
|
1042
|
+
args = split_args(self.content, paren, close)[first:]
|
|
1043
|
+
context = self._context(offset, close)
|
|
1044
|
+
guards = [
|
|
1045
|
+
g for s, e in flatten(self.content, args)[:_MAX_MIDDLEWARE_ARGS]
|
|
1046
|
+
if (g := guard_from_arg(self.content, s, e, context))
|
|
1047
|
+
][:_MAX_GUARDS]
|
|
1048
|
+
if guards:
|
|
1049
|
+
self._add_use(owner, offset, guards)
|
|
1050
|
+
|
|
1051
|
+
def _register(self, owner: Binding, offset: int, paren: int, close: int) -> None:
|
|
1052
|
+
"""Fastify ``register(plugin, { prefix })``: an inline closure, a
|
|
1053
|
+
same-file function or an imported module."""
|
|
1054
|
+
args = split_args(self.content, paren, close)
|
|
1055
|
+
if not args:
|
|
1056
|
+
return
|
|
1057
|
+
prefix = ""
|
|
1058
|
+
if len(args) > 1:
|
|
1059
|
+
option = _PREFIX_OPTION.search(self.content, args[1][0], min(args[1][1], args[1][0] + 400))
|
|
1060
|
+
prefix = option.group(1) if option else ""
|
|
1061
|
+
start, end = _strip_span(self.content, *args[0])
|
|
1062
|
+
skip_prefix = False
|
|
1063
|
+
wrapped = _PLUGIN_WRAP.match(self.content, start, end)
|
|
1064
|
+
if wrapped:
|
|
1065
|
+
inner_close = matching_close(self.content, wrapped.end() - 1)
|
|
1066
|
+
inner = split_args(self.content, wrapped.end() - 1, inner_close) if 0 < inner_close <= end else []
|
|
1067
|
+
if not inner:
|
|
1068
|
+
return
|
|
1069
|
+
start, end = _strip_span(self.content, *inner[0])
|
|
1070
|
+
skip_prefix = True
|
|
1071
|
+
mount = Mount(owner, offset, "" if skip_prefix else prefix, [], self)
|
|
1072
|
+
fn = function_at(self.content, start, end, block_only=True)
|
|
1073
|
+
if fn is not None:
|
|
1074
|
+
child = Binding(fn[0], fn[1], (fn[1], fn[2]))
|
|
1075
|
+
child.mounts.append(mount)
|
|
1076
|
+
self._bind(child)
|
|
1077
|
+
return
|
|
1078
|
+
ref = self._import_ref(start, end)
|
|
1079
|
+
if ref is not None:
|
|
1080
|
+
self.import_mounts.append((ref, mount))
|
|
1081
|
+
return
|
|
1082
|
+
ident = _IDENT_ONLY.match(self.content, start, end)
|
|
1083
|
+
binding = self._function_binding(ident.group(1)) if ident else None
|
|
1084
|
+
if binding is not None and binding is not owner:
|
|
1085
|
+
binding.mounts.append(mount)
|
|
1086
|
+
|
|
1087
|
+
def _group(self, owner: Binding, offset: int, paren: int, close: int, with_prefix: bool) -> None:
|
|
1088
|
+
"""Elysia ``group('/x', [opts,] (g) => ...)`` / ``guard(opts, (g) => ...)``."""
|
|
1089
|
+
args = split_args(self.content, paren, close)
|
|
1090
|
+
prefix = ""
|
|
1091
|
+
if with_prefix:
|
|
1092
|
+
literal = string_literal(self.content[args[0][0] : args[0][1]]) if args else None
|
|
1093
|
+
if literal is None:
|
|
1094
|
+
return
|
|
1095
|
+
prefix, args = literal, args[1:]
|
|
1096
|
+
if not args:
|
|
1097
|
+
return
|
|
1098
|
+
context = self._context(offset, close)
|
|
1099
|
+
callback = function_at(self.content, args[-1][0], args[-1][1])
|
|
1100
|
+
options = args[:-1] if callback else args
|
|
1101
|
+
guards = [g for s, e in options for g in route_option_guards(self.content, s, e, context)][:_MAX_GUARDS]
|
|
1102
|
+
if callback is None:
|
|
1103
|
+
if guards and not with_prefix:
|
|
1104
|
+
self._add_use(owner, offset, guards)
|
|
1105
|
+
return
|
|
1106
|
+
child = Binding(callback[0], callback[1], (callback[1], callback[2]), prefix=prefix)
|
|
1107
|
+
child.mounts.append(Mount(owner, offset, "", guards, self))
|
|
1108
|
+
self._bind(child)
|
|
1109
|
+
|
|
1110
|
+
def _call_mount(self, match: re.Match) -> None:
|
|
1111
|
+
"""``usersRoutes(app)``: an imported / same-file route function
|
|
1112
|
+
registers its routes on ``app``."""
|
|
1113
|
+
function, argument = match.group(1), match.group(2)
|
|
1114
|
+
if function in _KEYWORDS or argument in _KEYWORDS:
|
|
1115
|
+
return
|
|
1116
|
+
paren = self.content.find("(", match.start() + len(function))
|
|
1117
|
+
close = matching_close(self.content, paren)
|
|
1118
|
+
if close < 0 or _DEFINITION_AFTER.match(self.content, close + 1):
|
|
1119
|
+
return
|
|
1120
|
+
if self.resolve(argument, match.start(), create=argument in _CONVENTIONAL) is None:
|
|
1121
|
+
return
|
|
1122
|
+
target: Binding | ImportRef | None = None
|
|
1123
|
+
if function in self.imports:
|
|
1124
|
+
ref = self.imports[function]
|
|
1125
|
+
target = ImportRef(ref.spec, "default") if ref.name == "*" else ref
|
|
1126
|
+
elif function not in self._by_name:
|
|
1127
|
+
target = self._function_binding(function)
|
|
1128
|
+
if target is None:
|
|
1129
|
+
return
|
|
1130
|
+
self._mount_call(target, argument, match.start())
|
|
1131
|
+
|
|
1132
|
+
def _require_call_mount(self, match: re.Match) -> None:
|
|
1133
|
+
"""``require('./routes/users')(app)``."""
|
|
1134
|
+
member = match.group(2)
|
|
1135
|
+
ref = ImportRef(match.group(1), member if member and member != "default" else "default")
|
|
1136
|
+
self._mount_call(ref, match.group(3), match.start())
|
|
1137
|
+
|
|
1138
|
+
def _mount_call(self, target: Binding | ImportRef, argument: str, offset: int) -> None:
|
|
1139
|
+
owner = self.resolve(argument, offset, create=argument in _CONVENTIONAL)
|
|
1140
|
+
if owner is None or target is owner:
|
|
1141
|
+
return
|
|
1142
|
+
mount = Mount(owner, offset, "", [], self)
|
|
1143
|
+
if isinstance(target, Binding):
|
|
1144
|
+
target.mounts.append(mount)
|
|
1145
|
+
else:
|
|
1146
|
+
self.import_mounts.append((target, mount))
|
|
1147
|
+
|
|
1148
|
+
# -- exports --
|
|
1149
|
+
|
|
1150
|
+
def _value_binding(self, start: int) -> Binding | None:
|
|
1151
|
+
"""The router / route function an export's value names."""
|
|
1152
|
+
end = min(len(self.content), start + _ARG_SCAN)
|
|
1153
|
+
skip_prefix = False
|
|
1154
|
+
wrapped = _PLUGIN_WRAP.match(self.content, start, end)
|
|
1155
|
+
if wrapped:
|
|
1156
|
+
inner_close = matching_close(self.content, wrapped.end() - 1)
|
|
1157
|
+
inner = split_args(self.content, wrapped.end() - 1, inner_close) if inner_close > 0 else []
|
|
1158
|
+
if not inner:
|
|
1159
|
+
return None
|
|
1160
|
+
start, end = inner[0]
|
|
1161
|
+
skip_prefix = True
|
|
1162
|
+
binding: Binding | None = None
|
|
1163
|
+
fn = function_at(self.content, start, min(len(self.content), start + _ARG_SCAN * 50), block_only=True)
|
|
1164
|
+
if fn is not None:
|
|
1165
|
+
binding = self._param_binding(fn)
|
|
1166
|
+
else:
|
|
1167
|
+
ident = _VALUE_IDENT.match(self.content, start, end)
|
|
1168
|
+
if ident:
|
|
1169
|
+
binding = self.resolve(ident.group(1), start, create=False) or self._function_binding(ident.group(1))
|
|
1170
|
+
if binding is not None and skip_prefix:
|
|
1171
|
+
binding.skip_prefix = True
|
|
1172
|
+
return binding
|
|
1173
|
+
|
|
1174
|
+
def _parse_exports(self) -> None:
|
|
1175
|
+
last = None
|
|
1176
|
+
for last in _EXPORT_DEFAULT.finditer(self.content):
|
|
1177
|
+
pass
|
|
1178
|
+
if last is not None:
|
|
1179
|
+
binding = self._anonymous.get(last.start()) or self._value_binding(last.end())
|
|
1180
|
+
if binding is not None:
|
|
1181
|
+
self.exports["default"] = binding
|
|
1182
|
+
elif self.content.startswith("module", last.start()):
|
|
1183
|
+
self._object_exports(_WS.match(self.content, last.end()).end())
|
|
1184
|
+
for match in _EXPORT_DECL.finditer(self.content):
|
|
1185
|
+
name = match.group(1) or match.group(2)
|
|
1186
|
+
binding = self.resolve(name, match.end(), create=False) if match.group(1) else None
|
|
1187
|
+
binding = binding or self._function_binding(name)
|
|
1188
|
+
if binding is not None:
|
|
1189
|
+
self.exports.setdefault(name, binding)
|
|
1190
|
+
for match in _EXPORT_LIST.finditer(self.content):
|
|
1191
|
+
for part in match.group(1).split(","):
|
|
1192
|
+
names = _EXPORT_AS.match(part)
|
|
1193
|
+
if names:
|
|
1194
|
+
local = names.group(1)
|
|
1195
|
+
binding = self.resolve(local, match.start(), create=False) or self._function_binding(local)
|
|
1196
|
+
if binding is not None:
|
|
1197
|
+
self.exports.setdefault(names.group(2) or local, binding)
|
|
1198
|
+
for match in _CJS_EXPORT.finditer(self.content):
|
|
1199
|
+
binding = self._value_binding(match.end())
|
|
1200
|
+
if binding is not None:
|
|
1201
|
+
self.exports.setdefault(match.group(1), binding)
|
|
1202
|
+
|
|
1203
|
+
def _object_exports(self, open_idx: int) -> None:
|
|
1204
|
+
"""``module.exports = { users, admin: adminRouter }``."""
|
|
1205
|
+
if self.content[open_idx : open_idx + 1] != "{":
|
|
1206
|
+
return
|
|
1207
|
+
close = matching_close(self.content, open_idx)
|
|
1208
|
+
if close < 0:
|
|
1209
|
+
return
|
|
1210
|
+
for s, e in split_args(self.content, open_idx, close)[:256]:
|
|
1211
|
+
key, colon, value = self.content[s:e].partition(":")
|
|
1212
|
+
key = key.strip().strip("'\"")
|
|
1213
|
+
local = (value if colon else key).strip()
|
|
1214
|
+
if _IDENT_RE.fullmatch(key) and _IDENT_RE.fullmatch(local):
|
|
1215
|
+
binding = self.resolve(local, s, create=False) or self._function_binding(local)
|
|
1216
|
+
if binding is not None:
|
|
1217
|
+
self.exports.setdefault(key, binding)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
@dataclass
|
|
1221
|
+
class Resolution:
|
|
1222
|
+
auth: str = UNKNOWN
|
|
1223
|
+
guards: list[str] = field(default_factory=list)
|
|
1224
|
+
evidence: str | None = None
|
|
1225
|
+
# The routers each mount chain ends at (by identity): routes reaching one
|
|
1226
|
+
# app by the same path and method are the same endpoint to Express.
|
|
1227
|
+
roots: frozenset[int] = field(default_factory=frozenset)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
_TEST_DIRS = frozenset({"tests", "test", "__tests__", "spec", "specs", "testing", "e2e", "__mocks__", "fixtures"})
|
|
1231
|
+
_TEST_FILE = re.compile(r"[._](?:test|spec)\.[a-z0-9]+$", re.IGNORECASE)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def is_test_path(path: str) -> bool:
|
|
1235
|
+
"""Test / spec / fixture code (mirrors core's test-file heuristic)."""
|
|
1236
|
+
parts = path.split("/")
|
|
1237
|
+
return any(part.lower() in _TEST_DIRS for part in parts[:-1]) or bool(_TEST_FILE.search(parts[-1]))
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
class Registry:
|
|
1241
|
+
"""Every file's routers, so mounts of imported routers can be followed."""
|
|
1242
|
+
|
|
1243
|
+
def __init__(self, resolver: ModuleResolver | None = None) -> None:
|
|
1244
|
+
self.files: dict[str, FileRouters] = {}
|
|
1245
|
+
self.reexports: dict[str, Reexports] = {}
|
|
1246
|
+
self.resolver = resolver or ModuleResolver(None)
|
|
1247
|
+
self._export_memo: dict[tuple[str, str], Binding | None] = {}
|
|
1248
|
+
# Every name some file exports (or re-exports by name): `export *`
|
|
1249
|
+
# adds none, so a name outside it is a miss without a walk.
|
|
1250
|
+
self._exported: set[str] = set()
|
|
1251
|
+
self._module_memo: dict[tuple[str, str], str | None] = {}
|
|
1252
|
+
|
|
1253
|
+
def add(self, routers: FileRouters) -> None:
|
|
1254
|
+
self.files[routers.file] = routers
|
|
1255
|
+
self._exported.update(routers.exports)
|
|
1256
|
+
self._exported.update(routers.reexports.named)
|
|
1257
|
+
|
|
1258
|
+
def add_module(self, file: str, content: str) -> None:
|
|
1259
|
+
"""A file without routers that may re-export one (a barrel)."""
|
|
1260
|
+
if file not in self.files:
|
|
1261
|
+
reexports = parse_reexports(content)
|
|
1262
|
+
if reexports.named or reexports.star:
|
|
1263
|
+
self.reexports[file] = reexports
|
|
1264
|
+
self._exported.update(reexports.named)
|
|
1265
|
+
|
|
1266
|
+
def resolve_module(self, importer: str, spec: str) -> str | None:
|
|
1267
|
+
key = (importer, spec)
|
|
1268
|
+
if key not in self._module_memo:
|
|
1269
|
+
self._module_memo[key] = next(
|
|
1270
|
+
(
|
|
1271
|
+
candidate
|
|
1272
|
+
for base in self.resolver.bases(importer, spec)
|
|
1273
|
+
for candidate in file_candidates(base)
|
|
1274
|
+
if candidate in self.files or candidate in self.reexports
|
|
1275
|
+
),
|
|
1276
|
+
None,
|
|
1277
|
+
)
|
|
1278
|
+
return self._module_memo[key]
|
|
1279
|
+
|
|
1280
|
+
def lookup_export(self, file: str, name: str) -> Binding | None:
|
|
1281
|
+
"""The binding ``file`` exports as ``name``, through re-exports.
|
|
1282
|
+
Every (module, name) result is memoized, so each pair is expanded
|
|
1283
|
+
once per scan; a pair met again while it is still being looked up
|
|
1284
|
+
(an `export *` cycle) is a miss."""
|
|
1285
|
+
if name == "*":
|
|
1286
|
+
name = "default"
|
|
1287
|
+
if name not in self._exported:
|
|
1288
|
+
return None
|
|
1289
|
+
return self._lookup(file, name, 0, set(), [_MAX_LOOKUP_STEPS])
|
|
1290
|
+
|
|
1291
|
+
def _lookup(
|
|
1292
|
+
self, file: str, name: str, depth: int, visiting: set[tuple[str, str]], budget: list[int]
|
|
1293
|
+
) -> Binding | None:
|
|
1294
|
+
key = (file, name)
|
|
1295
|
+
if key in self._export_memo:
|
|
1296
|
+
return self._export_memo[key]
|
|
1297
|
+
budget[0] -= 1
|
|
1298
|
+
if key in visiting or depth >= _MAX_REEXPORT_DEPTH or budget[0] < 0:
|
|
1299
|
+
# A cycle (the pair is still being looked up), a chain too deep,
|
|
1300
|
+
# or a lookup that has expanded too many modules.
|
|
1301
|
+
return None
|
|
1302
|
+
visiting.add(key)
|
|
1303
|
+
found: Binding | None = None
|
|
1304
|
+
routers = self.files.get(file)
|
|
1305
|
+
if routers is not None and name in routers.exports:
|
|
1306
|
+
found = routers.exports[name]
|
|
1307
|
+
else:
|
|
1308
|
+
reexports = routers.reexports if routers is not None else self.reexports.get(file)
|
|
1309
|
+
ref = reexports.named.get(name) if reexports is not None else None
|
|
1310
|
+
if ref is not None:
|
|
1311
|
+
target = self.resolve_module(file, ref.spec)
|
|
1312
|
+
found = self._lookup(target, ref.name, depth + 1, visiting, budget) if target else None
|
|
1313
|
+
elif reexports is not None:
|
|
1314
|
+
for spec in reexports.star[:_MAX_STAR_EXPORTS]:
|
|
1315
|
+
target = self.resolve_module(file, spec)
|
|
1316
|
+
found = self._lookup(target, name, depth + 1, visiting, budget) if target else None
|
|
1317
|
+
if found is not None:
|
|
1318
|
+
break
|
|
1319
|
+
visiting.discard(key)
|
|
1320
|
+
# Every result is kept, misses included, so each (file, name) is
|
|
1321
|
+
# expanded once per scan. A miss cut short by a cycle stays a miss.
|
|
1322
|
+
self._export_memo[key] = found
|
|
1323
|
+
return found
|
|
1324
|
+
|
|
1325
|
+
def finalize(self) -> None:
|
|
1326
|
+
"""Attach cross-file mounts to the export they mount. Mounts made by
|
|
1327
|
+
test code would add paths that production never serves."""
|
|
1328
|
+
for routers in self.files.values():
|
|
1329
|
+
if is_test_path(routers.file):
|
|
1330
|
+
continue
|
|
1331
|
+
for ref, mount in routers.import_mounts:
|
|
1332
|
+
target = self.resolve_module(routers.file, ref.spec)
|
|
1333
|
+
binding = self.lookup_export(target, ref.name) if target else None
|
|
1334
|
+
if binding is not None and binding is not mount.owner:
|
|
1335
|
+
binding.mounts.append(mount)
|
|
1336
|
+
|
|
1337
|
+
def route_variants(
|
|
1338
|
+
self, binding: Binding | None, offset: int, path: str, local: list[Guard], fallback: str
|
|
1339
|
+
) -> list[tuple[str, Resolution]]:
|
|
1340
|
+
"""``(full path, auth)`` for each distinct path the route is mounted
|
|
1341
|
+
at. ``fallback`` (the file-local path) is kept when no mount or prefix
|
|
1342
|
+
applies."""
|
|
1343
|
+
if binding is None:
|
|
1344
|
+
return [(fallback, Resolution())]
|
|
1345
|
+
chains = self._paths(binding, offset, path, list(local), 0, [_MAX_PATHS])
|
|
1346
|
+
mounted = any(full != path for _, full, _, _ in chains)
|
|
1347
|
+
grouped: dict[str, list[Resolution]] = {}
|
|
1348
|
+
roots: dict[str, set[int]] = {}
|
|
1349
|
+
for guards, full, complete, root in chains:
|
|
1350
|
+
key = full if mounted else fallback
|
|
1351
|
+
if key in grouped or len(grouped) < _MAX_VARIANTS:
|
|
1352
|
+
grouped.setdefault(key, []).append(evaluate(guards, full if complete else None, complete))
|
|
1353
|
+
roots.setdefault(key, set()).add(root)
|
|
1354
|
+
out: list[tuple[str, Resolution]] = []
|
|
1355
|
+
for key, results in grouped.items():
|
|
1356
|
+
resolution = combine(results)
|
|
1357
|
+
resolution.roots = frozenset(roots[key])
|
|
1358
|
+
out.append((key, resolution))
|
|
1359
|
+
return out
|
|
1360
|
+
|
|
1361
|
+
def _paths(
|
|
1362
|
+
self, binding: Binding, offset: int, rel_path: str, applied: list[Guard], depth: int, budget: list[int]
|
|
1363
|
+
) -> list[tuple[list[Guard], str, bool, int]]:
|
|
1364
|
+
"""Every (guards, path, whole chain seen, top router) from a route up
|
|
1365
|
+
to a root."""
|
|
1366
|
+
budget[0] -= 1
|
|
1367
|
+
if binding.overflow or depth >= _MAX_DEPTH or budget[0] < 0:
|
|
1368
|
+
return [(applied, rel_path, False, id(binding))]
|
|
1369
|
+
applied = list(applied)
|
|
1370
|
+
for use_offset, guards in binding.uses:
|
|
1371
|
+
if use_offset >= offset:
|
|
1372
|
+
continue
|
|
1373
|
+
for guard in guards:
|
|
1374
|
+
if guard.prefix is None or _under(rel_path, guard.prefix):
|
|
1375
|
+
applied.append(guard)
|
|
1376
|
+
up_path = join_path(binding.prefix, rel_path)
|
|
1377
|
+
if not binding.mounts:
|
|
1378
|
+
return [(applied, up_path, binding.root, id(binding))]
|
|
1379
|
+
if len(binding.mounts) > _MAX_PATHS:
|
|
1380
|
+
return [(applied, up_path, False, id(binding))]
|
|
1381
|
+
out: list[tuple[list[Guard], str, bool, int]] = []
|
|
1382
|
+
for mount in binding.mounts:
|
|
1383
|
+
prefix = "" if binding.skip_prefix else mount.prefix
|
|
1384
|
+
out += self._paths(mount.owner, mount.offset, join_path(prefix, up_path), applied + mount.guards, depth + 1, budget)
|
|
1385
|
+
return out
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def combine(results: list[Resolution]) -> Resolution:
|
|
1389
|
+
"""One route's state over every mount chain that reaches it."""
|
|
1390
|
+
if not results or any(r.auth == UNKNOWN for r in results):
|
|
1391
|
+
return Resolution()
|
|
1392
|
+
if all(r.auth == REQUIRED for r in results):
|
|
1393
|
+
names = list(dict.fromkeys(n for r in results for n in r.guards))
|
|
1394
|
+
return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(r.evidence for r in results if r.evidence))))
|
|
1395
|
+
if all(r.auth == ANONYMOUS for r in results):
|
|
1396
|
+
return Resolution(ANONYMOUS, [], clip("; ".join(dict.fromkeys(r.evidence for r in results if r.evidence))))
|
|
1397
|
+
return Resolution()
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def evaluate(guards: list[Guard], full_path: str | None, complete: bool) -> Resolution:
|
|
1401
|
+
required: list[Guard] = []
|
|
1402
|
+
skipped: list[Guard] = []
|
|
1403
|
+
uncertain = False
|
|
1404
|
+
for guard in guards:
|
|
1405
|
+
if guard.uncertain:
|
|
1406
|
+
uncertain = True
|
|
1407
|
+
elif guard.skip is None:
|
|
1408
|
+
required.append(guard)
|
|
1409
|
+
elif not guard.skip.characterized or full_path is None:
|
|
1410
|
+
uncertain = True
|
|
1411
|
+
elif full_path in guard.skip.exact:
|
|
1412
|
+
skipped.append(guard)
|
|
1413
|
+
else:
|
|
1414
|
+
required.append(guard)
|
|
1415
|
+
if required:
|
|
1416
|
+
names = list(dict.fromkeys(g.name for g in required))
|
|
1417
|
+
return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(g.evidence for g in required))))
|
|
1418
|
+
if uncertain or not complete:
|
|
1419
|
+
return Resolution()
|
|
1420
|
+
if skipped:
|
|
1421
|
+
return Resolution(ANONYMOUS, [], clip("; ".join(dict.fromkeys(g.skip.evidence for g in skipped if g.skip))))
|
|
1422
|
+
return Resolution()
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
# ---------- NestJS ----------
|
|
1426
|
+
|
|
1427
|
+
_DECORATOR = re.compile(r"@([A-Za-z_$][\w$]*)\s*(\()?")
|
|
1428
|
+
_CLASS_AFTER = re.compile(r"(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\b")
|
|
1429
|
+
_PUBLIC_DECORATORS = frozenset({"Public", "SkipAuth", "AllowAnonymous", "IsPublic", "Anonymous", "NoAuth", "PublicRoute"})
|
|
1430
|
+
_PUBLIC_METADATA = re.compile(r"""^\s*['"](?:isPublic|IS_PUBLIC_KEY|public|skipAuth)['"]\s*,\s*true\s*$|^\s*IS_PUBLIC_KEY\s*,\s*true\s*$""")
|
|
1431
|
+
_PASSPORT_GUARD = re.compile(r"^\s*AuthGuard\s*\(")
|
|
1432
|
+
_NEST_GUARD_NAME = re.compile(r"auth(?!or)|jwt|session|api_?key|bearer|logged_?in|clerk|firebase", re.IGNORECASE)
|
|
1433
|
+
# Admits callers without credentials: not a guard.
|
|
1434
|
+
_NEST_NOT_GUARD = re.compile(r"optional|public|anonymous|guest|maybe|soft", re.IGNORECASE)
|
|
1435
|
+
# Reads role metadata (`@Roles(...)`); without that metadata it lets requests through.
|
|
1436
|
+
_NEST_METADATA_ROLE_GUARD = re.compile(r"role|permission|polic|casl|abilit|pbac|acl|scope", re.IGNORECASE)
|
|
1437
|
+
_NEST_ROLE_GUARD = re.compile(r"admin|staff|superuser", re.IGNORECASE)
|
|
1438
|
+
_NEST_ROLE_METADATA = re.compile(
|
|
1439
|
+
r"\w*Roles?|\w*Permissions?|CheckPolicies|Policies|Pbac|Scopes|RequireScopes|CheckAbilities|Abilities"
|
|
1440
|
+
)
|
|
1441
|
+
_GLOBAL_GUARDS = re.compile(r"\.\s*useGlobalGuards\s*\(")
|
|
1442
|
+
_APP_GUARD = re.compile(r"provide\s*:\s*APP_GUARD\s*,\s*use(?:Class|Existing)\s*:\s*([A-Za-z_$][\w$]*)")
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
@dataclass
|
|
1446
|
+
class DecoratorBlock:
|
|
1447
|
+
decorators: list[tuple[int, str, str]] # (offset, name, argument text)
|
|
1448
|
+
is_class: bool
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def decorator_blocks(content: str) -> list[DecoratorBlock]:
|
|
1452
|
+
"""Consecutive ``@Decorator(...)`` runs and whether a class follows."""
|
|
1453
|
+
blocks: list[DecoratorBlock] = []
|
|
1454
|
+
current: list[tuple[int, str, str]] = []
|
|
1455
|
+
pos = 0
|
|
1456
|
+
while True:
|
|
1457
|
+
match = _DECORATOR.search(content, pos)
|
|
1458
|
+
if not match:
|
|
1459
|
+
break
|
|
1460
|
+
args = ""
|
|
1461
|
+
end = match.end()
|
|
1462
|
+
if match.group(2):
|
|
1463
|
+
close = matching_close(content, match.end() - 1)
|
|
1464
|
+
if close < 0:
|
|
1465
|
+
pos = match.end()
|
|
1466
|
+
continue
|
|
1467
|
+
args = content[match.end() : min(close, match.end() + _ARG_SCAN)]
|
|
1468
|
+
end = close + 1
|
|
1469
|
+
current.append((match.start(), match.group(1), args))
|
|
1470
|
+
nxt = _WS.match(content, end).end()
|
|
1471
|
+
pos = end
|
|
1472
|
+
if content.startswith("@", nxt):
|
|
1473
|
+
continue
|
|
1474
|
+
blocks.append(DecoratorBlock(current, bool(_CLASS_AFTER.match(content, nxt))))
|
|
1475
|
+
current = []
|
|
1476
|
+
return blocks
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _nest_guards(args: str) -> list[tuple[str, bool, str]]:
|
|
1480
|
+
"""``(guard name, is Passport's AuthGuard(...), kind)`` for the auth and
|
|
1481
|
+
role guards in ``@UseGuards(...)`` / ``useGlobalGuards(...)`` arguments.
|
|
1482
|
+
``kind`` is ``authn``, ``role`` or ``metadata`` (a role guard that needs
|
|
1483
|
+
role metadata to reject anything)."""
|
|
1484
|
+
out = []
|
|
1485
|
+
for part in args.split(","):
|
|
1486
|
+
part = part.strip()
|
|
1487
|
+
name = _NEW.sub("", part).split("(", 1)[0].strip()
|
|
1488
|
+
if not name or _NEST_NOT_GUARD.search(name):
|
|
1489
|
+
continue
|
|
1490
|
+
passport = bool(_PASSPORT_GUARD.match(part))
|
|
1491
|
+
if _NEST_GUARD_NAME.search(name):
|
|
1492
|
+
out.append((part if passport else name, passport, AUTHN))
|
|
1493
|
+
elif _NEST_ROLE_GUARD.search(name):
|
|
1494
|
+
out.append((name, False, ROLE))
|
|
1495
|
+
elif _NEST_METADATA_ROLE_GUARD.search(name):
|
|
1496
|
+
out.append((name, False, "metadata"))
|
|
1497
|
+
return out
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def nest_global_guards(content: str) -> list[tuple[str, bool, str, str]]:
|
|
1501
|
+
found = []
|
|
1502
|
+
for match in _GLOBAL_GUARDS.finditer(content):
|
|
1503
|
+
close = matching_close(content, match.end() - 1)
|
|
1504
|
+
if close > 0:
|
|
1505
|
+
args = content[match.end() : min(close, match.end() + _ARG_SCAN)]
|
|
1506
|
+
evidence = clip(content[match.start() + 1 : close + 1])
|
|
1507
|
+
found += [(name, passport, kind, evidence) for name, passport, kind in _nest_guards(args)]
|
|
1508
|
+
for match in _APP_GUARD.finditer(content):
|
|
1509
|
+
for name, passport, kind in _nest_guards(match.group(1)):
|
|
1510
|
+
found.append((name, passport, kind, clip(match.group(0))))
|
|
1511
|
+
return found
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
class NestFile:
|
|
1515
|
+
"""Decorator blocks of one file, for its route decorators."""
|
|
1516
|
+
|
|
1517
|
+
def __init__(self, content: str) -> None:
|
|
1518
|
+
blocks = decorator_blocks(content)
|
|
1519
|
+
self._by_offset = {off: block for block in blocks for off, _, _ in block.decorators}
|
|
1520
|
+
self._classes = [b for b in blocks if b.is_class]
|
|
1521
|
+
self._class_starts = [b.decorators[0][0] for b in self._classes]
|
|
1522
|
+
|
|
1523
|
+
def resolve(self, route_offset: int, global_guards: list[tuple[str, bool, str, str]]) -> Resolution:
|
|
1524
|
+
block = self._by_offset.get(route_offset)
|
|
1525
|
+
if block is None:
|
|
1526
|
+
return Resolution()
|
|
1527
|
+
index = bisect.bisect_left(self._class_starts, route_offset) - 1
|
|
1528
|
+
owner = self._classes[index] if index >= 0 else None
|
|
1529
|
+
guards: list[tuple[str, bool, str, str]] = list(global_guards)
|
|
1530
|
+
public: list[str] = []
|
|
1531
|
+
metadata: list[str] = []
|
|
1532
|
+
for decorated in (owner, block):
|
|
1533
|
+
if decorated is None:
|
|
1534
|
+
continue
|
|
1535
|
+
for _, name, args in decorated.decorators:
|
|
1536
|
+
if name == "UseGuards":
|
|
1537
|
+
evidence = clip(f"@UseGuards({args})")
|
|
1538
|
+
guards += [(g, passport, kind, evidence) for g, passport, kind in _nest_guards(args)]
|
|
1539
|
+
elif name in _PUBLIC_DECORATORS or name == "SetMetadata" and _PUBLIC_METADATA.match(args):
|
|
1540
|
+
public.append(clip(f"@{name}({args})"))
|
|
1541
|
+
elif _NEST_ROLE_METADATA.fullmatch(name):
|
|
1542
|
+
metadata.append(clip(f"@{name}({args})"))
|
|
1543
|
+
# A metadata-driven role guard only rejects routes that carry its metadata.
|
|
1544
|
+
guards = [g for g in guards if g[2] != "metadata" or metadata]
|
|
1545
|
+
if public and not any(passport for _, passport, _, _ in guards):
|
|
1546
|
+
return Resolution(ANONYMOUS, [], clip("; ".join(public)))
|
|
1547
|
+
if guards:
|
|
1548
|
+
names = list(dict.fromkeys(g for g, _, _, _ in guards))
|
|
1549
|
+
evidence = list(dict.fromkeys(ev for _, _, _, ev in guards))
|
|
1550
|
+
if any(kind != AUTHN for _, _, kind, _ in guards):
|
|
1551
|
+
evidence += metadata
|
|
1552
|
+
return Resolution(REQUIRED, names, clip("; ".join(evidence)))
|
|
1553
|
+
return Resolution()
|