attackmap-analyzer-node-service 0.3.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_node_service/__init__.py +3 -0
- attackmap_analyzer_node_service/analyzer.py +1149 -0
- attackmap_analyzer_node_service/modules.py +366 -0
- attackmap_analyzer_node_service/route_auth.py +1553 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/METADATA +104 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/RECORD +10 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import bisect
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from attackmap.sdk import (
|
|
11
|
+
DEFAULT_SKIP_DIRS,
|
|
12
|
+
AnalyzerMetadata,
|
|
13
|
+
AuthHint,
|
|
14
|
+
DatabaseHint,
|
|
15
|
+
EdgeHint,
|
|
16
|
+
EntrypointHint,
|
|
17
|
+
ExternalCall,
|
|
18
|
+
iter_repo_files,
|
|
19
|
+
line_of,
|
|
20
|
+
line_snippet,
|
|
21
|
+
ProtocolHint,
|
|
22
|
+
read_source,
|
|
23
|
+
rel,
|
|
24
|
+
Route,
|
|
25
|
+
ScanResult,
|
|
26
|
+
SecretHint,
|
|
27
|
+
ServiceHint,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from . import route_auth as ra
|
|
31
|
+
from .modules import ModuleResolver
|
|
32
|
+
|
|
33
|
+
CODE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx"}
|
|
34
|
+
# Pruned by repo-relative directory name (attackmap.sdk.fs, AttackMap#253).
|
|
35
|
+
SKIP_DIRS = DEFAULT_SKIP_DIRS | {".svelte-kit"}
|
|
36
|
+
|
|
37
|
+
# ---- Route extraction ---------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Express / Hono / Koa / Elysia / Fastify share this shape:
|
|
40
|
+
# `<var>.<verb>('/path', ...)`. So does every `map.get('k')`,
|
|
41
|
+
# `req.get('Authorization')` and HTTP-client call (`axios.get('/x')`), so a
|
|
42
|
+
# match only becomes a route when the receiver is a known router (see
|
|
43
|
+
# ROUTER_ASSIGN_PATTERN / ROUTER_PARAM_PATTERN / CONVENTIONAL_ROUTER_NAMES) and
|
|
44
|
+
# the path literal is rooted (`/...`) or the `*` wildcard. Verbs are lower-case
|
|
45
|
+
# in every framework, so the match is case-sensitive.
|
|
46
|
+
EXPRESS_ROUTE_PATTERN = re.compile(
|
|
47
|
+
r"\b([A-Za-z_$][A-Za-z0-9_$]*)\.(get|post|put|patch|delete|options|head|all)\(\s*['\"`]([^'\"`]+)['\"`]",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Names that hold a router/app: `const app = express()`, `express.Router()`,
|
|
51
|
+
# `Router()`, `new Router()`, `new Hono()`, `new Koa()`, `new Elysia()`,
|
|
52
|
+
# `fastify(...)`, `Fastify(...)`, `polka()`.
|
|
53
|
+
ROUTER_ASSIGN_PATTERN = re.compile(
|
|
54
|
+
r"\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*[^=;\n]+)?=\s*(?:await\s+)?"
|
|
55
|
+
r"(?:express(?:\.Router)?\s*\(|Router\s*\(|fastify\s*\(|Fastify\s*\(|polka\s*\(|"
|
|
56
|
+
r"new\s+(?:Hono|OpenAPIHono|Koa|Router|KoaRouter|Elysia)\b)",
|
|
57
|
+
)
|
|
58
|
+
# Function parameters typed as a router/app: `(router: Router)`,
|
|
59
|
+
# `(app: express.Express)`, `(fastify: FastifyInstance)`, `(app: Hono)`.
|
|
60
|
+
ROUTER_PARAM_PATTERN = re.compile(
|
|
61
|
+
r"\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\??\s*:\s*(?:express\.)?"
|
|
62
|
+
r"(?:Router|IRouter|Express|Application|FastifyInstance|Hono|Elysia|KoaRouter)\b",
|
|
63
|
+
)
|
|
64
|
+
# Untyped JS routinely receives the app as a bare parameter
|
|
65
|
+
# (`module.exports = (app) => { app.get('/x', ...) }`); these conventional
|
|
66
|
+
# names are trusted as routers without a visible assignment.
|
|
67
|
+
CONVENTIONAL_ROUTER_NAMES = frozenset({"app", "router", "server", "fastify"})
|
|
68
|
+
|
|
69
|
+
# HTTP-client receivers. `<client>.<verb>('/path' | 'https://...')` is an
|
|
70
|
+
# *outbound* call, recorded as an ExternalCall with its method, never a route.
|
|
71
|
+
# Instances made with `axios.create(...)` / `got.extend(...)` / `ky.create(...)`
|
|
72
|
+
# are tracked too (CLIENT_ASSIGN_PATTERN).
|
|
73
|
+
KNOWN_CLIENT_NAMES = frozenset(
|
|
74
|
+
{"axios", "got", "ky", "api", "http", "https", "client", "fetcher", "superagent", "request", "httpClient", "apiClient"}
|
|
75
|
+
)
|
|
76
|
+
CLIENT_ASSIGN_PATTERN = re.compile(
|
|
77
|
+
r"\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::\s*[^=;\n]+)?=\s*"
|
|
78
|
+
r"(?:axios\.create|got\.extend|ky\.(?:create|extend))\s*\(",
|
|
79
|
+
)
|
|
80
|
+
CLIENT_VERBS = frozenset({"get", "post", "put", "patch", "delete", "options", "head"})
|
|
81
|
+
|
|
82
|
+
# `app.use('/prefix', router)` — subsequent routes on `router` inherit the prefix
|
|
83
|
+
# in the same file. Captures (owner_var, prefix, mounted_var).
|
|
84
|
+
EXPRESS_USE_MOUNT_PATTERN = re.compile(
|
|
85
|
+
r"\b([A-Za-z_][A-Za-z0-9_]*)\.use\(\s*['\"]([^'\"]+)['\"]\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Chained `router.route('/path').get(...).post(...)` shape. Handler bodies
|
|
89
|
+
# often contain their own parens (`res.status(200).end()`), so a single
|
|
90
|
+
# balanced-paren regex won't hold. Match the `.route(...)` opener alone,
|
|
91
|
+
# then follow the chain one call at a time: CHAIN_VERB_PATTERN must match right
|
|
92
|
+
# after the previous call's closing paren (whitespace and comments allowed).
|
|
93
|
+
EXPRESS_ROUTE_OPENER_PATTERN = re.compile(
|
|
94
|
+
r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*route\(\s*['\"]([^'\"]+)['\"]\s*\)",
|
|
95
|
+
)
|
|
96
|
+
CHAIN_VERB_PATTERN = re.compile(
|
|
97
|
+
r"\.\s*(get|post|put|patch|delete|options|head|all)\s*\(",
|
|
98
|
+
re.IGNORECASE,
|
|
99
|
+
)
|
|
100
|
+
# A path parameter segment (`:id`, `{id}`, `[id]`, `:id?`), compared by
|
|
101
|
+
# position only when matching copies of one route.
|
|
102
|
+
_PATH_PARAM = re.compile(r"(?<=/)(?::[^/]{1,200}|\{[^/{}]{1,200}\}|\[[^/\[\]]{1,200}\])")
|
|
103
|
+
CHAIN_WINDOW = 800 # bound on the scan for each chained verb
|
|
104
|
+
|
|
105
|
+
# Fastify's full declaration: `fastify.route({ method: 'GET', url: '/x', handler })`
|
|
106
|
+
# (`method` may be a list; `path` is an alias of `url`).
|
|
107
|
+
FASTIFY_ROUTE_OBJECT_PATTERN = re.compile(r"\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*route\s*\(\s*\{")
|
|
108
|
+
FASTIFY_ROUTE_KEY_PATTERN = re.compile(r"""\s*['"]?(method|url|path)['"]?\s*:""")
|
|
109
|
+
ROUTE_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"})
|
|
110
|
+
|
|
111
|
+
# A file that declares, mounts or chains routers gets a router map
|
|
112
|
+
# (route_auth.FileRouters) for mount prefixes and auth.
|
|
113
|
+
ROUTER_HINT_PATTERN = re.compile(
|
|
114
|
+
r"\.\s*(?:use|route|register|group|guard|basePath|addHook)\s*\("
|
|
115
|
+
r"|\bnew\s+(?:Hono|OpenAPIHono|Elysia|Koa|Router|KoaRouter)\b"
|
|
116
|
+
r"|\b(?:express|Router|fastify|Fastify|polka)\s*(?:\.\s*Router\s*)?\("
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# NestJS decorators. `@Controller('users')` above a class binds a prefix;
|
|
120
|
+
# `@Get('active')` / `@Post()` / `@All('*')` on methods declare routes.
|
|
121
|
+
# The class itself is detected via `NESTJS_CONTROLLER_PATTERN`; per-method
|
|
122
|
+
# routes via `NESTJS_METHOD_PATTERN`.
|
|
123
|
+
NESTJS_CONTROLLER_PATTERN = re.compile(
|
|
124
|
+
r"@Controller\(\s*(?:['\"]([^'\"]*)['\"]\s*)?\)"
|
|
125
|
+
# `@Controller({ path: 'bookings', version: '2' })`
|
|
126
|
+
r"|@Controller\(\s*\{[^{}]{0,500}?\bpath\s*:\s*['\"]([^'\"]*)['\"][^{}]{0,500}\}\s*\)",
|
|
127
|
+
)
|
|
128
|
+
NESTJS_METHOD_PATTERN = re.compile(
|
|
129
|
+
r"@(Get|Post|Put|Patch|Delete|Options|Head|All)\(\s*(?:['\"]([^'\"]*)['\"]\s*)?\)",
|
|
130
|
+
re.IGNORECASE,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# tRPC: `t.procedure.query(...)`, `t.procedure.mutation(...)`, etc. Also
|
|
134
|
+
# `router({ foo: publicProcedure.query(...) })`. Emit the procedure name
|
|
135
|
+
# as the route "path" prefixed with `trpc:` so downstream consumers know
|
|
136
|
+
# it's not a URL.
|
|
137
|
+
TRPC_PROCEDURE_PATTERN = re.compile(
|
|
138
|
+
r"\b([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?:publicProcedure|protectedProcedure|t\.procedure|procedure)\s*\.\s*(query|mutation|subscription)\s*\(",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# AT Protocol / XRPC handler registration. The Bluesky server registers
|
|
142
|
+
# handlers per NSID via `server.method('com.atproto.foo', handler)` or
|
|
143
|
+
# `xrpc.method('app.bsky.feed.getFeed', handler)`. Also captures the
|
|
144
|
+
# `createServer({ 'com.atproto.foo': handler })` object-literal form.
|
|
145
|
+
XRPC_HANDLER_PATTERN = re.compile(
|
|
146
|
+
r"\b(?:server|xrpc)\.method\(\s*['\"]((?:com|app|tools|chat)\.[A-Za-z0-9_.]+)['\"]",
|
|
147
|
+
)
|
|
148
|
+
XRPC_LEXICON_KEY_PATTERN = re.compile(
|
|
149
|
+
r"['\"]((?:com|app|tools|chat)\.[A-Za-z0-9_.]{3,})['\"]\s*:\s*(?:async\s*)?(?:\([^)]*\)|function|handler)",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# NextJS App-Router file-based routes: `app/**/route.ts` where each HTTP
|
|
153
|
+
# method is a named export (`export async function GET(...)`). Detected
|
|
154
|
+
# by combining file path + exported function name.
|
|
155
|
+
NEXTJS_APP_ROUTE_EXPORT_PATTERN = re.compile(
|
|
156
|
+
r"^\s*export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(",
|
|
157
|
+
re.MULTILINE,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# ---- Entrypoints --------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
ENTRYPOINT_PATTERNS = [
|
|
163
|
+
(re.compile(r"\bapp\.listen\s*\(", re.IGNORECASE), "entrypoint:express_listen"),
|
|
164
|
+
(re.compile(r"\bcreateServer\s*\(", re.IGNORECASE), "entrypoint:create_server"),
|
|
165
|
+
(re.compile(r"\bfastify\s*\(", re.IGNORECASE), "entrypoint:fastify_server"),
|
|
166
|
+
(re.compile(r"\bnew\s+Hono\s*\("), "entrypoint:hono_app"),
|
|
167
|
+
(re.compile(r"\bnew\s+Elysia\s*\("), "entrypoint:elysia_app"),
|
|
168
|
+
(re.compile(r"\bnew\s+Koa\s*\("), "entrypoint:koa_app"),
|
|
169
|
+
(re.compile(r"\bregisterHandler\b|\bregisterRoute\b", re.IGNORECASE), "entrypoint:handler_registration"),
|
|
170
|
+
# NestJS. `NestFactory.create(AppModule)` is the canonical bootstrap.
|
|
171
|
+
(re.compile(r"\bNestFactory\.create\s*\("), "entrypoint:nestjs_bootstrap"),
|
|
172
|
+
(re.compile(r"@Module\s*\(\s*\{"), "entrypoint:nestjs_module"),
|
|
173
|
+
# tRPC server bootstrap
|
|
174
|
+
(re.compile(r"\bcreateHTTPServer\s*\(\s*\{"), "entrypoint:trpc_http_server"),
|
|
175
|
+
# XRPC / AT Protocol server
|
|
176
|
+
(re.compile(r"\bcreateServer\s*\(\s*(?:LEXICONS|lexicons|schemas)"), "entrypoint:xrpc_server"),
|
|
177
|
+
# Async workers
|
|
178
|
+
(re.compile(r"\bnew\s+Worker\s*\(\s*['\"]"), "entrypoint:bullmq_worker"),
|
|
179
|
+
(re.compile(r"\bnew\s+Queue\s*\(\s*['\"]"), "entrypoint:bullmq_queue"),
|
|
180
|
+
(re.compile(r"\bconsumer\.subscribe\s*\("), "entrypoint:kafka_consumer"),
|
|
181
|
+
(re.compile(r"\bkafka\.consumer\s*\("), "entrypoint:kafka_consumer"),
|
|
182
|
+
(re.compile(r"\bproducer\.send\s*\("), "entrypoint:kafka_producer"),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
# BullMQ worker/queue name lets us cite the async edge target.
|
|
186
|
+
BULLMQ_QUEUE_NAME_PATTERN = re.compile(
|
|
187
|
+
r"\bnew\s+(?:Worker|Queue)\s*\(\s*['\"]([A-Za-z0-9_.-]+)['\"]",
|
|
188
|
+
)
|
|
189
|
+
# Kafka topic edges — `consumer.subscribe({ topic: 'foo' })` or `topics: ['a','b']`.
|
|
190
|
+
KAFKA_TOPIC_PATTERN = re.compile(
|
|
191
|
+
r"\btopics?\s*:\s*(?:['\"]([A-Za-z0-9_.-]+)['\"]|\[\s*['\"]([A-Za-z0-9_.-]+)['\"])",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# ---- Outbound HTTP / env-driven edges ----------------------------------------
|
|
195
|
+
|
|
196
|
+
OUTBOUND_PATTERNS = [
|
|
197
|
+
re.compile(r"\bfetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
198
|
+
# `axios.get(url)` / `got.post(url)` carry a method; they're handled by
|
|
199
|
+
# the client-receiver branch of _extract_routes instead.
|
|
200
|
+
re.compile(r"\bgot\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
201
|
+
re.compile(r"\bundici\.(?:request|fetch)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
202
|
+
re.compile(r"\bofetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
|
|
203
|
+
]
|
|
204
|
+
ENV_URL_PATTERN = re.compile(r"process\.env\.([A-Z0-9_]+_URL)\b")
|
|
205
|
+
|
|
206
|
+
# ---- Data stores --------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
DB_PATTERNS = [
|
|
209
|
+
(re.compile(r"\bnew\s+PrismaClient\s*\(", re.IGNORECASE), "sql"),
|
|
210
|
+
(re.compile(r"\bPool\s*\(\s*\{", re.IGNORECASE), "postgresql"),
|
|
211
|
+
# The `pg` driver by import/require only; a bare `pg` word matched
|
|
212
|
+
# comments, class names (`pg-header`) and loop variables.
|
|
213
|
+
(
|
|
214
|
+
re.compile(
|
|
215
|
+
r"\bfrom\s+['\"](?:pg|pg-pool|pg-promise|postgres)['\"]"
|
|
216
|
+
r"|\brequire\(\s*['\"](?:pg|pg-pool|pg-promise|postgres)['\"]\s*\)"
|
|
217
|
+
r"|\bnode-postgres\b"
|
|
218
|
+
),
|
|
219
|
+
"postgresql",
|
|
220
|
+
),
|
|
221
|
+
(re.compile(r"\bknex\s*\(", re.IGNORECASE), "sql"),
|
|
222
|
+
(re.compile(r"\bdrizzle\s*\(", re.IGNORECASE), "sql"),
|
|
223
|
+
(re.compile(r"\bmongoose\.connect\s*\(", re.IGNORECASE), "mongodb"),
|
|
224
|
+
(re.compile(r"\bMongoClient\s*\(", re.IGNORECASE), "mongodb"),
|
|
225
|
+
(re.compile(r"\bredis\.createClient\s*\(", re.IGNORECASE), "redis"),
|
|
226
|
+
(re.compile(r"\bioredis\b|\bnew\s+Redis\s*\(", re.IGNORECASE), "redis"),
|
|
227
|
+
(re.compile(r"\bS3Client\b|\bPutObjectCommand\b|\bGetObjectCommand\b"), "object_storage"),
|
|
228
|
+
(re.compile(r"\bnew\s+SQLite3?\b|\bbetter-sqlite3\b", re.IGNORECASE), "sqlite"),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
# ---- Auth signals -------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
AUTH_PATTERNS = [
|
|
234
|
+
(re.compile(r"\bAuthorization\b"), "authorization_header"),
|
|
235
|
+
(re.compile(r"\bBearer\b"), "bearer_token"),
|
|
236
|
+
# JWT verification only (AttackMap#325): core credits any auth hint near a
|
|
237
|
+
# route as that route's auth, and an import, `jwt.sign(` / `jwt.decode(` or a
|
|
238
|
+
# comment authenticates nobody. jsonwebtoken / jose / @fastify/jwt verify
|
|
239
|
+
# calls, express-jwt / koa-jwt middleware, passport-jwt and NestJS guards.
|
|
240
|
+
(
|
|
241
|
+
re.compile(
|
|
242
|
+
r"\bjwt\.verify\s*\(|\bjwtVerify\s*\(|\b(?:expressjwt|expressJwt|koaJwt|koajwt)\s*\("
|
|
243
|
+
r"|\bJwtStrategy\b|\bJwtAuthGuard\b"
|
|
244
|
+
),
|
|
245
|
+
"jwt",
|
|
246
|
+
),
|
|
247
|
+
# Crypto/JWT verify and sign only: a bare `verify(` / `sign(` matched
|
|
248
|
+
# test helpers, form validators and nearly every JS repo.
|
|
249
|
+
(
|
|
250
|
+
re.compile(
|
|
251
|
+
r"\b(?:jwt|JWT|jsonwebtoken|jose|crypto(?:\.subtle)?)\.verify\s*\("
|
|
252
|
+
r"|\bjwtVerify\s*\(|\bcrypto\.createVerify\s*\("
|
|
253
|
+
),
|
|
254
|
+
"signature_verify",
|
|
255
|
+
),
|
|
256
|
+
(
|
|
257
|
+
re.compile(
|
|
258
|
+
r"\b(?:jwt|JWT|jsonwebtoken|jose|crypto(?:\.subtle)?)\.sign\s*\("
|
|
259
|
+
r"|\bnew\s+SignJWT\s*\(|\bcrypto\.createSign\s*\("
|
|
260
|
+
),
|
|
261
|
+
"signature_sign",
|
|
262
|
+
),
|
|
263
|
+
(re.compile(r"\boauth\b|\boidc\b", re.IGNORECASE), "oauth"),
|
|
264
|
+
(re.compile(r"\bDPoP\b|\bdpop\b"), "dpop"),
|
|
265
|
+
(re.compile(r"\bpassport\.authenticate\b", re.IGNORECASE), "passport_authenticate"),
|
|
266
|
+
(re.compile(r"@UseGuards\("), "nestjs_guard"),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
# ---- Secrets ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
SECRET_PATTERNS = [
|
|
272
|
+
re.compile(r"process\.env\.([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)"),
|
|
273
|
+
]
|
|
274
|
+
# EXPO_PUBLIC_* vars are inlined into the client bundle at build time —
|
|
275
|
+
# any secret named this way is effectively public. Emitted as a normal
|
|
276
|
+
# secret hint with the "expo_public:" name prefix so downstream findings
|
|
277
|
+
# can elevate them.
|
|
278
|
+
EXPO_PUBLIC_PATTERN = re.compile(r"process\.env\.(EXPO_PUBLIC_[A-Z0-9_]+)")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class NodeServiceAnalyzer:
|
|
282
|
+
metadata = AnalyzerMetadata(
|
|
283
|
+
name="node-service",
|
|
284
|
+
display_name="Node Service Analyzer",
|
|
285
|
+
version="0.3.0",
|
|
286
|
+
description="Broad Node.js/TypeScript service analyzer covering Express/Fastify/NestJS/Hono/Koa/Elysia/tRPC + NextJS routes, XRPC handlers, workspaces, async workers.",
|
|
287
|
+
scope="Node/TypeScript backend and monorepo repositories with service boundaries, framework handlers, RPC handlers, and inter-service/async messaging.",
|
|
288
|
+
targets=["node-service", "node", "typescript-service"],
|
|
289
|
+
languages=["javascript", "typescript"],
|
|
290
|
+
priority=25,
|
|
291
|
+
experimental=True,
|
|
292
|
+
enabled_by_default=False,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def name(self) -> str:
|
|
297
|
+
return self.metadata.name
|
|
298
|
+
|
|
299
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
300
|
+
root = Path(repo_path).resolve()
|
|
301
|
+
if not root.exists() or not root.is_dir():
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
has_package = (root / "package.json").exists() or self._has_file(root, "package.json")
|
|
305
|
+
has_typescript = (root / "tsconfig.json").exists() or self._has_file(root, "tsconfig.json")
|
|
306
|
+
has_service_dirs = (root / "services").is_dir() or (root / "packages").is_dir() or (root / "apps").is_dir()
|
|
307
|
+
has_pnpm_workspace = (root / "pnpm-workspace.yaml").exists()
|
|
308
|
+
has_nx_or_turbo = (root / "nx.json").exists() or (root / "turbo.json").exists()
|
|
309
|
+
|
|
310
|
+
if has_package and (has_typescript or has_service_dirs or has_pnpm_workspace or has_nx_or_turbo):
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
for file_path in iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
314
|
+
content = read_source(file_path)
|
|
315
|
+
if not content:
|
|
316
|
+
continue
|
|
317
|
+
if re.search(
|
|
318
|
+
r"\bapp\.listen\b|\bfastify\b|\bcreateServer\b|\bNestFactory\b|\bnew\s+Hono\b|\bcreateHTTPServer\b|services?/|packages?/",
|
|
319
|
+
content,
|
|
320
|
+
):
|
|
321
|
+
return True
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
325
|
+
root = Path(repo_path).resolve()
|
|
326
|
+
result = ScanResult(root=str(root))
|
|
327
|
+
if not root.exists() or not root.is_dir():
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
root_package_name = self._package_name_from(root / "package.json", root)
|
|
331
|
+
|
|
332
|
+
# Workspaces contribute service topology hints (one per workspace
|
|
333
|
+
# package). Done once, up front, then per-file walk fills in the rest.
|
|
334
|
+
self._emit_workspace_service_hints(root, result)
|
|
335
|
+
# Route auth (AttackMap#256) is resolved after the walk: routers are
|
|
336
|
+
# mounted, and NestJS global guards declared, in other files.
|
|
337
|
+
self._auth_registry = ra.Registry(ModuleResolver(root))
|
|
338
|
+
self._auth_pending: list[tuple[int, object]] = []
|
|
339
|
+
self._nest_global_guards: list[tuple[str, bool, str, str]] = []
|
|
340
|
+
|
|
341
|
+
for file_path in iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
342
|
+
result.files_scanned += 1
|
|
343
|
+
language = "typescript" if file_path.suffix.lower() in {".ts", ".tsx"} else "javascript"
|
|
344
|
+
if language not in result.languages:
|
|
345
|
+
result.languages.append(language)
|
|
346
|
+
|
|
347
|
+
content = read_source(file_path)
|
|
348
|
+
if content is None:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
relative = rel(file_path, root)
|
|
352
|
+
service_name = self._infer_service_name(relative, root_package_name)
|
|
353
|
+
service_role = self._infer_service_role(service_name, relative)
|
|
354
|
+
|
|
355
|
+
# Service identity is inferred from the file's path (or the root
|
|
356
|
+
# package name), not from a source line: anchor it at line 1.
|
|
357
|
+
derived = f"inferred from path {relative}"
|
|
358
|
+
self._append_unique_hint(
|
|
359
|
+
result.service_hints, ServiceHint, f"service_name:{service_name}", relative,
|
|
360
|
+
evidence=derived, confidence=0.6,
|
|
361
|
+
)
|
|
362
|
+
self._append_unique_hint(
|
|
363
|
+
result.service_hints, ServiceHint, f"service_role:{service_role}", relative,
|
|
364
|
+
evidence=derived, confidence=0.5,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
self._extract_routes(content, relative, result)
|
|
368
|
+
self._extract_nestjs_routes(content, relative, result)
|
|
369
|
+
if "useGlobalGuards" in content or "APP_GUARD" in content:
|
|
370
|
+
self._nest_global_guards += ra.nest_global_guards(content)
|
|
371
|
+
if relative not in self._auth_registry.files:
|
|
372
|
+
# A barrel (`export { default as users } from './users'`).
|
|
373
|
+
self._auth_registry.add_module(relative, content)
|
|
374
|
+
self._extract_trpc_procedures(content, relative, result)
|
|
375
|
+
self._extract_xrpc_handlers(content, relative, result)
|
|
376
|
+
self._extract_nextjs_file_routes(file_path, root, content, result)
|
|
377
|
+
self._extract_entrypoint_hints(content, relative, result)
|
|
378
|
+
self._extract_outbound_and_edges(content, relative, service_name, result)
|
|
379
|
+
self._extract_async_worker_edges(content, relative, service_name, result)
|
|
380
|
+
self._extract_datastore_hints(content, relative, result)
|
|
381
|
+
self._extract_auth_hints(content, relative, result)
|
|
382
|
+
self._extract_secret_hints(content, relative, result)
|
|
383
|
+
|
|
384
|
+
self._auth_registry.finalize()
|
|
385
|
+
self._expand_routes(result)
|
|
386
|
+
self._auth_pending = []
|
|
387
|
+
|
|
388
|
+
result.languages.sort()
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
def _defer_auth(self, index: int | None, variants) -> None: # type: ignore[no-untyped-def]
|
|
392
|
+
"""Resolve a route after the walk: ``variants()`` returns its
|
|
393
|
+
``(full path, Resolution)`` per mount, once every file's routers and
|
|
394
|
+
exports are known."""
|
|
395
|
+
pending = getattr(self, "_auth_pending", None)
|
|
396
|
+
if index is not None and pending is not None:
|
|
397
|
+
pending.append((index, variants))
|
|
398
|
+
|
|
399
|
+
def _expand_routes(self, result: ScanResult) -> None:
|
|
400
|
+
"""Replace each deferred route by one route per mounted path, with its
|
|
401
|
+
auth, then deduplicate by (path, method, file). Duplicates that
|
|
402
|
+
disagree on auth become ``unknown``: Express serves the first
|
|
403
|
+
registration, which this order-free resolution can't name, and a
|
|
404
|
+
guarded copy must not vouch for an unguarded one. That holds across
|
|
405
|
+
files too, for routes that reach the same app (two routers mounted
|
|
406
|
+
at one prefix). Copies overlap when their paths are equal up to
|
|
407
|
+
parameter names (`/users/:id`, `/users/:userId`) and their methods
|
|
408
|
+
are equal or either is ANY (`app.all`)."""
|
|
409
|
+
pending = dict(self._auth_pending)
|
|
410
|
+
routes: list[Route] = []
|
|
411
|
+
seen: dict[tuple[str, str, str], int] = {}
|
|
412
|
+
endpoints: dict[tuple[str, object], list[int]] = {}
|
|
413
|
+
for index, route in enumerate(result.routes):
|
|
414
|
+
variants = pending[index]() if index in pending else [(route.path, ra.Resolution())] # type: ignore[operator]
|
|
415
|
+
for path, resolution in variants:
|
|
416
|
+
key = (path, route.method, route.file)
|
|
417
|
+
if key in seen:
|
|
418
|
+
at = seen[key]
|
|
419
|
+
if routes[at].auth != resolution.auth:
|
|
420
|
+
routes[at] = self._unknown(routes[at])
|
|
421
|
+
else:
|
|
422
|
+
at = seen[key] = len(routes)
|
|
423
|
+
if path == route.path and resolution.auth == ra.UNKNOWN:
|
|
424
|
+
routes.append(route)
|
|
425
|
+
else:
|
|
426
|
+
routes.append(self._with_auth(route, path, resolution))
|
|
427
|
+
shape = _PATH_PARAM.sub(":", path.rstrip("/") or "/")
|
|
428
|
+
for scope in (route.file, *resolution.roots):
|
|
429
|
+
group = endpoints.setdefault((shape, scope), [])
|
|
430
|
+
if at not in group and len(group) < 256:
|
|
431
|
+
group.append(at)
|
|
432
|
+
for group in endpoints.values():
|
|
433
|
+
if len(group) < 2 or len({routes[at].auth for at in group}) < 2:
|
|
434
|
+
continue
|
|
435
|
+
methods = {routes[at].method for at in group}
|
|
436
|
+
for method in methods - {"ANY"} or {"ANY"}:
|
|
437
|
+
overlapping = [at for at in group if routes[at].method in (method, "ANY")]
|
|
438
|
+
if len({routes[at].auth for at in overlapping}) > 1:
|
|
439
|
+
for at in overlapping:
|
|
440
|
+
routes[at] = self._unknown(routes[at])
|
|
441
|
+
result.routes = routes
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def _unknown(route: Route) -> Route:
|
|
445
|
+
if route.auth == ra.UNKNOWN:
|
|
446
|
+
return route
|
|
447
|
+
return Route(path=route.path, method=route.method, file=route.file, line=route.line)
|
|
448
|
+
|
|
449
|
+
def _file_routers(self, content: str, relative: str) -> ra.FileRouters | None:
|
|
450
|
+
registry = getattr(self, "_auth_registry", None)
|
|
451
|
+
if registry is None:
|
|
452
|
+
return None
|
|
453
|
+
existing = registry.files.get(relative)
|
|
454
|
+
if existing is None:
|
|
455
|
+
existing = ra.FileRouters(content, relative)
|
|
456
|
+
registry.add(existing)
|
|
457
|
+
return existing
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def _registers_guard(content: str, after_path: int) -> bool:
|
|
461
|
+
"""Whether a registration's arguments after its path include a guard."""
|
|
462
|
+
paren = content.rfind("(", 0, after_path)
|
|
463
|
+
close = ra.matching_close(content, paren)
|
|
464
|
+
if close < 0:
|
|
465
|
+
return False
|
|
466
|
+
context = content[paren : min(close + 1, paren + 400)]
|
|
467
|
+
return any(
|
|
468
|
+
ra.guard_from_arg(content, s, e, context)
|
|
469
|
+
for s, e in ra.flatten(content, ra.split_args(content, paren, close)[1:])[:64]
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _local_guards(content: str, paren: int, first: int, start: int) -> list[ra.Guard]:
|
|
474
|
+
"""Auth middleware among a registration's arguments from ``first`` up
|
|
475
|
+
to (not including) the handler; Fastify route options count too."""
|
|
476
|
+
close = ra.matching_close(content, paren)
|
|
477
|
+
if close < 0:
|
|
478
|
+
return []
|
|
479
|
+
context = content[start : min(close + 1, start + 400)]
|
|
480
|
+
every = ra.split_args(content, paren, close)[first:]
|
|
481
|
+
args = every[:-1]
|
|
482
|
+
guards: list[ra.Guard] = []
|
|
483
|
+
for s, e in args:
|
|
484
|
+
guards += ra.route_option_guards(content, s, e, context)
|
|
485
|
+
for s, e in ra.flatten(content, args)[:64]:
|
|
486
|
+
guard = ra.guard_from_arg(content, s, e, context)
|
|
487
|
+
if guard is not None:
|
|
488
|
+
guards.append(guard)
|
|
489
|
+
guards = guards[:8]
|
|
490
|
+
# `app.get('/api/Users', security.isAuthorized())`: a registration made
|
|
491
|
+
# only of guards is path middleware; the handler is registered later.
|
|
492
|
+
return ra.guard_only_registration(content, every, context) or guards
|
|
493
|
+
|
|
494
|
+
# ---- Route extractors ----
|
|
495
|
+
|
|
496
|
+
def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
|
|
497
|
+
prefixes = self._collect_express_prefixes(content)
|
|
498
|
+
routers = self._router_names(content, prefixes)
|
|
499
|
+
registry = getattr(self, "_auth_registry", None)
|
|
500
|
+
fr: ra.FileRouters | None = None
|
|
501
|
+
if registry is not None and (
|
|
502
|
+
ROUTER_HINT_PATTERN.search(content)
|
|
503
|
+
or any(self._is_route_path(m.group(3)) for m in EXPRESS_ROUTE_PATTERN.finditer(content))
|
|
504
|
+
):
|
|
505
|
+
fr = self._file_routers(content, relative)
|
|
506
|
+
if fr is not None:
|
|
507
|
+
# Elysia `group` callbacks, Fastify `register` closures, Hono `basePath`.
|
|
508
|
+
routers |= fr.names()
|
|
509
|
+
|
|
510
|
+
def defer(
|
|
511
|
+
index: int | None, owner: str | ra.Binding, at: int, path: str, local: list, fallback: str,
|
|
512
|
+
*, mounted_only: bool = False,
|
|
513
|
+
) -> None:
|
|
514
|
+
if index is None or fr is None or registry is None:
|
|
515
|
+
return
|
|
516
|
+
binding = fr.resolve(owner, at) if isinstance(owner, str) else owner
|
|
517
|
+
self._defer_auth(
|
|
518
|
+
index,
|
|
519
|
+
# A receiver not known to be a router counts only when another
|
|
520
|
+
# file registers / calls it as one (`fastify.register(plugin)`).
|
|
521
|
+
lambda: [] if mounted_only and not (binding and binding.mounts)
|
|
522
|
+
else registry.route_variants(binding, at, path, local, fallback),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
for match in EXPRESS_ROUTE_PATTERN.finditer(content):
|
|
526
|
+
owner, method, path = match.group(1), match.group(2).upper(), match.group(3)
|
|
527
|
+
if not self._is_route_path(path):
|
|
528
|
+
continue
|
|
529
|
+
conditional = owner not in routers
|
|
530
|
+
if conditional and (fr is None or owner in KNOWN_CLIENT_NAMES):
|
|
531
|
+
continue
|
|
532
|
+
prefix = prefixes.get(owner, "")
|
|
533
|
+
full_path = self._join_route(prefix, path)
|
|
534
|
+
# `app.all('/api/*', requireAuth)` is middleware, not a route.
|
|
535
|
+
if method == "ALL" and path.endswith("*") and self._registers_guard(content, match.end() - 1):
|
|
536
|
+
continue
|
|
537
|
+
# Express `.all()` matches every verb: core's sentinel is ANY.
|
|
538
|
+
method = "ANY" if method == "ALL" else method
|
|
539
|
+
index = self._append_route(result, full_path, method, relative, line_of(content, match.start()))
|
|
540
|
+
local = self._local_guards(content, content.find("(", match.start() + len(owner)), 1, match.start())
|
|
541
|
+
defer(index, owner, match.start(), path, local, full_path, mounted_only=conditional)
|
|
542
|
+
|
|
543
|
+
# `router.route('/x').get(...).post(...)`
|
|
544
|
+
for match in EXPRESS_ROUTE_OPENER_PATTERN.finditer(content):
|
|
545
|
+
owner, path = match.group(1), match.group(2)
|
|
546
|
+
if owner not in routers or not self._is_route_path(path):
|
|
547
|
+
continue
|
|
548
|
+
prefix = prefixes.get(owner, "")
|
|
549
|
+
full_path = self._join_route(prefix, path)
|
|
550
|
+
# Follow the chain call by call (`.get(h)` then `.post(h)` ...); a
|
|
551
|
+
# later statement's `app.get(` is not part of it, even without `;`.
|
|
552
|
+
pos = match.end()
|
|
553
|
+
for _ in range(32):
|
|
554
|
+
verb_match = CHAIN_VERB_PATTERN.match(content, ra.skip_space(content, pos), pos + CHAIN_WINDOW)
|
|
555
|
+
if not verb_match:
|
|
556
|
+
break
|
|
557
|
+
paren = verb_match.end() - 1
|
|
558
|
+
close = ra.matching_close(content, paren)
|
|
559
|
+
if close < 0:
|
|
560
|
+
break
|
|
561
|
+
verb = verb_match.group(1).upper()
|
|
562
|
+
index = self._append_route(
|
|
563
|
+
result, full_path, "ANY" if verb == "ALL" else verb, relative, line_of(content, verb_match.start())
|
|
564
|
+
)
|
|
565
|
+
local = self._local_guards(content, paren, 0, match.start())
|
|
566
|
+
defer(index, owner, match.start(), path, local, full_path)
|
|
567
|
+
pos = close + 1
|
|
568
|
+
|
|
569
|
+
if fr is None:
|
|
570
|
+
return
|
|
571
|
+
# Chained calls: `new Hono().get('/x', h).post('/y', h)`, `app.get(...).post(...)`.
|
|
572
|
+
for chained in fr.chain_routes:
|
|
573
|
+
conditional = chained.head is not None and chained.head not in routers
|
|
574
|
+
if not self._is_route_path(chained.path) or conditional and chained.head in KNOWN_CLIENT_NAMES:
|
|
575
|
+
continue
|
|
576
|
+
method = "ANY" if chained.method == "ALL" else chained.method
|
|
577
|
+
index = self._append_route(result, chained.path, method, relative, line_of(content, chained.offset))
|
|
578
|
+
local = self._local_guards(content, chained.paren, 1, chained.offset)
|
|
579
|
+
defer(index, chained.binding, chained.offset, chained.path, local, chained.path, mounted_only=conditional)
|
|
580
|
+
|
|
581
|
+
# Fastify `X.route({ method, url, preHandler, handler })`.
|
|
582
|
+
for match in FASTIFY_ROUTE_OBJECT_PATTERN.finditer(content):
|
|
583
|
+
owner = match.group(1)
|
|
584
|
+
if owner not in routers:
|
|
585
|
+
continue
|
|
586
|
+
brace = match.end() - 1
|
|
587
|
+
close = ra.matching_close(content, brace)
|
|
588
|
+
if close < 0:
|
|
589
|
+
continue
|
|
590
|
+
methods, path = self._fastify_route_object(content, brace, close)
|
|
591
|
+
if path is None or not self._is_route_path(path):
|
|
592
|
+
continue
|
|
593
|
+
context = content[match.start() : min(close + 2, match.start() + 400)]
|
|
594
|
+
local = ra.route_option_guards(content, brace, close + 1, context)
|
|
595
|
+
for method in methods:
|
|
596
|
+
index = self._append_route(result, path, method, relative, line_of(content, match.start()))
|
|
597
|
+
defer(index, owner, match.start(), path, local, path)
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _fastify_route_object(content: str, brace: int, close: int) -> tuple[list[str], str | None]:
|
|
601
|
+
"""``(methods, url)`` of a ``route({ method, url })`` options object."""
|
|
602
|
+
methods: list[str] = []
|
|
603
|
+
path: str | None = None
|
|
604
|
+
for start, end in ra.split_args(content, brace, close):
|
|
605
|
+
key = FASTIFY_ROUTE_KEY_PATTERN.match(content, start, end)
|
|
606
|
+
if not key:
|
|
607
|
+
continue
|
|
608
|
+
value_start, value_end = key.end(), end
|
|
609
|
+
if key.group(1) in ("url", "path"):
|
|
610
|
+
path = ra.string_literal(content[value_start:value_end])
|
|
611
|
+
continue
|
|
612
|
+
value = content[value_start:value_end].strip()
|
|
613
|
+
items = [value]
|
|
614
|
+
if value.startswith("[") and value.endswith("]"):
|
|
615
|
+
items = value[1:-1].split(",")
|
|
616
|
+
for item in items:
|
|
617
|
+
literal = ra.string_literal(item)
|
|
618
|
+
if literal is not None and literal.upper() in ROUTE_METHODS:
|
|
619
|
+
methods.append(literal.upper())
|
|
620
|
+
return list(dict.fromkeys(methods)), path
|
|
621
|
+
|
|
622
|
+
@staticmethod
|
|
623
|
+
def _router_names(content: str, prefixes: dict[str, str]) -> set[str]:
|
|
624
|
+
"""Receivers whose `.get('/x')` etc. declare inbound routes in this file."""
|
|
625
|
+
names = set(CONVENTIONAL_ROUTER_NAMES)
|
|
626
|
+
names.update(m.group(1) for m in ROUTER_ASSIGN_PATTERN.finditer(content))
|
|
627
|
+
names.update(m.group(1) for m in ROUTER_PARAM_PATTERN.finditer(content))
|
|
628
|
+
# `app.use('/api', v1)`: both sides of a prefix mount are routers.
|
|
629
|
+
names.update(prefixes)
|
|
630
|
+
names.update(owner for owner, _prefix, _mounted in EXPRESS_USE_MOUNT_PATTERN.findall(content))
|
|
631
|
+
return names
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _client_names(content: str, routers: set[str]) -> set[str]:
|
|
635
|
+
"""HTTP-client receivers in this file (never ones also bound as routers)."""
|
|
636
|
+
names = set(KNOWN_CLIENT_NAMES)
|
|
637
|
+
names.update(m.group(1) for m in CLIENT_ASSIGN_PATTERN.finditer(content))
|
|
638
|
+
return names - routers
|
|
639
|
+
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _is_route_path(path: str) -> bool:
|
|
642
|
+
return path.startswith("/") or path == "*"
|
|
643
|
+
|
|
644
|
+
def _collect_express_prefixes(self, content: str) -> dict[str, str]:
|
|
645
|
+
"""Walk `app.use('/prefix', router)` mounts to build per-owner prefixes.
|
|
646
|
+
|
|
647
|
+
Chained mounts collapse: if `app.use('/api', v1)` and `v1.use('/orgs', orgs)`
|
|
648
|
+
both appear in the file, `orgs` inherits `/api/orgs`. Fixed-point
|
|
649
|
+
iteration keeps this simple; the graph is tiny in practice.
|
|
650
|
+
"""
|
|
651
|
+
prefixes: dict[str, str] = {}
|
|
652
|
+
pairs = [
|
|
653
|
+
(owner, prefix, mounted)
|
|
654
|
+
for owner, prefix, mounted in EXPRESS_USE_MOUNT_PATTERN.findall(content)
|
|
655
|
+
]
|
|
656
|
+
# Iterate until no more prefix propagation happens.
|
|
657
|
+
for _ in range(5):
|
|
658
|
+
changed = False
|
|
659
|
+
for owner, prefix, mounted in pairs:
|
|
660
|
+
parent_prefix = prefixes.get(owner, "")
|
|
661
|
+
new_prefix = self._join_route(parent_prefix, prefix)
|
|
662
|
+
if prefixes.get(mounted) != new_prefix:
|
|
663
|
+
prefixes[mounted] = new_prefix
|
|
664
|
+
changed = True
|
|
665
|
+
if not changed:
|
|
666
|
+
break
|
|
667
|
+
return prefixes
|
|
668
|
+
|
|
669
|
+
@staticmethod
|
|
670
|
+
def _join_route(prefix: str, path: str) -> str:
|
|
671
|
+
base = prefix.rstrip("/") if prefix else ""
|
|
672
|
+
# Prefixes may be declared without a leading slash (NestJS
|
|
673
|
+
# `@Controller("users")`, Express `app.use("api", ...)`) — normalize
|
|
674
|
+
# so the emitted path is always rooted at "/".
|
|
675
|
+
if base and not base.startswith("/"):
|
|
676
|
+
base = f"/{base}"
|
|
677
|
+
if path == "":
|
|
678
|
+
combined = base or "/"
|
|
679
|
+
else:
|
|
680
|
+
suffix = path if path.startswith("/") else f"/{path}"
|
|
681
|
+
combined = f"{base}{suffix}"
|
|
682
|
+
while "//" in combined:
|
|
683
|
+
combined = combined.replace("//", "/")
|
|
684
|
+
if combined != "/":
|
|
685
|
+
combined = combined.rstrip("/") or "/"
|
|
686
|
+
return combined
|
|
687
|
+
|
|
688
|
+
def _extract_nestjs_routes(self, content: str, relative: str, result: ScanResult) -> None:
|
|
689
|
+
# Each @Get/@Post/etc. is a route under the prefix of the nearest
|
|
690
|
+
# @Controller above it (a file may declare several controllers).
|
|
691
|
+
controllers = [
|
|
692
|
+
(m.start(), m.group(1) or m.group(2) or "") for m in NESTJS_CONTROLLER_PATTERN.finditer(content)
|
|
693
|
+
]
|
|
694
|
+
if not controllers and not NESTJS_METHOD_PATTERN.search(content):
|
|
695
|
+
return
|
|
696
|
+
starts = [start for start, _ in controllers]
|
|
697
|
+
nest: list[ra.NestFile] = []
|
|
698
|
+
for match in NESTJS_METHOD_PATTERN.finditer(content):
|
|
699
|
+
owner = bisect.bisect_right(starts, match.start()) - 1
|
|
700
|
+
prefix = controllers[owner][1] if owner >= 0 else (controllers[0][1] if controllers else "")
|
|
701
|
+
verb, sub_path = match.group(1).upper(), (match.group(2) or "")
|
|
702
|
+
full_path = self._join_route(prefix, sub_path)
|
|
703
|
+
method = "ANY" if verb == "ALL" else verb
|
|
704
|
+
index = self._append_unique_route(result, full_path, method, relative, line_of(content, match.start()))
|
|
705
|
+
if index is not None:
|
|
706
|
+
if not nest:
|
|
707
|
+
nest.append(ra.NestFile(content))
|
|
708
|
+
self._defer_auth(
|
|
709
|
+
index,
|
|
710
|
+
lambda nest_file=nest[0], at=match.start(), path=full_path: [
|
|
711
|
+
(path, nest_file.resolve(at, self._nest_global_guards))
|
|
712
|
+
],
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
def _extract_trpc_procedures(self, content: str, relative: str, result: ScanResult) -> None:
|
|
716
|
+
for match in TRPC_PROCEDURE_PATTERN.finditer(content):
|
|
717
|
+
name, kind = match.group(1), match.group(2).lower()
|
|
718
|
+
method = "POST" if kind in {"mutation"} else "GET" if kind == "query" else "STREAM"
|
|
719
|
+
self._append_unique_route(result, f"trpc:{name}", method, relative, line_of(content, match.start()))
|
|
720
|
+
|
|
721
|
+
def _extract_xrpc_handlers(self, content: str, relative: str, result: ScanResult) -> None:
|
|
722
|
+
seen: set[str] = set()
|
|
723
|
+
for match in XRPC_HANDLER_PATTERN.finditer(content):
|
|
724
|
+
nsid = match.group(1)
|
|
725
|
+
if nsid in seen:
|
|
726
|
+
continue
|
|
727
|
+
seen.add(nsid)
|
|
728
|
+
self._append_unique_route(result, f"/xrpc/{nsid}", "ANY", relative, line_of(content, match.start()))
|
|
729
|
+
self._append_unique_hint(
|
|
730
|
+
result.protocol_hints, ProtocolHint, f"atproto_lexicon:{nsid}", relative,
|
|
731
|
+
content=content, offset=match.start(), confidence=0.8,
|
|
732
|
+
)
|
|
733
|
+
for match in XRPC_LEXICON_KEY_PATTERN.finditer(content):
|
|
734
|
+
nsid = match.group(1)
|
|
735
|
+
if nsid in seen:
|
|
736
|
+
continue
|
|
737
|
+
seen.add(nsid)
|
|
738
|
+
self._append_unique_route(result, f"/xrpc/{nsid}", "ANY", relative, line_of(content, match.start()))
|
|
739
|
+
self._append_unique_hint(
|
|
740
|
+
result.protocol_hints, ProtocolHint, f"atproto_lexicon:{nsid}", relative,
|
|
741
|
+
content=content, offset=match.start(), confidence=0.8,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def _extract_nextjs_file_routes(
|
|
745
|
+
self, file_path: Path, root: Path, content: str, result: ScanResult
|
|
746
|
+
) -> None:
|
|
747
|
+
relative = rel(file_path, root)
|
|
748
|
+
parts = relative.split("/")
|
|
749
|
+
|
|
750
|
+
# App Router: `app/**/route.ts` where each HTTP verb is a named export.
|
|
751
|
+
if file_path.name in {"route.ts", "route.tsx", "route.js", "route.mjs"} and "app" in parts:
|
|
752
|
+
app_idx = parts.index("app")
|
|
753
|
+
segments = parts[app_idx + 1 : -1] # everything between `app/` and `route.ts`
|
|
754
|
+
url_path = "/" + "/".join(self._nextjs_segment(s) for s in segments)
|
|
755
|
+
url_path = url_path.rstrip("/") or "/"
|
|
756
|
+
for match in NEXTJS_APP_ROUTE_EXPORT_PATTERN.finditer(content):
|
|
757
|
+
# group(1), not start(): `^\s*` in MULTILINE also swallows blank lines above.
|
|
758
|
+
self._append_unique_route(
|
|
759
|
+
result, url_path, match.group(1).upper(), relative, line_of(content, match.start(1))
|
|
760
|
+
)
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
# Pages Router: `pages/api/**/*.ts`. Every file under `pages/api/` is
|
|
764
|
+
# a route; the default export is the handler. We don't know the
|
|
765
|
+
# method(s) without deeper parsing, so we emit "ANY".
|
|
766
|
+
if "pages" in parts:
|
|
767
|
+
pages_idx = parts.index("pages")
|
|
768
|
+
if len(parts) > pages_idx + 1 and parts[pages_idx + 1] == "api":
|
|
769
|
+
segments = parts[pages_idx + 2 :]
|
|
770
|
+
# drop extension from the final segment
|
|
771
|
+
last = segments[-1].rsplit(".", 1)[0] if segments else ""
|
|
772
|
+
if last == "index":
|
|
773
|
+
segments = segments[:-1]
|
|
774
|
+
else:
|
|
775
|
+
segments = segments[:-1] + [last]
|
|
776
|
+
url_path = "/api/" + "/".join(self._nextjs_segment(s) for s in segments)
|
|
777
|
+
url_path = url_path.rstrip("/") or "/api"
|
|
778
|
+
# The default export is the handler; fall back to line 1.
|
|
779
|
+
default_export = re.search(r"^[ \t]*export\s+default\b", content, re.MULTILINE)
|
|
780
|
+
line = line_of(content, default_export.start()) if default_export else 1
|
|
781
|
+
self._append_unique_route(result, url_path, "ANY", relative, line)
|
|
782
|
+
|
|
783
|
+
@staticmethod
|
|
784
|
+
def _nextjs_segment(segment: str) -> str:
|
|
785
|
+
# NextJS dynamic segment: `[id]` → `:id`, `[...slug]` → `*slug`.
|
|
786
|
+
if segment.startswith("[...") and segment.endswith("]"):
|
|
787
|
+
return f"*{segment[4:-1]}"
|
|
788
|
+
if segment.startswith("[") and segment.endswith("]"):
|
|
789
|
+
return f":{segment[1:-1]}"
|
|
790
|
+
return segment
|
|
791
|
+
|
|
792
|
+
def _extract_entrypoint_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
793
|
+
for pattern, hint in ENTRYPOINT_PATTERNS:
|
|
794
|
+
match = pattern.search(content)
|
|
795
|
+
if match:
|
|
796
|
+
self._append_unique_hint(
|
|
797
|
+
result.entrypoint_hints, EntrypointHint, hint, relative,
|
|
798
|
+
content=content, offset=match.start(), confidence=0.8,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
def _extract_outbound_and_edges(
|
|
802
|
+
self, content: str, relative: str, service_name: str, result: ScanResult
|
|
803
|
+
) -> None:
|
|
804
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
805
|
+
for match in pattern.finditer(content):
|
|
806
|
+
target = match.group(1)
|
|
807
|
+
self._append_unique_external(result, target, relative, content, match.start())
|
|
808
|
+
target_service = self._service_from_url(target)
|
|
809
|
+
if target_service:
|
|
810
|
+
self._append_unique_hint(
|
|
811
|
+
result.edge_hints, EdgeHint, f"edge:{service_name}->{target_service}", relative,
|
|
812
|
+
content=content, offset=match.start(), confidence=0.6,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# `axios.get('/internal/health')`, `api.post('https://...')`: outbound
|
|
816
|
+
# calls with a known verb, so the method is recorded for cross-repo
|
|
817
|
+
# contract linking.
|
|
818
|
+
clients = self._client_names(content, self._router_names(content, self._collect_express_prefixes(content)))
|
|
819
|
+
for match in EXPRESS_ROUTE_PATTERN.finditer(content):
|
|
820
|
+
owner, verb, target = match.group(1), match.group(2), match.group(3)
|
|
821
|
+
if owner not in clients or verb not in CLIENT_VERBS:
|
|
822
|
+
continue
|
|
823
|
+
# A template literal's interpolation is the dynamic tail:
|
|
824
|
+
# `/users/${id}` -> `/users/` (core treats a trailing `/` as `*`).
|
|
825
|
+
target = target.split("${", 1)[0]
|
|
826
|
+
if not (target.startswith("/") or re.match(r"https?://", target, re.IGNORECASE)):
|
|
827
|
+
continue
|
|
828
|
+
self._append_unique_external(result, target, relative, content, match.start(), method=verb.upper())
|
|
829
|
+
target_service = self._service_from_url(target)
|
|
830
|
+
if target_service:
|
|
831
|
+
self._append_unique_hint(
|
|
832
|
+
result.edge_hints, EdgeHint, f"edge:{service_name}->{target_service}", relative,
|
|
833
|
+
content=content, offset=match.start(), confidence=0.6,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
for match in ENV_URL_PATTERN.finditer(content):
|
|
837
|
+
env_name = match.group(1)
|
|
838
|
+
self._append_unique_external(result, f"env://{env_name}", relative, content, match.start())
|
|
839
|
+
target_service = self._service_from_env_name(env_name)
|
|
840
|
+
if target_service:
|
|
841
|
+
self._append_unique_hint(
|
|
842
|
+
result.edge_hints, EdgeHint, f"edge:{service_name}->{target_service}", relative,
|
|
843
|
+
content=content, offset=match.start(), confidence=0.5,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
def _extract_async_worker_edges(
|
|
847
|
+
self, content: str, relative: str, service_name: str, result: ScanResult
|
|
848
|
+
) -> None:
|
|
849
|
+
for match in BULLMQ_QUEUE_NAME_PATTERN.finditer(content):
|
|
850
|
+
queue = match.group(1)
|
|
851
|
+
self._append_unique_external(result, f"queue://bullmq/{queue}", relative, content, match.start())
|
|
852
|
+
self._append_unique_hint(
|
|
853
|
+
result.edge_hints, EdgeHint, f"edge:{service_name}->queue:{queue}", relative,
|
|
854
|
+
content=content, offset=match.start(), confidence=0.8,
|
|
855
|
+
)
|
|
856
|
+
for match in KAFKA_TOPIC_PATTERN.finditer(content):
|
|
857
|
+
topic = match.group(1) or match.group(2)
|
|
858
|
+
if not topic:
|
|
859
|
+
continue
|
|
860
|
+
self._append_unique_external(result, f"queue://kafka/{topic}", relative, content, match.start())
|
|
861
|
+
self._append_unique_hint(
|
|
862
|
+
result.edge_hints, EdgeHint, f"edge:{service_name}->topic:{topic}", relative,
|
|
863
|
+
content=content, offset=match.start(), confidence=0.8,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
def _extract_datastore_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
867
|
+
for pattern, kind in DB_PATTERNS:
|
|
868
|
+
match = pattern.search(content)
|
|
869
|
+
if match:
|
|
870
|
+
self._append_unique_database(result, kind, relative, content, match.start())
|
|
871
|
+
|
|
872
|
+
def _extract_auth_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
873
|
+
for pattern, hint in AUTH_PATTERNS:
|
|
874
|
+
match = pattern.search(content)
|
|
875
|
+
if match:
|
|
876
|
+
self._append_unique_hint(
|
|
877
|
+
result.auth_hints, AuthHint, hint, relative, content=content, offset=match.start()
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
881
|
+
for pattern in SECRET_PATTERNS:
|
|
882
|
+
for match in pattern.finditer(content):
|
|
883
|
+
self._append_unique_secret(result, match.group(1), relative, content, match.start())
|
|
884
|
+
for match in EXPO_PUBLIC_PATTERN.finditer(content):
|
|
885
|
+
# Expo prefix means the value is inlined into the client
|
|
886
|
+
# bundle at build time — an important distinction downstream
|
|
887
|
+
# findings should elevate. Tag it with an "expo_public:" prefix
|
|
888
|
+
# so the finder can recognize it.
|
|
889
|
+
self._append_unique_secret(result, f"expo_public:{match.group(1)}", relative, content, match.start())
|
|
890
|
+
|
|
891
|
+
def _emit_workspace_service_hints(self, root: Path, result: ScanResult) -> None:
|
|
892
|
+
"""Read package.json `workspaces` (npm/yarn) and pnpm-workspace.yaml.
|
|
893
|
+
|
|
894
|
+
For each workspace package, emit a `service_name:<pkg>` service hint
|
|
895
|
+
anchored at the package's package.json `"name"` line. This gives downstream
|
|
896
|
+
threat-model chain builders visibility into repos where services
|
|
897
|
+
live outside `services/*` / `packages/*`.
|
|
898
|
+
"""
|
|
899
|
+
seen: set[str] = set()
|
|
900
|
+
# One pruned walk for every nested package.json; workspace globs are
|
|
901
|
+
# matched against these repo-relative paths instead of `root.glob()`,
|
|
902
|
+
# which descended node_modules and followed symlinks out of the repo.
|
|
903
|
+
package_jsons: list[str] | None = None
|
|
904
|
+
|
|
905
|
+
def matching(pattern: str) -> list[Path]:
|
|
906
|
+
nonlocal package_jsons
|
|
907
|
+
if package_jsons is None:
|
|
908
|
+
package_jsons = [
|
|
909
|
+
rel(path, root) for path in iter_repo_files(root, names={"package.json"}, skip_dirs=SKIP_DIRS)
|
|
910
|
+
]
|
|
911
|
+
wanted = [seg for seg in pattern.strip().strip("/").split("/") if seg not in {"", "."}]
|
|
912
|
+
return [
|
|
913
|
+
root / relative
|
|
914
|
+
for relative in package_jsons
|
|
915
|
+
if self._workspace_glob_match(wanted, relative.split("/")[:-1])
|
|
916
|
+
]
|
|
917
|
+
|
|
918
|
+
root_pkg_text = read_source(root / "package.json", root=root)
|
|
919
|
+
if root_pkg_text is not None:
|
|
920
|
+
try:
|
|
921
|
+
data = json.loads(root_pkg_text)
|
|
922
|
+
except json.JSONDecodeError:
|
|
923
|
+
data = None
|
|
924
|
+
patterns = data.get("workspaces") if isinstance(data, dict) else None
|
|
925
|
+
if isinstance(patterns, dict):
|
|
926
|
+
patterns = patterns.get("packages", [])
|
|
927
|
+
if isinstance(patterns, list):
|
|
928
|
+
for pattern in patterns:
|
|
929
|
+
if not isinstance(pattern, str):
|
|
930
|
+
continue
|
|
931
|
+
for pkg_json in matching(pattern):
|
|
932
|
+
self._record_workspace_service(pkg_json, root, seen, result)
|
|
933
|
+
|
|
934
|
+
text = read_source(root / "pnpm-workspace.yaml", root=root)
|
|
935
|
+
if text is not None:
|
|
936
|
+
# Lightweight yaml: pull quoted `packages:` list entries
|
|
937
|
+
for pattern_match in re.finditer(r"-\s*['\"]?([^'\"\n]+)['\"]?", text):
|
|
938
|
+
pattern = pattern_match.group(1).strip()
|
|
939
|
+
if not pattern or pattern.startswith("#"):
|
|
940
|
+
continue
|
|
941
|
+
for pkg_json in matching(pattern):
|
|
942
|
+
self._record_workspace_service(pkg_json, root, seen, result)
|
|
943
|
+
|
|
944
|
+
@staticmethod
|
|
945
|
+
def _workspace_glob_match(pattern: list[str], parts: list[str]) -> bool:
|
|
946
|
+
"""Match a workspace glob (``packages/*``, ``apps/**``) against dir parts."""
|
|
947
|
+
if not pattern:
|
|
948
|
+
return not parts
|
|
949
|
+
head, rest = pattern[0], pattern[1:]
|
|
950
|
+
if head == "**":
|
|
951
|
+
return any(
|
|
952
|
+
NodeServiceAnalyzer._workspace_glob_match(rest, parts[i:]) for i in range(len(parts) + 1)
|
|
953
|
+
)
|
|
954
|
+
return bool(parts) and fnmatch.fnmatchcase(parts[0], head) and NodeServiceAnalyzer._workspace_glob_match(
|
|
955
|
+
rest, parts[1:]
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
def _record_workspace_service(
|
|
959
|
+
self, pkg_json: Path, root: Path, seen: set[str], result: ScanResult
|
|
960
|
+
) -> None:
|
|
961
|
+
pkg_name = self._package_name_from(pkg_json, root)
|
|
962
|
+
if not pkg_name:
|
|
963
|
+
return
|
|
964
|
+
short = pkg_name.split("/")[-1].lower()
|
|
965
|
+
if short in seen:
|
|
966
|
+
return
|
|
967
|
+
seen.add(short)
|
|
968
|
+
relative = rel(pkg_json, root)
|
|
969
|
+
text = read_source(pkg_json, root=root) or ""
|
|
970
|
+
name_match = re.search(r'"name"\s*:', text)
|
|
971
|
+
offset = name_match.start() if name_match else None
|
|
972
|
+
for hint in (f"service_name:{short}", f"workspace_package:{pkg_name}"):
|
|
973
|
+
self._append_unique_hint(
|
|
974
|
+
result.service_hints, ServiceHint, hint, relative,
|
|
975
|
+
content=text, offset=offset, confidence=0.9,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
def _infer_service_name(self, relative: str, root_package_name: str | None) -> str:
|
|
979
|
+
normalized = relative.replace("\\", "/")
|
|
980
|
+
parts = normalized.split("/")
|
|
981
|
+
if len(parts) >= 2 and parts[0] in {"services", "packages", "apps"}:
|
|
982
|
+
return parts[1].lower()
|
|
983
|
+
if root_package_name:
|
|
984
|
+
return root_package_name.lower().split("/")[-1]
|
|
985
|
+
return "repo-service"
|
|
986
|
+
|
|
987
|
+
@staticmethod
|
|
988
|
+
def _infer_service_role(service_name: str, relative: str) -> str:
|
|
989
|
+
name = f"{service_name} {relative}".lower()
|
|
990
|
+
if any(token in name for token in {"api", "server", "gateway", "http"}):
|
|
991
|
+
return "api"
|
|
992
|
+
if any(token in name for token in {"worker", "queue", "job", "consumer"}):
|
|
993
|
+
return "worker"
|
|
994
|
+
if any(token in name for token in {"store", "db", "cache", "redis"}):
|
|
995
|
+
return "storage"
|
|
996
|
+
return "service"
|
|
997
|
+
|
|
998
|
+
@staticmethod
|
|
999
|
+
def _service_from_url(target: str) -> str | None:
|
|
1000
|
+
try:
|
|
1001
|
+
host = urlparse(target).hostname or ""
|
|
1002
|
+
except ValueError:
|
|
1003
|
+
return None
|
|
1004
|
+
if not host:
|
|
1005
|
+
return None
|
|
1006
|
+
if host in {"localhost", "127.0.0.1"}:
|
|
1007
|
+
return "local-service"
|
|
1008
|
+
return host.split(".")[0].split("-")[0].lower()
|
|
1009
|
+
|
|
1010
|
+
@staticmethod
|
|
1011
|
+
def _service_from_env_name(env_name: str) -> str | None:
|
|
1012
|
+
token = env_name.upper().removesuffix("_URL")
|
|
1013
|
+
if token.endswith("_SERVICE"):
|
|
1014
|
+
token = token.removesuffix("_SERVICE")
|
|
1015
|
+
token = token.replace("__", "_").strip("_")
|
|
1016
|
+
if not token:
|
|
1017
|
+
return None
|
|
1018
|
+
return token.lower().replace("_", "-")
|
|
1019
|
+
|
|
1020
|
+
@staticmethod
|
|
1021
|
+
def _package_name_from(path: Path, root: Path) -> str | None:
|
|
1022
|
+
text = read_source(path, root=root)
|
|
1023
|
+
if text is None:
|
|
1024
|
+
return None
|
|
1025
|
+
try:
|
|
1026
|
+
data = json.loads(text)
|
|
1027
|
+
except json.JSONDecodeError:
|
|
1028
|
+
return None
|
|
1029
|
+
if not isinstance(data, dict):
|
|
1030
|
+
return None
|
|
1031
|
+
name = data.get("name")
|
|
1032
|
+
if isinstance(name, str) and name.strip():
|
|
1033
|
+
return name.strip()
|
|
1034
|
+
return None
|
|
1035
|
+
|
|
1036
|
+
@staticmethod
|
|
1037
|
+
def _has_file(root: Path, name: str) -> bool:
|
|
1038
|
+
# Stops at the first match; never enters node_modules et al.
|
|
1039
|
+
return next(iter_repo_files(root, names={name}, skip_dirs=SKIP_DIRS), None) is not None
|
|
1040
|
+
|
|
1041
|
+
@staticmethod
|
|
1042
|
+
def _append_unique_route(
|
|
1043
|
+
result: ScanResult,
|
|
1044
|
+
path: str,
|
|
1045
|
+
method: str,
|
|
1046
|
+
file: str,
|
|
1047
|
+
line: int,
|
|
1048
|
+
*,
|
|
1049
|
+
auth: str = ra.UNKNOWN,
|
|
1050
|
+
guards: list[str] | None = None,
|
|
1051
|
+
guard_evidence: str | None = None,
|
|
1052
|
+
) -> int | None:
|
|
1053
|
+
"""Append a route once per (path, method, file); return its index, or
|
|
1054
|
+
None when it was already recorded."""
|
|
1055
|
+
key = (path, method, file)
|
|
1056
|
+
if any((item.path, item.method, item.file) == key for item in result.routes):
|
|
1057
|
+
return None
|
|
1058
|
+
result.routes.append(
|
|
1059
|
+
Route(
|
|
1060
|
+
path=path, method=method, file=file, line=line,
|
|
1061
|
+
auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
return len(result.routes) - 1
|
|
1065
|
+
|
|
1066
|
+
@staticmethod
|
|
1067
|
+
def _append_route(result: ScanResult, path: str, method: str, file: str, line: int) -> int:
|
|
1068
|
+
"""Append a route whose final path and auth are resolved after the walk
|
|
1069
|
+
(``_expand_routes`` deduplicates)."""
|
|
1070
|
+
result.routes.append(Route(path=path, method=method, file=file, line=line))
|
|
1071
|
+
return len(result.routes) - 1
|
|
1072
|
+
|
|
1073
|
+
@staticmethod
|
|
1074
|
+
def _with_auth(route: Route, path: str, resolution: ra.Resolution) -> Route:
|
|
1075
|
+
"""Declare a route's path and auth (AttackMap#256); older cores ignore
|
|
1076
|
+
the auth fields, and the file-level `nestjs_guard` /
|
|
1077
|
+
`passport_authenticate` hints stay for one release. Rebuilt rather than
|
|
1078
|
+
mutated so Route's guard_evidence redaction runs."""
|
|
1079
|
+
known = resolution.auth != ra.UNKNOWN
|
|
1080
|
+
return Route(
|
|
1081
|
+
path=path, method=route.method, file=route.file, line=route.line,
|
|
1082
|
+
auth=resolution.auth if known else route.auth,
|
|
1083
|
+
guards=list(resolution.guards) if known else list(route.guards),
|
|
1084
|
+
guard_evidence=resolution.evidence if known else route.guard_evidence,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
@staticmethod
|
|
1088
|
+
def _append_unique_external(
|
|
1089
|
+
result: ScanResult, target: str, file: str, content: str, offset: int, *, method: str | None = None
|
|
1090
|
+
) -> None:
|
|
1091
|
+
key = (target, method, file)
|
|
1092
|
+
if any((item.target, item.method, item.file) == key for item in result.external_calls):
|
|
1093
|
+
return
|
|
1094
|
+
line = line_of(content, offset)
|
|
1095
|
+
result.external_calls.append(
|
|
1096
|
+
ExternalCall(
|
|
1097
|
+
target=target, file=file, line=line, method=method,
|
|
1098
|
+
evidence_text=line_snippet(content, line) or target,
|
|
1099
|
+
)
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
@staticmethod
|
|
1103
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, content: str, offset: int) -> None:
|
|
1104
|
+
key = (kind, file)
|
|
1105
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
1106
|
+
return
|
|
1107
|
+
line = line_of(content, offset)
|
|
1108
|
+
result.databases.append(
|
|
1109
|
+
DatabaseHint(kind=kind, file=file, line=line, evidence_text=line_snippet(content, line) or kind)
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
@staticmethod
|
|
1113
|
+
def _append_unique_hint(
|
|
1114
|
+
bucket: list,
|
|
1115
|
+
model: type,
|
|
1116
|
+
hint: str,
|
|
1117
|
+
file: str,
|
|
1118
|
+
*,
|
|
1119
|
+
content: str | None = None,
|
|
1120
|
+
offset: int | None = None,
|
|
1121
|
+
evidence: str | None = None,
|
|
1122
|
+
confidence: float | None = None,
|
|
1123
|
+
) -> None:
|
|
1124
|
+
"""Append a typed hint (Auth/Service/Edge/Entrypoint/Protocol) once per (hint, file).
|
|
1125
|
+
|
|
1126
|
+
With ``content`` + ``offset`` the hint is located at that line and
|
|
1127
|
+
quotes it; without (path-derived hints) it is anchored at line 1 and
|
|
1128
|
+
``evidence`` explains where it came from.
|
|
1129
|
+
"""
|
|
1130
|
+
if any((item.hint, item.file) == (hint, file) for item in bucket):
|
|
1131
|
+
return
|
|
1132
|
+
if content is not None and offset is not None:
|
|
1133
|
+
line = line_of(content, offset)
|
|
1134
|
+
evidence_text = line_snippet(content, line) or evidence or hint
|
|
1135
|
+
else:
|
|
1136
|
+
line = 1
|
|
1137
|
+
evidence_text = evidence or hint
|
|
1138
|
+
extra = {"confidence": confidence} if confidence is not None else {}
|
|
1139
|
+
bucket.append(model(hint=hint, file=file, line=line, evidence_text=evidence_text, **extra))
|
|
1140
|
+
|
|
1141
|
+
@staticmethod
|
|
1142
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, content: str, offset: int) -> None:
|
|
1143
|
+
key = (name, file)
|
|
1144
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
1145
|
+
return
|
|
1146
|
+
line = line_of(content, offset)
|
|
1147
|
+
result.secret_hints.append(
|
|
1148
|
+
SecretHint(name=name, file=file, line=line, evidence_text=line_snippet(content, line) or name)
|
|
1149
|
+
)
|