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