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,797 @@
1
+ """Rust ecosystem analyzer for AttackMap.
2
+
3
+ Coverage (v0.1):
4
+ - Web frameworks: axum, actix-web, rocket, poem, salvo, loco, warp, tide (routes
5
+ with the prefixes of the routers that mount them; entrypoints)
6
+ - Databases: sqlx, diesel, sea-orm, mongodb, redis, rusqlite, tokio-postgres
7
+ - Auth crates: jsonwebtoken, argon2/bcrypt/scrypt, oauth2, tower-http auth, axum-login,
8
+ actix-identity, tower-sessions
9
+ - HTTP clients (external calls): reqwest, hyper-client, isahc, surf, ureq
10
+ - Secrets: std::env::var, dotenv/dotenvy, secrecy crate
11
+ - Entrypoints: axum::serve, HttpServer::new(...).run, rocket::build, tide::new
12
+ - Workspace/crate names from Cargo.toml for service-name hints
13
+
14
+ Emits Signal v2 fields (line numbers + evidence snippets + confidence) so
15
+ downstream insights can cite `path/to/file.rs:NN`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from urllib.parse import urlparse
24
+
25
+ from attackmap.sdk import (
26
+ DEFAULT_SKIP_DIRS,
27
+ AnalyzerMetadata,
28
+ AuthHint,
29
+ DatabaseHint,
30
+ EntrypointHint,
31
+ ExternalCall,
32
+ FrameworkHint,
33
+ iter_repo_files,
34
+ line_of,
35
+ read_source,
36
+ rel,
37
+ Route,
38
+ ScanResult,
39
+ SecretHint,
40
+ ServiceHint,
41
+ )
42
+
43
+ from . import frameworks as fw
44
+ from . import route_auth as ra
45
+ from . import routing as rt
46
+
47
+ CODE_SUFFIXES = {".rs"}
48
+ # Rust-specific directories on top of the SDK defaults (which already cover
49
+ # target/, vendor/, .git and node_modules). Matched against directory names
50
+ # inside the repo only (mlaify/AttackMap#253).
51
+ SKIP_DIRS = DEFAULT_SKIP_DIRS | {".cargo"}
52
+ _SNIPPET_MAX_CHARS = 160
53
+
54
+
55
+ # ---------- Patterns ----------
56
+
57
+ # Axum: Router::new().route("/path", get(h).post(h2))
58
+ # We capture the path then walk a restricted method-chain that follows.
59
+ AXUM_ROUTE_PATTERN = re.compile(
60
+ r'\.route\(\s*"([^"]*)"\s*,\s*([a-zA-Z_][a-zA-Z0-9_:]*)\s*\(',
61
+ re.MULTILINE,
62
+ )
63
+ # Method-chain on the routing function, e.g. get(h).post(h2)
64
+ AXUM_METHOD_CHAIN_PATTERN = re.compile(
65
+ r"\.(get|post|put|delete|patch|head|options|trace|connect)\s*\(",
66
+ re.IGNORECASE,
67
+ )
68
+
69
+ # Actix-web attribute macros: #[get("/path")] / #[post("/path")] etc.
70
+ ACTIX_ATTRIBUTE_ROUTE_PATTERN = re.compile(
71
+ r'#\[\s*(get|post|put|delete|patch|head|options)\s*\(\s*"([^"]+)"',
72
+ re.IGNORECASE,
73
+ )
74
+ # Actix-web .route(): .route("/path", web::get().to(h))
75
+ ACTIX_WEB_ROUTE_PATTERN = re.compile(
76
+ r'\.route\(\s*"([^"]+)"\s*,\s*web::(get|post|put|delete|patch|head|options)\s*\(\s*\)',
77
+ re.IGNORECASE,
78
+ )
79
+ # Actix-web service routes: web::resource("/path").route(web::get().to(...)).
80
+ # Kept for API compatibility; resources and scopes are parsed as chains
81
+ # (routing.actix_chains), which also joins their prefixes.
82
+ ACTIX_RESOURCE_PATTERN = re.compile(
83
+ r'web::(?:resource|scope)\(\s*"([^"]+)"',
84
+ re.IGNORECASE,
85
+ )
86
+ # The method of an actix route: `web::get()`, bare `get()` (imported), or
87
+ # `web::method(Method::POST)`.
88
+ ACTIX_METHOD_PATTERN = re.compile(
89
+ r"\s*(?:web\s*::\s*)?(get|post|put|delete|patch|head|options|trace|connect|method)\s*\(\s*(?:(?:http\s*::\s*)?Method\s*::\s*(\w+))?",
90
+ re.IGNORECASE,
91
+ )
92
+
93
+ # The handler fn an attribute macro decorates, and the dynamic segments of a
94
+ # Rocket path / data param (`<id>`, `<path..>`, `data = "<form>"`), which bind
95
+ # parameters that aren't request guards.
96
+ FN_AFTER_ATTRIBUTE_PATTERN = re.compile(r"\bfn\s+(\w+)")
97
+ ROCKET_PATH_PARAM_PATTERN = re.compile(r"<(\w+)(?:\.\.)?>")
98
+ # The handler of an actix `.route("/x", web::post().to(handler))`.
99
+ ACTIX_TO_PATTERN = re.compile(r"\.\s*to\s*\(\s*([\w:]+)\s*\)")
100
+
101
+ # Rocket attribute macros: #[get("/path")], also with format/data: #[post("/users", data = "...")]
102
+ ROCKET_ATTRIBUTE_ROUTE_PATTERN = re.compile(
103
+ r'#\[\s*(get|post|put|delete|patch|head|options)\s*\(\s*"([^"]+)"',
104
+ re.IGNORECASE,
105
+ )
106
+
107
+ # External HTTP calls
108
+ OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
109
+ re.compile(
110
+ r'\breqwest::(?:get|Client::new\(\)\.[a-z]+|blocking::get)\s*\(\s*["]([^"]+)["]',
111
+ re.IGNORECASE,
112
+ ),
113
+ re.compile(r'\.(?:get|post|put|delete|patch)\s*\(\s*["](https?://[^"]+)["]', re.IGNORECASE),
114
+ re.compile(r'\bisahc::(?:get|post|put|delete|patch)\s*\(\s*["]([^"]+)["]', re.IGNORECASE),
115
+ re.compile(r'\bsurf::(?:get|post|put|delete|patch)\s*\(\s*["]([^"]+)["]', re.IGNORECASE),
116
+ re.compile(r'\bureq::(?:get|post|put|delete|patch)\s*\(\s*["]([^"]+)["]', re.IGNORECASE),
117
+ ]
118
+
119
+ # Database libs (regex anchored on common entry points)
120
+ DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
121
+ (re.compile(r"\bsqlx::(?:postgres|Postgres)", re.IGNORECASE), "postgresql"),
122
+ (re.compile(r"\bsqlx::(?:mysql|MySql)", re.IGNORECASE), "mysql"),
123
+ (re.compile(r"\bsqlx::(?:sqlite|Sqlite)", re.IGNORECASE), "sqlite"),
124
+ (re.compile(r"\bsqlx::(?:Pool|Connection|query|query_as|query!)", re.IGNORECASE), "sql"),
125
+ (re.compile(r"\bdiesel::", re.IGNORECASE), "sql"),
126
+ (re.compile(r"\bsea_orm::|\bSeaOrm\b", re.IGNORECASE), "sql"),
127
+ (re.compile(r"\btokio_postgres::|\bpostgres::Client::", re.IGNORECASE), "postgresql"),
128
+ (re.compile(r"\brusqlite::", re.IGNORECASE), "sqlite"),
129
+ (re.compile(r"\bmongodb::Client", re.IGNORECASE), "mongodb"),
130
+ (re.compile(r"\bredis::(?:Client::open|aio::|Connection|cmd)", re.IGNORECASE), "redis"),
131
+ (re.compile(r"\bdeadpool_postgres", re.IGNORECASE), "postgresql"),
132
+ (re.compile(r"\bdeadpool_redis", re.IGNORECASE), "redis"),
133
+ (re.compile(r"\baws_sdk_s3::|\bS3Client\b", re.IGNORECASE), "object_storage"),
134
+ (re.compile(r"\baws_sdk_dynamodb::", re.IGNORECASE), "dynamodb"),
135
+ ]
136
+
137
+ # Auth-related signals (pattern, hint label). Core credits any auth hint near a
138
+ # route as that route's auth, so `jwt` needs a verifying call (AttackMap#325):
139
+ # jsonwebtoken's `decode(token, &key, &validation)` checks the signature, while
140
+ # `use` lines, `encode` and `decode_header` authenticate nobody. Only counted in
141
+ # files that use the crate; see `_extract_auth`.
142
+ AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
143
+ (re.compile(r"\bdecode(?:::<[^>]{0,80}>)?\s*\([^;]{0,300}?\b[Vv]alidation\b"), "jwt", 0.85),
144
+ (re.compile(r"\bargon2::", re.IGNORECASE), "argon2", 0.9),
145
+ (re.compile(r"\bbcrypt::", re.IGNORECASE), "bcrypt", 0.9),
146
+ (re.compile(r"\bscrypt::", re.IGNORECASE), "scrypt", 0.9),
147
+ (re.compile(r"\bpassword_hash::", re.IGNORECASE), "password_hash", 0.85),
148
+ (re.compile(r"\boauth2::", re.IGNORECASE), "oauth", 0.85),
149
+ (re.compile(r"\baxum_login::", re.IGNORECASE), "axum_login", 0.85),
150
+ (re.compile(r"\bactix_identity::", re.IGNORECASE), "actix_identity", 0.85),
151
+ (re.compile(r"\btower_sessions::", re.IGNORECASE), "tower_sessions", 0.85),
152
+ (re.compile(r"\btower_http::auth::", re.IGNORECASE), "tower_http_auth", 0.85),
153
+ (re.compile(r"\bAuthorization\b", re.IGNORECASE), "authorization_header", 0.6),
154
+ (re.compile(r"\bBearer\b", re.IGNORECASE), "bearer_token", 0.6),
155
+ (re.compile(r"\bapi[_-]?key\b", re.IGNORECASE), "api_key", 0.6),
156
+ ]
157
+
158
+ # Web framework presence. Confidence high — these markers are unambiguous.
159
+ FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
160
+ (re.compile(r"\baxum::", re.IGNORECASE), "axum"),
161
+ (re.compile(r"\bactix_web::", re.IGNORECASE), "actix-web"),
162
+ (re.compile(r"#\[\s*launch\s*\]|\brocket::build\s*\(", re.IGNORECASE), "rocket"),
163
+ (re.compile(r"\btide::", re.IGNORECASE), "tide"),
164
+ (re.compile(r"\bwarp::", re.IGNORECASE), "warp"),
165
+ (re.compile(r"\bpoem::", re.IGNORECASE), "poem"),
166
+ (re.compile(r"\bsalvo::", re.IGNORECASE), "salvo"),
167
+ (re.compile(r"\btower::Service\b", re.IGNORECASE), "tower"),
168
+ (re.compile(r"\bhyper::", re.IGNORECASE), "hyper"),
169
+ (re.compile(r"\btokio::", re.IGNORECASE), "tokio"),
170
+ ]
171
+
172
+ # Entrypoint markers — server bind / listen / startup
173
+ ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
174
+ (re.compile(r"\baxum::serve\s*\(", re.IGNORECASE), "axum_serve"),
175
+ (re.compile(r"\baxum::Server::bind\s*\(", re.IGNORECASE), "axum_server_bind"),
176
+ (re.compile(r"\bHttpServer::new\s*\(", re.IGNORECASE), "actix_http_server"),
177
+ (re.compile(r"\brocket::build\s*\(", re.IGNORECASE), "rocket_build"),
178
+ (re.compile(r"#\[\s*launch\s*\]", re.IGNORECASE), "rocket_launch_attr"),
179
+ (re.compile(r"\btide::new\s*\(", re.IGNORECASE), "tide_new"),
180
+ (re.compile(r"#\[\s*tokio::main\s*\]", re.IGNORECASE), "tokio_main"),
181
+ (re.compile(r"#\[\s*actix_web::main\s*\]", re.IGNORECASE), "actix_main"),
182
+ ]
183
+
184
+ # Secrets — env var names containing SECRET/TOKEN/KEY/PASSWORD/PASS/PWD
185
+ SECRET_PATTERNS: list[re.Pattern[str]] = [
186
+ re.compile(
187
+ r'(?:std::)?env::var\s*\(\s*["]([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)["]',
188
+ re.IGNORECASE,
189
+ ),
190
+ re.compile(
191
+ r'\bdotenv(?:y)?::var\s*\(\s*["]([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)["]',
192
+ re.IGNORECASE,
193
+ ),
194
+ re.compile(
195
+ r'\benv!\s*\(\s*["]([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)["]',
196
+ re.IGNORECASE,
197
+ ),
198
+ re.compile(r'\bsecrecy::SecretString::new\s*\(', re.IGNORECASE),
199
+ ]
200
+
201
+
202
+ # Kept rather than attackmap.sdk.line_snippet: this takes a match offset and
203
+ # splits on "\n" only, so it stays consistent with line_of() on files that
204
+ # contain form feeds or other str.splitlines() separators, and it costs
205
+ # O(line) per match instead of O(file).
206
+ def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
207
+ line_start = content.rfind("\n", 0, offset) + 1
208
+ line_end = content.find("\n", offset)
209
+ if line_end == -1:
210
+ line_end = len(content)
211
+ line = content[line_start:line_end].strip()
212
+ if len(line) > max_chars:
213
+ line = line[: max_chars - 1] + "…"
214
+ return line
215
+
216
+
217
+ def _crate_name_from_cargo(cargo_path: Path, root: Path | None = None) -> str | None:
218
+ text = read_source(cargo_path, root=root)
219
+ if text is None:
220
+ return None
221
+ match = re.search(r'^\s*\[package\]\s*\n.*?^\s*name\s*=\s*"([^"]+)"', text, re.MULTILINE | re.DOTALL)
222
+ if match:
223
+ return match.group(1)
224
+ return None
225
+
226
+
227
+ def _workspace_members(cargo_path: Path, root: Path | None = None) -> list[str]:
228
+ text = read_source(cargo_path, root=root)
229
+ if text is None:
230
+ return []
231
+ block = re.search(r"\[workspace\][^\[]*?members\s*=\s*\[([^\]]*)\]", text, re.DOTALL)
232
+ if not block:
233
+ return []
234
+ return re.findall(r'"([^"]+)"', block.group(1))
235
+
236
+
237
+ # `use salvo::prelude::*;` / `pub(crate) use poem::{..}` / `extern crate warp;`
238
+ _USE_CRATE = {
239
+ crate: re.compile(rf"^[ \t]*(?:pub(?:\([^)\n]{{0,40}}\))?[ \t]+)?(?:use|extern[ \t]+crate)[ \t]+(?:::)?{crate}\b", re.MULTILINE)
240
+ for crate in ("axum", "salvo", "poem", "loco_rs", "warp", "tide")
241
+ }
242
+ # Crate paths used without a `use` line (`warp::path!(..)`, `tide::new()`).
243
+ WARP_PATH_PATTERN = re.compile(r"\bwarp\s*::\s*(?:path!|get\s*\(|post\s*\(|Filter\b|serve\s*\()")
244
+ TIDE_PATH_PATTERN = re.compile(r"\btide\s*::\s*(?:new|with_state|Server|Request)\b")
245
+
246
+
247
+ def _uses(content: str, crate: str) -> bool:
248
+ return _USE_CRATE[crate].search(content) is not None
249
+
250
+
251
+ @dataclass
252
+ class _Candidate:
253
+ """A route found in one file, emitted after the walk (#2)."""
254
+
255
+ path: str # as written, before any prefix
256
+ method: str
257
+ file: str
258
+ line: int | None
259
+ # "chain": item `where` of a router chain; "service": actix attribute
260
+ # handler; "rocket": rocket handler; "plain": no router context.
261
+ kind: str
262
+ where: tuple | None = None
263
+ handler: object = None # handler name / Ref
264
+ sig: ra.Signature | None = None
265
+ prefix: str = ""
266
+
267
+
268
+ class RustAnalyzer:
269
+ metadata = AnalyzerMetadata(
270
+ name="rust",
271
+ display_name="Rust Analyzer",
272
+ version="0.2.0",
273
+ description="Rust ecosystem analyzer covering axum, actix-web, rocket, common DB/auth crates, and reqwest.",
274
+ scope="Rust crates and workspaces. Cargo-bearing repos are auto-detected; pure .rs trees also work.",
275
+ targets=["rust", "axum", "actix-web", "rocket"],
276
+ languages=["rust"],
277
+ priority=20,
278
+ experimental=False,
279
+ enabled_by_default=True,
280
+ )
281
+
282
+ @property
283
+ def name(self) -> str:
284
+ return self.metadata.name
285
+
286
+ # ---------- Public entry points ----------
287
+
288
+ def detect(self, repo_path: str | Path) -> bool:
289
+ root = Path(repo_path).resolve()
290
+ if not root.exists() or not root.is_dir():
291
+ return False
292
+ if (root / "Cargo.toml").exists() or (root / "Cargo.lock").exists():
293
+ return True
294
+ # Any nested Cargo.toml or .rs file; stop at the first one.
295
+ return next(iter_repo_files(root, suffixes=CODE_SUFFIXES, names={"Cargo.toml"}, skip_dirs=SKIP_DIRS), None) is not None
296
+
297
+ def analyze(self, repo_path: str | Path) -> ScanResult:
298
+ root = Path(repo_path).resolve()
299
+ result = ScanResult(root=str(root))
300
+ if not root.exists() or not root.is_dir():
301
+ return result
302
+
303
+ crate_name = _crate_name_from_cargo(root / "Cargo.toml", root)
304
+ workspace_members = _workspace_members(root / "Cargo.toml", root)
305
+ if crate_name:
306
+ self._append_unique_service(result, f"crate:{crate_name}", "Cargo.toml")
307
+ for member in workspace_members:
308
+ self._append_unique_service(result, f"workspace_member:{member}", "Cargo.toml")
309
+
310
+ # Routes are emitted after the walk: their prefixes and auth come from
311
+ # routers mounted and handlers defined across files (#2, AttackMap#256).
312
+ registry = rt.Registry()
313
+ pending: list[_Candidate] = []
314
+ for file_path in iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS):
315
+ content = read_source(file_path, root=root)
316
+ if content is None:
317
+ continue
318
+
319
+ result.files_scanned += 1
320
+ if "rust" not in result.languages:
321
+ result.languages.append("rust")
322
+
323
+ relative = rel(file_path, root)
324
+ self._extract_routes(content, relative, result, registry, pending)
325
+ self._extract_databases(content, relative, result)
326
+ self._extract_auth(content, relative, result)
327
+ self._extract_secrets(content, relative, result)
328
+ self._extract_external_calls(content, relative, result)
329
+ self._extract_frameworks(content, relative, result)
330
+ self._extract_entrypoints(content, relative, result)
331
+ self._infer_service_role(content, relative, result)
332
+
333
+ self._emit_routes(result, registry, pending)
334
+
335
+ result.languages.sort()
336
+ return result
337
+
338
+ def _emit_routes(self, result: ScanResult, registry: rt.Registry, pending: list[_Candidate]) -> None:
339
+ # (path, method, file) already emitted: a set, so dedup stays linear.
340
+ seen = {(r.path, r.method, r.file) for r in result.routes}
341
+ for cand in pending:
342
+ for prefix, state, sig in self._expand(registry, cand):
343
+ if prefix in ("", "/") and cand.path:
344
+ path = cand.path
345
+ else:
346
+ path = rt.join_path(prefix, cand.path)
347
+ index = self._append_unique_route(result, path, cand.method, cand.file, cand.line, seen=seen)
348
+ if index is not None:
349
+ self._set_route_auth(result, index, ra.resolve(state, sig))
350
+
351
+ @staticmethod
352
+ def _expand(registry: rt.Registry, cand: _Candidate) -> list[tuple[str, ra.PathState, ra.Signature | None]]:
353
+ """``(prefix, layers, handler signature)`` for each path the route is
354
+ reachable under."""
355
+ if cand.kind == "chain" and cand.where is not None:
356
+ sig = registry.signature(cand.handler, cand.file)
357
+ return [(p, state, sig) for p, state in registry.mounts(*cand.where)]
358
+ if cand.kind == "service":
359
+ sig = registry.confirm(cand.sig)
360
+ return [(p, state, sig) for p, state in registry.service_mounts(str(cand.handler), cand.file)]
361
+ if cand.kind == "rocket":
362
+ # Rocket has no route middleware: request guards are the whole story.
363
+ sig = registry.confirm(cand.sig)
364
+ return [(p, ra.PathState(), sig) for p in registry.rocket_prefixes(str(cand.handler), cand.file)]
365
+ sig = registry.signature(cand.handler, cand.file) if cand.handler else None
366
+ return [(cand.prefix, ra.PathState(known=False), sig)]
367
+
368
+ # ---------- Extractors ----------
369
+
370
+ def _extract_routes(
371
+ self,
372
+ content: str,
373
+ relative: str,
374
+ result: ScanResult,
375
+ registry: rt.Registry | None = None,
376
+ pending: list[_Candidate] | None = None,
377
+ ) -> None:
378
+ """Collect route candidates; ``analyze`` emits them once every file
379
+ is read. Called on its own (no registry), it emits them directly."""
380
+ standalone = registry is None
381
+ registry = registry if registry is not None else rt.Registry()
382
+ pending = pending if pending is not None else []
383
+ before = len(pending)
384
+
385
+ # Frameworks that share builder names are told apart by what the file
386
+ # imports, not by words that might sit in a comment.
387
+ salvo_file = _uses(content, "salvo") and not _uses(content, "axum")
388
+ groups = [
389
+ rt.axum_like_chains(content, relative, "axum") if "Router" in content and not salvo_file else [],
390
+ rt.actix_chains(content, relative) if "actix" in content else [],
391
+ rt.axum_like_chains(content, relative, "poem") if "Route" in content and _uses(content, "poem") else [],
392
+ rt.salvo_chains(content, relative) if salvo_file and "Router" in content else [],
393
+ rt.loco_chains(content, relative) if "Routes" in content and _uses(content, "loco_rs") else [],
394
+ ]
395
+ for group in groups:
396
+ rt.nest_chains(group)
397
+ registry.add_chains(group)
398
+ axum, actix, poem, salvo, loco = groups
399
+ if axum or poem or salvo or loco:
400
+ registry.add_served(rt.served_refs(content), relative)
401
+ if axum:
402
+ dots = {item.dot for chain in axum for item in chain.items}
403
+ registry.opaque |= rt.opaque_router_names(content, dots)
404
+ signatures = ra.fn_signatures(content)
405
+ registry.add_signatures(relative, signatures)
406
+ if "FromRequest" in content:
407
+ for name, flags in ra.extractor_impls(content).items():
408
+ registry.extractors.setdefault(name, []).extend(flags)
409
+ route_items = {
410
+ item.dot: (chain, i)
411
+ for chain in axum + actix
412
+ for i, item in enumerate(chain.items)
413
+ if item.kind == "route"
414
+ }
415
+
416
+ def add(path: str, method: str, offset: int, kind: str, **kw) -> None:
417
+ pending.append(_Candidate(path, method, relative, line_of(content, offset), kind, **kw))
418
+
419
+ # axum: .route("/x", get(h).post(h2)) — pull every method in the routing chain.
420
+ # The capture-group regex sits on the *first* method call (e.g. `get(`); after the opening
421
+ # paren we walk forward looking for additional `.method(` chain links.
422
+ _http_verbs = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT"}
423
+ for match in AXUM_ROUTE_PATTERN.finditer(content):
424
+ path = match.group(1)
425
+ where = route_items.get(match.start())
426
+ # `.route("/x", web::post().to(h))` inside an actix App/scope chain
427
+ # also matches here; it is resolved as actix.
428
+ actix_route = where is not None and where[0].framework == "actix"
429
+ if not path and not actix_route:
430
+ continue # only an actix scope route may be empty ("" is the scope itself)
431
+ close = ra.matching_close(content, content.find("(", match.start()))
432
+ if actix_route:
433
+ to = ACTIX_TO_PATTERN.search(content, match.end(), close) if close > 0 else None
434
+ method = self._actix_method(content, match.start(2), close)
435
+ add(path, method, match.start(), "chain", where=where, handler=to.group(1) if to else None)
436
+ continue
437
+ methods: set[str] = set()
438
+ # First method is the one captured in group 2 (when it's an HTTP verb).
439
+ first_call = match.group(2).split("::")[-1].upper()
440
+ if first_call in _http_verbs:
441
+ methods.add(first_call)
442
+ # Walk the rest of the `.route(..)` call for chained `.post(...)` / `.delete(...)`
443
+ # etc., bounded, and stopping at the next .route( or Router::new boundary.
444
+ window_end = close if close > 0 else match.end() + 300
445
+ chain_window = content[match.end() : min(window_end, match.end() + 300)]
446
+ stop = chain_window.find(".route(")
447
+ stop_alt = chain_window.find("Router::new")
448
+ if stop_alt != -1 and (stop == -1 or stop_alt < stop):
449
+ stop = stop_alt
450
+ if stop != -1:
451
+ chain_window = chain_window[:stop]
452
+ for chain_match in AXUM_METHOD_CHAIN_PATTERN.finditer(chain_window):
453
+ methods.add(chain_match.group(1).upper())
454
+ if not methods:
455
+ methods = {"ANY"}
456
+ handlers = ra.handlers_by_method(content, match.start(2), close) if close > 0 else {}
457
+ for method in sorted(methods):
458
+ add(path, method, match.start(), "chain" if where else "plain", where=where,
459
+ handler=handlers.get(method))
460
+
461
+ # actix web::resource("/x").route(web::get().to(h)) / .to(h): the
462
+ # resource's own path is the route's.
463
+ for chain in actix:
464
+ if not chain.resource:
465
+ continue
466
+ for i, item in enumerate(chain.items):
467
+ if item.kind != "route":
468
+ continue
469
+ args = ra.split_args(content, item.open, item.close)
470
+ if item.value == "to":
471
+ handler = rt.parse_ref(content, *args[0]) if len(args) == 1 else None
472
+ add("", "ANY", item.dot, "chain", where=(chain, i), handler=handler)
473
+ elif len(args) == 1 and rt.literal(content, *args[0]) is None:
474
+ to = ACTIX_TO_PATTERN.search(content, item.open, item.close)
475
+ method = self._actix_method(content, item.open + 1, item.close)
476
+ add("", method, item.dot, "chain", where=(chain, i), handler=to.group(1) if to else None)
477
+
478
+ # poem Route::new().at("/x", get(h).post(h2)) and loco Routes::new().add("/x", get(h)).
479
+ for chain in poem + loco:
480
+ for i, item in enumerate(chain.items):
481
+ if item.kind != "route":
482
+ continue
483
+ args = ra.split_args(content, item.open, item.close)
484
+ path = rt.literal(content, *args[0]) if len(args) == 2 else None
485
+ if path is None:
486
+ continue
487
+ handlers = ra.handlers_by_method(content, args[1][0], args[1][1])
488
+ if not handlers:
489
+ add(path, "ANY", item.dot, "chain", where=(chain, i), handler=rt.parse_ref(content, *args[1]))
490
+ for method, handler in handlers.items():
491
+ add(path, method, item.dot, "chain", where=(chain, i), handler=handler)
492
+
493
+ # salvo Router::with_path("x").get(h): the router's own path is the route's.
494
+ for chain in salvo:
495
+ for i, item in enumerate(chain.items):
496
+ if item.kind == "route":
497
+ args = ra.split_args(content, item.open, item.close)
498
+ handler = rt.parse_ref(content, *args[0]) if len(args) == 1 else None
499
+ add("", str(item.value), item.dot, "chain", where=(chain, i), handler=handler)
500
+
501
+ # actix / rocket attribute routes: #[get("/path")] just above an fn
502
+ is_rocket = "rocket" in content.lower() and "actix_web" not in content
503
+ for match in ACTIX_ATTRIBUTE_ROUTE_PATTERN.finditer(content):
504
+ method, path = match.group(1).upper(), match.group(2)
505
+ fn = FN_AFTER_ATTRIBUTE_PATTERN.search(content, match.end(), match.end() + 600)
506
+ sig = None
507
+ if fn:
508
+ sig = next((s for offset, s in signatures.get(fn.group(1), []) if offset == fn.start()), None)
509
+ registry.attr_handlers.setdefault(fn.group(1), set()).add(relative)
510
+ handler = fn.group(1) if fn else None
511
+ if is_rocket:
512
+ if sig is not None:
513
+ attr_end = content.find("]", match.end())
514
+ attr = content[match.start() : attr_end if attr_end > 0 else match.end()]
515
+ sig = sig.without_params(set(ROCKET_PATH_PARAM_PATTERN.findall(attr)))
516
+ add(path, method, match.start(), "rocket" if handler else "plain", handler=handler, sig=sig)
517
+ elif handler:
518
+ add(path, method, match.start(), "service", handler=handler, sig=sig)
519
+ else:
520
+ add(path, method, match.start(), "plain")
521
+
522
+ # actix .route(...): only fires when web:: namespace appears (so it doesn't double-count axum)
523
+ for match in ACTIX_WEB_ROUTE_PATTERN.finditer(content):
524
+ path, method = match.group(1), match.group(2).upper()
525
+ close = ra.matching_close(content, content.find("(", match.start()))
526
+ to = ACTIX_TO_PATTERN.search(content, match.end(), close) if close > 0 else None
527
+ where = route_items.get(match.start())
528
+ add(path, method, match.start(), "chain" if where else "plain", where=where,
529
+ handler=to.group(1) if to else None)
530
+
531
+ # rocket .mount("/base", routes![..]) prefixes.
532
+ if "routes!" in content or ("mount" in content and "rocket" in content.lower()):
533
+ registry.add_rocket(*rt.rocket_mounts(content, relative), relative)
534
+
535
+ # warp path! filters and tide app.at("/x").get(h) chains.
536
+ if "warp" in content and (_uses(content, "warp") or WARP_PATH_PATTERN.search(content)):
537
+ for found in fw.warp_routes(content):
538
+ add(found.path, found.method, found.offset, "plain")
539
+ if "tide" in content and (_uses(content, "tide") or TIDE_PATH_PATTERN.search(content)):
540
+ for found in fw.tide_routes(content):
541
+ add(found.path, found.method, found.offset, "plain")
542
+
543
+ if standalone:
544
+ self._emit_routes(result, registry, pending[before:])
545
+ del pending[before:]
546
+
547
+ @staticmethod
548
+ def _actix_method(content: str, start: int, end: int) -> str:
549
+ """``web::get()`` / ``get()`` / ``web::method(Method::POST)`` -> verb."""
550
+ match = ACTIX_METHOD_PATTERN.match(content, start, end)
551
+ if not match:
552
+ return "ANY"
553
+ if match.group(1).lower() == "method":
554
+ return match.group(2).upper() if match.group(2) else "ANY"
555
+ return match.group(1).upper()
556
+
557
+ def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
558
+ for pattern, kind in DB_PATTERNS:
559
+ match = pattern.search(content)
560
+ if match is None:
561
+ continue
562
+ self._append_unique_database(
563
+ result,
564
+ kind,
565
+ relative,
566
+ line_of(content, match.start()),
567
+ _line_snippet(content, match.start()),
568
+ )
569
+
570
+ def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
571
+ jwt_verifies = "jsonwebtoken" in content and "insecure_disable_signature_validation" not in content
572
+ for pattern, hint, confidence in AUTH_PATTERNS:
573
+ if hint == "jwt" and not jwt_verifies:
574
+ continue
575
+ match = pattern.search(content)
576
+ if match is None:
577
+ continue
578
+ self._append_unique_auth(
579
+ result,
580
+ hint,
581
+ relative,
582
+ line_of(content, match.start()),
583
+ _line_snippet(content, match.start()),
584
+ confidence,
585
+ )
586
+
587
+ def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
588
+ for pattern in SECRET_PATTERNS:
589
+ for match in pattern.finditer(content):
590
+ groups = match.groups()
591
+ name = groups[0] if groups and groups[0] else "secrecy::SecretString"
592
+ self._append_unique_secret(
593
+ result,
594
+ name,
595
+ relative,
596
+ line_of(content, match.start()),
597
+ _line_snippet(content, match.start()),
598
+ )
599
+
600
+ def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
601
+ for pattern in OUTBOUND_PATTERNS:
602
+ for match in pattern.finditer(content):
603
+ target = match.group(1)
604
+ # Skip path-only matches (no URL scheme); they're often intra-crate paths.
605
+ if not (target.startswith("http://") or target.startswith("https://") or target.startswith("env://")):
606
+ continue
607
+ self._append_unique_external(
608
+ result,
609
+ target,
610
+ relative,
611
+ line_of(content, match.start()),
612
+ _line_snippet(content, match.start()),
613
+ )
614
+
615
+ def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
616
+ for pattern, name in FRAMEWORK_PATTERNS:
617
+ match = pattern.search(content)
618
+ if match is None:
619
+ continue
620
+ self._append_unique_framework(
621
+ result,
622
+ name,
623
+ relative,
624
+ line_of(content, match.start()),
625
+ _line_snippet(content, match.start()),
626
+ )
627
+
628
+ def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
629
+ for pattern, hint in ENTRYPOINT_PATTERNS:
630
+ match = pattern.search(content)
631
+ if match is None:
632
+ continue
633
+ self._append_unique_entrypoint(
634
+ result,
635
+ hint,
636
+ relative,
637
+ line_of(content, match.start()),
638
+ _line_snippet(content, match.start()),
639
+ )
640
+
641
+ def _infer_service_role(self, content: str, relative: str, result: ScanResult) -> None:
642
+ haystack = (relative + " " + content[:500]).lower()
643
+ role: str | None = None
644
+ if any(token in haystack for token in ("worker", "consumer", "queue", "background")):
645
+ role = "worker"
646
+ elif any(token in haystack for token in ("api", "server", "handler", "routes")):
647
+ role = "api"
648
+ elif any(token in haystack for token in ("client", "sdk")):
649
+ role = "client"
650
+ if role:
651
+ self._append_unique_service(result, f"service_role:{role}", relative)
652
+
653
+ # ---------- Append helpers (dedup-aware) ----------
654
+
655
+ @staticmethod
656
+ def _append_unique_route(
657
+ result: ScanResult,
658
+ path: str,
659
+ method: str,
660
+ file: str,
661
+ line: int | None,
662
+ *,
663
+ auth: str = ra.UNKNOWN,
664
+ guards: list[str] | None = None,
665
+ guard_evidence: str | None = None,
666
+ seen: set[tuple[str, str, str]] | None = None,
667
+ ) -> int | None:
668
+ """Append a route once per (path, method, file); return its index, or
669
+ None when it was already recorded. ``seen`` (the keys already in
670
+ ``result.routes``) replaces the linear scan."""
671
+ key = (path, method, file)
672
+ if seen is not None:
673
+ if key in seen:
674
+ return None
675
+ seen.add(key)
676
+ elif any((item.path, item.method, item.file) == key for item in result.routes):
677
+ return None
678
+ result.routes.append(
679
+ Route(
680
+ path=path, method=method, file=file, line=line,
681
+ auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
682
+ )
683
+ )
684
+ return len(result.routes) - 1
685
+
686
+ @staticmethod
687
+ def _set_route_auth(result: ScanResult, index: int, resolution: ra.Resolution) -> None:
688
+ """Declare a route's auth (AttackMap#256); older cores ignore the fields.
689
+ Rebuilt rather than mutated so Route's guard_evidence redaction runs."""
690
+ if resolution.auth == ra.UNKNOWN:
691
+ return
692
+ route = result.routes[index]
693
+ result.routes[index] = Route(
694
+ path=route.path, method=route.method, file=route.file, line=route.line,
695
+ auth=resolution.auth, guards=list(resolution.guards), guard_evidence=resolution.evidence,
696
+ )
697
+
698
+ @staticmethod
699
+ def _append_unique_database(
700
+ result: ScanResult,
701
+ kind: str,
702
+ file: str,
703
+ line: int | None,
704
+ evidence: str | None,
705
+ ) -> None:
706
+ key = (kind, file)
707
+ if any((item.kind, item.file) == key for item in result.databases):
708
+ return
709
+ result.databases.append(
710
+ DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence)
711
+ )
712
+
713
+ @staticmethod
714
+ def _append_unique_auth(
715
+ result: ScanResult,
716
+ hint: str,
717
+ file: str,
718
+ line: int | None,
719
+ evidence: str | None,
720
+ confidence: float,
721
+ ) -> None:
722
+ key = (hint, file)
723
+ if any((item.hint, item.file) == key for item in result.auth_hints):
724
+ return
725
+ result.auth_hints.append(
726
+ AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence)
727
+ )
728
+
729
+ @staticmethod
730
+ def _append_unique_secret(
731
+ result: ScanResult,
732
+ name: str,
733
+ file: str,
734
+ line: int | None,
735
+ evidence: str | None,
736
+ ) -> None:
737
+ key = (name, file)
738
+ if any((item.name, item.file) == key for item in result.secret_hints):
739
+ return
740
+ result.secret_hints.append(
741
+ SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85)
742
+ )
743
+
744
+ @staticmethod
745
+ def _append_unique_external(
746
+ result: ScanResult,
747
+ target: str,
748
+ file: str,
749
+ line: int | None,
750
+ evidence: str | None,
751
+ ) -> None:
752
+ key = (target, file)
753
+ if any((item.target, item.file) == key for item in result.external_calls):
754
+ return
755
+ result.external_calls.append(
756
+ ExternalCall(target=target, file=file, line=line, evidence_text=evidence)
757
+ )
758
+
759
+ @staticmethod
760
+ def _append_unique_framework(
761
+ result: ScanResult,
762
+ hint: str,
763
+ file: str,
764
+ line: int | None,
765
+ evidence: str | None,
766
+ ) -> None:
767
+ key = (hint, file)
768
+ if any((item.hint, item.file) == key for item in result.framework_hints):
769
+ return
770
+ result.framework_hints.append(
771
+ FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence)
772
+ )
773
+
774
+ @staticmethod
775
+ def _append_unique_entrypoint(
776
+ result: ScanResult,
777
+ hint: str,
778
+ file: str,
779
+ line: int | None,
780
+ evidence: str | None,
781
+ ) -> None:
782
+ key = (hint, file)
783
+ if any((item.hint, item.file) == key for item in result.entrypoint_hints):
784
+ return
785
+ result.entrypoint_hints.append(
786
+ EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence)
787
+ )
788
+
789
+ @staticmethod
790
+ def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
791
+ key = (hint, file)
792
+ if any((item.hint, item.file) == key for item in result.service_hints):
793
+ return
794
+ result.service_hints.append(ServiceHint(hint=hint, file=file))
795
+
796
+
797
+ __all__ = ["RustAnalyzer"]