attackmap-analyzer-node-service 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of attackmap-analyzer-node-service might be problematic. Click here for more details.

@@ -8,66 +8,175 @@ from urllib.parse import urlparse
8
8
  from .contracts import AnalyzerMetadata, AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
9
9
 
10
10
  CODE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx"}
11
- SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".turbo"}
11
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".turbo", ".svelte-kit", "out"}
12
12
 
13
+ # ---- Route extraction ---------------------------------------------------------
14
+
15
+ # Express / Hono / Koa / Elysia share this shape: `<var>.<verb>('/path', ...)`.
16
+ # They're structurally indistinguishable at the regex level; framework
17
+ # discrimination happens via ENTRYPOINT_PATTERNS below.
13
18
  EXPRESS_ROUTE_PATTERN = re.compile(
14
- r"\b(?:app|router)\.(get|post|put|patch|delete|options|head)\(\s*['\"]([^'\"]+)['\"]",
19
+ r"\b([A-Za-z_][A-Za-z0-9_]*)\.(get|post|put|patch|delete|options|head|all)\(\s*['\"]([^'\"]+)['\"]",
15
20
  re.IGNORECASE,
16
21
  )
17
22
  FASTIFY_ROUTE_PATTERN = re.compile(
18
23
  r"\bfastify\.(get|post|put|patch|delete|options|head)\(\s*['\"]([^'\"]+)['\"]",
19
24
  re.IGNORECASE,
20
25
  )
