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,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"]
|