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.
- attackmap_analyzer_rust/__init__.py +3 -0
- attackmap_analyzer_rust/analyzer.py +797 -0
- attackmap_analyzer_rust/frameworks.py +163 -0
- attackmap_analyzer_rust/route_auth.py +446 -0
- attackmap_analyzer_rust/routing.py +909 -0
- attackmap_analyzer_rust-0.2.0.dist-info/METADATA +115 -0
- attackmap_analyzer_rust-0.2.0.dist-info/RECORD +11 -0
- attackmap_analyzer_rust-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_rust-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_rust-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_rust-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|