attackmap 0.6.4__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/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""Handler-level session checks in JS/TS route handlers (#348).
|
|
2
|
+
|
|
3
|
+
`unauth-state-change` resolves auth on a route's middleware chain. Many
|
|
4
|
+
JS/TS handlers authenticate in their own body instead::
|
|
5
|
+
|
|
6
|
+
const session = await getSession(c);
|
|
7
|
+
if (!session.user) {
|
|
8
|
+
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
This module recognises that shape structurally, and nothing looser. When in
|
|
12
|
+
doubt, the finding stays.
|
|
13
|
+
|
|
14
|
+
1. Strings and comments are masked before any test.
|
|
15
|
+
2. The handler body is the brace block of the route's handler function (the
|
|
16
|
+
first `{` after `=>` or a parameter list), clamped to its closing brace.
|
|
17
|
+
3. A *session lookup* is a declaration whose initializer resolves the
|
|
18
|
+
caller's identity: `getSession(`, `getServerSession(`,
|
|
19
|
+
`getServerAuthSession(`, a zero-argument `auth()`, `currentUser(` /
|
|
20
|
+
`getCurrentUser(`, `supabase.auth.getUser(`, or `req.session.user` /
|
|
21
|
+
`req.session.userId` / `req.user` (passport). The initializer must be the
|
|
22
|
+
lookup itself (after an optional `await`), not an expression mentioning
|
|
23
|
+
one (`!req.user`, `findFirst({ authorId: req.user.id })`); a destructured
|
|
24
|
+
alias binds its alias (`{ user: me }` tracks `me`). A later reassignment
|
|
25
|
+
(`=`, `||=`, `??=`, destructuring) of a tracked name stops tracking it.
|
|
26
|
+
4. A *gate* is an `if` whose condition, parsed with balanced parentheses, is
|
|
27
|
+
exactly `!name[.member…]` for a tracked name, or `!req.user`,
|
|
28
|
+
`!req.session.user[Id]`, `!req.isAuthenticated()`. A destructured `data`
|
|
29
|
+
or `error` is not tracked (supabase v2 always returns a `data` object),
|
|
30
|
+
and a whole `auth()` / `.auth.getUser()` / Remix or h3 `getSession(…)`
|
|
31
|
+
result needs a member test (`data` alone doesn't count). A
|
|
32
|
+
member test must name identity (`user`, `id`, `email`, …), not a property
|
|
33
|
+
of it (`!session.user.isGuest`).
|
|
34
|
+
5. The gate must be a top-level statement of the handler body: not inside a
|
|
35
|
+
branch, loop, callback or nested function, not an `else if`, and not the
|
|
36
|
+
body of a braceless `if` / loop. A `try { … }` block is transparent only
|
|
37
|
+
if its `catch` rethrows or returns (or there is none); an
|
|
38
|
+
immediately-invoked function that is the body's last statement (`void
|
|
39
|
+
(async () => { … })()`) is transparent. Nothing that may act for an
|
|
40
|
+
anonymous caller may come before it: a state-changing call (a persistence
|
|
41
|
+
method, a plain `createX(` / `deleteX(` call, `$executeRaw`), an awaited
|
|
42
|
+
call other than the lookup or a request read (`req.json()`, `parse…`), or
|
|
43
|
+
a `return` that hands off rather than responds. A lookup with a fallback (`?? {…}`, `|| {…}`, a
|
|
44
|
+
ternary, `.catch(…)`) doesn't count, a later write to the name or one of
|
|
45
|
+
its members (`session.user ||= guest`) disqualifies it, and so does
|
|
46
|
+
assigning `req.user` / `req.session…` before a direct check.
|
|
47
|
+
6. Its then-branch (a braced block or a single statement) must start with a
|
|
48
|
+
`return` of nothing, a literal, a response, a redirect or an error (not a
|
|
49
|
+
hand-off like `return createGuestOrder(req, res)`, and not `return next()`, `return next(null)`, `return await
|
|
50
|
+
next()` or `return void next()`) or a `throw`. A bare `redirect(...)`
|
|
51
|
+
doesn't count: it throws in Next, but Remix and SvelteKit 1 return it. An
|
|
52
|
+
Express-style `next(err)` or `res.status(401|403)…` counts only when the
|
|
53
|
+
next statement is a `return`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import re
|
|
59
|
+
|
|
60
|
+
_SESSION_CALL = re.compile(
|
|
61
|
+
r"(?<![\w$.])(?:getSession|getServerSession|getServerAuthSession|currentUser|getCurrentUser)\s*+\("
|
|
62
|
+
r"|(?<![\w$.])auth\s*+\(\s*+\)"
|
|
63
|
+
r"|\.\s*auth\s*+\.\s*getUser\s*+\("
|
|
64
|
+
r"|(?<![\w$.])(?:req|request)\s*+\.\s*+(?:user\b|session\s*+\.\s*+(?:user|userId)\b)"
|
|
65
|
+
)
|
|
66
|
+
_DECLARATION = re.compile(
|
|
67
|
+
r"(?<![\w$])(?:const|let|var)\s++(?:\{(?P<names>[^{}]{1,300})\}|(?P<name>[A-Za-z_$][\w$]{0,80}+))"
|
|
68
|
+
r"\s*+(?::[^=;\n]{1,120})?=(?![=>])"
|
|
69
|
+
)
|
|
70
|
+
_IDENT = re.compile(r"[A-Za-z_$][\w$]{0,80}")
|
|
71
|
+
_IF = re.compile(r"(?<![\w$.])if\s*+\(")
|
|
72
|
+
_GATE_CONDITION = re.compile(
|
|
73
|
+
r"\A\s*!\s*(?:(?P<direct>(?:req|request)\s*\.\s*(?:user|session\s*\.\s*(?:user|userId)|isAuthenticated\s*\(\s*\)))"
|
|
74
|
+
r"|(?P<name>[A-Za-z_$][\w$]{0,80})(?P<members>(?:\s*\??\.\s*[A-Za-z_$][\w$]{0,80})*))\s*\Z"
|
|
75
|
+
)
|
|
76
|
+
# Members a gate may test: identity, not a property of it (`!session.user.isGuest`).
|
|
77
|
+
_IDENTITY_MEMBERS = frozenset(
|
|
78
|
+
{"user", "id", "userid", "uid", "sub", "email", "account", "accountid", "session", "sessionid", "identity", "profile"}
|
|
79
|
+
)
|
|
80
|
+
# `data` is an envelope (supabase `{ data: { user } }`): fine on the way to an
|
|
81
|
+
# identity member, never as the member tested.
|
|
82
|
+
_ENVELOPE_MEMBERS = frozenset({"data"})
|
|
83
|
+
_SESSION_ID_MEMBERS = frozenset({"id", "sessionid"})
|
|
84
|
+
_USER_MEMBERS = frozenset({"user", "account", "identity", "profile"})
|
|
85
|
+
_MEMBER_NAME = re.compile(r"[A-Za-z_$][\w$]{0,80}")
|
|
86
|
+
# Lookups that never come back falsy as a whole: supabase's `{ data, error }`,
|
|
87
|
+
# Clerk's `auth()` object. A bare `!x` on them never fires; a member test does.
|
|
88
|
+
# Remix (`getSession(request.headers.get("Cookie"))`) and h3 (`getSession(event, …)`)
|
|
89
|
+
# session stores always return a session object too.
|
|
90
|
+
_ALWAYS_OBJECT = re.compile(
|
|
91
|
+
r"(?<![\w$.])auth\s*+\(\s*+\)|\.\s*auth\s*+\.\s*getUser\s*+\("
|
|
92
|
+
r"|(?<![\w$.])getSession\s*+\(\s*+(?:event\b|[^()\n]{0,200}(?:\([^()\n]{0,200}\))?[^()\n]{0,200}(?i:cookie|headers))"
|
|
93
|
+
)
|
|
94
|
+
# The initializer is the lookup itself (`await getSession(req)`,
|
|
95
|
+
# `supabase.auth.getUser()`), not an expression that mentions one.
|
|
96
|
+
# Only a member lookup (`.auth.getUser(`) may have a receiver before it.
|
|
97
|
+
_LOOKUP_PREFIX = re.compile(r"\s*+(?:await\s++)?")
|
|
98
|
+
# An async lookup without `await` is a Promise, never falsy: `if (!session)` never fires.
|
|
99
|
+
_AWAITED = re.compile(r"(?<!\s)\s*+await\s")
|
|
100
|
+
_RECEIVER_PREFIX = re.compile(r"\s*+(?:await\s++)?[A-Za-z_$][\w$]{0,80}+(?:\s*+\??\.\s*+[A-Za-z_$][\w$]{0,80}+){0,6}+\s*+")
|
|
101
|
+
_REJECTING_FIRST = re.compile(r"\A\s*throw\b")
|
|
102
|
+
_RETURN = re.compile(r"\A\s*return\b(?P<rest>.*)\Z", re.DOTALL)
|
|
103
|
+
# What a rejecting `return` may return: nothing, a literal, a response, a
|
|
104
|
+
# redirect or an error. `return createGuestOrder(req, res)` doesn't count.
|
|
105
|
+
_RETURN_VALUE = re.compile(
|
|
106
|
+
r"\A\s*+(?:\Z|;|['\"`\d]|[{\[][^()]*+\Z|null\b|false\b|undefined\b|new\s++\w{0,40}(?:Response|Error|Exception)\b"
|
|
107
|
+
r"|(?:void\s++)?(?:res|response|reply|ctx|c|context|h|event)\s*+\.\s*+"
|
|
108
|
+
r"(?:status|sendStatus|send|json|end|redirect|render|code|text|body|html|notFound|header|set|type|writeHead|response|throw)\b"
|
|
109
|
+
r"|(?:Next)?Response\s*+\.|NextResponse\b"
|
|
110
|
+
r"|(?:await\s++)?(?i:(?:send|respond(?:with)?|make|create|throw|to)?_?"
|
|
111
|
+
r"(?:unauthori[sz]ed|forbidden|error|json|redirect|status|login|sign_?in|abort|den(?:y|ied)|reject(?:ed)?"
|
|
112
|
+
r"|fail(?:ure|ed)?|not_?allowed)(?:_?(?:response|error|page))?)\s*+\("
|
|
113
|
+
r"|next\s*+\(\s*+(?:new\s|(?i:\w{0,40}(?:error|err|unauthori[sz]ed|forbidden|boom)\w{0,20})\s*+[,()]|e\s*+\)))"
|
|
114
|
+
)
|
|
115
|
+
# `next()` continues the chain, as do `next(null)` / `next(undefined)`.
|
|
116
|
+
_BARE_NEXT = re.compile(r"\A\s*(?:(?:void|await)\s+)?next\s*\(\s*(?:null|undefined)?\s*\)\s*\Z")
|
|
117
|
+
# `next('route')` / `next('router')` skip to the next route: a hand-off, not a
|
|
118
|
+
# rejection. Strings are masked by then, so no string argument counts.
|
|
119
|
+
# Only an error object counts: `next(new Error(…))`, `next(createError(401))`,
|
|
120
|
+
# `next(err)`; `next(skip)` may be anything.
|
|
121
|
+
_NEXT_WITH_ERROR = re.compile(
|
|
122
|
+
r"\A\s*next\s*\(\s*(?:new\s|(?i:\w{0,40}(?:error|err|unauthori[sz]ed|forbidden|boom)\w{0,20})\s*[,()]|e\s*\))"
|
|
123
|
+
)
|
|
124
|
+
_STATUS_REJECT = re.compile(r"\A\s*(?:res|response|reply|ctx)\s*\.\s*(?:status|code)\s*\(\s*(?:401|403)\s*\)")
|
|
125
|
+
# The handler function's body: the first `{` after `=>` or a parameter list.
|
|
126
|
+
_BODY_OPEN = re.compile(r"(?:=>|\))\s*+(?::[^{};=\n]{1,120})?\s*+\{")
|
|
127
|
+
# A state-changing call: persistence or an outbound write.
|
|
128
|
+
_MUTATION = re.compile(
|
|
129
|
+
r"\$(?:execute|query)Raw"
|
|
130
|
+
r"|(?:\.\s*|(?<![\w$.]))[a-z_$][A-Za-z_$]{0,40}(?:Remove|Delete|Update|Create|Insert|Save|Destroy|Upsert|Refund|Charge|Write)\w{0,20}\s*\("
|
|
131
|
+
r"|(?:\.\s*|(?<![\w$.]))(?:create(?![A-Za-z]{0,40}Client\s*\()|createMany|update|updateMany|upsert|delete|deleteMany|insert|insertOne|insertMany"
|
|
132
|
+
r"|save|remove|destroy|execute|exec|query|write|enqueue|publish|add|set|del|unlink|rm|post|put|patch"
|
|
133
|
+
r"|send|push|append|incr|decr|hset|lpush|rpush|sadd|zadd|expire|rename|copy|mkdir|fetch|charge|transfer"
|
|
134
|
+
r"|run|emit|dispatch|invalidate|revalidate\w{0,20})\w{0,40}\s*\("
|
|
135
|
+
)
|
|
136
|
+
_TRY_OPEN = re.compile(r"\btry\s*\Z")
|
|
137
|
+
# A fallback in a session initializer: `??`, `||`, a ternary (not `?.`), or
|
|
138
|
+
# a `.catch(...)` that resolves to something.
|
|
139
|
+
# A write inside the rejecting statement: an awaited call or a persistence
|
|
140
|
+
# method (`res.status(401).send(…)` and `createError(…)` are responses).
|
|
141
|
+
_BRANCH_WRITE = re.compile(
|
|
142
|
+
r"(?<![\w$.])await\s|\.\s*(?:create|update|upsert|delete|insert|save|remove|destroy|exec|execute|query|run)\w{0,40}\s*\("
|
|
143
|
+
)
|
|
144
|
+
_LITERAL_START = re.compile(r"(?<!\s)\s*+(?:['\"`\d]|null\b|false\b|undefined\b)")
|
|
145
|
+
# A literal return and nothing else (strings are masked to spaces by now).
|
|
146
|
+
_LITERAL_ONLY = re.compile(
|
|
147
|
+
r"(?<!\s)\s*+(?:(['\"`])[^'\"`]{0,4000}+\1|\d[\w.]{0,40}+|null|false|undefined)\s*+;?\s*+"
|
|
148
|
+
)
|
|
149
|
+
_COMPARISON = re.compile(r"[=!]=|&&|[<>]|\binstanceof\b|\bin\b|\btypeof\b")
|
|
150
|
+
_FALLBACK = re.compile(r"\?\?|\|\||\?(?![.?])|\.\s*catch\s*\(|\.\s*then\s*\(")
|
|
151
|
+
# Assigning the request identity directly: `req.user = …`, `req.session.user ||= …`.
|
|
152
|
+
_DIRECT_ASSIGN = re.compile(
|
|
153
|
+
r"(?<![\w$.])(?:req|request)\s*\.\s*(?:user|session)(?:\s*\.\s*(?:user|userId))?\s*(?:\|\||&&|\?\?)?=(?![=>])"
|
|
154
|
+
)
|
|
155
|
+
_ENVELOPE_NAMES = frozenset({"data", "error"})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _bindings(pattern: str) -> list[str]:
|
|
159
|
+
"""Names bound by a flat object pattern: `{ user: me, session }` binds
|
|
160
|
+
`me` and `session`. Keys of the response envelope (`data`, `error`, which
|
|
161
|
+
supabase always returns) and `...rest` objects are not tracked."""
|
|
162
|
+
names: list[str] = []
|
|
163
|
+
for element in pattern.split(","):
|
|
164
|
+
key, _, alias = element.partition(":")
|
|
165
|
+
key = key.strip()
|
|
166
|
+
if not key or key.startswith("...") or key in _ENVELOPE_NAMES:
|
|
167
|
+
continue
|
|
168
|
+
bound = (alias or key).strip()
|
|
169
|
+
if _IDENT.fullmatch(bound):
|
|
170
|
+
names.append(bound)
|
|
171
|
+
return names
|
|
172
|
+
_IIFE_HEAD = re.compile(r"\A\s*(?:async\s+)?(?:\([^()]{0,200}\)|[A-Za-z_$][\w$]{0,80})\s*=>\s*\{|\A\s*(?:async\s+)?function\b")
|
|
173
|
+
_MAX_SPAN_CHARS = 20_000
|
|
174
|
+
_MAX_IFS = 200
|
|
175
|
+
_MAX_CONDITION_CHARS = 400
|
|
176
|
+
_MAX_BRANCH_CHARS = 4_000
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def mask(text: str) -> str:
|
|
180
|
+
"""``text`` with string literals and comments replaced by spaces (same length).
|
|
181
|
+
|
|
182
|
+
Template-literal interpolations (`${…}`) stay visible as code. A regex
|
|
183
|
+
literal is skipped unmasked, so a quote or backtick inside it can't start
|
|
184
|
+
a string that hides the code after it."""
|
|
185
|
+
out = list(text)
|
|
186
|
+
n = len(text)
|
|
187
|
+
|
|
188
|
+
def blank(a: int, b: int) -> None:
|
|
189
|
+
for k in range(a, min(b, n)):
|
|
190
|
+
if out[k] != "\n":
|
|
191
|
+
out[k] = " "
|
|
192
|
+
|
|
193
|
+
stack: list[int] = [] # brace depth inside each open `${`
|
|
194
|
+
prev = "" # last code character that isn't whitespace
|
|
195
|
+
i = 0
|
|
196
|
+
while i < n:
|
|
197
|
+
c = text[i]
|
|
198
|
+
if c == "`" or (c == "}" and stack and stack[-1] == 0):
|
|
199
|
+
if c == "}":
|
|
200
|
+
stack.pop()
|
|
201
|
+
j = i + 1
|
|
202
|
+
while j < n and text[j] != "`" and not text.startswith("${", j):
|
|
203
|
+
j += 2 if text[j] == "\\" else 1
|
|
204
|
+
blank(i + 1, j)
|
|
205
|
+
if text.startswith("${", j):
|
|
206
|
+
stack.append(0)
|
|
207
|
+
i, prev = j + 2, "{"
|
|
208
|
+
else:
|
|
209
|
+
i, prev = j + 1, "`"
|
|
210
|
+
continue
|
|
211
|
+
if stack and c in "{}":
|
|
212
|
+
stack[-1] += 1 if c == "{" else -1
|
|
213
|
+
if c in "'\"":
|
|
214
|
+
j = i + 1
|
|
215
|
+
while j < n and text[j] != c and text[j] != "\n":
|
|
216
|
+
j += 2 if text[j] == "\\" else 1
|
|
217
|
+
blank(i + 1, j)
|
|
218
|
+
i, prev = j + 1, c
|
|
219
|
+
continue
|
|
220
|
+
if text.startswith("//", i):
|
|
221
|
+
j = text.find("\n", i)
|
|
222
|
+
j = n if j < 0 else j
|
|
223
|
+
blank(i, j)
|
|
224
|
+
i = j
|
|
225
|
+
continue
|
|
226
|
+
if text.startswith("/*", i):
|
|
227
|
+
j = text.find("*/", i + 2)
|
|
228
|
+
j = n if j < 0 else j + 2
|
|
229
|
+
blank(i, j)
|
|
230
|
+
i = j
|
|
231
|
+
continue
|
|
232
|
+
if c == "/" and prev in _REGEX_PREV:
|
|
233
|
+
j = i + 1
|
|
234
|
+
in_class = False
|
|
235
|
+
while j < n and text[j] != "\n":
|
|
236
|
+
if text[j] == "\\":
|
|
237
|
+
j += 2
|
|
238
|
+
continue
|
|
239
|
+
if text[j] == "[":
|
|
240
|
+
in_class = True
|
|
241
|
+
elif text[j] == "]":
|
|
242
|
+
in_class = False
|
|
243
|
+
elif text[j] == "/" and not in_class:
|
|
244
|
+
break
|
|
245
|
+
j += 1
|
|
246
|
+
i, prev = j + 1, "/"
|
|
247
|
+
continue
|
|
248
|
+
if not c.isspace():
|
|
249
|
+
prev = c
|
|
250
|
+
i += 1
|
|
251
|
+
return "".join(out)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# A `/` after one of these (or at the start) begins a regex literal, not a division.
|
|
255
|
+
_REGEX_PREV = frozenset(["", *"(,=:[!&|?{};+-*%<>~^"])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _matches(code: str) -> dict[int, int]:
|
|
259
|
+
"""Opening bracket offset → its closing offset (unbalanced ones omitted)."""
|
|
260
|
+
pairs: dict[int, int] = {}
|
|
261
|
+
stack: list[int] = []
|
|
262
|
+
closer = {")": "(", "]": "[", "}": "{"}
|
|
263
|
+
for i, ch in enumerate(code):
|
|
264
|
+
if ch in "([{":
|
|
265
|
+
stack.append(i)
|
|
266
|
+
elif ch in closer:
|
|
267
|
+
while stack and code[stack[-1]] != closer[ch]:
|
|
268
|
+
stack.pop()
|
|
269
|
+
if stack:
|
|
270
|
+
pairs[stack.pop()] = i
|
|
271
|
+
return pairs
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _statements(block: str) -> list[str]:
|
|
275
|
+
"""Top-level statements of a block body, split on `;` and newlines at depth 0."""
|
|
276
|
+
out: list[str] = []
|
|
277
|
+
depth = 0
|
|
278
|
+
current: list[str] = []
|
|
279
|
+
for ch in block:
|
|
280
|
+
if ch in "([{":
|
|
281
|
+
depth += 1
|
|
282
|
+
elif ch in ")]}":
|
|
283
|
+
depth -= 1
|
|
284
|
+
if depth == 0 and ch in ";\n":
|
|
285
|
+
stmt = "".join(current).strip()
|
|
286
|
+
if stmt:
|
|
287
|
+
out.append(stmt)
|
|
288
|
+
current = []
|
|
289
|
+
continue
|
|
290
|
+
current.append(ch)
|
|
291
|
+
stmt = "".join(current).strip()
|
|
292
|
+
if stmt:
|
|
293
|
+
out.append(stmt)
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _rejects(statements: list[str]) -> bool:
|
|
298
|
+
if not statements:
|
|
299
|
+
return False
|
|
300
|
+
first = statements[0]
|
|
301
|
+
second = statements[1] if len(statements) > 1 else ""
|
|
302
|
+
if _BRANCH_WRITE.search(first) or _BRANCH_WRITE.search(second):
|
|
303
|
+
return False # `return json(await db.item.deleteMany())`: the rejection writes
|
|
304
|
+
if _REJECTING_FIRST.match(first):
|
|
305
|
+
return True
|
|
306
|
+
returned = _RETURN.match(first)
|
|
307
|
+
if returned:
|
|
308
|
+
# `return null\n || guestCheckout(…)`: no ASI before an operator.
|
|
309
|
+
rest = " ".join([returned.group("rest"), *_continuation(statements[1:])])
|
|
310
|
+
if _LITERAL_START.match(rest) and not _LITERAL_ONLY.fullmatch(rest):
|
|
311
|
+
return False # `return 0 || guestCheckout(req, res)`
|
|
312
|
+
return not _BARE_NEXT.match(rest) and _RETURN_VALUE.match(rest) is not None
|
|
313
|
+
if _NEXT_WITH_ERROR.match(first) or _STATUS_REJECT.match(first):
|
|
314
|
+
# `res.status(401); return` / `next(err); return`, not `return next()`.
|
|
315
|
+
then = _RETURN.match(second)
|
|
316
|
+
return then is not None and not _BARE_NEXT.match(then.group("rest")) and _RETURN_VALUE.match(then.group("rest")) is not None
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _continuation(statements: list[str]) -> list[str]:
|
|
321
|
+
"""Leading statements that continue the previous one (start with an operator)."""
|
|
322
|
+
out: list[str] = []
|
|
323
|
+
for stmt in statements:
|
|
324
|
+
if not stmt.lstrip().startswith(_CONTINUATION):
|
|
325
|
+
break
|
|
326
|
+
out.append(stmt)
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _then_branch(code: str, after_paren: int, pairs: dict[int, int]) -> list[str] | None:
|
|
331
|
+
i = after_paren
|
|
332
|
+
while i < len(code) and code[i] in " \t\r\n":
|
|
333
|
+
i += 1
|
|
334
|
+
if i >= len(code):
|
|
335
|
+
return None
|
|
336
|
+
if code[i] == "{":
|
|
337
|
+
end = pairs.get(i)
|
|
338
|
+
if end is None or end - i > _MAX_BRANCH_CHARS:
|
|
339
|
+
return None
|
|
340
|
+
return _statements(code[i + 1 : end])
|
|
341
|
+
statements = _statements(code[i : i + _MAX_BRANCH_CHARS])
|
|
342
|
+
# A single statement, with any lines that continue it (`return null\n || x`).
|
|
343
|
+
return statements[:1] + _continuation(statements[1:]) if statements else []
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def handler_body(text: str, code: str | None = None) -> tuple[int, int] | None:
|
|
347
|
+
"""``(open, close)`` offsets of the handler function's body braces in
|
|
348
|
+
``text`` (which starts at the route registration or function header).
|
|
349
|
+
``code`` is ``text`` already masked, when the caller has it."""
|
|
350
|
+
code = (mask(text[:_MAX_SPAN_CHARS]) if code is None else code)[:_MAX_SPAN_CHARS]
|
|
351
|
+
match = _BODY_OPEN.search(code)
|
|
352
|
+
if match is None:
|
|
353
|
+
return None
|
|
354
|
+
close = _matches(code).get(match.end() - 1)
|
|
355
|
+
return (match.end() - 1, close) if close is not None else None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _transparent(code: str, open_at: int, pairs: dict[int, int], body_close: int) -> bool:
|
|
359
|
+
"""A bracket an `if` may sit inside and still be top-level: a `try` block,
|
|
360
|
+
or the body (and wrapping parenthesis) of an immediately-invoked function
|
|
361
|
+
that is the handler's last statement (a `return` in it ends the handler)."""
|
|
362
|
+
ch = code[open_at]
|
|
363
|
+
if ch == "{":
|
|
364
|
+
before = code[max(0, open_at - 40) : open_at]
|
|
365
|
+
if _TRY_OPEN.search(before.rstrip()):
|
|
366
|
+
return _catch_rejects(code, open_at, pairs)
|
|
367
|
+
close = pairs.get(open_at)
|
|
368
|
+
if close is None or not re.search(r"=>\s*\Z", before):
|
|
369
|
+
return False
|
|
370
|
+
call = _IIFE_CALL.match(code, close + 1, min(body_close, close + 20))
|
|
371
|
+
if call is None:
|
|
372
|
+
return False
|
|
373
|
+
wrap_close = code.index(")", close + 1)
|
|
374
|
+
wrap_open = next((o for o, c in pairs.items() if c == wrap_close), None)
|
|
375
|
+
return wrap_open is not None and _wraps(code, wrap_open) and _ends_body(code, call.end() - 1, pairs, body_close)
|
|
376
|
+
if ch == "(":
|
|
377
|
+
close = pairs.get(open_at)
|
|
378
|
+
if close is None or _IIFE_HEAD.match(code[open_at + 1 : open_at + 300]) is None or not _wraps(code, open_at):
|
|
379
|
+
return False
|
|
380
|
+
call = _CALL_OPEN.match(code, close + 1, min(body_close, close + 20))
|
|
381
|
+
return call is not None and _ends_body(code, call.end() - 1, pairs, body_close)
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# Anchored after a non-space so a search() over hostile whitespace stays linear.
|
|
386
|
+
def _wraps(code: str, open_paren: int) -> bool:
|
|
387
|
+
"""The `(` at ``open_paren`` groups (`(async () => …)()`), not a call's
|
|
388
|
+
argument list (`guestOr(async () => …)(req, res)`)."""
|
|
389
|
+
before = code[max(0, open_paren - 200) : open_paren].rstrip()
|
|
390
|
+
return not before or before[-1] not in _CALLEE_END or _KEYWORD_END.search(before) is not None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
_KEYWORD_END = re.compile(r"(?<![\w$.])(?:void|await|return|yield|typeof)\Z")
|
|
394
|
+
_CALLEE_END = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$)]")
|
|
395
|
+
_IIFE_CALL = re.compile(r"(?<!\s)\s*+\)\s*+\(")
|
|
396
|
+
_CALL_OPEN = re.compile(r"(?<!\s)\s*+\(")
|
|
397
|
+
_BODY_TAIL = re.compile(r"[\s;]*")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _ends_body(code: str, call_open: int, pairs: dict[int, int], body_close: int) -> bool:
|
|
401
|
+
"""The call whose `(` is at ``call_open`` is the last statement of the body."""
|
|
402
|
+
call_close = pairs.get(call_open)
|
|
403
|
+
if call_close is None or call_close >= body_close:
|
|
404
|
+
return False
|
|
405
|
+
return _BODY_TAIL.match(code, call_close + 1, body_close).end() == body_close
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
_CATCH_HEAD = re.compile(r"\A\s*catch\s*(?:\([^()]{0,200}\))?\s*\{")
|
|
409
|
+
_FINALLY_HEAD = re.compile(r"\A\s*finally\s*\{")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _catch_rejects(code: str, try_open: int, pairs: dict[int, int]) -> bool:
|
|
413
|
+
"""A `try` is see-through only if its `catch` rethrows or returns at its
|
|
414
|
+
top level (so a `throw` in the try still rejects), or there is no catch."""
|
|
415
|
+
try_close = pairs.get(try_open)
|
|
416
|
+
if try_close is None:
|
|
417
|
+
return False
|
|
418
|
+
after = code[try_close + 1 : try_close + 260]
|
|
419
|
+
head = _CATCH_HEAD.match(after)
|
|
420
|
+
if head is None:
|
|
421
|
+
final = _FINALLY_HEAD.match(after)
|
|
422
|
+
if final is None:
|
|
423
|
+
return False
|
|
424
|
+
# try/finally doesn't swallow, unless the finally acts for the caller.
|
|
425
|
+
final_open = try_close + final.end()
|
|
426
|
+
final_close = pairs.get(final_open)
|
|
427
|
+
return final_close is not None and not _BRANCH_WRITE.search(code, final_open, final_close)
|
|
428
|
+
catch_open = try_close + head.end()
|
|
429
|
+
catch_close = pairs.get(catch_open)
|
|
430
|
+
if catch_close is None or _BRANCH_WRITE.search(code, catch_open, catch_close):
|
|
431
|
+
return False # `catch (e) { await db.log.create(…); throw e }` acts before rethrowing
|
|
432
|
+
final = _FINALLY_HEAD.match(code[catch_close + 1 : catch_close + 260])
|
|
433
|
+
if final is not None:
|
|
434
|
+
# `try {…} catch {…} finally { await Audit.create(…) }`: the finally
|
|
435
|
+
# runs on the rejecting path too.
|
|
436
|
+
final_open = catch_close + 1 + final.end() - 1
|
|
437
|
+
final_close = pairs.get(final_open)
|
|
438
|
+
if final_close is None or _BRANCH_WRITE.search(code, final_open, final_close):
|
|
439
|
+
return False
|
|
440
|
+
for stmt in _statements(code[catch_open + 1 : catch_close]):
|
|
441
|
+
if re.match(r"\A\s*throw\b", stmt):
|
|
442
|
+
return True
|
|
443
|
+
returned = _RETURN.match(stmt)
|
|
444
|
+
if returned and not _BARE_NEXT.match(returned.group("rest")) and _RETURN_VALUE.match(returned.group("rest")):
|
|
445
|
+
return True
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _top_level(code: str, body_open: int, body_close: int, pos: int, pairs: dict[int, int]) -> bool:
|
|
450
|
+
"""``pos`` sits directly in the handler body, only inside transparent brackets."""
|
|
451
|
+
for open_at, close in pairs.items():
|
|
452
|
+
if body_open < open_at < pos < close and not _transparent(code, open_at, pairs, body_close):
|
|
453
|
+
return False
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
_CONTROL_HEAD = re.compile(r"(?<![\w$.])(?:if|for|while|with|switch|catch)\s*\Z")
|
|
458
|
+
_DANGLING_KEYWORD = re.compile(r"(?<![\w$.])(?:else|do)\s*\Z")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _starts_statement(code: str, pos: int, pairs: dict[int, int]) -> bool:
|
|
462
|
+
"""``pos`` starts a statement of its block, not the body of a braceless
|
|
463
|
+
`if` / `for` / `while`, nor an `else if`."""
|
|
464
|
+
before = code[max(0, pos - 400) : pos]
|
|
465
|
+
stripped = before.rstrip()
|
|
466
|
+
if not stripped or stripped[-1] in "{};":
|
|
467
|
+
return True
|
|
468
|
+
if _DANGLING_KEYWORD.search(stripped):
|
|
469
|
+
return False
|
|
470
|
+
if stripped[-1] == ")":
|
|
471
|
+
close = pos - (len(before) - len(stripped)) - 1
|
|
472
|
+
opener = next((o for o, c in pairs.items() if c == close), None)
|
|
473
|
+
if opener is None or _CONTROL_HEAD.search(code[max(0, opener - 40) : opener]):
|
|
474
|
+
return False
|
|
475
|
+
# A statement ended without `;` (ASI) only at a line break.
|
|
476
|
+
return "\n" in before[len(stripped) :]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _reassigned(name: str, text: str) -> bool:
|
|
480
|
+
# `session = …`, `session.user ||= guest`, `session["user"] ??= …`
|
|
481
|
+
plain = re.search(
|
|
482
|
+
rf"(?<![\w$.]){re.escape(name)}(?:\s*+(?:\??\.\s*+[A-Za-z_$][\w$]{{0,80}}+|\[[^\[\]\n]{{0,80}}\]))*+"
|
|
483
|
+
rf"\s*+(?:\|\||&&|\?\?)?=(?![=>])",
|
|
484
|
+
text,
|
|
485
|
+
)
|
|
486
|
+
destructured = re.search(rf"(?<![\w$.]){re.escape(name)}(?![\w$])[^;=\n]{{0,80}}?[}}\]]\s*=(?![=>])", text)
|
|
487
|
+
assigned = re.search(rf"\bObject\s*\.\s*assign\s*\(\s*{re.escape(name)}(?![\w$])", text)
|
|
488
|
+
return bool(plain or destructured or assigned)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# Awaits that only read the request: body parsing and validation, Next's
|
|
492
|
+
# `cookies()` / `headers()` / `params`.
|
|
493
|
+
_READ_AWAIT = re.compile(
|
|
494
|
+
r"(?:[A-Za-z_$][\w$]{0,80}+\s*+\.\s*+){0,6}+"
|
|
495
|
+
r"(?:(?:json|text|formData|arrayBuffer|blob|parse|parseAsync|safeParse|safeParseAsync|validate|validateAsync"
|
|
496
|
+
r"|validateSync|validateBody|validateQuery|validateParams)\s*+\("
|
|
497
|
+
r"|(?:cookies|headers)\s*+\(\s*+\)|(?:params|searchParams)\b)"
|
|
498
|
+
)
|
|
499
|
+
_AWAIT = re.compile(r"(?<![\w$.])await\s++")
|
|
500
|
+
_EARLY_RETURN = re.compile(r"(?<![\w$.])return\b")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _first_unsafe(code: str, body_open: int, body_close: int) -> int:
|
|
504
|
+
"""Offset of the first thing in the body that may act for an anonymous
|
|
505
|
+
caller, or ``body_close``: a write (`_MUTATION`), an awaited call other
|
|
506
|
+
than the session lookup or a request read (`await processOrder(req.body)`),
|
|
507
|
+
or a `return` that hands off (`if (req.query.guest) return
|
|
508
|
+
handleGuest(req, res)`). Computed once per body; a gate counts only
|
|
509
|
+
before it."""
|
|
510
|
+
first = body_close
|
|
511
|
+
mutation = _MUTATION.search(code, body_open, body_close)
|
|
512
|
+
if mutation is not None:
|
|
513
|
+
first = mutation.start()
|
|
514
|
+
for match in _AWAIT.finditer(code, body_open, first):
|
|
515
|
+
if _unsafe_await(code, match, first):
|
|
516
|
+
first = match.start()
|
|
517
|
+
break
|
|
518
|
+
for match in _EARLY_RETURN.finditer(code, body_open, first):
|
|
519
|
+
stmt = _statements(code[match.end() : min(first, match.end() + 400)])
|
|
520
|
+
if not _RETURN_VALUE.match(stmt[0] if stmt else ""):
|
|
521
|
+
first = match.start()
|
|
522
|
+
break
|
|
523
|
+
return first
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _unsafe_await(code: str, match: re.Match[str], limit: int) -> bool:
|
|
527
|
+
after = code[match.end() : min(limit, match.end() + 300)]
|
|
528
|
+
lookup = _SESSION_CALL.search(after)
|
|
529
|
+
if lookup is not None:
|
|
530
|
+
prefix = _RECEIVER_PREFIX if after[lookup.start()] == "." else _LOOKUP_PREFIX
|
|
531
|
+
if prefix.fullmatch(after, 0, lookup.start()) is None:
|
|
532
|
+
lookup = None
|
|
533
|
+
return lookup is None and _READ_AWAIT.match(after) is None
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
_CONTINUATION = ("??", "||", "&&", "?", ":", ".", "+")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _initializer(window: str) -> str:
|
|
540
|
+
"""The declaration's initializer: its first line plus any lines that
|
|
541
|
+
continue it, while brackets are open (`getCurrentUser(\\n req,\\n)`)
|
|
542
|
+
or a line starts with an operator (`getSession(req)\\n ?? fallback`)."""
|
|
543
|
+
lines = window.split("\n")
|
|
544
|
+
out = [lines[0]]
|
|
545
|
+
depth = _bracket_depth(lines[0])
|
|
546
|
+
for line in lines[1:]:
|
|
547
|
+
if depth <= 0 and not line.lstrip().startswith(_CONTINUATION):
|
|
548
|
+
break
|
|
549
|
+
out.append(line)
|
|
550
|
+
depth += _bracket_depth(line)
|
|
551
|
+
return "\n".join(out)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _bracket_depth(line: str) -> int:
|
|
555
|
+
return sum(line.count(c) for c in "([{") - sum(line.count(c) for c in ")]}")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def session_gate(text: str, code: str | None = None) -> str | None:
|
|
559
|
+
"""The gate line if the handler in ``text`` rejects a request whose
|
|
560
|
+
session lookup comes back empty; else None. ``code`` is ``text`` already
|
|
561
|
+
masked (same offsets), when the caller has it."""
|
|
562
|
+
text = text[:_MAX_SPAN_CHARS]
|
|
563
|
+
code = mask(text) if code is None else code[:_MAX_SPAN_CHARS]
|
|
564
|
+
body = handler_body(text, code)
|
|
565
|
+
if body is None:
|
|
566
|
+
return None
|
|
567
|
+
body_open, body_close = body
|
|
568
|
+
pairs = _matches(code)
|
|
569
|
+
tracked: dict[str, int] = {}
|
|
570
|
+
needs_member: set[str] = set()
|
|
571
|
+
unsafe_from = _first_unsafe(code, body_open, body_close)
|
|
572
|
+
for decl in _DECLARATION.finditer(code, body_open, body_close):
|
|
573
|
+
window = code[decl.end() : min(body_close, decl.end() + 400)].split(";", 1)[0]
|
|
574
|
+
rest = window.split("\n", 1)[0]
|
|
575
|
+
lookup = _SESSION_CALL.search(rest)
|
|
576
|
+
if lookup is None or not _top_level(code, body_open, body_close, decl.start(), pairs):
|
|
577
|
+
continue
|
|
578
|
+
prefix = _RECEIVER_PREFIX if rest[lookup.start()] == "." else _LOOKUP_PREFIX
|
|
579
|
+
if prefix.fullmatch(rest, 0, lookup.start()) is None:
|
|
580
|
+
continue # `!req.user`, `typeof req.user`, `findFirst({ authorId: req.user.id })`
|
|
581
|
+
if lookup.group(0).endswith("(") and not _AWAITED.match(rest):
|
|
582
|
+
continue # `!req.user`, `typeof req.user`, `findFirst({ authorId: req.user.id })`
|
|
583
|
+
if _COMPARISON.search(_initializer(window), lookup.end()):
|
|
584
|
+
continue # `const isAnon = req.user == null`: a boolean about the lookup, not the lookup
|
|
585
|
+
if _FALLBACK.search(_initializer(window)):
|
|
586
|
+
continue # `getSession(c) ?? { user: guest }`: never empty, so the gate proves nothing
|
|
587
|
+
if decl.group("names") and "=" in decl.group("names"):
|
|
588
|
+
continue # `const { user = guest } = await getSession(req)`: a default, never empty
|
|
589
|
+
if decl.group("name"):
|
|
590
|
+
names = [decl.group("name")]
|
|
591
|
+
if _ALWAYS_OBJECT.search(_initializer(window)):
|
|
592
|
+
needs_member.add(decl.group("name"))
|
|
593
|
+
else:
|
|
594
|
+
names = _bindings(decl.group("names") or "")
|
|
595
|
+
for name in names:
|
|
596
|
+
tracked.setdefault(name, decl.end())
|
|
597
|
+
for count, match in enumerate(_IF.finditer(code, body_open, body_close)):
|
|
598
|
+
if count >= _MAX_IFS:
|
|
599
|
+
break
|
|
600
|
+
open_paren = match.end() - 1
|
|
601
|
+
close = pairs.get(open_paren)
|
|
602
|
+
if close is None or close - open_paren > _MAX_CONDITION_CHARS:
|
|
603
|
+
continue
|
|
604
|
+
condition = _GATE_CONDITION.match(code[open_paren + 1 : close])
|
|
605
|
+
if condition is None:
|
|
606
|
+
continue
|
|
607
|
+
name = condition.group("name")
|
|
608
|
+
if name is not None:
|
|
609
|
+
if name not in tracked or tracked[name] > match.start():
|
|
610
|
+
continue
|
|
611
|
+
members = [m.lower() for m in _MEMBER_NAME.findall(condition.group("members") or "")]
|
|
612
|
+
if any(m not in _IDENTITY_MEMBERS | _ENVELOPE_MEMBERS for m in members) or (
|
|
613
|
+
members and members[-1] in _ENVELOPE_MEMBERS
|
|
614
|
+
):
|
|
615
|
+
continue # `!session.user.isGuest`: a property of the identity, not its absence
|
|
616
|
+
if members and members[-1] in _SESSION_ID_MEMBERS and not set(members[:-1]) & _USER_MEMBERS:
|
|
617
|
+
continue # `!session.id`: anonymous visitors have a session id too
|
|
618
|
+
if name in needs_member and not members:
|
|
619
|
+
continue # `!res` on supabase's `{ data, error }`: never falsy
|
|
620
|
+
if _reassigned(name, code[tracked[name] : match.start()]):
|
|
621
|
+
continue
|
|
622
|
+
elif _DIRECT_ASSIGN.search(code, body_open, match.start()):
|
|
623
|
+
continue # `req.user = req.user || { id: 0 }` before the check
|
|
624
|
+
if not _top_level(code, body_open, body_close, match.start(), pairs):
|
|
625
|
+
continue
|
|
626
|
+
if not _starts_statement(code, match.start(), pairs):
|
|
627
|
+
continue # `else if`, or the body of a braceless `if` / loop
|
|
628
|
+
# Nothing may act for an anonymous caller before the check.
|
|
629
|
+
if match.start() > unsafe_from:
|
|
630
|
+
break # every later check comes after the same unsafe call
|
|
631
|
+
branch = _then_branch(code, close + 1, pairs)
|
|
632
|
+
if branch is not None and _rejects(branch):
|
|
633
|
+
line_start = code.rfind("\n", 0, match.start()) + 1
|
|
634
|
+
line_end = code.find("\n", match.start())
|
|
635
|
+
return text[line_start : line_end if line_end >= 0 else len(text)].strip()
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
__all__ = ["handler_body", "mask", "session_gate"]
|