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.
- {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
- {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/pyproject.toml +1 -1
- attackmap_analyzer_node_service-0.2.0/src/attackmap_analyzer_node_service/analyzer.py +630 -0
- {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
- attackmap_analyzer_node_service-0.2.0/tests/test_node_service_analyzer.py +270 -0
- attackmap_analyzer_node_service-0.1.0/src/attackmap_analyzer_node_service/analyzer.py +0 -284
- attackmap_analyzer_node_service-0.1.0/tests/test_node_service_analyzer.py +0 -74
- {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/LICENSE +0 -0
- {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/README.md +0 -0
- {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/setup.cfg +0 -0
- {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service/__init__.py +0 -0
- {attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/src/attackmap_analyzer_node_service/contracts.py +0 -0
- {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
- {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
- {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
- {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
- {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
{attackmap_analyzer_node_service-0.1.0 → attackmap_analyzer_node_service-0.2.0}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "attackmap-analyzer-node-service"
|
|
7
|
-
version = "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))
|
|
@@ -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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|