attackmap-analyzer-rust 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ """Routes of frameworks that aren't built as one router chain (#2).
2
+
3
+ - **warp** — ``warp::path!("users" / u32)`` is one route (``/users/{u32}``).
4
+ Its method is the ``warp::get()`` filter combined with it right before
5
+ (``warp::get().and(warp::path!(..))``) or after (``.and(warp::post())``);
6
+ without one it is ``ANY``. A ``path!`` that ends in ``..`` is a prefix
7
+ filter, and one composed under another filter (``x.and(warp::path!(..))``)
8
+ has a prefix that isn't visible, so neither is emitted.
9
+ - **tide** — ``app.at("/x").at("/y").get(h).post(h2)``. A receiver bound with
10
+ ``let mut api = app.at("/api");`` or nested with ``app.at("/api").nest(api)``
11
+ adds its path (same file, bounded depth).
12
+
13
+ Only route paths and methods are extracted: neither framework declares auth in
14
+ a way that can be read from one call site, so these routes stay ``unknown``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import bisect
20
+ import re
21
+ from dataclasses import dataclass
22
+
23
+ from . import route_auth as ra
24
+ from .routing import HTTP_VERBS, Ref, join_path, literal, parse_ref
25
+
26
+ _MAX_SEGMENTS = 64
27
+ _MAX_LINKS = 64
28
+ _MAX_DEPTH = 8
29
+
30
+
31
+ @dataclass
32
+ class FoundRoute:
33
+ path: str
34
+ method: str
35
+ offset: int
36
+ handler: Ref | None = None
37
+
38
+
39
+ # ---------- warp ----------
40
+
41
+ _WARP_PATH = re.compile(r"(?<![\w.:])(?:warp\s*::\s*)?path!\s*\(")
42
+ _WARP_SEGMENT = re.compile(r'\s*(?:"([^"\\\n]*)"|(\.\.)|([A-Za-z_][\w:]*(?:\s*<[^<>()]{0,80}>)?))\s*')
43
+ _WARP_METHOD = re.compile(
44
+ r"\s*warp\s*::\s*(?:filters\s*::\s*method\s*::\s*)?(get|post|put|delete|patch|head|options)\s*\(\s*\)\s*$"
45
+ )
46
+ _WARP_METHOD_BEFORE = re.compile(
47
+ r"warp\s*::\s*(get|post|put|delete|patch|head|options)\s*\(\s*\)\s*\.\s*and\s*\(\s*$"
48
+ )
49
+ _AND_BEFORE = re.compile(r"\.\s*and\s*\(\s*$")
50
+
51
+
52
+ def _warp_path(content: str, open_idx: int, close: int) -> str | None:
53
+ segments: list[str] = []
54
+ pos = open_idx + 1
55
+ while pos < close and len(segments) < _MAX_SEGMENTS:
56
+ seg = _WARP_SEGMENT.match(content, pos, close)
57
+ if not seg or seg.end() == pos or seg.group(2):
58
+ return None # unparsable, or a `..` prefix filter
59
+ if seg.group(1) is not None:
60
+ segments.append(seg.group(1).strip("/"))
61
+ else:
62
+ segments.append("{" + seg.group(3).split("::")[-1].split("<")[0].strip() + "}")
63
+ pos = seg.end()
64
+ if pos < close:
65
+ if content[pos] != "/":
66
+ return None
67
+ pos += 1
68
+ if pos < close or not segments:
69
+ return None
70
+ return "/" + "/".join(s for s in segments if s)
71
+
72
+
73
+ def warp_routes(content: str) -> list[FoundRoute]:
74
+ found = []
75
+ for match in _WARP_PATH.finditer(content):
76
+ open_idx = match.end() - 1
77
+ close = ra.matching_close(content, open_idx)
78
+ if close < 0:
79
+ continue
80
+ path = _warp_path(content, open_idx, close)
81
+ if path is None:
82
+ continue
83
+ before = content[max(0, match.start() - 120) : match.start()]
84
+ method = None
85
+ method_before = _WARP_METHOD_BEFORE.search(before)
86
+ if method_before:
87
+ method = method_before.group(1).upper()
88
+ elif _AND_BEFORE.search(before):
89
+ continue # under another filter whose prefix isn't visible
90
+ handler = None
91
+ for link in ra.parse_chain(content, close + 1, _MAX_LINKS):
92
+ if link.name == "and" and method is None:
93
+ inner = _WARP_METHOD.match(content, link.open + 1, link.close)
94
+ if inner:
95
+ method = inner.group(1).upper()
96
+ elif link.name in ("and_then", "map", "then") and handler is None:
97
+ handler = parse_ref(content, link.open + 1, link.close)
98
+ found.append(FoundRoute(path, method or "ANY", match.start(), handler))
99
+ return found
100
+
101
+
102
+ # ---------- tide ----------
103
+
104
+ _TIDE_AT = re.compile(r"\b([A-Za-z_]\w*)\s*\.\s*at\s*\(")
105
+ _TIDE_VERBS = frozenset(HTTP_VERBS) | {"all"}
106
+
107
+
108
+ def tide_routes(content: str) -> list[FoundRoute]:
109
+ entries: list[tuple[int, str, str, list[tuple[str, ra.Link]]]] = []
110
+ bindings: dict[str, list[tuple[int, str, str]]] = {} # `let api = app.at("/api")`
111
+ nests: dict[str, list[tuple[int, str, str]]] = {} # `app.at("/api").nest(api)`
112
+ for match in _TIDE_AT.finditer(content):
113
+ close = ra.matching_close(content, match.end() - 1)
114
+ if close < 0:
115
+ continue
116
+ path = literal(content, match.end(), close)
117
+ if path is None:
118
+ continue
119
+ verbs: list[tuple[str, ra.Link]] = []
120
+ for link in ra.parse_chain(content, close + 1, _MAX_LINKS):
121
+ if link.name == "at":
122
+ more = literal(content, link.open + 1, link.close)
123
+ if more is None:
124
+ break
125
+ path = join_path(path, more)
126
+ elif link.name in _TIDE_VERBS:
127
+ verbs.append((link.name, link))
128
+ elif link.name == "nest":
129
+ ref = parse_ref(content, link.open + 1, link.close)
130
+ if ref is not None and not ref.call:
131
+ nests.setdefault(ref.name, []).append((match.start(), match.group(1), path))
132
+ let = ra._LET_NAME.search(content[max(0, match.start() - 200) : match.start()])
133
+ if let and not verbs:
134
+ bindings.setdefault(let.group(1), []).append((match.start(), match.group(1), path))
135
+ if verbs:
136
+ entries.append((match.start(), match.group(1), path, verbs))
137
+
138
+ # Bindings are appended in source order, so their offsets are sorted.
139
+ binding_starts = {name: [b[0] for b in bound] for name, bound in bindings.items()}
140
+
141
+ def prefix(name: str, offset: int, depth: int) -> str:
142
+ if depth > _MAX_DEPTH:
143
+ return ""
144
+ bound = bindings.get(name, [])
145
+ index = bisect.bisect_left(binding_starts.get(name, []), offset)
146
+ if index > 0:
147
+ at, receiver, path = bound[index - 1]
148
+ elif len(nests.get(name, [])) == 1:
149
+ at, receiver, path = nests[name][0]
150
+ else:
151
+ return ""
152
+ return join_path(prefix(receiver, at, depth + 1), path)
153
+
154
+ found = []
155
+ for offset, receiver, path, verbs in entries:
156
+ full = join_path(prefix(receiver, offset, 0), path)
157
+ for verb, link in verbs:
158
+ method = "ANY" if verb == "all" else verb.upper()
159
+ found.append(FoundRoute(full, method, offset, parse_ref(content, link.open + 1, link.close)))
160
+ return found
161
+
162
+
163
+ __all__ = ["FoundRoute", "tide_routes", "warp_routes"]
@@ -0,0 +1,446 @@
1
+ """Route-level auth resolution for the AttackMap#256 contract.
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
+ resolution (which can't read Rust), so this only declares what the source
7
+ verifiably says.
8
+
9
+ - **axum** — a ``Router::new()...`` chain is read in order: ``.layer(L)`` and
10
+ ``.route_layer(L)`` wrap the routes added *before* them, so a route added
11
+ after the layer is outside it. Routers bound with ``let name = ...`` or
12
+ returned from ``fn name() -> Router`` are followed through
13
+ ``.merge(name)`` / ``.nest("/x", name)`` into the router that mounts them,
14
+ across files, and pick up that router's later layers too.
15
+ - **actix-web** — ``.wrap(L)`` on ``App::new()`` / ``web::scope(...)`` wraps
16
+ every service in it, nested scopes included. ``.service(handler)``
17
+ links attribute-macro handlers to the scope that mounts them.
18
+ - **Extractor / request guards** (axum, actix, rocket, poem, loco) — a handler
19
+ argument whose type is named like an auth extractor (``Claims``, ``AuthUser``,
20
+ ``CurrentUser``, ``Jwt<..>``, ...) rejects anonymous callers when its
21
+ ``FromRequestParts`` / ``FromRequest`` impl is in the repo and can fail, or
22
+ when it is a known library extractor (``BearerAuth``, ``Identity``, loco
23
+ ``JWT``).
24
+ ``Option<AuthUser>`` explicitly admits them: when no auth layer applies and
25
+ every layer on the path is a known non-auth one, the route is
26
+ ``anonymous``.
27
+
28
+ A layer is a guard when its constructor or ``from_fn`` function names auth
29
+ (``middleware::from_fn(require_auth)``, ``RequireAuthorizationLayer::bearer``,
30
+ ``ValidateRequestHeaderLayer::bearer``, ``login_required!(..)``,
31
+ ``HttpAuthentication::bearer(..)``). axum-login's ``AuthManagerLayer`` and
32
+ ``AuthSession`` only load the user, so they don't count. Anything else stays
33
+ ``unknown``.
34
+
35
+ Linear: brackets are matched in one cached pass per file, argument lists are
36
+ split by jumping over nested closures, and lookups are bounded. Router chains,
37
+ mounts and prefixes live in ``routing.py``; this module holds the shared
38
+ bracket scanner, layer and extractor classification, and ``resolve``.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import functools
44
+ import re
45
+ from dataclasses import dataclass, field
46
+
47
+ REQUIRED = "required"
48
+ ANONYMOUS = "anonymous"
49
+ UNKNOWN = "unknown"
50
+
51
+ _EVIDENCE_MAX = 200
52
+ _ARG_SCAN = 4000
53
+ # Router mount levels followed upward before giving up.
54
+ _MAX_DEPTH = 16
55
+ # Mount points of one router considered before giving up.
56
+ _MAX_REFS = 16
57
+
58
+
59
+ def clip(text: str) -> str:
60
+ text = " ".join(text.split())
61
+ return text if len(text) <= _EVIDENCE_MAX else text[: _EVIDENCE_MAX - 1] + "…"
62
+
63
+
64
+ # ---------- Brackets ----------
65
+
66
+ # Strings ("..", r#".."#), char literals ('a', '\n') and comments are
67
+ # skipped. A `'` that doesn't close a char literal is a lifetime.
68
+ _SCAN_TOKEN = re.compile(r"\"|\br(#*)\"|'(?:[^'\\\n]|\\[^\n]{1,10})'|//|/\*|[()\[\]{}]")
69
+ _ARG_TOKEN = re.compile(r"\"|\br(#*)\"|'(?:[^'\\\n]|\\[^\n]{1,10})'|//|/\*|[(\[{,]")
70
+ _STRING_BODY = re.compile(r'[^"\\]*(?:\\.[^"\\]*)*"', re.DOTALL)
71
+ _OPEN = "([{"
72
+
73
+
74
+ def _skip(content: str, match: re.Match, limit: int) -> int:
75
+ """Offset after the string/comment ``match`` opens (or after the token)."""
76
+ token = match.group()
77
+ pos = match.end()
78
+ if token == '"':
79
+ body = _STRING_BODY.match(content, pos, limit)
80
+ return body.end() if body else limit
81
+ if match.group(1) is not None: # raw string r#"..."#
82
+ end = content.find('"' + match.group(1), pos, limit)
83
+ return limit if end < 0 else end + 1 + len(match.group(1))
84
+ if token == "//":
85
+ nl = content.find("\n", pos, limit)
86
+ return limit if nl < 0 else nl
87
+ if token == "/*":
88
+ end = content.find("*/", pos, limit)
89
+ return limit if end < 0 else end + 2
90
+ return pos
91
+
92
+
93
+ @functools.lru_cache(maxsize=4)
94
+ def bracket_pairs(content: str) -> dict[int, int]:
95
+ """Opening-bracket offset -> closing-bracket offset, in one forward pass."""
96
+ pairs: dict[int, int] = {}
97
+ stack: list[int] = []
98
+ pos = 0
99
+ n = len(content)
100
+ while pos < n:
101
+ match = _SCAN_TOKEN.search(content, pos)
102
+ if not match:
103
+ break
104
+ token = match.group()
105
+ if len(token) == 1 and token in _OPEN:
106
+ stack.append(match.start())
107
+ pos = match.end()
108
+ elif len(token) == 1 and token in ")]}":
109
+ if stack:
110
+ pairs[stack.pop()] = match.start()
111
+ pos = match.end()
112
+ else:
113
+ pos = _skip(content, match, n)
114
+ return pairs
115
+
116
+
117
+ def matching_close(content: str, open_idx: int) -> int:
118
+ if open_idx < 0:
119
+ return -1
120
+ return bracket_pairs(content).get(open_idx, -1)
121
+
122
+
123
+ def split_args(content: str, open_idx: int, close: int) -> list[tuple[int, int]]:
124
+ """``(start, end)`` of each top-level argument of the bracket at
125
+ ``open_idx``; nested brackets and strings are jumped over."""
126
+ pairs = bracket_pairs(content)
127
+ args: list[tuple[int, int]] = []
128
+ start = pos = open_idx + 1
129
+ while pos < close:
130
+ match = _ARG_TOKEN.search(content, pos, close)
131
+ if not match:
132
+ break
133
+ token = match.group()
134
+ if token == ",":
135
+ args.append((start, match.start()))
136
+ start = pos = match.end()
137
+ elif len(token) == 1 and token in _OPEN:
138
+ pos = max(match.end(), pairs.get(match.start(), close - 1) + 1)
139
+ else:
140
+ pos = _skip(content, match, close)
141
+ if content[start:close].strip():
142
+ args.append((start, close))
143
+ return args
144
+
145
+
146
+ # Whitespace and (bounded) comments may sit between chain links.
147
+ _GAP = r"(?:\s|//[^\n]{0,500}|/\*(?:[^*]|\*(?!/)){0,500}\*/)*"
148
+ _CHAIN_LINK = re.compile(_GAP + r"\.\s*(\w+)\s*(?:::\s*<[^<>()]*>\s*)?\(")
149
+
150
+
151
+ @dataclass
152
+ class Link:
153
+ name: str
154
+ start: int # offset of the `.`
155
+ open: int
156
+ close: int
157
+
158
+
159
+ def parse_chain(content: str, idx: int, limit: int = 4096) -> list[Link]:
160
+ """``.name(args)`` calls chained after ``idx`` (at most ``limit``)."""
161
+ links: list[Link] = []
162
+ while len(links) < limit:
163
+ match = _CHAIN_LINK.match(content, idx)
164
+ if not match:
165
+ break
166
+ open_idx = match.end() - 1
167
+ close = matching_close(content, open_idx)
168
+ if close < 0:
169
+ break
170
+ links.append(Link(match.group(1), content.rindex(".", match.start(), match.start(1)), open_idx, close))
171
+ idx = close + 1
172
+ return links
173
+
174
+
175
+ def _text(content: str, start: int, end: int) -> str:
176
+ return content[start : min(end, start + _ARG_SCAN)]
177
+
178
+
179
+ # ---------- Layers ----------
180
+
181
+ _LAYER_AUTH = re.compile(
182
+ r"auth(?!or)|jwt|bearer|login_required|permission_required|require_?(?:user|login)|api_?key",
183
+ re.IGNORECASE,
184
+ )
185
+ # Set-up layers that load a user or a session but never reject a request.
186
+ _LAYER_NOT_AUTH = re.compile(r"AuthManagerLayer|AuthSessionLayer|IdentityMiddleware|SessionManagerLayer")
187
+ _LAYER_BENIGN = re.compile(
188
+ r"^(?:TraceLayer|CorsLayer|Cors|CompressionLayer|Compress|DecompressionLayer|TimeoutLayer"
189
+ r"|RequestBodyLimitLayer|DefaultBodyLimit|CatchPanicLayer|PropagateRequestIdLayer"
190
+ r"|SetRequestIdLayer|Extension|AddExtensionLayer|HandleErrorLayer|ConcurrencyLimitLayer"
191
+ r"|BufferLayer|NormalizePathLayer|NormalizePath|SetResponseHeaderLayer|SetSensitiveHeadersLayer"
192
+ r"|SetSensitiveRequestHeadersLayer|CookieManagerLayer|SessionManagerLayer|AuthManagerLayer"
193
+ r"|AuthManagerLayerBuilder|IdentityMiddleware|SessionMiddleware|Logger|DefaultHeaders"
194
+ r"|ErrorHandlers|GovernorLayer|Governor|RateLimitLayer|LoadShedLayer|ServiceBuilder)$"
195
+ )
196
+ _CALLEE = re.compile(r"\s*([A-Za-z_][\w:]*!?)")
197
+ _FROM_FN = re.compile(r"(?:\w+::)*(from_fn|from_fn_with_state|from_extractor|from_extractor_with_state)$")
198
+ _TURBOFISH = re.compile(r"::\s*<\s*([\w:]+)")
199
+
200
+
201
+ @dataclass
202
+ class Layer:
203
+ kind: str # "auth" | "benign" | "other"
204
+ name: str = ""
205
+ evidence: str = ""
206
+
207
+
208
+ def classify_layer(content: str, start: int, end: int, evidence: str) -> Layer:
209
+ """Classify the layer/middleware expression ``content[start:end]``."""
210
+ end = min(end, start + _ARG_SCAN)
211
+ callee = _CALLEE.match(content, start, end)
212
+ if not callee:
213
+ return Layer("other")
214
+ path = callee.group(1).rstrip(":")
215
+ rest = callee.end()
216
+ from_fn = _FROM_FN.search(path.rstrip("!"))
217
+ if from_fn:
218
+ # The function (or extractor type) is what authenticates, not the state.
219
+ if from_fn.group(1).startswith("from_extractor"):
220
+ target = _TURBOFISH.search(content, callee.start(1), min(end, rest + 200))
221
+ target_name = target.group(1) if target else ""
222
+ else:
223
+ paren = content.find("(", rest, end)
224
+ close = matching_close(content, paren)
225
+ args = split_args(content, paren, close) if paren >= 0 and close > 0 else []
226
+ index = 1 if from_fn.group(1) == "from_fn_with_state" else 0
227
+ target_name = content[args[index][0] : args[index][1]].strip() if len(args) > index else ""
228
+ if _LAYER_AUTH.search(target_name) and not _LAYER_NOT_AUTH.search(target_name):
229
+ return Layer("auth", f"{from_fn.group(1)}({target_name.split('::')[-1]})", evidence)
230
+ return Layer("other")
231
+ segments = path.rstrip("!").split("::")
232
+ type_name = next((s for s in reversed(segments) if s[:1].isupper()), segments[-1])
233
+ if path.startswith("ServiceBuilder"):
234
+ inner = [l for l in parse_chain(content, content.find(")", rest, end) + 1, 64) if l.name == "layer"]
235
+ kinds = [classify_layer(content, l.open + 1, l.close, evidence) for l in inner]
236
+ auth = [k for k in kinds if k.kind == "auth"]
237
+ if auth:
238
+ return Layer("auth", auth[0].name, evidence)
239
+ return Layer("benign" if all(k.kind == "benign" for k in kinds) else "other")
240
+ if _LAYER_NOT_AUTH.search(path):
241
+ return Layer("benign")
242
+ if _LAYER_AUTH.search(path):
243
+ return Layer("auth", path, evidence)
244
+ if _LAYER_BENIGN.match(type_name):
245
+ return Layer("benign")
246
+ return Layer("other")
247
+
248
+
249
+ # ---------- Handler extractors ----------
250
+
251
+ _FN_DECL = re.compile(r"\bfn\s+(\w+)\s*(?:<[^<>{}()]*>\s*)?\(")
252
+ _GUARD_TYPE = re.compile(
253
+ r"auth(?!or)|claims|jwt|bearer|current_?user|logged_?(?:in|user)|session_?user|identity|api_?key|admin",
254
+ re.IGNORECASE,
255
+ )
256
+ # Extractors that load an optional user without rejecting.
257
+ _NOT_GUARD_TYPE = re.compile(r"^(?:AuthSession|Session|TypedHeader|HeaderMap|Authorization)$")
258
+ # `MaybeAuthUser`, `OptionalUser`, `OptAuth`: named as admitting anonymous callers.
259
+ _OPTIONAL_TYPE = re.compile(r"(?:Maybe|Optional|Opt)(?=[A-Z_])")
260
+ _WORD = re.compile(r"\w+")
261
+ _TYPE_HEAD = re.compile(r"\s*(?:&\s*)?(?:'\w+\s+)?(?:mut\s+)?([\w:]+)\s*(<)?")
262
+
263
+
264
+ @dataclass
265
+ class Signature:
266
+ name: str
267
+ # (type, evidence, binding names in the parameter pattern)
268
+ guards: list[tuple[str, str, frozenset[str]]] = field(default_factory=list)
269
+ optional: list[tuple[str, str, frozenset[str]]] = field(default_factory=list)
270
+
271
+ def without_params(self, names: set[str]) -> "Signature":
272
+ """Drop parameters bound from the path (rocket `<id>`), which aren't
273
+ request guards."""
274
+ if not names:
275
+ return self
276
+ return Signature(
277
+ self.name,
278
+ [g for g in self.guards if not g[2] & names],
279
+ [g for g in self.optional if not g[2] & names],
280
+ )
281
+
282
+
283
+ def _param_type(content: str, start: int, end: int) -> tuple[str, str] | None:
284
+ """``(pattern, type)`` of a ``pat: Type`` parameter."""
285
+ text = content[start : min(end, start + 400)]
286
+ depth = 0
287
+ for i, ch in enumerate(text):
288
+ if ch in "([{<":
289
+ depth += 1
290
+ elif ch in ")]}>":
291
+ depth -= 1
292
+ elif ch == ":" and depth == 0 and text[i + 1 : i + 2] != ":" and text[i - 1 : i] != ":":
293
+ return text[:i].strip(), text[i + 1 :].strip()
294
+ return None
295
+
296
+
297
+ def _classify_type(type_text: str) -> tuple[str, str] | None:
298
+ """("guard" | "optional", type name) for an auth extractor type."""
299
+ head = _TYPE_HEAD.match(type_text)
300
+ if not head:
301
+ return None
302
+ name = head.group(1).split("::")[-1]
303
+ if name == "Option" and head.group(2):
304
+ inner = _classify_type(type_text[head.end() :])
305
+ return ("optional", inner[1]) if inner and inner[0] == "guard" else None
306
+ if name in ("Extension", "State", "Json", "Form", "Query", "Path", "Data", "web"):
307
+ return None
308
+ if _NOT_GUARD_TYPE.match(name) or _OPTIONAL_TYPE.match(name) or not _GUARD_TYPE.search(name):
309
+ return None
310
+ return "guard", name
311
+
312
+
313
+ def fn_signatures(content: str) -> dict[str, list[tuple[int, Signature]]]:
314
+ """Every ``fn name(params)`` with the auth extractors among its params."""
315
+ found: dict[str, list[tuple[int, Signature]]] = {}
316
+ for match in _FN_DECL.finditer(content):
317
+ open_idx = match.end() - 1
318
+ close = matching_close(content, open_idx)
319
+ if close < 0:
320
+ continue
321
+ sig = Signature(match.group(1))
322
+ for start, end in split_args(content, open_idx, close)[:32]:
323
+ param = _param_type(content, start, end)
324
+ if param is None:
325
+ continue
326
+ pattern, type_text = param
327
+ kind = _classify_type(type_text)
328
+ if kind is None:
329
+ continue
330
+ evidence = clip(f"{pattern}: {type_text}")
331
+ names = frozenset(_WORD.findall(pattern[:100]))
332
+ (sig.guards if kind[0] == "guard" else sig.optional).append((kind[1], evidence, names))
333
+ found.setdefault(sig.name, []).append((match.start(), sig))
334
+ return found
335
+
336
+
337
+ # ---------- Extractor impls ----------
338
+
339
+ # `impl<S> FromRequestParts<S> for AuthUser` / `impl FromRequest for Identity`
340
+ # / `impl<'r> FromRequest<'r> for User`: the extractor is defined in the repo.
341
+ # (`OptionalFromRequestParts`, which backs `Option<T>`, doesn't count.)
342
+ _EXTRACTOR_IMPL = re.compile(
343
+ r"\bimpl\b[^{};]{0,300}?\b(?:FromRequestParts|FromRequest)\b"
344
+ r"[^{};]{0,200}?\bfor\s+(?:[A-Za-z_]\w*::){0,8}([A-Za-z_]\w*)"
345
+ )
346
+ _IMPL_BODY = re.compile(r"[^{};]{0,400}\{")
347
+ # An extractor that can't fail never rejects a request.
348
+ _INFALLIBLE = re.compile(r"\btype\s+(?:Rejection|Error)\s*=\s*(?:[\w:]*::)?Infallible\b")
349
+ # What makes an impl an auth check: it reads credentials (an auth header, a
350
+ # cookie, the session, a token) or rejects with 401/403. A rejecting
351
+ # extractor that merely has an auth-like name (`AdminFilters`) isn't one.
352
+ _IMPL_AUTH_EVIDENCE = re.compile(
353
+ r"\bAUTHORIZATION\b|\bAuthorization\b|\bBearer\b|\bCOOKIE\b|\bCookie(?:Jar)?\b|\bcookies?\s*\(|\bcookie\s*\("
354
+ r"|\bSession\b|\bsession\b|\b(?:jwt|token|api_?key)\b|\bdecode\s*(?:::\s*<[^<>]{0,80}>\s*)?\("
355
+ r"|\bIdentity\b|\bUNAUTHORIZED\b|\bUnauthorized\b|\bFORBIDDEN\b|\bForbidden\b|\b40[13]\b",
356
+ )
357
+
358
+ # ...and it must have a way to reject a request that lacks them. An extractor
359
+ # that only parses credentials when present (`MaybeAuthUser`:
360
+ # `Ok(Self(headers.get(AUTHORIZATION).map(..).transpose()?))`) admits anonymous
361
+ # callers, so a bare `?` doesn't count.
362
+ _IMPL_REJECTS = re.compile(
363
+ r"\.\s*ok_or(?:_else)?\s*\("
364
+ r"|\breturn\s+(?:[\w:]+\s*\(\s*)?Err\s*\("
365
+ r"|\bErr\s*\([^;]{0,200}?\b(?:UNAUTHORIZED|Unauthorized|Unauthenticated|FORBIDDEN|Forbidden|40[13])\b"
366
+ r"|\bOutcome\s*::\s*(?:Error|Failure|Forward)\s*\([^;]{0,120}?\b(?:Unauthorized|Forbidden)\b"
367
+ r"|\bNone\s*=>\s*(?:return\s+)?[\w:]*(?:Err|Error|Failure)\b"
368
+ )
369
+ # A required typed header extraction rejects when the header is missing
370
+ # (`parts.extract::<TypedHeader<Authorization<Bearer>>>().await.map_err(..)?`).
371
+ _REQUIRED_TYPED_HEADER = re.compile(r"(?<!Option<)\bTypedHeader\s*<\s*Authorization\b")
372
+
373
+ # Library extractors that reject anonymous callers, whose impl lives outside
374
+ # the repo: actix-web-httpauth, actix-identity, loco.
375
+ LIBRARY_AUTH_EXTRACTORS = frozenset({"BearerAuth", "BasicAuth", "Identity", "JWT", "JWTWithUser"})
376
+
377
+
378
+ def extractor_impls(content: str) -> dict[str, list[bool]]:
379
+ """Extractor type -> one flag per ``FromRequest(Parts)`` impl in
380
+ ``content``: True when the impl can reject, its body checks credentials
381
+ (``_IMPL_AUTH_EVIDENCE``) and it rejects when they're missing
382
+ (``_IMPL_REJECTS``)."""
383
+ found: dict[str, list[bool]] = {}
384
+ for match in _EXTRACTOR_IMPL.finditer(content):
385
+ body = _IMPL_BODY.match(content, match.end())
386
+ guard = False
387
+ if body:
388
+ close = matching_close(content, body.end() - 1)
389
+ end = min(close if close > 0 else body.end(), body.end() + _ARG_SCAN)
390
+ guard = (
391
+ _INFALLIBLE.search(content, body.end(), end) is None
392
+ and _IMPL_AUTH_EVIDENCE.search(content, body.end(), end) is not None
393
+ and (
394
+ _IMPL_REJECTS.search(content, body.end(), end) is not None
395
+ or _REQUIRED_TYPED_HEADER.search(content, body.end(), end) is not None
396
+ )
397
+ )
398
+ found.setdefault(match.group(1), []).append(guard)
399
+ return found
400
+
401
+
402
+ # ---------- Router chains (shared patterns; see routing.py) ----------
403
+
404
+ _AXUM_START = re.compile(r"\bRouter\s*(?:::\s*<[^<>]*>\s*)?::\s*new\s*\(\s*\)")
405
+ _LET_NAME = re.compile(r"let\s+(?:mut\s+)?(\w+)\s*(?::[^=;{}]{0,120})?=\s*$")
406
+ _FN_RETURNING = re.compile(r"\bfn\s+(\w+)\s*(?:<[^<>{}]{0,120}>)?\s*\([^{};]{0,400}\)[^{};]{0,200}\{\s*$")
407
+ _HANDLER_CALL = re.compile(r"\b(?:get|post|put|delete|patch|head|options|trace|any)\s*\(\s*([\w:]+)\s*\)")
408
+
409
+
410
+ @dataclass
411
+ class Resolution:
412
+ auth: str = UNKNOWN
413
+ guards: list[str] = field(default_factory=list)
414
+ evidence: str | None = None
415
+
416
+
417
+ @dataclass
418
+ class PathState:
419
+ """Layers on one path from a route up to the app."""
420
+
421
+ guards: list[Layer] = field(default_factory=list)
422
+ clean: bool = True # every layer on the path is a known non-auth one
423
+ known: bool = True # the whole path was followed
424
+
425
+
426
+ def resolve(path: PathState, sig: Signature | None) -> Resolution:
427
+ guards = [(layer.name, layer.evidence) for layer in path.guards]
428
+ if sig is not None:
429
+ guards += [(name, ev) for name, ev, _ in sig.guards]
430
+ if guards:
431
+ names = list(dict.fromkeys(name for name, _ in guards))
432
+ return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(ev for _, ev in guards))))
433
+ if sig is not None and sig.optional and path.known and path.clean:
434
+ return Resolution(ANONYMOUS, [], clip("; ".join(ev for _, ev, _ in sig.optional)))
435
+ return Resolution()
436
+
437
+
438
+ def handlers_by_method(content: str, start: int, end: int) -> dict[str, str]:
439
+ """``get(h).post(h2)`` -> {"GET": "h", "POST": "h2"} within an axum
440
+ ``.route(path, ...)`` call."""
441
+ text = content[start : min(end, start + _ARG_SCAN)]
442
+ out: dict[str, str] = {}
443
+ for match in _HANDLER_CALL.finditer(text):
444
+ method = match.group(0).split("(", 1)[0].strip().split("::")[-1].upper()
445
+ out.setdefault(method, match.group(1))
446
+ return out