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.
- attackmap_analyzer_node_service/__init__.py +3 -0
- attackmap_analyzer_node_service/analyzer.py +1149 -0
- attackmap_analyzer_node_service/modules.py +366 -0
- attackmap_analyzer_node_service/route_auth.py +1553 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/METADATA +104 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/RECORD +10 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_node_service-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|