26
+
27
+ # `app.use('/prefix', router)` — subsequent routes on `router` inherit the prefix
28
+ # in the same file. Captures (owner_var, prefix, mounted_var).
29
+ EXPRESS_USE_MOUNT_PATTERN = re.compile(
30
+ r"\b([A-Za-z_][A-Za-z0-9_]*)\.use\(\s*['\"]([^'\"]+)['\"]\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)",
31
+ )
32
+
33
+ # Chained `router.route('/path').get(...).post(...)` shape. Handler bodies
34
+ # often contain their own parens (`res.status(200).end()`), so a single
35
+ # balanced-paren regex won't hold. Match the `.route(...)` opener alone
36
+ # and scan forward from its end with CHAIN_VERB_PATTERN for the chained
37
+ # verbs, terminating at the first line that doesn't start with `.`.
38
+ EXPRESS_ROUTE_OPENER_PATTERN = re.compile(
39
+ r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*route\(\s*['\"]([^'\"]+)['\"]\s*\)",
40
+ )
41
+ CHAIN_VERB_PATTERN = re.compile(
42
+ r"\.\s*(get|post|put|patch|delete|options|head|all)\s*\(",
43
+ re.IGNORECASE,
44
+ )
45
+ CHAIN_WINDOW = 800 # chars scanned after `.route(...)` for chained verbs
46
+
47
+ # NestJS decorators. `@Controller('users')` above a class binds a prefix;
48
+ # `@Get('active')` / `@Post()` / `@All('*')` on methods declare routes.
49
+ # The class itself is detected via `NESTJS_CONTROLLER_PATTERN`; per-method
50
+ # routes via `NESTJS_METHOD_PATTERN`.
51
+ NESTJS_CONTROLLER_PATTERN = re.compile(
52
+ r"@Controller\(\s*(?:['\"]([^'\"]*)['\"]\s*)?\)",
53
+ )
54
+ NESTJS_METHOD_PATTERN = re.compile(
55
+ r"@(Get|Post|Put|Patch|Delete|Options|Head|All)\(\s*(?:['\"]([^'\"]*)['\"]\s*)?\)",
56
+ re.IGNORECASE,
57
+ )
58
+
59
+ # tRPC: `t.procedure.query(...)`, `t.procedure.mutation(...)`, etc. Also
60
+ # `router({ foo: publicProcedure.query(...) })`. Emit the procedure name
61
+ # as the route "path" prefixed with `trpc:` so downstream consumers know
62
+ # it's not a URL.
63
+ TRPC_PROCEDURE_PATTERN = re.compile(
64
+ r"\b([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?:publicProcedure|protectedProcedure|t\.procedure|procedure)\s*\.\s*(query|mutation|subscription)\s*\(",
65
+ )
66
+
67
+ # AT Protocol / XRPC handler registration. The Bluesky server registers
68
+ # handlers per NSID via `server.method('com.atproto.foo', handler)` or
69
+ # `xrpc.method('app.bsky.feed.getFeed', handler)`. Also captures the
70
+ # `createServer({ 'com.atproto.foo': handler })` object-literal form.
71
+ XRPC_HANDLER_PATTERN = re.compile(
72
+ r"\b(?:server|xrpc)\.method\(\s*['\"]((?:com|app|tools|chat)\.[A-Za-z0-9_.]+)['\"]",
73
+ )
74
+ XRPC_LEXICON_KEY_PATTERN = re.compile(
75
+ r"['\"]((?:com|app|tools|chat)\.[A-Za-z0-9_.]{3,})['\"]\s*:\s*(?:async\s*)?(?:\([^)]*\)|function|handler)",
76
+ )
77
+
78
+ # NextJS App-Router file-based routes: `app/**/route.ts` where each HTTP
79
+ # method is a named export (`export async function GET(...)`). Detected
80
+ # by combining file path + exported function name.
81
+ NEXTJS_APP_ROUTE_EXPORT_PATTERN = re.compile(
82
+ r"^\s*export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(",
83
+ re.MULTILINE,
84
+ )
85
+
86
+ # ---- Entrypoints --------------------------------------------------------------
87
+
21
88
  ENTRYPOINT_PATTERNS = [
22
89
  (re.compile(r"\bapp\.listen\s*\(", re.IGNORECASE), "entrypoint:express_listen"),
23
90
  (re.compile(r"\bcreateServer\s*\(", re.IGNORECASE), "entrypoint:create_server"),
24
91
  (re.compile(r"\bfastify\s*\(", re.IGNORECASE), "entrypoint:fastify_server"),
25
- (re.compile(r"\bnew\s+Hono\s*\(", re.IGNORECASE), "entrypoint:hono_app"),
92
+ (re.compile(r"\bnew\s+Hono\s*\("), "entrypoint:hono_app"),
93
+ (re.compile(r"\bnew\s+Elysia\s*\("), "entrypoint:elysia_app"),
94
+ (re.compile(r"\bnew\s+Koa\s*\("), "entrypoint:koa_app"),
26
95
  (re.compile(r"\bregisterHandler\b|\bregisterRoute\b", re.IGNORECASE), "entrypoint:handler_registration"),
96
+ # NestJS. `NestFactory.create(AppModule)` is the canonical bootstrap.
97
+ (re.compile(r"\bNestFactory\.create\s*\("), "entrypoint:nestjs_bootstrap"),
98
+ (re.compile(r"@Module\s*\(\s*\{"), "entrypoint:nestjs_module"),
99
+ # tRPC server bootstrap
100
+ (re.compile(r"\bcreateHTTPServer\s*\(\s*\{"), "entrypoint:trpc_http_server"),
101
+ # XRPC / AT Protocol server
102
+ (re.compile(r"\bcreateServer\s*\(\s*(?:LEXICONS|lexicons|schemas)"), "entrypoint:xrpc_server"),
103
+ # Async workers
104
+ (re.compile(r"\bnew\s+Worker\s*\(\s*['\"]"), "entrypoint:bullmq_worker"),
105
+ (re.compile(r"\bnew\s+Queue\s*\(\s*['\"]"), "entrypoint:bullmq_queue"),
106
+ (re.compile(r"\bconsumer\.subscribe\s*\("), "entrypoint:kafka_consumer"),
107
+ (re.compile(r"\bkafka\.consumer\s*\("), "entrypoint:kafka_consumer"),
108
+ (re.compile(r"\bproducer\.send\s*\("), "entrypoint:kafka_producer"),
27
109
  ]
28
110
 
111
+ # BullMQ worker/queue name lets us cite the async edge target.
112
+ BULLMQ_QUEUE_NAME_PATTERN = re.compile(
113
+ r"\bnew\s+(?:Worker|Queue)\s*\(\s*['\"]([A-Za-z0-9_.-]+)['\"]",
114
+ )
115
+ # Kafka topic edges — `consumer.subscribe({ topic: 'foo' })` or `topics: ['a','b']`.
116
+ KAFKA_TOPIC_PATTERN = re.compile(
117
+ r"\btopics?\s*:\s*(?:['\"]([A-Za-z0-9_.-]+)['\"]|\[\s*['\"]([A-Za-z0-9_.-]+)['\"])",
118
+ )
119
+
120
+ # ---- Outbound HTTP / env-driven edges ----------------------------------------
121
+
29
122
  OUTBOUND_PATTERNS = [
30
123
  re.compile(r"\bfetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
31
124
  re.compile(r"\baxios\.(?:get|post|put|patch|delete)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
32
125
  re.compile(r"\bgot(?:\.(?:get|post|put|patch|delete))?\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
33
126
  re.compile(r"\bundici\.(?:request|fetch)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
127
+ re.compile(r"\bofetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
34
128
  ]
35
129
  ENV_URL_PATTERN = re.compile(r"process\.env\.([A-Z0-9_]+_URL)\b")
36
130
 
131
+ # ---- Data stores --------------------------------------------------------------
132
+
37
133
  DB_PATTERNS = [
38
134
  (re.compile(r"\bnew\s+PrismaClient\s*\(", re.IGNORECASE), "sql"),
39
135
  (re.compile(r"\bPool\s*\(\s*\{", re.IGNORECASE), "postgresql"),
40
136
  (re.compile(r"\bpg\b|\bnode-postgres\b", re.IGNORECASE), "postgresql"),
41
137
  (re.compile(r"\bknex\s*\(", re.IGNORECASE), "sql"),
138
+ (re.compile(r"\bdrizzle\s*\(", re.IGNORECASE), "sql"),
42
139
  (re.compile(r"\bmongoose\.connect\s*\(", re.IGNORECASE), "mongodb"),
43
140
  (re.compile(r"\bMongoClient\s*\(", re.IGNORECASE), "mongodb"),
44
141
  (re.compile(r"\bredis\.createClient\s*\(", re.IGNORECASE), "redis"),
45
142
  (re.compile(r"\bioredis\b|\bnew\s+Redis\s*\(", re.IGNORECASE), "redis"),
46
- (re.compile(r"\bS3Client\b|\bPutObjectCommand\b|\bGetObjectCommand\b", re.IGNORECASE), "object_storage"),
143
+ (re.compile(r"\bS3Client\b|\bPutObjectCommand\b|\bGetObjectCommand\b"), "object_storage"),
144
+ (re.compile(r"\bnew\s+SQLite3?\b|\bbetter-sqlite3\b", re.IGNORECASE), "sqlite"),
47
145
  ]
48
146
 
147
+ # ---- Auth signals -------------------------------------------------------------
148
+
49
149
  AUTH_PATTERNS = [
50
- (re.compile(r"\bAuthorization\b", re.IGNORECASE), "authorization_header"),
51
- (re.compile(r"\bBearer\b", re.IGNORECASE), "bearer_token"),
150
+ (re.compile(r"\bAuthorization\b"), "authorization_header"),
151
+ (re.compile(r"\bBearer\b"), "bearer_token"),
52
152
  (re.compile(r"\bjwt\b|\bjsonwebtoken\b", re.IGNORECASE), "jwt"),
53
- (re.compile(r"\bverify\b\s*\(", re.IGNORECASE), "signature_verify"),
54
- (re.compile(r"\bsign\b\s*\(", re.IGNORECASE), "signature_sign"),
153
+ (re.compile(r"\bverify\s*\(", re.IGNORECASE), "signature_verify"),
154
+ (re.compile(r"\bsign\s*\(", re.IGNORECASE), "signature_sign"),
55
155
  (re.compile(r"\boauth\b|\boidc\b", re.IGNORECASE), "oauth"),
56
- (re.compile(r"\btoken\b", re.IGNORECASE), "token"),
156
+ (re.compile(r"\bDPoP\b|\bdpop\b"), "dpop"),
157
+ (re.compile(r"\bpassport\.authenticate\b", re.IGNORECASE), "passport_authenticate"),
158
+ (re.compile(r"@UseGuards\("), "nestjs_guard"),
57
159
  ]
58
160
 
161
+ # ---- Secrets ------------------------------------------------------------------
162
+
59
163
  SECRET_PATTERNS = [
60
164
  re.compile(r"process\.env\.([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)"),
61
165
  ]
166
+ # EXPO_PUBLIC_* vars are inlined into the client bundle at build time —
167
+ # any secret named this way is effectively public. Emitted as a normal
168
+ # secret hint with the "expo_public:" name prefix so downstream findings
169
+ # can elevate them.
170
+ EXPO_PUBLIC_PATTERN = re.compile(r"process\.env\.(EXPO_PUBLIC_[A-Z0-9_]+)")
62
171
 
63
172
 
64
173
  class NodeServiceAnalyzer:
65
174
  metadata = AnalyzerMetadata(
66
175
  name="node-service",
67
176
  display_name="Node Service Analyzer",
68
- version="0.1.0",
69
- description="Broad Node.js/TypeScript service analyzer for distributed backend repositories.",
70
- scope="Node/TypeScript backend service repos with service boundaries, handler registration, and inter-service calls.",
177
+ version="0.2.0",
178
+ description="Broad Node.js/TypeScript service analyzer covering Express/Fastify/NestJS/Hono/Koa/Elysia/tRPC + NextJS routes, XRPC handlers, workspaces, async workers.",
179
+ scope="Node/TypeScript backend and monorepo repositories with service boundaries, framework handlers, RPC handlers, and inter-service/async messaging.",
71
180
  targets=["node-service", "node", "typescript-service"],
72
181
  languages=["javascript", "typescript"],
73
182
  priority=25,
@@ -86,9 +195,11 @@ class NodeServiceAnalyzer:
86
195
 
87
196
  has_package = (root / "package.json").exists() or any(root.glob("**/package.json"))
88
197
  has_typescript = (root / "tsconfig.json").exists() or any(root.glob("**/tsconfig.json"))
89
- has_service_dirs = (root / "services").is_dir() or (root / "packages").is_dir()
198
+ has_service_dirs = (root / "services").is_dir() or (root / "packages").is_dir() or (root / "apps").is_dir()
199
+ has_pnpm_workspace = (root / "pnpm-workspace.yaml").exists()
200
+ has_nx_or_turbo = (root / "nx.json").exists() or (root / "turbo.json").exists()
90
201
 
91
- if has_package and (has_typescript or has_service_dirs):
202
+ if has_package and (has_typescript or has_service_dirs or has_pnpm_workspace or has_nx_or_turbo):
92
203
  return True
93
204
 
94
205
  for file_path in root.rglob("*"):
@@ -99,7 +210,10 @@ class NodeServiceAnalyzer:
99
210
  content = self._read_text(file_path)
100
211
  if not content:
101
212
  continue
102
- if re.search(r"\bapp\.listen\b|\bfastify\b|\bcreateServer\b|\bservices?/|packages?/", content, re.IGNORECASE):
213
+ if re.search(
214
+ r"\bapp\.listen\b|\bfastify\b|\bcreateServer\b|\bNestFactory\b|\bnew\s+Hono\b|\bcreateHTTPServer\b|services?/|packages?/",
215
+ content,
216
+ ):
103
217
  return True
104
218
  return False
105
219
 
@@ -110,6 +224,11 @@ class NodeServiceAnalyzer:
110
224
  return result
111
225
 
112
226
  root_package_name = self._package_name_from(root / "package.json")
227
+
228
+ # Workspaces contribute service topology hints (one per workspace
229
+ # package). Done once, up front, then per-file walk fills in the rest.
230
+ self._emit_workspace_service_hints(root, result)
231
+
113
232
  for file_path in root.rglob("*"):
114
233
  if not file_path.is_file() or file_path.suffix not in CODE_SUFFIXES:
115
234
  continue
@@ -125,7 +244,7 @@ class NodeServiceAnalyzer:
125
244
  if content is None:
126
245
  continue
127
246
 
128
- relative = str(file_path.relative_to(root))
247
+ relative = str(file_path.relative_to(root)).replace("\\", "/")
129
248
  service_name = self._infer_service_name(relative, root_package_name)
130
249
  service_role = self._infer_service_role(service_name, relative)
131
250
 
@@ -133,8 +252,13 @@ class NodeServiceAnalyzer:
133
252
  self._append_unique_auth(result, f"service_role:{service_role}", relative)
134
253
 
135
254
  self._extract_routes(content, relative, result)
255
+ self._extract_nestjs_routes(content, relative, result)
256
+ self._extract_trpc_procedures(content, relative, result)
257
+ self._extract_xrpc_handlers(content, relative, result)
258
+ self._extract_nextjs_file_routes(file_path, root, content, result)
136
259
  self._extract_entrypoint_hints(content, relative, result)
137
260
  self._extract_outbound_and_edges(content, relative, service_name, result)
261
+ self._extract_async_worker_edges(content, relative, service_name, result)
138
262
  self._extract_datastore_hints(content, relative, result)
139
263
  self._extract_auth_hints(content, relative, result)
140
264
  self._extract_secret_hints(content, relative, result)
@@ -142,20 +266,169 @@ class NodeServiceAnalyzer:
142
266
  result.languages.sort()
143
267
  return result
144
268
 
269
+ # ---- Route extractors ----
270
+
145
271
  def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
272
+ prefixes = self._collect_express_prefixes(content)
273
+
146
274
  for match in EXPRESS_ROUTE_PATTERN.finditer(content):
147
- method, path = match.group(1).upper(), match.group(2)
148
- self._append_unique_route(result, path, method, relative)
275
+ owner, method, path = match.group(1), match.group(2).upper(), match.group(3)
276
+ prefix = prefixes.get(owner, "")
277
+ full_path = self._join_route(prefix, path)
278
+ self._append_unique_route(result, full_path, method, relative)
149
279
  for match in FASTIFY_ROUTE_PATTERN.finditer(content):
150
280
  method, path = match.group(1).upper(), match.group(2)
151
281
  self._append_unique_route(result, path, method, relative)
152
282
 
283
+ # `router.route('/x').get(...).post(...)`
284
+ for match in EXPRESS_ROUTE_OPENER_PATTERN.finditer(content):
285
+ owner, path = match.group(1), match.group(2)
286
+ prefix = prefixes.get(owner, "")
287
+ full_path = self._join_route(prefix, path)
288
+ # Scan forward from the opener for a short window; grab every
289
+ # chained verb until the trail visibly ends (statement-ending
290
+ # semicolon or a non-chain identifier at line start).
291
+ window = content[match.end() : match.end() + CHAIN_WINDOW]
292
+ terminator = window.find(";")
293
+ if terminator != -1:
294
+ window = window[:terminator]
295
+ for verb_match in CHAIN_VERB_PATTERN.finditer(window):
296
+ self._append_unique_route(result, full_path, verb_match.group(1).upper(), relative)
297
+
298
+ def _collect_express_prefixes(self, content: str) -> dict[str, str]:
299
+ """Walk `app.use('/prefix', router)` mounts to build per-owner prefixes.
300
+
301
+ Chained mounts collapse: if `app.use('/api', v1)` and `v1.use('/orgs', orgs)`
302
+ both appear in the file, `orgs` inherits `/api/orgs`. Fixed-point
303
+ iteration keeps this simple; the graph is tiny in practice.
304
+ """
305
+ prefixes: dict[str, str] = {}
306
+ pairs = [
307
+ (owner, prefix, mounted)
308
+ for owner, prefix, mounted in EXPRESS_USE_MOUNT_PATTERN.findall(content)
309
+ ]
310
+ # Iterate until no more prefix propagation happens.
311
+ for _ in range(5):
312
+ changed = False
313
+ for owner, prefix, mounted in pairs:
314
+ parent_prefix = prefixes.get(owner, "")
315
+ new_prefix = self._join_route(parent_prefix, prefix)
316
+ if prefixes.get(mounted) != new_prefix:
317
+ prefixes[mounted] = new_prefix
318
+ changed = True
319
+ if not changed:
320
+ break
321
+ return prefixes
322
+
323
+ @staticmethod
324
+ def _join_route(prefix: str, path: str) -> str:
325
+ base = prefix.rstrip("/") if prefix else ""
326
+ # Prefixes may be declared without a leading slash (NestJS
327
+ # `@Controller("users")`, Express `app.use("api", ...)`) — normalize
328
+ # so the emitted path is always rooted at "/".
329
+ if base and not base.startswith("/"):
330
+ base = f"/{base}"
331
+ if path == "":
332
+ combined = base or "/"
333
+ else:
334
+ suffix = path if path.startswith("/") else f"/{path}"
335
+ combined = f"{base}{suffix}"
336
+ while "//" in combined:
337
+ combined = combined.replace("//", "/")
338
+ if combined != "/":
339
+ combined = combined.rstrip("/") or "/"
340
+ return combined
341
+
342
+ def _extract_nestjs_routes(self, content: str, relative: str, result: ScanResult) -> None:
343
+ # A file's @Controller() (there's typically one per file) sets the
344
+ # prefix; each @Get/@Post/etc. below it is a route.
345
+ controller_match = NESTJS_CONTROLLER_PATTERN.search(content)
346
+ if controller_match:
347
+ prefix = controller_match.group(1) or ""
348
+ else:
349
+ # No controller decorator on this file; nothing to do.
350
+ if not NESTJS_METHOD_PATTERN.search(content):
351
+ return
352
+ prefix = ""
353
+ for match in NESTJS_METHOD_PATTERN.finditer(content):
354
+ verb, sub_path = match.group(1).upper(), (match.group(2) or "")
355
+ full_path = self._join_route(prefix, sub_path)
356
+ method = "ANY" if verb == "ALL" else verb
357
+ self._append_unique_route(result, full_path, method, relative)
358
+
359
+ def _extract_trpc_procedures(self, content: str, relative: str, result: ScanResult) -> None:
360
+ for match in TRPC_PROCEDURE_PATTERN.finditer(content):
361
+ name, kind = match.group(1), match.group(2).lower()
362
+ method = "POST" if kind in {"mutation"} else "GET" if kind == "query" else "STREAM"
363
+ self._append_unique_route(result, f"trpc:{name}", method, relative)
364
+
365
+ def _extract_xrpc_handlers(self, content: str, relative: str, result: ScanResult) -> None:
366
+ seen: set[str] = set()
367
+ for match in XRPC_HANDLER_PATTERN.finditer(content):
368
+ nsid = match.group(1)
369
+ if nsid in seen:
370
+ continue
371
+ seen.add(nsid)
372
+ self._append_unique_route(result, f"/xrpc/{nsid}", "ANY", relative)
373
+ self._append_unique_auth(result, f"atproto_lexicon:{nsid}", relative)
374
+ for match in XRPC_LEXICON_KEY_PATTERN.finditer(content):
375
+ nsid = match.group(1)
376
+ if nsid in seen:
377
+ continue
378
+ seen.add(nsid)
379
+ self._append_unique_route(result, f"/xrpc/{nsid}", "ANY", relative)
380
+ self._append_unique_auth(result, f"atproto_lexicon:{nsid}", relative)
381
+
382
+ def _extract_nextjs_file_routes(
383
+ self, file_path: Path, root: Path, content: str, result: ScanResult
384
+ ) -> None:
385
+ relative = str(file_path.relative_to(root)).replace("\\", "/")
386
+ parts = relative.split("/")
387
+
388
+ # App Router: `app/**/route.ts` where each HTTP verb is a named export.
389
+ if file_path.name in {"route.ts", "route.tsx", "route.js", "route.mjs"} and "app" in parts:
390
+ app_idx = parts.index("app")
391
+ segments = parts[app_idx + 1 : -1] # everything between `app/` and `route.ts`
392
+ url_path = "/" + "/".join(self._nextjs_segment(s) for s in segments)
393
+ url_path = url_path.rstrip("/") or "/"
394
+ for match in NEXTJS_APP_ROUTE_EXPORT_PATTERN.finditer(content):
395
+ self._append_unique_route(result, url_path, match.group(1).upper(), relative)
396
+ return
397
+
398
+ # Pages Router: `pages/api/**/*.ts`. Every file under `pages/api/` is
399
+ # a route; the default export is the handler. We don't know the
400
+ # method(s) without deeper parsing, so we emit "ANY".
401
+ if "pages" in parts:
402
+ pages_idx = parts.index("pages")
403
+ if len(parts) > pages_idx + 1 and parts[pages_idx + 1] == "api":
404
+ segments = parts[pages_idx + 2 :]
405
+ # drop extension from the final segment
406
+ last = segments[-1].rsplit(".", 1)[0] if segments else ""
407
+ if last == "index":
408
+ segments = segments[:-1]
409
+ else:
410
+ segments = segments[:-1] + [last]
411
+ url_path = "/api/" + "/".join(self._nextjs_segment(s) for s in segments)
412
+ url_path = url_path.rstrip("/") or "/api"
413
+ self._append_unique_route(result, url_path, "ANY", relative)
414
+
415
+ @staticmethod
416
+ def _nextjs_segment(segment: str) -> str:
417
+ # NextJS dynamic segment: `[id]` → `:id`, `[...slug]` → `*slug`.
418
+ if segment.startswith("[...") and segment.endswith("]"):
419
+ return f"*{segment[4:-1]}"
420
+ if segment.startswith("[") and segment.endswith("]"):
421
+ return f":{segment[1:-1]}"
422
+ return segment
423
+
153
424
  def _extract_entrypoint_hints(self, content: str, relative: str, result: ScanResult) -> None:
154
425
  for pattern, hint in ENTRYPOINT_PATTERNS:
155
426
  if pattern.search(content):
156
427
  self._append_unique_auth(result, hint, relative)
157
428
 
158
- def _extract_outbound_and_edges(self, content: str, relative: str, service_name: str, result: ScanResult) -> None:
429
+ def _extract_outbound_and_edges(
430
+ self, content: str, relative: str, service_name: str, result: ScanResult
431
+ ) -> None:
159
432
  for pattern in OUTBOUND_PATTERNS:
160
433
  for match in pattern.finditer(content):
161
434
  target = match.group(1)
@@ -171,6 +444,20 @@ class NodeServiceAnalyzer:
171
444
  if target_service:
172
445
  self._append_unique_auth(result, f"edge:{service_name}->{target_service}", relative)
173
446
 
447
+ def _extract_async_worker_edges(
448
+ self, content: str, relative: str, service_name: str, result: ScanResult
449
+ ) -> None:
450
+ for match in BULLMQ_QUEUE_NAME_PATTERN.finditer(content):
451
+ queue = match.group(1)
452
+ self._append_unique_external(result, f"queue://bullmq/{queue}", relative)
453
+ self._append_unique_auth(result, f"edge:{service_name}->queue:{queue}", relative)
454
+ for match in KAFKA_TOPIC_PATTERN.finditer(content):
455
+ topic = match.group(1) or match.group(2)
456
+ if not topic:
457
+ continue
458
+ self._append_unique_external(result, f"queue://kafka/{topic}", relative)
459
+ self._append_unique_auth(result, f"edge:{service_name}->topic:{topic}", relative)
460
+
174
461
  def _extract_datastore_hints(self, content: str, relative: str, result: ScanResult) -> None:
175
462
  for pattern, kind in DB_PATTERNS:
176
463
  if pattern.search(content):
@@ -185,11 +472,70 @@ class NodeServiceAnalyzer:
185
472
  for pattern in SECRET_PATTERNS:
186
473
  for match in pattern.finditer(content):
187
474
  self._append_unique_secret(result, match.group(1), relative)
475
+ for match in EXPO_PUBLIC_PATTERN.finditer(content):
476
+ # Expo prefix means the value is inlined into the client
477
+ # bundle at build time — an important distinction downstream
478
+ # findings should elevate. Tag it with an "expo_public:" prefix
479
+ # so the finder can recognize it.
480
+ self._append_unique_secret(result, f"expo_public:{match.group(1)}", relative)
481
+
482
+ def _emit_workspace_service_hints(self, root: Path, result: ScanResult) -> None:
483
+ """Read package.json `workspaces` (npm/yarn) and pnpm-workspace.yaml.
484
+
485
+ For each workspace package, emit a `service_name:<pkg>` auth hint
486
+ anchored at the package's package.json. This gives downstream
487
+ threat-model chain builders visibility into repos where services
488
+ live outside `services/*` / `packages/*`.
489
+ """
490
+ seen: set[str] = set()
491
+ root_pkg = root / "package.json"
492
+ if root_pkg.exists():
493
+ try:
494
+ data = json.loads(root_pkg.read_text(encoding="utf-8"))
495
+ patterns = data.get("workspaces")
496
+ if isinstance(patterns, dict):
497
+ patterns = patterns.get("packages", [])
498
+ if isinstance(patterns, list):
499
+ for pattern in patterns:
500
+ for pkg_json in root.glob(f"{pattern}/package.json"):
501
+ self._record_workspace_service(pkg_json, root, seen, result)
502
+ except (UnicodeDecodeError, json.JSONDecodeError):
503
+ pass
504
+
505
+ pnpm = root / "pnpm-workspace.yaml"
506
+ if pnpm.exists():
507
+ try:
508
+ # Lightweight yaml: pull quoted `packages:` list entries
509
+ text = pnpm.read_text(encoding="utf-8")
510
+ for pattern_match in re.finditer(r"-\s*['\"]?([^'\"\n]+)['\"]?", text):
511
+ pattern = pattern_match.group(1).strip()
512
+ if not pattern or pattern.startswith("#"):
513
+ continue
514
+ for pkg_json in root.glob(f"{pattern}/package.json"):
515
+ self._record_workspace_service(pkg_json, root, seen, result)
516
+ except UnicodeDecodeError:
517
+ pass
518
+
519
+ def _record_workspace_service(
520
+ self, pkg_json: Path, root: Path, seen: set[str], result: ScanResult
521
+ ) -> None:
522
+ if any(part in SKIP_DIRS for part in pkg_json.parts):
523
+ return
524
+ pkg_name = self._package_name_from(pkg_json)
525
+ if not pkg_name:
526
+ return
527
+ short = pkg_name.split("/")[-1].lower()
528
+ if short in seen:
529
+ return
530
+ seen.add(short)
531
+ relative = str(pkg_json.relative_to(root)).replace("\\", "/")
532
+ self._append_unique_auth(result, f"service_name:{short}", relative)
533
+ self._append_unique_auth(result, f"workspace_package:{pkg_name}", relative)
188
534
 
189
535
  def _infer_service_name(self, relative: str, root_package_name: str | None) -> str:
190
536
  normalized = relative.replace("\\", "/")
191
537
  parts = normalized.split("/")
192
- if len(parts) >= 2 and parts[0] in {"services", "packages"}:
538
+ if len(parts) >= 2 and parts[0] in {"services", "packages", "apps"}:
193
539
  return parts[1].lower()
194
540
  if root_package_name:
195
541
  return root_package_name.lower().split("/")[-1]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: attackmap-analyzer-node-service
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Broad Node.js/TypeScript service analyzer plugin for AttackMap
5
5
  Author: AttackMap Contributors
6
6
  Author-email: Matthew Davis <matthewd@matthewd.xyz>
@@ -0,0 +1,9 @@
1
+ attackmap_analyzer_node_service/__init__.py,sha256=2f30LPbsX_sHS3g8Z3Gw2F2IQLm6KpfNpDQj3KWKUTw,77
2
+ attackmap_analyzer_node_service/analyzer.py,sha256=F9kOe5TpiR9N80lbcfXvxrjoUiiIUPrZZVERuIDMSSI,29887
3
+ attackmap_analyzer_node_service/contracts.py,sha256=uwshWT7fHGD_lTg8LGS-4JxuHlRQJ7ejBS92Ls3p6Vc,496
4
+ attackmap_analyzer_node_service-0.2.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
5
+ attackmap_analyzer_node_service-0.2.0.dist-info/METADATA,sha256=hBsS1X_Xbkb4GlHxRskngegiqVGrHUa1vItqrNQQI24,2568
6
+ attackmap_analyzer_node_service-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ attackmap_analyzer_node_service-0.2.0.dist-info/entry_points.txt,sha256=PPfCR-i9If3fa2v7AgkM8Bule6pnvUGqDCkBqLvYEIk,89
8
+ attackmap_analyzer_node_service-0.2.0.dist-info/top_level.txt,sha256=DwrLiRQLyrae24-tVGmY5U58jy2evKwVV5h5_e3xXhc,32
9
+ attackmap_analyzer_node_service-0.2.0.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- attackmap_analyzer_node_service/__init__.py,sha256=2f30LPbsX_sHS3g8Z3Gw2F2IQLm6KpfNpDQj3KWKUTw,77
2
- attackmap_analyzer_node_service/analyzer.py,sha256=EDruEivasmvn9Fl8fAfuObLNLTOGUNIXUVsVvCnUltQ,12492
3
- attackmap_analyzer_node_service/contracts.py,sha256=uwshWT7fHGD_lTg8LGS-4JxuHlRQJ7ejBS92Ls3p6Vc,496
4
- attackmap_analyzer_node_service-0.1.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
5
- attackmap_analyzer_node_service-0.1.0.dist-info/METADATA,sha256=2SEUQhZb9S_nqEwPZe82wCJkf4_LmPZilI2S81ETr6A,2568
6
- attackmap_analyzer_node_service-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
- attackmap_analyzer_node_service-0.1.0.dist-info/entry_points.txt,sha256=PPfCR-i9If3fa2v7AgkM8Bule6pnvUGqDCkBqLvYEIk,89
8
- attackmap_analyzer_node_service-0.1.0.dist-info/top_level.txt,sha256=DwrLiRQLyrae24-tVGmY5U58jy2evKwVV5h5_e3xXhc,32
9
- attackmap_analyzer_node_service-0.1.0.dist-info/RECORD,,