attackmap-analyzer-node-service 0.1.0__tar.gz → 0.2.0__tar.gz

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.

Files changed (17) hide show
  1. {attackmap_analyzer_node_service-0.1.0/src/attackmap_analyzer_node_service.egg-info → attackmap_analyzer_node_service-0.2.0}/PKG-INFO +1 -1
  2. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/pyproject.toml +1 -1
  3. attackmap_analyzer_node_service-0.2.0/src/attackmap_analyzer_node_service/analyzer.py +630 -0
  4. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0/src/attackmap_analyzer_node_service.egg-info}/PKG-INFO +1 -1
  5. attackmap_analyzer_node_service-0.2.0/tests/test_node_service_analyzer.py +270 -0
  6. attackmap_analyzer_node_service-0.1.0/src/attackmap_analyzer_node_service/analyzer.py +0 -284
  7. attackmap_analyzer_node_service-0.1.0/tests/test_node_service_analyzer.py +0 -74
  8. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/LICENSE +0 -0
  9. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/README.md +0 -0
  10. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/setup.cfg +0 -0
  11. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service/__init__.py +0 -0
  12. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service/contracts.py +0 -0
  13. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service.egg-info/SOURCES.txt +0 -0
  14. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service.egg-info/dependency_links.txt +0 -0
  15. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service.egg-info/entry_points.txt +0 -0
  16. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service.egg-info/requires.txt +0 -0
  17. {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service.egg-info/top_level.txt +0 -0
@@ -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>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "attackmap-analyzer-node-service"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Broad Node.js/TypeScript service analyzer plugin for AttackMap"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,630 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse
7
+
8
+ from .contracts import AnalyzerMetadata, AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
9
+
10
+ CODE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx"}
11
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".turbo", ".svelte-kit", "out"}
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.
18
+ EXPRESS_ROUTE_PATTERN = re.compile(
19
+ r"\b([A-Za-z_][A-Za-z0-9_]*)\.(get|post|put|patch|delete|options|head|all)\(\s*['\"]([^'\"]+)['\"]",
20
+ re.IGNORECASE,
21
+ )
22
+ FASTIFY_ROUTE_PATTERN = re.compile(
23
+ r"\bfastify\.(get|post|put|patch|delete|options|head)\(\s*['\"]([^'\"]+)['\"]",
24
+ re.IGNORECASE,
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
+
88
+ ENTRYPOINT_PATTERNS = [
89
+ (re.compile(r"\bapp\.listen\s*\(", re.IGNORECASE), "entrypoint:express_listen"),
90
+ (re.compile(r"\bcreateServer\s*\(", re.IGNORECASE), "entrypoint:create_server"),
91
+ (re.compile(r"\bfastify\s*\(", re.IGNORECASE), "entrypoint:fastify_server"),
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"),
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"),
109
+ ]
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
+
122
+ OUTBOUND_PATTERNS = [
123
+ re.compile(r"\bfetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
124
+ re.compile(r"\baxios\.(?:get|post|put|patch|delete)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
125
+ re.compile(r"\bgot(?:\.(?:get|post|put|patch|delete))?\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
126
+ re.compile(r"\bundici\.(?:request|fetch)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
127
+ re.compile(r"\bofetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
128
+ ]
129
+ ENV_URL_PATTERN = re.compile(r"process\.env\.([A-Z0-9_]+_URL)\b")
130
+
131
+ # ---- Data stores --------------------------------------------------------------
132
+
133
+ DB_PATTERNS = [
134
+ (re.compile(r"\bnew\s+PrismaClient\s*\(", re.IGNORECASE), "sql"),
135
+ (re.compile(r"\bPool\s*\(\s*\{", re.IGNORECASE), "postgresql"),
136
+ (re.compile(r"\bpg\b|\bnode-postgres\b", re.IGNORECASE), "postgresql"),
137
+ (re.compile(r"\bknex\s*\(", re.IGNORECASE), "sql"),
138
+ (re.compile(r"\bdrizzle\s*\(", re.IGNORECASE), "sql"),
139
+ (re.compile(r"\bmongoose\.connect\s*\(", re.IGNORECASE), "mongodb"),
140
+ (re.compile(r"\bMongoClient\s*\(", re.IGNORECASE), "mongodb"),
141
+ (re.compile(r"\bredis\.createClient\s*\(", re.IGNORECASE), "redis"),
142
+ (re.compile(r"\bioredis\b|\bnew\s+Redis\s*\(", re.IGNORECASE), "redis"),
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"),
145
+ ]
146
+
147
+ # ---- Auth signals -------------------------------------------------------------
148
+
149
+ AUTH_PATTERNS = [
150
+ (re.compile(r"\bAuthorization\b"), "authorization_header"),
151
+ (re.compile(r"\bBearer\b"), "bearer_token"),
152
+ (re.compile(r"\bjwt\b|\bjsonwebtoken\b", re.IGNORECASE), "jwt"),
153
+ (re.compile(r"\bverify\s*\(", re.IGNORECASE), "signature_verify"),
154
+ (re.compile(r"\bsign\s*\(", re.IGNORECASE), "signature_sign"),
155
+ (re.compile(r"\boauth\b|\boidc\b", re.IGNORECASE), "oauth"),
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"),
159
+ ]
160
+
161
+ # ---- Secrets ------------------------------------------------------------------
162
+
163
+ SECRET_PATTERNS = [
164
+ re.compile(r"process\.env\.([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)"),
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_]+)")
171
+
172
+
173
+ class NodeServiceAnalyzer:
174
+ metadata = AnalyzerMetadata(
175
+ name="node-service",
176
+ display_name="Node Service Analyzer",
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.",
180
+ targets=["node-service", "node", "typescript-service"],
181
+ languages=["javascript", "typescript"],
182
+ priority=25,
183
+ experimental=True,
184
+ enabled_by_default=False,
185
+ )
186
+
187
+ @property
188
+ def name(self) -> str:
189
+ return self.metadata.name
190
+
191
+ def detect(self, repo_path: str | Path) -> bool:
192
+ root = Path(repo_path).resolve()
193
+ if not root.exists() or not root.is_dir():
194
+ return False
195
+
196
+ has_package = (root / "package.json").exists() or any(root.glob("**/package.json"))
197
+ has_typescript = (root / "tsconfig.json").exists() or any(root.glob("**/tsconfig.json"))
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()
201
+
202
+ if has_package and (has_typescript or has_service_dirs or has_pnpm_workspace or has_nx_or_turbo):
203
+ return True
204
+
205
+ for file_path in root.rglob("*"):
206
+ if not file_path.is_file() or file_path.suffix not in CODE_SUFFIXES:
207
+ continue
208
+ if any(part in SKIP_DIRS for part in file_path.parts):
209
+ continue
210
+ content = self._read_text(file_path)
211
+ if not content:
212
+ continue
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
+ ):
217
+ return True
218
+ return False
219
+
220
+ def analyze(self, repo_path: str | Path) -> ScanResult:
221
+ root = Path(repo_path).resolve()
222
+ result = ScanResult(root=str(root))
223
+ if not root.exists() or not root.is_dir():
224
+ return result
225
+
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
+
232
+ for file_path in root.rglob("*"):
233
+ if not file_path.is_file() or file_path.suffix not in CODE_SUFFIXES:
234
+ continue
235
+ if any(part in SKIP_DIRS for part in file_path.parts):
236
+ continue
237
+
238
+ result.files_scanned += 1
239
+ language = "typescript" if file_path.suffix in {".ts", ".tsx"} else "javascript"
240
+ if language not in result.languages:
241
+ result.languages.append(language)
242
+
243
+ content = self._read_text(file_path)
244
+ if content is None:
245
+ continue
246
+
247
+ relative = str(file_path.relative_to(root)).replace("\\", "/")
248
+ service_name = self._infer_service_name(relative, root_package_name)
249
+ service_role = self._infer_service_role(service_name, relative)
250
+
251
+ self._append_unique_auth(result, f"service_name:{service_name}", relative)
252
+ self._append_unique_auth(result, f"service_role:{service_role}", relative)
253
+
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)
259
+ self._extract_entrypoint_hints(content, relative, result)
260
+ self._extract_outbound_and_edges(content, relative, service_name, result)
261
+ self._extract_async_worker_edges(content, relative, service_name, result)
262
+ self._extract_datastore_hints(content, relative, result)
263
+ self._extract_auth_hints(content, relative, result)
264
+ self._extract_secret_hints(content, relative, result)
265
+
266
+ result.languages.sort()
267
+ return result
268
+
269
+ # ---- Route extractors ----
270
+
271
+ def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
272
+ prefixes = self._collect_express_prefixes(content)
273
+
274
+ for match in EXPRESS_ROUTE_PATTERN.finditer(content):
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)
279
+ for match in FASTIFY_ROUTE_PATTERN.finditer(content):
280
+ method, path = match.group(1).upper(), match.group(2)
281
+ self._append_unique_route(result, path, method, relative)
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
+
424
+ def _extract_entrypoint_hints(self, content: str, relative: str, result: ScanResult) -> None:
425
+ for pattern, hint in ENTRYPOINT_PATTERNS:
426
+ if pattern.search(content):
427
+ self._append_unique_auth(result, hint, relative)
428
+
429
+ def _extract_outbound_and_edges(
430
+ self, content: str, relative: str, service_name: str, result: ScanResult
431
+ ) -> None:
432
+ for pattern in OUTBOUND_PATTERNS:
433
+ for match in pattern.finditer(content):
434
+ target = match.group(1)
435
+ self._append_unique_external(result, target, relative)
436
+ target_service = self._service_from_url(target)
437
+ if target_service:
438
+ self._append_unique_auth(result, f"edge:{service_name}->{target_service}", relative)
439
+
440
+ for match in ENV_URL_PATTERN.finditer(content):
441
+ env_name = match.group(1)
442
+ self._append_unique_external(result, f"env://{env_name}", relative)
443
+ target_service = self._service_from_env_name(env_name)
444
+ if target_service:
445
+ self._append_unique_auth(result, f"edge:{service_name}->{target_service}", relative)
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
+
461
+ def _extract_datastore_hints(self, content: str, relative: str, result: ScanResult) -> None:
462
+ for pattern, kind in DB_PATTERNS:
463
+ if pattern.search(content):
464
+ self._append_unique_database(result, kind, relative)
465
+
466
+ def _extract_auth_hints(self, content: str, relative: str, result: ScanResult) -> None:
467
+ for pattern, hint in AUTH_PATTERNS:
468
+ if pattern.search(content):
469
+ self._append_unique_auth(result, hint, relative)
470
+
471
+ def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
472
+ for pattern in SECRET_PATTERNS:
473
+ for match in pattern.finditer(content):
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)
534
+
535
+ def _infer_service_name(self, relative: str, root_package_name: str | None) -> str:
536
+ normalized = relative.replace("\\", "/")
537
+ parts = normalized.split("/")
538
+ if len(parts) >= 2 and parts[0] in {"services", "packages", "apps"}:
539
+ return parts[1].lower()
540
+ if root_package_name:
541
+ return root_package_name.lower().split("/")[-1]
542
+ return "repo-service"
543
+
544
+ @staticmethod
545
+ def _infer_service_role(service_name: str, relative: str) -> str:
546
+ name = f"{service_name} {relative}".lower()
547
+ if any(token in name for token in {"api", "server", "gateway", "http"}):
548
+ return "api"
549
+ if any(token in name for token in {"worker", "queue", "job", "consumer"}):
550
+ return "worker"
551
+ if any(token in name for token in {"store", "db", "cache", "redis"}):
552
+ return "storage"
553
+ return "service"
554
+
555
+ @staticmethod
556
+ def _service_from_url(target: str) -> str | None:
557
+ try:
558
+ host = urlparse(target).hostname or ""
559
+ except ValueError:
560
+ return None
561
+ if not host:
562
+ return None
563
+ if host in {"localhost", "127.0.0.1"}:
564
+ return "local-service"
565
+ return host.split(".")[0].split("-")[0].lower()
566
+
567
+ @staticmethod
568
+ def _service_from_env_name(env_name: str) -> str | None:
569
+ token = env_name.upper().removesuffix("_URL")
570
+ if token.endswith("_SERVICE"):
571
+ token = token.removesuffix("_SERVICE")
572
+ token = token.replace("__", "_").strip("_")
573
+ if not token:
574
+ return None
575
+ return token.lower().replace("_", "-")
576
+
577
+ @staticmethod
578
+ def _package_name_from(path: Path) -> str | None:
579
+ if not path.exists():
580
+ return None
581
+ try:
582
+ data = json.loads(path.read_text(encoding="utf-8"))
583
+ except (UnicodeDecodeError, json.JSONDecodeError):
584
+ return None
585
+ name = data.get("name")
586
+ if isinstance(name, str) and name.strip():
587
+ return name.strip()
588
+ return None
589
+
590
+ @staticmethod
591
+ def _read_text(path: Path) -> str | None:
592
+ try:
593
+ return path.read_text(encoding="utf-8")
594
+ except UnicodeDecodeError:
595
+ return None
596
+
597
+ @staticmethod
598
+ def _append_unique_route(result: ScanResult, path: str, method: str, file: str) -> None:
599
+ key = (path, method, file)
600
+ if any((item.path, item.method, item.file) == key for item in result.routes):
601
+ return
602
+ result.routes.append(Route(path=path, method=method, file=file))
603
+
604
+ @staticmethod
605
+ def _append_unique_external(result: ScanResult, target: str, file: str) -> None:
606
+ key = (target, file)
607
+ if any((item.target, item.file) == key for item in result.external_calls):
608
+ return
609
+ result.external_calls.append(ExternalCall(target=target, file=file))
610
+
611
+ @staticmethod
612
+ def _append_unique_database(result: ScanResult, kind: str, file: str) -> None:
613
+ key = (kind, file)
614
+ if any((item.kind, item.file) == key for item in result.databases):
615
+ return
616
+ result.databases.append(DatabaseHint(kind=kind, file=file))
617
+
618
+ @staticmethod
619
+ def _append_unique_auth(result: ScanResult, hint: str, file: str) -> None:
620
+ key = (hint, file)
621
+ if any((item.hint, item.file) == key for item in result.auth_hints):
622
+ return
623
+ result.auth_hints.append(AuthHint(hint=hint, file=file))
624
+
625
+ @staticmethod
626
+ def _append_unique_secret(result: ScanResult, name: str, file: str) -> None:
627
+ key = (name, file)
628
+ if any((item.name, item.file) == key for item in result.secret_hints):
629
+ return
630
+ result.secret_hints.append(SecretHint(name=name, file=file))
@@ -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,270 @@
1
+ from pathlib import Path
2
+
3
+ from attackmap.sdk.contracts import AnalyzerMetadata as SharedAnalyzerMetadata
4
+ from attackmap.sdk.models import ScanResult as SharedScanResult
5
+ from attackmap_analyzer_node_service.contracts import AnalyzerMetadata, ScanResult
6
+ from attackmap_analyzer_node_service import NodeServiceAnalyzer
7
+
8
+ FIXTURES = Path(__file__).parent / "fixtures"
9
+
10
+
11
+ def _analyze(fixture_name: str):
12
+ return NodeServiceAnalyzer().analyze(FIXTURES / fixture_name)
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Baseline: contract shape, metadata, detect()
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def test_contracts_use_shared_sdk_types() -> None:
21
+ assert AnalyzerMetadata is SharedAnalyzerMetadata
22
+ assert ScanResult is SharedScanResult
23
+
24
+
25
+ def test_metadata_contains_required_fields() -> None:
26
+ metadata = NodeServiceAnalyzer().metadata
27
+ assert metadata.name == "node-service"
28
+ assert metadata.display_name == "Node Service Analyzer"
29
+ assert metadata.version == "0.2.0"
30
+ assert metadata.description
31
+ assert metadata.scope
32
+ assert "node-service" in metadata.targets
33
+ assert "typescript" in metadata.languages
34
+
35
+
36
+ def test_detect_identifies_node_service_repo() -> None:
37
+ assert NodeServiceAnalyzer().detect(FIXTURES / "node_service_repo") is True
38
+
39
+
40
+ def test_analyze_returns_core_compatible_scan_shape() -> None:
41
+ result = _analyze("node_service_repo")
42
+ assert isinstance(result.root, str)
43
+ assert isinstance(result.files_scanned, int)
44
+ assert isinstance(result.languages, list)
45
+ for attr in ("routes", "external_calls", "databases", "auth_hints", "secret_hints"):
46
+ assert hasattr(result, attr)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Baseline coverage (pre-0.2 behavior) — still works
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def test_analyze_extracts_service_signals_and_edges() -> None:
55
+ result = _analyze("node_service_repo")
56
+ route_keys = {(route.path, route.method) for route in result.routes}
57
+ auth_hints = {hint.hint for hint in result.auth_hints}
58
+ external_targets = {call.target for call in result.external_calls}
59
+ databases = {db.kind for db in result.databases}
60
+ secret_names = {secret.name for secret in result.secret_hints}
61
+
62
+ assert ("/xrpc/ping", "GET") in route_keys
63
+ assert ("/internal/rebuild", "POST") in route_keys
64
+ assert "service_name:api" in auth_hints
65
+ assert "service_role:api" in auth_hints
66
+ assert "service_name:worker" in auth_hints
67
+ assert "entrypoint:express_listen" in auth_hints
68
+ assert "edge:api->worker" in auth_hints
69
+ assert "edge:api->feedgen" in auth_hints
70
+ assert "https://worker.internal.local/rebuild" in external_targets
71
+ assert "env://FEEDGEN_URL" in external_targets
72
+ assert "postgresql" in databases
73
+ assert "redis" in databases
74
+ assert "JWT_SIGNING_KEY" in secret_names
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # NestJS decorators (#17)
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def test_nestjs_decorators_produce_prefixed_routes() -> None:
83
+ result = _analyze("nestjs_repo")
84
+ keys = {(r.path, r.method) for r in result.routes}
85
+ # @Controller("users") + method decorators combine to full paths.
86
+ assert ("/users", "GET") in keys
87
+ assert ("/users/:id", "GET") in keys
88
+ assert ("/users", "POST") in keys
89
+ assert ("/users/:id", "DELETE") in keys
90
+ # @All → ANY method sentinel
91
+ assert ("/users/proxy", "ANY") in keys
92
+
93
+
94
+ def test_nestjs_hints_are_emitted() -> None:
95
+ result = _analyze("nestjs_repo")
96
+ hints = {h.hint for h in result.auth_hints}
97
+ assert "entrypoint:nestjs_bootstrap" in hints
98
+ assert "entrypoint:nestjs_module" in hints
99
+ assert "nestjs_guard" in hints # @UseGuards()
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # NextJS file-based routing (#17)
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ def test_nextjs_app_router_named_exports_become_method_specific_routes() -> None:
108
+ result = _analyze("nextjs_repo")
109
+ keys = {(r.path, r.method) for r in result.routes}
110
+ assert ("/api/health", "GET") in keys
111
+ # `app/orders/[id]/route.ts` → `/orders/:id` — App Router doesn't
112
+ # require an `api/` segment; any route.ts file becomes a route.
113
+ assert ("/orders/:id", "GET") in keys
114
+ assert ("/orders/:id", "DELETE") in keys
115
+
116
+
117
+ def test_nextjs_pages_router_files_become_any_routes() -> None:
118
+ result = _analyze("nextjs_repo")
119
+ keys = {(r.path, r.method) for r in result.routes}
120
+ assert ("/api/legacy", "ANY") in keys
121
+ assert ("/api/webhooks/:provider", "ANY") in keys
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Express advanced: prefix mounts + chained .route() (#17)
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def test_express_use_prefix_mounts_propagate_through_nested_routers() -> None:
130
+ result = _analyze("express_advanced_repo")
131
+ keys = {(r.path, r.method) for r in result.routes}
132
+ assert ("/health", "GET") in keys
133
+ # Three-level prefix chain: /api + /v1 + /orgs/:orgId + /members
134
+ assert ("/api/v1/orgs/:orgId/members", "GET") in keys
135
+ assert ("/api/v1/orgs/:orgId/invite", "POST") in keys
136
+
137
+
138
+ def test_express_route_chain_emits_route_per_chained_verb() -> None:
139
+ result = _analyze("express_advanced_repo")
140
+ keys = {(r.path, r.method) for r in result.routes}
141
+ # `v1.route("/status").get().post()` — v1 is mounted at /api, and
142
+ # `.route()` sits directly on v1 (not the nested orgs router), so the
143
+ # prefix here is /api, not /api/v1.
144
+ assert ("/api/status", "GET") in keys
145
+ assert ("/api/status", "POST") in keys
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Framework variety: Hono, Koa, Elysia, tRPC (#17)
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def test_hono_koa_elysia_routes_are_extracted() -> None:
154
+ result = _analyze("framework_variety_repo")
155
+ keys = {(r.path, r.method) for r in result.routes}
156
+ assert ("/hello", "GET") in keys
157
+ assert ("/hello", "POST") in keys
158
+ assert ("/koa-hello", "GET") in keys
159
+ assert ("/koa-items", "POST") in keys
160
+ assert ("/elysia/ping", "GET") in keys
161
+ assert ("/elysia/events", "POST") in keys
162
+
163
+
164
+ def test_hono_koa_elysia_entrypoints_emit_hints() -> None:
165
+ result = _analyze("framework_variety_repo")
166
+ hints = {h.hint for h in result.auth_hints}
167
+ assert "entrypoint:hono_app" in hints
168
+ assert "entrypoint:koa_app" in hints
169
+ assert "entrypoint:elysia_app" in hints
170
+
171
+
172
+ def test_trpc_procedures_are_extracted_as_prefixed_routes() -> None:
173
+ result = _analyze("framework_variety_repo")
174
+ keys = {(r.path, r.method) for r in result.routes}
175
+ assert ("trpc:listUsers", "GET") in keys # .query -> GET
176
+ assert ("trpc:createUser", "POST") in keys # .mutation -> POST
177
+ assert ("trpc:streamEvents", "STREAM") in keys
178
+ hints = {h.hint for h in result.auth_hints}
179
+ assert "entrypoint:trpc_http_server" in hints
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # XRPC / AT Protocol handler registration (#17, progresses #19)
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def test_xrpc_handlers_emit_xrpc_routes_and_lexicon_hints() -> None:
188
+ result = _analyze("xrpc_repo")
189
+ keys = {(r.path, r.method) for r in result.routes}
190
+ hints = {h.hint for h in result.auth_hints}
191
+ # `server.method(...)` form
192
+ assert ("/xrpc/com.atproto.server.createSession", "ANY") in keys
193
+ assert ("/xrpc/app.bsky.feed.getFeed", "ANY") in keys
194
+ # Object-literal handler map form
195
+ assert ("/xrpc/com.atproto.repo.putRecord", "ANY") in keys
196
+ assert ("/xrpc/chat.bsky.convo.getConvo", "ANY") in keys
197
+ # Lexicon hints per NSID
198
+ assert "atproto_lexicon:com.atproto.server.createSession" in hints
199
+ assert "atproto_lexicon:app.bsky.feed.getFeed" in hints
200
+ assert "entrypoint:xrpc_server" in hints
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # EXPO_PUBLIC_* client-bundled secrets (#17, from Bluesky FINDINGS §3)
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ def test_expo_public_env_vars_are_tagged_as_client_bundled_secrets() -> None:
209
+ result = _analyze("expo_secrets_repo")
210
+ names = {s.name for s in result.secret_hints}
211
+ # Expo prefix tagged so downstream findings can elevate it.
212
+ assert "expo_public:EXPO_PUBLIC_BITDRIFT_API_KEY" in names
213
+ assert "expo_public:EXPO_PUBLIC_POSTHOG_KEY" in names
214
+ # Non-Expo secrets are still tagged normally, without the prefix.
215
+ assert "JWT_SIGNING_KEY" in names
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Workspaces topology (#17)
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def test_npm_workspaces_declared_at_root_emit_service_hints_for_each_package() -> None:
224
+ # The existing node_service_repo fixture declares services/* + packages/*
225
+ # in its root package.json workspaces.
226
+ result = _analyze("node_service_repo")
227
+ hints = {h.hint for h in result.auth_hints}
228
+ assert "workspace_package:@attackmap/node-service-demo" not in hints # root itself excluded
229
+ # `services/api`, `services/worker`, `packages/shared` each have their
230
+ # own package.json in the fixture — but only if they exist as workspace
231
+ # entries with package.json files. Our current fixture doesn't put
232
+ # per-package package.json files inside services/*, so this test just
233
+ # verifies workspace parsing didn't crash. The pnpm test below covers
234
+ # the actual per-package emission path.
235
+
236
+
237
+ def test_pnpm_workspace_emits_service_hints_for_each_workspace_package() -> None:
238
+ result = _analyze("pnpm_workspace_repo")
239
+ hints = {h.hint for h in result.auth_hints}
240
+ assert "service_name:gateway" in hints
241
+ assert "service_name:telemetry" in hints
242
+ assert "workspace_package:@attackmap/gateway" in hints
243
+ assert "workspace_package:@attackmap/telemetry" in hints
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Async workers: BullMQ + Kafka (#17)
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ def test_bullmq_workers_and_queues_emit_queue_edges() -> None:
252
+ result = _analyze("async_workers_repo")
253
+ hints = {h.hint for h in result.auth_hints}
254
+ targets = {c.target for c in result.external_calls}
255
+ assert "entrypoint:bullmq_worker" in hints
256
+ assert "entrypoint:bullmq_queue" in hints
257
+ assert "queue://bullmq/image-processing" in targets
258
+ assert "queue://bullmq/email-outbox" in targets
259
+ # Per-service outbound edge to the named queue.
260
+ assert any(h.startswith("edge:async-workers->queue:") for h in hints)
261
+
262
+
263
+ def test_kafka_consumer_topics_emit_topic_edges() -> None:
264
+ result = _analyze("async_workers_repo")
265
+ hints = {h.hint for h in result.auth_hints}
266
+ targets = {c.target for c in result.external_calls}
267
+ assert "entrypoint:kafka_consumer" in hints
268
+ assert "queue://kafka/orders-created" in targets
269
+ assert "queue://kafka/payments-events" in targets
270
+ assert any(h.endswith("->topic:orders-created") for h in hints)
@@ -1,284 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import re
5
- from pathlib import Path
6
- from urllib.parse import urlparse
7
-
8
- from .contracts import AnalyzerMetadata, AuthHint, DatabaseHint, ExternalCall, Route, ScanResult, SecretHint
9
-
10
- CODE_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx"}
11
- SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".turbo"}
12
-
13
- EXPRESS_ROUTE_PATTERN = re.compile(
14
- r"\b(?:app|router)\.(get|post|put|patch|delete|options|head)\(\s*['\"]([^'\"]+)['\"]",
15
- re.IGNORECASE,
16
- )
17
- FASTIFY_ROUTE_PATTERN = re.compile(
18
- r"\bfastify\.(get|post|put|patch|delete|options|head)\(\s*['\"]([^'\"]+)['\"]",
19
- re.IGNORECASE,
20
- )
21
- ENTRYPOINT_PATTERNS = [
22
- (re.compile(r"\bapp\.listen\s*\(", re.IGNORECASE), "entrypoint:express_listen"),
23
- (re.compile(r"\bcreateServer\s*\(", re.IGNORECASE), "entrypoint:create_server"),
24
- (re.compile(r"\bfastify\s*\(", re.IGNORECASE), "entrypoint:fastify_server"),
25
- (re.compile(r"\bnew\s+Hono\s*\(", re.IGNORECASE), "entrypoint:hono_app"),
26
- (re.compile(r"\bregisterHandler\b|\bregisterRoute\b", re.IGNORECASE), "entrypoint:handler_registration"),
27
- ]
28
-
29
- OUTBOUND_PATTERNS = [
30
- re.compile(r"\bfetch\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
31
- re.compile(r"\baxios\.(?:get|post|put|patch|delete)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
32
- re.compile(r"\bgot(?:\.(?:get|post|put|patch|delete))?\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
33
- re.compile(r"\bundici\.(?:request|fetch)\(\s*['\"](https?://[^'\"]+)['\"]", re.IGNORECASE),
34
- ]
35
- ENV_URL_PATTERN = re.compile(r"process\.env\.([A-Z0-9_]+_URL)\b")
36
-
37
- DB_PATTERNS = [
38
- (re.compile(r"\bnew\s+PrismaClient\s*\(", re.IGNORECASE), "sql"),
39
- (re.compile(r"\bPool\s*\(\s*\{", re.IGNORECASE), "postgresql"),
40
- (re.compile(r"\bpg\b|\bnode-postgres\b", re.IGNORECASE), "postgresql"),
41
- (re.compile(r"\bknex\s*\(", re.IGNORECASE), "sql"),
42
- (re.compile(r"\bmongoose\.connect\s*\(", re.IGNORECASE), "mongodb"),
43
- (re.compile(r"\bMongoClient\s*\(", re.IGNORECASE), "mongodb"),
44
- (re.compile(r"\bredis\.createClient\s*\(", re.IGNORECASE), "redis"),
45
- (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"),
47
- ]
48
-
49
- AUTH_PATTERNS = [
50
- (re.compile(r"\bAuthorization\b", re.IGNORECASE), "authorization_header"),
51
- (re.compile(r"\bBearer\b", re.IGNORECASE), "bearer_token"),
52
- (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"),
55
- (re.compile(r"\boauth\b|\boidc\b", re.IGNORECASE), "oauth"),
56
- (re.compile(r"\btoken\b", re.IGNORECASE), "token"),
57
- ]
58
-
59
- SECRET_PATTERNS = [
60
- re.compile(r"process\.env\.([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD)[A-Z0-9_]*)"),
61
- ]
62
-
63
-
64
- class NodeServiceAnalyzer:
65
- metadata = AnalyzerMetadata(
66
- name="node-service",
67
- 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.",
71
- targets=["node-service", "node", "typescript-service"],
72
- languages=["javascript", "typescript"],
73
- priority=25,
74
- experimental=True,
75
- enabled_by_default=False,
76
- )
77
-
78
- @property
79
- def name(self) -> str:
80
- return self.metadata.name
81
-
82
- def detect(self, repo_path: str | Path) -> bool:
83
- root = Path(repo_path).resolve()
84
- if not root.exists() or not root.is_dir():
85
- return False
86
-
87
- has_package = (root / "package.json").exists() or any(root.glob("**/package.json"))
88
- 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()
90
-
91
- if has_package and (has_typescript or has_service_dirs):
92
- return True
93
-
94
- for file_path in root.rglob("*"):
95
- if not file_path.is_file() or file_path.suffix not in CODE_SUFFIXES:
96
- continue
97
- if any(part in SKIP_DIRS for part in file_path.parts):
98
- continue
99
- content = self._read_text(file_path)
100
- if not content:
101
- continue
102
- if re.search(r"\bapp\.listen\b|\bfastify\b|\bcreateServer\b|\bservices?/|packages?/", content, re.IGNORECASE):
103
- return True
104
- return False
105
-
106
- def analyze(self, repo_path: str | Path) -> ScanResult:
107
- root = Path(repo_path).resolve()
108
- result = ScanResult(root=str(root))
109
- if not root.exists() or not root.is_dir():
110
- return result
111
-
112
- root_package_name = self._package_name_from(root / "package.json")
113
- for file_path in root.rglob("*"):
114
- if not file_path.is_file() or file_path.suffix not in CODE_SUFFIXES:
115
- continue
116
- if any(part in SKIP_DIRS for part in file_path.parts):
117
- continue
118
-
119
- result.files_scanned += 1
120
- language = "typescript" if file_path.suffix in {".ts", ".tsx"} else "javascript"
121
- if language not in result.languages:
122
- result.languages.append(language)
123
-
124
- content = self._read_text(file_path)
125
- if content is None:
126
- continue
127
-
128
- relative = str(file_path.relative_to(root))
129
- service_name = self._infer_service_name(relative, root_package_name)
130
- service_role = self._infer_service_role(service_name, relative)
131
-
132
- self._append_unique_auth(result, f"service_name:{service_name}", relative)
133
- self._append_unique_auth(result, f"service_role:{service_role}", relative)
134
-
135
- self._extract_routes(content, relative, result)
136
- self._extract_entrypoint_hints(content, relative, result)
137
- self._extract_outbound_and_edges(content, relative, service_name, result)
138
- self._extract_datastore_hints(content, relative, result)
139
- self._extract_auth_hints(content, relative, result)
140
- self._extract_secret_hints(content, relative, result)
141
-
142
- result.languages.sort()
143
- return result
144
-
145
- def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
146
- 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)
149
- for match in FASTIFY_ROUTE_PATTERN.finditer(content):
150
- method, path = match.group(1).upper(), match.group(2)
151
- self._append_unique_route(result, path, method, relative)
152
-
153
- def _extract_entrypoint_hints(self, content: str, relative: str, result: ScanResult) -> None:
154
- for pattern, hint in ENTRYPOINT_PATTERNS:
155
- if pattern.search(content):
156
- self._append_unique_auth(result, hint, relative)
157
-
158
- def _extract_outbound_and_edges(self, content: str, relative: str, service_name: str, result: ScanResult) -> None:
159
- for pattern in OUTBOUND_PATTERNS:
160
- for match in pattern.finditer(content):
161
- target = match.group(1)
162
- self._append_unique_external(result, target, relative)
163
- target_service = self._service_from_url(target)
164
- if target_service:
165
- self._append_unique_auth(result, f"edge:{service_name}->{target_service}", relative)
166
-
167
- for match in ENV_URL_PATTERN.finditer(content):
168
- env_name = match.group(1)
169
- self._append_unique_external(result, f"env://{env_name}", relative)
170
- target_service = self._service_from_env_name(env_name)
171
- if target_service:
172
- self._append_unique_auth(result, f"edge:{service_name}->{target_service}", relative)
173
-
174
- def _extract_datastore_hints(self, content: str, relative: str, result: ScanResult) -> None:
175
- for pattern, kind in DB_PATTERNS:
176
- if pattern.search(content):
177
- self._append_unique_database(result, kind, relative)
178
-
179
- def _extract_auth_hints(self, content: str, relative: str, result: ScanResult) -> None:
180
- for pattern, hint in AUTH_PATTERNS:
181
- if pattern.search(content):
182
- self._append_unique_auth(result, hint, relative)
183
-
184
- def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
185
- for pattern in SECRET_PATTERNS:
186
- for match in pattern.finditer(content):
187
- self._append_unique_secret(result, match.group(1), relative)
188
-
189
- def _infer_service_name(self, relative: str, root_package_name: str | None) -> str:
190
- normalized = relative.replace("\\", "/")
191
- parts = normalized.split("/")
192
- if len(parts) >= 2 and parts[0] in {"services", "packages"}:
193
- return parts[1].lower()
194
- if root_package_name:
195
- return root_package_name.lower().split("/")[-1]
196
- return "repo-service"
197
-
198
- @staticmethod
199
- def _infer_service_role(service_name: str, relative: str) -> str:
200
- name = f"{service_name} {relative}".lower()
201
- if any(token in name for token in {"api", "server", "gateway", "http"}):
202
- return "api"
203
- if any(token in name for token in {"worker", "queue", "job", "consumer"}):
204
- return "worker"
205
- if any(token in name for token in {"store", "db", "cache", "redis"}):
206
- return "storage"
207
- return "service"
208
-
209
- @staticmethod
210
- def _service_from_url(target: str) -> str | None:
211
- try:
212
- host = urlparse(target).hostname or ""
213
- except ValueError:
214
- return None
215
- if not host:
216
- return None
217
- if host in {"localhost", "127.0.0.1"}:
218
- return "local-service"
219
- return host.split(".")[0].split("-")[0].lower()
220
-
221
- @staticmethod
222
- def _service_from_env_name(env_name: str) -> str | None:
223
- token = env_name.upper().removesuffix("_URL")
224
- if token.endswith("_SERVICE"):
225
- token = token.removesuffix("_SERVICE")
226
- token = token.replace("__", "_").strip("_")
227
- if not token:
228
- return None
229
- return token.lower().replace("_", "-")
230
-
231
- @staticmethod
232
- def _package_name_from(path: Path) -> str | None:
233
- if not path.exists():
234
- return None
235
- try:
236
- data = json.loads(path.read_text(encoding="utf-8"))
237
- except (UnicodeDecodeError, json.JSONDecodeError):
238
- return None
239
- name = data.get("name")
240
- if isinstance(name, str) and name.strip():
241
- return name.strip()
242
- return None
243
-
244
- @staticmethod
245
- def _read_text(path: Path) -> str | None:
246
- try:
247
- return path.read_text(encoding="utf-8")
248
- except UnicodeDecodeError:
249
- return None
250
-
251
- @staticmethod
252
- def _append_unique_route(result: ScanResult, path: str, method: str, file: str) -> None:
253
- key = (path, method, file)
254
- if any((item.path, item.method, item.file) == key for item in result.routes):
255
- return
256
- result.routes.append(Route(path=path, method=method, file=file))
257
-
258
- @staticmethod
259
- def _append_unique_external(result: ScanResult, target: str, file: str) -> None:
260
- key = (target, file)
261
- if any((item.target, item.file) == key for item in result.external_calls):
262
- return
263
- result.external_calls.append(ExternalCall(target=target, file=file))
264
-
265
- @staticmethod
266
- def _append_unique_database(result: ScanResult, kind: str, file: str) -> None:
267
- key = (kind, file)
268
- if any((item.kind, item.file) == key for item in result.databases):
269
- return
270
- result.databases.append(DatabaseHint(kind=kind, file=file))
271
-
272
- @staticmethod
273
- def _append_unique_auth(result: ScanResult, hint: str, file: str) -> None:
274
- key = (hint, file)
275
- if any((item.hint, item.file) == key for item in result.auth_hints):
276
- return
277
- result.auth_hints.append(AuthHint(hint=hint, file=file))
278
-
279
- @staticmethod
280
- def _append_unique_secret(result: ScanResult, name: str, file: str) -> None:
281
- key = (name, file)
282
- if any((item.name, item.file) == key for item in result.secret_hints):
283
- return
284
- result.secret_hints.append(SecretHint(name=name, file=file))
@@ -1,74 +0,0 @@
1
- from pathlib import Path
2
-
3
- from attackmap.sdk.contracts import AnalyzerMetadata as SharedAnalyzerMetadata
4
- from attackmap.sdk.models import ScanResult as SharedScanResult
5
- from attackmap_analyzer_node_service.contracts import AnalyzerMetadata, ScanResult
6
- from attackmap_analyzer_node_service import NodeServiceAnalyzer
7
-
8
- FIXTURES = Path(__file__).parent / "fixtures"
9
-
10
-
11
- def test_contracts_use_shared_sdk_types() -> None:
12
- assert AnalyzerMetadata is SharedAnalyzerMetadata
13
- assert ScanResult is SharedScanResult
14
-
15
-
16
- def test_metadata_contains_required_fields() -> None:
17
- analyzer = NodeServiceAnalyzer()
18
- metadata = analyzer.metadata
19
-
20
- assert metadata.name == "node-service"
21
- assert metadata.display_name == "Node Service Analyzer"
22
- assert metadata.version == "0.1.0"
23
- assert metadata.description
24
- assert metadata.scope
25
- assert "node-service" in metadata.targets
26
- assert "typescript" in metadata.languages
27
-
28
-
29
- def test_detect_identifies_node_service_repo() -> None:
30
- analyzer = NodeServiceAnalyzer()
31
- assert analyzer.detect(FIXTURES / "node_service_repo") is True
32
-
33
-
34
- def test_analyze_extracts_service_signals_and_edges() -> None:
35
- analyzer = NodeServiceAnalyzer()
36
- result = analyzer.analyze(FIXTURES / "node_service_repo")
37
-
38
- route_keys = {(route.path, route.method) for route in result.routes}
39
- auth_hints = {hint.hint for hint in result.auth_hints}
40
- external_targets = {call.target for call in result.external_calls}
41
- databases = {db.kind for db in result.databases}
42
- secret_names = {secret.name for secret in result.secret_hints}
43
-
44
- assert ("/xrpc/ping", "GET") in route_keys
45
- assert ("/internal/rebuild", "POST") in route_keys
46
-
47
- assert "service_name:api" in auth_hints
48
- assert "service_role:api" in auth_hints
49
- assert "service_name:worker" in auth_hints
50
- assert "service_role:worker" in auth_hints
51
- assert "entrypoint:express_listen" in auth_hints
52
- assert "edge:api->worker" in auth_hints
53
- assert "edge:api->feedgen" in auth_hints
54
-
55
- assert "https://worker.internal.local/rebuild" in external_targets
56
- assert "env://FEEDGEN_URL" in external_targets
57
-
58
- assert "postgresql" in databases
59
- assert "redis" in databases
60
- assert "JWT_SIGNING_KEY" in secret_names
61
-
62
-
63
- def test_analyze_returns_core_compatible_scan_shape() -> None:
64
- analyzer = NodeServiceAnalyzer()
65
- result = analyzer.analyze(FIXTURES / "node_service_repo")
66
-
67
- assert isinstance(result.root, str)
68
- assert isinstance(result.files_scanned, int)
69
- assert isinstance(result.languages, list)
70
- assert hasattr(result, "routes")
71
- assert hasattr(result, "external_calls")
72
- assert hasattr(result, "databases")
73
- assert hasattr(result, "auth_hints")
74
- assert hasattr(result, "secret_hints")