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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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"]