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.
@@ -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
+ )