attackmap-analyzer-node-service 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
1
+ """Import / export maps and module-specifier resolution (#4).
2
+
3
+ Cross-file router mounts need to know which file an import names and which
4
+ binding that file exports. Everything here is a bounded regex or a lookup:
5
+
6
+ - **imports**: ``const x = require('s')`` (``.default`` / ``.name``),
7
+ ``const { a, b: c } = require('s')``, ``import x, { a as b } from 's'``,
8
+ ``import * as ns from 's'``;
9
+ - **re-exports** (barrel files): ``export { default as x, y } from 's'``,
10
+ ``export * from 's'``, ``module.exports = require('s')``,
11
+ ``exports.x = require('s')``;
12
+ - **specifiers**: relative paths, then ``compilerOptions.paths`` /
13
+ ``baseUrl`` from the nearest ``tsconfig.json`` / ``jsconfig.json`` (one
14
+ level of relative ``extends`` per step, three steps at most), then workspace
15
+ packages by their ``package.json`` ``name``. A path resolves to a file by
16
+ extension (``.js`` / ``.ts`` / ...) or to its ``index`` file. Nothing outside
17
+ the repo is ever read, and specifiers that leave it are dropped.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import posixpath
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from attackmap.sdk import DEFAULT_SKIP_DIRS, contained_file, iter_repo_files, read_source, rel
29
+
30
+ SKIP_DIRS = DEFAULT_SKIP_DIRS | {".svelte-kit"}
31
+
32
+ _EXTENSIONS = (".ts", ".js", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".jsx")
33
+ _STRIP_EXT = re.compile(r"\.(?:js|mjs|cjs|jsx|ts|tsx|mts|cts)$")
34
+ _MAX_IMPORTS = 512 # import bindings kept per file
35
+ _MAX_REEXPORTS = 256
36
+ _MAX_CONFIGS = 2000 # tsconfig / package.json files read per repo
37
+ _MAX_CONFIG_BYTES = 256 * 1024
38
+ _MAX_EXTENDS = 3
39
+ _MAX_BASES = 8
40
+
41
+ _IDENT = r"[A-Za-z_$][\w$]*"
42
+ _SPEC = r"""['"]([^'"\n]{1,300})['"]"""
43
+
44
+ # `const x = require('s')`, `const x = require('s').default`, `... .router`
45
+ _REQUIRE_DEFAULT = re.compile(
46
+ rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*(?:await\s+)?require\s*\(\s*{_SPEC}\s*\)(?:\s*\.\s*({_IDENT}))?"
47
+ )
48
+ # `const { a, b: c } = require('s')`
49
+ _REQUIRE_NAMED = re.compile(rf"\b(?:const|let|var)\s*\{{([^{{}}]{{0,1000}})\}}\s*=\s*require\s*\(\s*{_SPEC}\s*\)")
50
+ # `import x from 's'`, `import x, { a as b } from 's'`, `import { a } from 's'`,
51
+ # `import * as ns from 's'`, `import type ...` (harmless: types never mount).
52
+ _IMPORT_FROM = re.compile(
53
+ rf"\bimport\s+(?:type\s+)?(?:({_IDENT})\s*,?\s*)?(?:\*\s*as\s+({_IDENT})\s*)?"
54
+ rf"(?:\{{([^{{}}]{{0,1000}})\}}\s*)?from\s*{_SPEC}"
55
+ )
56
+ # `export { default as x, y as z } from 's'` / `export * from 's'`
57
+ _REEXPORT_NAMED = re.compile(rf"\bexport\s+(?:type\s+)?\{{([^{{}}]{{0,1000}})\}}\s*from\s*{_SPEC}")
58
+ _REEXPORT_STAR = re.compile(rf"\bexport\s+\*\s+from\s*{_SPEC}")
59
+ # `module.exports = require('s')` / `exports.x = require('s')` / `module.exports.x = require('s')`
60
+ _CJS_REEXPORT = re.compile(
61
+ rf"\b(?:module\s*\.\s*exports(?:\s*\.\s*({_IDENT}))?|exports\s*\.\s*({_IDENT}))\s*=\s*require\s*\(\s*{_SPEC}\s*\)"
62
+ )
63
+ _AS = re.compile(rf"^\s*(?:type\s+)?({_IDENT})(?:\s+as\s+({_IDENT}))?\s*$")
64
+ _COLON = re.compile(rf"^\s*({_IDENT})(?:\s*:\s*({_IDENT}))?\s*(?:=[^,]*)?$")
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ImportRef:
69
+ """``name`` exported by the module ``spec``: ``default``, a named export,
70
+ or ``*`` for a namespace / CommonJS object import."""
71
+
72
+ spec: str
73
+ name: str
74
+
75
+
76
+ def parse_imports(content: str) -> dict[str, ImportRef]:
77
+ """Local name -> what it imports."""
78
+ imports: dict[str, ImportRef] = {}
79
+
80
+ def add(local: str, ref: ImportRef) -> None:
81
+ if len(imports) < _MAX_IMPORTS:
82
+ imports.setdefault(local, ref)
83
+
84
+ for match in _REQUIRE_DEFAULT.finditer(content):
85
+ member = match.group(3)
86
+ add(match.group(1), ImportRef(match.group(2), member if member and member != "default" else "*"))
87
+ for match in _REQUIRE_NAMED.finditer(content):
88
+ for part in match.group(1).split(","):
89
+ names = _COLON.match(part)
90
+ if names:
91
+ add(names.group(2) or names.group(1), ImportRef(match.group(2), names.group(1)))
92
+ for match in _IMPORT_FROM.finditer(content):
93
+ default, namespace, named, spec = match.groups()
94
+ if default:
95
+ add(default, ImportRef(spec, "default"))
96
+ if namespace:
97
+ add(namespace, ImportRef(spec, "*"))
98
+ for part in (named or "").split(","):
99
+ names = _AS.match(part)
100
+ if names:
101
+ add(names.group(2) or names.group(1), ImportRef(spec, names.group(1)))
102
+ return imports
103
+
104
+
105
+ @dataclass
106
+ class Reexports:
107
+ named: dict[str, ImportRef] = field(default_factory=dict)
108
+ star: list[str] = field(default_factory=list)
109
+
110
+
111
+ def parse_reexports(content: str) -> Reexports:
112
+ out = Reexports()
113
+ if "export" not in content:
114
+ return out
115
+ for match in _REEXPORT_NAMED.finditer(content):
116
+ for part in match.group(1).split(","):
117
+ names = _AS.match(part)
118
+ if names and len(out.named) < _MAX_REEXPORTS:
119
+ out.named.setdefault(names.group(2) or names.group(1), ImportRef(match.group(2), names.group(1)))
120
+ for match in _REEXPORT_STAR.finditer(content):
121
+ if len(out.star) < _MAX_REEXPORTS:
122
+ out.star.append(match.group(1))
123
+ for match in _CJS_REEXPORT.finditer(content):
124
+ # `exports.x = require('s')`: x is what `s` assigned to module.exports.
125
+ # `module.exports = require('s')` re-exports all of `s`.
126
+ name = match.group(1) or match.group(2) or "default"
127
+ if len(out.named) < _MAX_REEXPORTS:
128
+ out.named.setdefault(name, ImportRef(match.group(3), "default"))
129
+ if name == "default":
130
+ out.star.append(match.group(3))
131
+ return out
132
+
133
+
134
+ # ---------- Specifier resolution ----------
135
+
136
+ _JSONC_START = re.compile(r'"|//|/\*')
137
+ _JSON_STRING_BODY = re.compile(r'[^"\\\n]*(?:\\.[^"\\\n]*)*"')
138
+ _TRAILING_COMMA = re.compile(r",(\s*[}\]])")
139
+
140
+
141
+ def _strip_comments(text: str) -> str:
142
+ """Drop ``//`` and ``/* */`` comments outside strings, in one pass."""
143
+ out: list[str] = []
144
+ pos, n = 0, len(text)
145
+ while pos < n:
146
+ match = _JSONC_START.search(text, pos)
147
+ if not match:
148
+ out.append(text[pos:])
149
+ break
150
+ out.append(text[pos : match.start()])
151
+ token = match.group()
152
+ if token == '"':
153
+ body = _JSON_STRING_BODY.match(text, match.end())
154
+ end = body.end() if body else n
155
+ out.append(text[match.start() : end])
156
+ pos = end
157
+ elif token == "//":
158
+ newline = text.find("\n", match.end())
159
+ pos = n if newline < 0 else newline
160
+ else:
161
+ close = text.find("*/", match.end())
162
+ pos = n if close < 0 else close + 2
163
+ return "".join(out)
164
+
165
+
166
+ def load_jsonc(text: str) -> object:
167
+ """``tsconfig.json`` allows comments and trailing commas."""
168
+ if len(text) > _MAX_CONFIG_BYTES:
169
+ return None
170
+ stripped = _strip_comments(text)
171
+ try:
172
+ return json.loads(_TRAILING_COMMA.sub(r"\1", stripped))
173
+ except (ValueError, RecursionError):
174
+ return None
175
+
176
+
177
+ def file_candidates(base: str) -> list[str]:
178
+ """Files a resolved specifier may name: itself, with an extension (a
179
+ ``.js`` specifier may name the ``.ts`` source), or its ``index``."""
180
+ stem = _STRIP_EXT.sub("", base)
181
+ return [base, *(stem + ext for ext in _EXTENSIONS), *(f"{base}/index{ext}" for ext in _EXTENSIONS)]
182
+
183
+
184
+ def _inside(path: str) -> str | None:
185
+ path = posixpath.normpath(path)
186
+ if path == ".." or path.startswith("../") or path.startswith("/"):
187
+ return None
188
+ return "" if path == "." else path
189
+
190
+
191
+ @dataclass
192
+ class _PathConfig:
193
+ base: str # repo-relative directory paths are resolved against
194
+ paths: list[tuple[str, list[str]]]
195
+ base_url: str | None
196
+
197
+
198
+ class ModuleResolver:
199
+ """Specifier -> candidate repo-relative paths (without extension)."""
200
+
201
+ def __init__(self, root: Path | None) -> None:
202
+ self.root = root
203
+ self._configs: dict[str, _PathConfig] | None = None
204
+ self._packages: list[tuple[str, str, str | None]] | None = None
205
+
206
+ def bases(self, importer: str, spec: str) -> list[str]:
207
+ if spec.startswith("."):
208
+ base = _inside(posixpath.join(posixpath.dirname(importer), spec))
209
+ return [] if base is None else [base]
210
+ if self.root is None or spec.startswith("/") or ":" in spec:
211
+ return []
212
+ out = self._config_bases(importer, spec) + self._package_bases(spec)
213
+ return list(dict.fromkeys(out))[:_MAX_BASES]
214
+
215
+ # -- tsconfig / jsconfig `paths` --
216
+
217
+ def _config_bases(self, importer: str, spec: str) -> list[str]:
218
+ configs = self._load_configs()
219
+ directory = posixpath.dirname(importer)
220
+ while True:
221
+ config = configs.get(directory)
222
+ if config is not None:
223
+ return self._apply(config, spec)
224
+ if not directory:
225
+ return []
226
+ directory = posixpath.dirname(directory)
227
+
228
+ @staticmethod
229
+ def _apply(config: _PathConfig, spec: str) -> list[str]:
230
+ out: list[str] = []
231
+ for pattern, targets in config.paths:
232
+ head, star, tail = pattern.partition("*")
233
+ if star:
234
+ if not (spec.startswith(head) and spec.endswith(tail) and len(spec) >= len(head) + len(tail)):
235
+ continue
236
+ middle = spec[len(head) : len(spec) - len(tail)]
237
+ elif spec != pattern:
238
+ continue
239
+ else:
240
+ middle = ""
241
+ for target in targets:
242
+ resolved = _inside(posixpath.join(config.base, target.replace("*", middle, 1)))
243
+ if resolved is not None:
244
+ out.append(resolved)
245
+ if config.base_url is not None:
246
+ resolved = _inside(posixpath.join(config.base_url, spec))
247
+ if resolved is not None:
248
+ out.append(resolved)
249
+ return out
250
+
251
+ def _load_configs(self) -> dict[str, _PathConfig]:
252
+ if self._configs is not None:
253
+ return self._configs
254
+ self._configs = {}
255
+ assert self.root is not None
256
+ raw: dict[str, dict] = {}
257
+ for count, path in enumerate(
258
+ iter_repo_files(self.root, names={"tsconfig.json", "jsconfig.json"}, skip_dirs=SKIP_DIRS)
259
+ ):
260
+ if count >= _MAX_CONFIGS:
261
+ break
262
+ data = load_jsonc(read_source(path, root=self.root) or "")
263
+ if isinstance(data, dict):
264
+ raw.setdefault(rel(path, self.root), data)
265
+ for config_path, data in list(raw.items()):
266
+ options = self._options(config_path, raw)
267
+ directory = posixpath.dirname(config_path)
268
+ paths_owner, paths = options.get("paths", (directory, None))
269
+ url_owner, base_url = options.get("baseUrl", (directory, None))
270
+ if not isinstance(paths, dict) and not isinstance(base_url, str):
271
+ continue
272
+ base_url_dir = _inside(posixpath.join(url_owner, base_url)) if isinstance(base_url, str) else None
273
+ pairs = [
274
+ (pattern, [t for t in targets if isinstance(t, str)][:8])
275
+ for pattern, targets in (paths.items() if isinstance(paths, dict) else [])
276
+ if isinstance(pattern, str) and isinstance(targets, list)
277
+ ][:64]
278
+ # `paths` resolve against baseUrl when set, else the declaring config.
279
+ base = base_url_dir if base_url_dir is not None else paths_owner
280
+ self._configs.setdefault(directory, _PathConfig(base, pairs, base_url_dir))
281
+ return self._configs
282
+
283
+ def _options(self, config_path: str, raw: dict[str, dict]) -> dict[str, tuple[str, object]]:
284
+ """``compilerOptions`` ``paths`` / ``baseUrl`` with the directory of the
285
+ config that set each, following relative ``extends`` (which may name
286
+ any file, e.g. ``tsconfig.base.json``)."""
287
+ found: dict[str, tuple[str, object]] = {}
288
+ current: str | None = config_path
289
+ seen: set[str] = set()
290
+ for _ in range(_MAX_EXTENDS + 1):
291
+ if current is None or current in seen:
292
+ break
293
+ if current not in raw:
294
+ data = load_jsonc(self._read_config(current) or "")
295
+ if not isinstance(data, dict):
296
+ break
297
+ raw[current] = data
298
+ seen.add(current)
299
+ config = raw[current]
300
+ options = config.get("compilerOptions")
301
+ if isinstance(options, dict):
302
+ for key in ("paths", "baseUrl"):
303
+ if key in options and key not in found:
304
+ found[key] = (posixpath.dirname(current), options[key])
305
+ parent = config.get("extends")
306
+ following: str | None = None
307
+ if isinstance(parent, str) and parent.startswith("."):
308
+ target = _inside(posixpath.join(posixpath.dirname(current), parent))
309
+ if target is not None:
310
+ following = target if target.endswith(".json") else target + ".json"
311
+ current = following
312
+ return found
313
+
314
+ def _read_config(self, relative: str) -> str | None:
315
+ """An ``extends`` target, held to the walker's rules: a regular file
316
+ inside the repo (no symlinks), outside pruned directories such as
317
+ ``node_modules``, and within the config size cap."""
318
+ assert self.root is not None
319
+ if any(part in SKIP_DIRS for part in relative.split("/")[:-1]):
320
+ return None
321
+ path = contained_file(self.root, relative)
322
+ if path is None:
323
+ return None
324
+ try:
325
+ if not path.is_file() or path.stat().st_size > _MAX_CONFIG_BYTES:
326
+ return None
327
+ except OSError:
328
+ return None
329
+ return read_source(path, root=self.root)
330
+
331
+ # -- workspace packages --
332
+
333
+ def _package_bases(self, spec: str) -> list[str]:
334
+ best: tuple[str, str, str | None] | None = None
335
+ for name, directory, main in self._load_packages():
336
+ if (spec == name or spec.startswith(name + "/")) and (best is None or len(name) > len(best[0])):
337
+ best = (name, directory, main)
338
+ if best is None:
339
+ return []
340
+ name, directory, main = best
341
+ sub = spec[len(name) + 1 :]
342
+ join = lambda *parts: _inside(posixpath.join(*parts)) # noqa: E731
343
+ if sub:
344
+ candidates = [join(directory, sub), join(directory, "src", sub)]
345
+ else:
346
+ candidates = [join(directory, main) if main else None, join(directory, "src", "index"), directory or None]
347
+ if main:
348
+ # `main` usually names build output; its source lives under src/.
349
+ candidates.insert(1, join(directory, "src", posixpath.basename(_STRIP_EXT.sub("", main))))
350
+ return [c for c in candidates if c]
351
+
352
+ def _load_packages(self) -> list[tuple[str, str, str | None]]:
353
+ if self._packages is not None:
354
+ return self._packages
355
+ self._packages = []
356
+ assert self.root is not None
357
+ for count, path in enumerate(iter_repo_files(self.root, names={"package.json"}, skip_dirs=SKIP_DIRS)):
358
+ if count >= _MAX_CONFIGS:
359
+ break
360
+ data = load_jsonc(read_source(path, root=self.root) or "")
361
+ if not isinstance(data, dict) or not isinstance(data.get("name"), str) or not data["name"]:
362
+ continue
363
+ main = data.get("main") if isinstance(data.get("main"), str) else None
364
+ directory = posixpath.dirname(rel(path, self.root))
365
+ self._packages.append((data["name"], directory, _STRIP_EXT.sub("", main) if main else None))
366
+ return self._packages