jscope 0.4.2__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.
- jscope/__init__.py +1 -0
- jscope/__main__.py +10 -0
- jscope/ai/__init__.py +0 -0
- jscope/ai/explain.py +180 -0
- jscope/ai/providers.py +71 -0
- jscope/analyzers/__init__.py +0 -0
- jscope/analyzers/auth_analyzer.py +170 -0
- jscope/analyzers/authorization_analyzer.py +196 -0
- jscope/analyzers/base.py +56 -0
- jscope/analyzers/common.py +78 -0
- jscope/analyzers/dataflow_analyzer.py +192 -0
- jscope/analyzers/endpoint_analyzer.py +150 -0
- jscope/analyzers/framework_detector.py +82 -0
- jscope/analyzers/graphql_analyzer.py +198 -0
- jscope/analyzers/graphql_schema_analyzer.py +132 -0
- jscope/analyzers/html_analyzer.py +196 -0
- jscope/analyzers/manager.py +114 -0
- jscope/analyzers/openapi_analyzer.py +278 -0
- jscope/analyzers/router_analyzer.py +502 -0
- jscope/analyzers/secret_analyzer.py +99 -0
- jscope/analyzers/service_worker_analyzer.py +142 -0
- jscope/analyzers/source_map_analyzer.py +119 -0
- jscope/analyzers/storage_analyzer.py +125 -0
- jscope/analyzers/wasm_analyzer.py +139 -0
- jscope/assessment_engine.py +285 -0
- jscope/assessment_reporting.py +71 -0
- jscope/benchmark/__init__.py +1 -0
- jscope/benchmark/fixtures.py +105 -0
- jscope/benchmark/runner.py +155 -0
- jscope/cli.py +1241 -0
- jscope/config.py +72 -0
- jscope/core/__init__.py +0 -0
- jscope/core/artifacts.py +139 -0
- jscope/core/cache.py +198 -0
- jscope/core/classification.py +56 -0
- jscope/core/evidence.py +411 -0
- jscope/core/graph.py +196 -0
- jscope/core/redact.py +67 -0
- jscope/crawler/__init__.py +0 -0
- jscope/crawler/crawler.py +176 -0
- jscope/dataflow/__init__.py +1 -0
- jscope/dataflow/engine.py +1684 -0
- jscope/dataflow/modules.py +205 -0
- jscope/dataflow/rules.py +118 -0
- jscope/downloader/__init__.py +0 -0
- jscope/downloader/downloader.py +116 -0
- jscope/parsing/__init__.py +0 -0
- jscope/parsing/ast_engine.py +322 -0
- jscope/parsing/graphql_schema.py +153 -0
- jscope/parsing/html.py +55 -0
- jscope/parsing/queries.py +385 -0
- jscope/parsing/wasm.py +146 -0
- jscope/passive_discovery.py +63 -0
- jscope/pipeline.py +196 -0
- jscope/plugin/__init__.py +0 -0
- jscope/plugin/_worker.py +199 -0
- jscope/plugin/loader.py +225 -0
- jscope/plugin/sandbox.py +207 -0
- jscope/reporting/__init__.py +0 -0
- jscope/reporting/formats.py +208 -0
- jscope/reporting/human.py +137 -0
- jscope/reporting/json_report.py +35 -0
- jscope/reporting/priority.py +116 -0
- jscope/runtime/__init__.py +0 -0
- jscope/runtime/observe.py +397 -0
- jscope/runtime/prober.py +259 -0
- jscope/runtime/scope.py +288 -0
- jscope/security_transport.py +246 -0
- jscope/verify/__init__.py +1 -0
- jscope/verify/engine.py +290 -0
- jscope/workspace/__init__.py +0 -0
- jscope/workspace/assessment.py +133 -0
- jscope/workspace/common.py +25 -0
- jscope/workspace/diff.py +143 -0
- jscope/workspace/store.py +206 -0
- jscope-0.4.2.dist-info/METADATA +180 -0
- jscope-0.4.2.dist-info/RECORD +81 -0
- jscope-0.4.2.dist-info/WHEEL +5 -0
- jscope-0.4.2.dist-info/entry_points.txt +2 -0
- jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
- jscope-0.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Higher-level AST queries shared by the Phase 2 analyzers.
|
|
2
|
+
|
|
3
|
+
Sits on top of ASTEngine so analyzers never touch raw tree-sitter grammar
|
|
4
|
+
quirks. Results are memoized per ParsedFile (`pf.memo`) because several
|
|
5
|
+
analyzers ask the same questions of the same file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import posixpath
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from jscope.parsing.ast_engine import ASTEngine, CallSite, ParsedFile, ResolvedValue
|
|
16
|
+
|
|
17
|
+
_PUNCT = {"(", ")", ",", "[", "]", "{", "}"}
|
|
18
|
+
_FUNC_TYPES = {"function_declaration", "function_expression", "function", "arrow_function",
|
|
19
|
+
"generator_function_declaration", "method_definition"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def norm_path(p: str) -> str:
|
|
23
|
+
return posixpath.normpath(p.replace("\\", "/"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bindings_for(engine: ASTEngine, pf: ParsedFile) -> dict[str, Any]:
|
|
27
|
+
if "bindings" not in pf.memo:
|
|
28
|
+
pf.memo["bindings"] = engine._local_bindings(pf, pf.tree.root_node) if pf.tree else {}
|
|
29
|
+
return pf.memo["bindings"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _callee_node_name(engine: ASTEngine, pf: ParsedFile, fn) -> str | None:
|
|
33
|
+
# minified JSX runtime calls look like (0,r.jsx)(...): unwrap to the last member
|
|
34
|
+
if fn.type == "parenthesized_expression":
|
|
35
|
+
inner = [c for c in fn.children if c.type not in ("(", ")")]
|
|
36
|
+
if inner and inner[0].type == "sequence_expression":
|
|
37
|
+
inner = [c for c in inner[0].children if c.type != ","]
|
|
38
|
+
return _callee_node_name(engine, pf, inner[-1]) if inner else None
|
|
39
|
+
return engine._callee_dotted_name(pf, fn)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def iter_calls(engine: ASTEngine, pf: ParsedFile) -> list[CallSite]:
|
|
43
|
+
"""EVERY call_expression and new_expression, with dotted callee when resolvable."""
|
|
44
|
+
if "calls" in pf.memo:
|
|
45
|
+
return pf.memo["calls"]
|
|
46
|
+
sites: list[CallSite] = []
|
|
47
|
+
if pf.tree is not None:
|
|
48
|
+
bindings = bindings_for(engine, pf)
|
|
49
|
+
for node in engine.walk(pf.tree.root_node):
|
|
50
|
+
if node.type == "call_expression":
|
|
51
|
+
fn = node.child_by_field_name("function")
|
|
52
|
+
elif node.type == "new_expression":
|
|
53
|
+
fn = node.child_by_field_name("constructor")
|
|
54
|
+
else:
|
|
55
|
+
continue
|
|
56
|
+
if fn is None:
|
|
57
|
+
continue
|
|
58
|
+
name = _callee_node_name(engine, pf, fn)
|
|
59
|
+
if name is None:
|
|
60
|
+
continue
|
|
61
|
+
args = call_args(node)
|
|
62
|
+
sites.append(CallSite(
|
|
63
|
+
callee=name, args_raw=[pf.node_text(a) for a in args],
|
|
64
|
+
args_resolved=[engine.resolve_value(pf, a, bindings) for a in args],
|
|
65
|
+
line=pf.line_of(node), node=node))
|
|
66
|
+
pf.memo["calls"] = sites
|
|
67
|
+
return sites
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def call_args(call_node) -> list[Any]:
|
|
71
|
+
args = call_node.child_by_field_name("arguments")
|
|
72
|
+
if args is None:
|
|
73
|
+
return []
|
|
74
|
+
if args.type == "template_string": # gql`...` tagged template
|
|
75
|
+
return [args]
|
|
76
|
+
return [c for c in args.children if c.type not in _PUNCT]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def last_segment(dotted: str) -> str:
|
|
80
|
+
return dotted.rsplit(".", 1)[-1]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def key_name(pf: ParsedFile, key_node) -> str | None:
|
|
84
|
+
if key_node is None:
|
|
85
|
+
return None
|
|
86
|
+
if key_node.type in ("property_identifier", "identifier", "shorthand_property_identifier"):
|
|
87
|
+
return pf.node_text(key_node)
|
|
88
|
+
if key_node.type == "string":
|
|
89
|
+
return pf.node_text(key_node).strip("'\"")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def object_props(pf: ParsedFile, obj) -> dict[str, Any]:
|
|
94
|
+
"""name -> value node for a JS object literal (pairs and shorthand)."""
|
|
95
|
+
out: dict[str, Any] = {}
|
|
96
|
+
if obj is None or obj.type != "object":
|
|
97
|
+
return out
|
|
98
|
+
for ch in obj.children:
|
|
99
|
+
if ch.type == "pair":
|
|
100
|
+
k = key_name(pf, ch.child_by_field_name("key"))
|
|
101
|
+
v = ch.child_by_field_name("value")
|
|
102
|
+
if k is not None and v is not None:
|
|
103
|
+
out[k] = v
|
|
104
|
+
elif ch.type == "shorthand_property_identifier":
|
|
105
|
+
out[pf.node_text(ch)] = ch
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def array_items(node) -> list[Any]:
|
|
110
|
+
if node is None or node.type != "array":
|
|
111
|
+
return []
|
|
112
|
+
return [c for c in node.children if c.type not in _PUNCT]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def string_list(engine: ASTEngine, pf: ParsedFile, node) -> list[str]:
|
|
116
|
+
"""['a','b'] or 'a' -> list of resolved strings (unresolvable items skipped)."""
|
|
117
|
+
if node is None:
|
|
118
|
+
return []
|
|
119
|
+
b = bindings_for(engine, pf)
|
|
120
|
+
items = array_items(node) if node.type == "array" else [node]
|
|
121
|
+
out = []
|
|
122
|
+
for it in items:
|
|
123
|
+
rv = engine.resolve_value(pf, it, b)
|
|
124
|
+
if rv.value is not None and not rv.is_placeholder:
|
|
125
|
+
out.append(rv.value)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def enclosing_function_name(pf: ParsedFile, node) -> str | None:
|
|
130
|
+
cur = node.parent
|
|
131
|
+
while cur is not None:
|
|
132
|
+
if cur.type in ("function_declaration", "class_declaration", "generator_function_declaration"):
|
|
133
|
+
n = cur.child_by_field_name("name")
|
|
134
|
+
if n is not None:
|
|
135
|
+
return pf.node_text(n)
|
|
136
|
+
if cur.type in ("arrow_function", "function_expression", "function"):
|
|
137
|
+
par = cur.parent
|
|
138
|
+
# unwrap HOCs like memo(forwardRef(() => ...))
|
|
139
|
+
while par is not None and par.type in ("arguments", "call_expression", "parenthesized_expression"):
|
|
140
|
+
par = par.parent
|
|
141
|
+
if par is not None and par.type == "variable_declarator":
|
|
142
|
+
n = par.child_by_field_name("name")
|
|
143
|
+
if n is not None and n.type == "identifier":
|
|
144
|
+
return pf.node_text(n)
|
|
145
|
+
cur = cur.parent
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def function_index(engine: ASTEngine, pf: ParsedFile) -> dict[str, Any]:
|
|
150
|
+
"""name -> node whose byte range contains the function/component body."""
|
|
151
|
+
if "funcs" in pf.memo:
|
|
152
|
+
return pf.memo["funcs"]
|
|
153
|
+
idx: dict[str, Any] = {}
|
|
154
|
+
if pf.tree is not None:
|
|
155
|
+
for node in engine.walk(pf.tree.root_node):
|
|
156
|
+
if node.type in ("function_declaration", "class_declaration", "generator_function_declaration"):
|
|
157
|
+
n = node.child_by_field_name("name")
|
|
158
|
+
if n is not None:
|
|
159
|
+
idx[pf.node_text(n)] = node
|
|
160
|
+
elif node.type == "variable_declarator":
|
|
161
|
+
n = node.child_by_field_name("name")
|
|
162
|
+
v = node.child_by_field_name("value")
|
|
163
|
+
if n is None or v is None or n.type != "identifier":
|
|
164
|
+
continue
|
|
165
|
+
if v.type in _FUNC_TYPES or (v.type == "call_expression" and any(
|
|
166
|
+
d.type in _FUNC_TYPES for d in engine.walk(v))):
|
|
167
|
+
idx[pf.node_text(n)] = v
|
|
168
|
+
pf.memo["funcs"] = idx
|
|
169
|
+
return idx
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class ImportRef:
|
|
174
|
+
source: str
|
|
175
|
+
kind: str # "import" | "require" | "lazy_import"
|
|
176
|
+
imported: str | None = None
|
|
177
|
+
line: int = 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _str_of(pf: ParsedFile, node) -> str | None:
|
|
181
|
+
return pf.node_text(node).strip("'\"`") if node is not None and node.type == "string" else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _dynamic_import_source(pf: ParsedFile, node) -> str | None:
|
|
185
|
+
for d in _walk(node):
|
|
186
|
+
if d.type == "call_expression":
|
|
187
|
+
fn = d.child_by_field_name("function")
|
|
188
|
+
if fn is not None and fn.type == "import":
|
|
189
|
+
a = call_args(d)
|
|
190
|
+
return _str_of(pf, a[0]) if a else None
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _walk(node):
|
|
195
|
+
yield node
|
|
196
|
+
for c in node.children:
|
|
197
|
+
yield from _walk(c)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def imports(engine: ASTEngine, pf: ParsedFile) -> tuple[dict[str, ImportRef], list[ImportRef]]:
|
|
201
|
+
"""(local_name -> ImportRef, every module edge incl. side-effect/dynamic imports)."""
|
|
202
|
+
if "imports" in pf.memo:
|
|
203
|
+
return pf.memo["imports"]
|
|
204
|
+
by_name: dict[str, ImportRef] = {}
|
|
205
|
+
edges: list[ImportRef] = []
|
|
206
|
+
if pf.tree is not None:
|
|
207
|
+
for node in engine.walk(pf.tree.root_node):
|
|
208
|
+
if node.type == "import_statement":
|
|
209
|
+
src = next((_str_of(pf, c) for c in node.children if c.type == "string"), None)
|
|
210
|
+
if src is None:
|
|
211
|
+
continue
|
|
212
|
+
edges.append(ImportRef(src, "import", None, pf.line_of(node)))
|
|
213
|
+
clause = next((c for c in node.children if c.type == "import_clause"), None)
|
|
214
|
+
if clause is None:
|
|
215
|
+
continue
|
|
216
|
+
for c in clause.children:
|
|
217
|
+
if c.type == "identifier":
|
|
218
|
+
by_name[pf.node_text(c)] = ImportRef(src, "import", "default", pf.line_of(node))
|
|
219
|
+
elif c.type == "named_imports":
|
|
220
|
+
for spec in c.children:
|
|
221
|
+
if spec.type == "import_specifier":
|
|
222
|
+
ids = [x for x in spec.children if x.type == "identifier"]
|
|
223
|
+
if ids:
|
|
224
|
+
local = pf.node_text(ids[-1])
|
|
225
|
+
by_name[local] = ImportRef(src, "import", pf.node_text(ids[0]), pf.line_of(node))
|
|
226
|
+
elif c.type == "namespace_import":
|
|
227
|
+
ids = [x for x in c.children if x.type == "identifier"]
|
|
228
|
+
if ids:
|
|
229
|
+
by_name[pf.node_text(ids[-1])] = ImportRef(src, "import", "*", pf.line_of(node))
|
|
230
|
+
elif node.type == "variable_declarator":
|
|
231
|
+
n = node.child_by_field_name("name")
|
|
232
|
+
v = node.child_by_field_name("value")
|
|
233
|
+
if n is None or v is None or n.type != "identifier":
|
|
234
|
+
continue
|
|
235
|
+
if v.type == "call_expression":
|
|
236
|
+
fn = v.child_by_field_name("function")
|
|
237
|
+
if fn is not None and fn.type == "identifier" and pf.node_text(fn) == "require":
|
|
238
|
+
a = call_args(v)
|
|
239
|
+
src = _str_of(pf, a[0]) if a else None
|
|
240
|
+
if src:
|
|
241
|
+
by_name[pf.node_text(n)] = ImportRef(src, "require", None, pf.line_of(node))
|
|
242
|
+
edges.append(ImportRef(src, "require", None, pf.line_of(node)))
|
|
243
|
+
continue
|
|
244
|
+
src = _dynamic_import_source(pf, v) # lazy(() => import('./X')), dynamic(...), loadable(...)
|
|
245
|
+
if src:
|
|
246
|
+
by_name[pf.node_text(n)] = ImportRef(src, "lazy_import", None, pf.line_of(node))
|
|
247
|
+
elif node.type == "call_expression":
|
|
248
|
+
fn = node.child_by_field_name("function")
|
|
249
|
+
if fn is not None and fn.type == "import":
|
|
250
|
+
a = call_args(node)
|
|
251
|
+
src = _str_of(pf, a[0]) if a else None
|
|
252
|
+
if src:
|
|
253
|
+
edges.append(ImportRef(src, "dynamic_import", None, pf.line_of(node)))
|
|
254
|
+
pf.memo["imports"] = (by_name, edges)
|
|
255
|
+
return pf.memo["imports"]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class FileIndex:
|
|
259
|
+
"""Resolve relative module specifiers to project files. Alias paths
|
|
260
|
+
('@/x', bare package names) are intentionally NOT resolved -- Phase 2
|
|
261
|
+
does not read tsconfig/webpack alias config."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, files: list[ParsedFile]):
|
|
264
|
+
self.by_path = {norm_path(f.path): f for f in files if not f.skipped_reason}
|
|
265
|
+
|
|
266
|
+
def resolve(self, from_path: str, source: str) -> ParsedFile | None:
|
|
267
|
+
if not source.startswith("."):
|
|
268
|
+
return None
|
|
269
|
+
base = posixpath.normpath(posixpath.join(posixpath.dirname(norm_path(from_path)), source))
|
|
270
|
+
for cand in (base, base + ".js", base + ".jsx", base + ".mjs", base + ".cjs",
|
|
271
|
+
base + "/index.js", base + "/index.jsx"):
|
|
272
|
+
if cand in self.by_path:
|
|
273
|
+
return self.by_path[cand]
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def by_basename(self, name: str) -> ParsedFile | None:
|
|
277
|
+
name = name.split("?")[0].lstrip("/")
|
|
278
|
+
for p, f in self.by_path.items():
|
|
279
|
+
if p == name or p.endswith("/" + name):
|
|
280
|
+
return f
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def calls_within(sites: list[CallSite], container) -> list[CallSite]:
|
|
285
|
+
return [s for s in sites if container.start_byte <= s.node.start_byte and s.node.end_byte <= container.end_byte]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---- JSX / element helpers -------------------------------------------------
|
|
289
|
+
|
|
290
|
+
@dataclass
|
|
291
|
+
class ElementUse:
|
|
292
|
+
name: str
|
|
293
|
+
props: dict[str, str] # prop name -> raw value text (quotes/braces stripped)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _jsx_name(pf: ParsedFile, el) -> str | None:
|
|
297
|
+
for c in el.children:
|
|
298
|
+
if c.type in ("identifier", "member_expression", "nested_identifier", "jsx_namespace_name"):
|
|
299
|
+
return pf.node_text(c)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def jsx_attrs(pf: ParsedFile, el) -> dict[str, tuple[str, Any]]:
|
|
304
|
+
"""attr name -> (raw text, value node)."""
|
|
305
|
+
out = {}
|
|
306
|
+
for c in el.children:
|
|
307
|
+
if c.type != "jsx_attribute":
|
|
308
|
+
continue
|
|
309
|
+
kids = [k for k in c.children if k.type != "="]
|
|
310
|
+
if not kids:
|
|
311
|
+
continue
|
|
312
|
+
name = pf.node_text(kids[0])
|
|
313
|
+
val = kids[1] if len(kids) > 1 else None
|
|
314
|
+
if val is not None and val.type == "jsx_expression":
|
|
315
|
+
inner = [k for k in val.children if k.type not in ("{", "}")]
|
|
316
|
+
val = inner[0] if inner else None
|
|
317
|
+
raw = pf.node_text(val).strip("'\"") if val is not None else "true"
|
|
318
|
+
out[name] = (raw, val)
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def element_chain(engine: ASTEngine, pf: ParsedFile, node, _depth: int = 0) -> list[ElementUse]:
|
|
323
|
+
"""Component names from outermost wrapper to innermost for an element
|
|
324
|
+
expression: <Guard role="x"><Page/></Guard>, jsx(Guard,{children:jsx(Page,{})}),
|
|
325
|
+
createElement(Guard, props, createElement(Page))."""
|
|
326
|
+
if node is None or _depth > 6:
|
|
327
|
+
return []
|
|
328
|
+
t = node.type
|
|
329
|
+
if t == "jsx_expression":
|
|
330
|
+
inner = [k for k in node.children if k.type not in ("{", "}")]
|
|
331
|
+
return element_chain(engine, pf, inner[0], _depth + 1) if inner else []
|
|
332
|
+
if t == "jsx_self_closing_element":
|
|
333
|
+
n = _jsx_name(pf, node)
|
|
334
|
+
return [ElementUse(n, {k: v[0] for k, v in jsx_attrs(pf, node).items()})] if n else []
|
|
335
|
+
if t == "jsx_element":
|
|
336
|
+
opening = next((c for c in node.children if c.type == "jsx_opening_element"), None)
|
|
337
|
+
if opening is None:
|
|
338
|
+
return []
|
|
339
|
+
n = _jsx_name(pf, opening)
|
|
340
|
+
if not n:
|
|
341
|
+
return []
|
|
342
|
+
me = [ElementUse(n, {k: v[0] for k, v in jsx_attrs(pf, opening).items()})]
|
|
343
|
+
for c in node.children:
|
|
344
|
+
if c.type in ("jsx_element", "jsx_self_closing_element", "jsx_expression"):
|
|
345
|
+
sub = element_chain(engine, pf, c, _depth + 1)
|
|
346
|
+
if sub:
|
|
347
|
+
return me + sub
|
|
348
|
+
return me
|
|
349
|
+
if t == "call_expression":
|
|
350
|
+
fn = node.child_by_field_name("function")
|
|
351
|
+
name = _callee_node_name(engine, pf, fn) if fn is not None else None
|
|
352
|
+
if name and last_segment(name) in ("jsx", "jsxs", "jsxDEV", "createElement", "h"):
|
|
353
|
+
args = call_args(node)
|
|
354
|
+
if not args:
|
|
355
|
+
return []
|
|
356
|
+
first = pf.node_text(args[0]).strip("'\"")
|
|
357
|
+
if args[0].type not in ("identifier", "member_expression"):
|
|
358
|
+
return []
|
|
359
|
+
props_node = args[1] if len(args) > 1 and args[1].type == "object" else None
|
|
360
|
+
props = {k: pf.node_text(v).strip("'\"") for k, v in object_props(pf, props_node).items()}
|
|
361
|
+
me = [ElementUse(first, props)]
|
|
362
|
+
child = object_props(pf, props_node).get("children") if props_node else None
|
|
363
|
+
if child is None and len(args) > 2:
|
|
364
|
+
child = args[2]
|
|
365
|
+
if child is not None:
|
|
366
|
+
if child.type == "array":
|
|
367
|
+
for it in array_items(child):
|
|
368
|
+
sub = element_chain(engine, pf, it, _depth + 1)
|
|
369
|
+
if sub:
|
|
370
|
+
return me + sub
|
|
371
|
+
else:
|
|
372
|
+
sub = element_chain(engine, pf, child, _depth + 1)
|
|
373
|
+
if sub:
|
|
374
|
+
return me + sub
|
|
375
|
+
return me
|
|
376
|
+
if t == "identifier": # component: Admin / Component: Admin
|
|
377
|
+
return [ElementUse(pf.node_text(node), {})]
|
|
378
|
+
if t in ("arrow_function", "function_expression"): # () => import('./x') or () => <X/>
|
|
379
|
+
body = node.child_by_field_name("body")
|
|
380
|
+
return element_chain(engine, pf, body, _depth + 1) if body is not None else []
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def quoted_strings(raw: str) -> list[str]:
|
|
385
|
+
return re.findall(r"['\"]([^'\"]+)['\"]", raw)
|
jscope/parsing/wasm.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Tiny WebAssembly binary reader (pure Python, no dependencies).
|
|
2
|
+
|
|
3
|
+
Extracts what recon needs, nothing more: section layout, imports, exports, memory limits, the `producers` and `name`
|
|
4
|
+
custom sections, and the byte range of the data section (so string hits can be tied to real data segments).
|
|
5
|
+
It stops at the first malformed construct and returns what it has -- never raises on hostile input.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_KINDS = {0: "func", 1: "table", 2: "memory", 3: "global", 4: "tag"}
|
|
14
|
+
_PRINTABLE = re.compile(rb"[\x20-\x7e]{8,}")
|
|
15
|
+
MAX_SECTIONS = 200
|
|
16
|
+
MAX_ITEMS = 5000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Bad(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _leb(data: bytes, i: int) -> tuple[int, int]:
|
|
24
|
+
result = shift = 0
|
|
25
|
+
while True:
|
|
26
|
+
if i >= len(data) or shift > 63:
|
|
27
|
+
raise _Bad
|
|
28
|
+
b = data[i]
|
|
29
|
+
i += 1
|
|
30
|
+
result |= (b & 0x7F) << shift
|
|
31
|
+
if not b & 0x80:
|
|
32
|
+
return result, i
|
|
33
|
+
shift += 7
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _name(data: bytes, i: int) -> tuple[str, int]:
|
|
37
|
+
n, i = _leb(data, i)
|
|
38
|
+
if i + n > len(data):
|
|
39
|
+
raise _Bad
|
|
40
|
+
return data[i:i + n].decode("utf-8", errors="replace"), i + n
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _limits(data: bytes, i: int) -> tuple[dict, int]:
|
|
44
|
+
flag, i = _leb(data, i)
|
|
45
|
+
lo, i = _leb(data, i)
|
|
46
|
+
hi = None
|
|
47
|
+
if flag & 1:
|
|
48
|
+
hi, i = _leb(data, i)
|
|
49
|
+
return {"min": lo, "max": hi, "shared": bool(flag & 2), "memory64": bool(flag & 4)}, i
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_wasm(data: bytes) -> bool:
|
|
53
|
+
return data[:4] == b"\x00asm"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_wasm(data: bytes) -> dict[str, Any]:
|
|
57
|
+
info: dict[str, Any] = {"version": None, "sections": [], "imports": [], "exports": [], "memories": [], "customs": [],
|
|
58
|
+
"producers": {}, "has_name_section": False, "data_range": None, "truncated": False}
|
|
59
|
+
if not is_wasm(data) or len(data) < 8:
|
|
60
|
+
info["truncated"] = True
|
|
61
|
+
return info
|
|
62
|
+
info["version"] = int.from_bytes(data[4:8], "little")
|
|
63
|
+
i = 8
|
|
64
|
+
try:
|
|
65
|
+
while i < len(data) and len(info["sections"]) < MAX_SECTIONS:
|
|
66
|
+
sid = data[i]
|
|
67
|
+
size, j = _leb(data, i + 1)
|
|
68
|
+
end = j + size
|
|
69
|
+
if end > len(data):
|
|
70
|
+
raise _Bad
|
|
71
|
+
info["sections"].append({"id": sid, "size": size, "offset": j})
|
|
72
|
+
if sid == 0:
|
|
73
|
+
cname, k = _name(data, j)
|
|
74
|
+
info["customs"].append(cname)
|
|
75
|
+
if cname == "name":
|
|
76
|
+
info["has_name_section"] = True
|
|
77
|
+
elif cname == "producers":
|
|
78
|
+
_producers(data, k, end, info)
|
|
79
|
+
elif sid == 2:
|
|
80
|
+
_imports(data, j, end, info)
|
|
81
|
+
elif sid == 5:
|
|
82
|
+
cnt, k = _leb(data, j)
|
|
83
|
+
for _ in range(min(cnt, 16)):
|
|
84
|
+
lim, k = _limits(data, k)
|
|
85
|
+
info["memories"].append(lim)
|
|
86
|
+
elif sid == 7:
|
|
87
|
+
cnt, k = _leb(data, j)
|
|
88
|
+
for _ in range(min(cnt, MAX_ITEMS)):
|
|
89
|
+
nm, k = _name(data, k)
|
|
90
|
+
kind, k = data[k], k + 1
|
|
91
|
+
_, k = _leb(data, k)
|
|
92
|
+
info["exports"].append({"name": nm, "kind": _KINDS.get(kind, str(kind))})
|
|
93
|
+
elif sid == 11:
|
|
94
|
+
info["data_range"] = (j, end)
|
|
95
|
+
i = end
|
|
96
|
+
except (_Bad, IndexError):
|
|
97
|
+
info["truncated"] = True
|
|
98
|
+
return info
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _producers(data: bytes, k: int, end: int, info: dict) -> None:
|
|
102
|
+
try:
|
|
103
|
+
nfields, k = _leb(data, k)
|
|
104
|
+
for _ in range(min(nfields, 8)):
|
|
105
|
+
field, k = _name(data, k)
|
|
106
|
+
nvals, k = _leb(data, k)
|
|
107
|
+
vals = []
|
|
108
|
+
for _ in range(min(nvals, 16)):
|
|
109
|
+
nm, k = _name(data, k)
|
|
110
|
+
ver, k = _name(data, k)
|
|
111
|
+
vals.append(f"{nm} {ver}".strip())
|
|
112
|
+
info["producers"][field] = vals
|
|
113
|
+
except (_Bad, IndexError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _imports(data: bytes, j: int, end: int, info: dict) -> None:
|
|
118
|
+
cnt, k = _leb(data, j)
|
|
119
|
+
for _ in range(min(cnt, MAX_ITEMS)):
|
|
120
|
+
mod, k = _name(data, k)
|
|
121
|
+
nm, k = _name(data, k)
|
|
122
|
+
kind, k = data[k], k + 1
|
|
123
|
+
if kind == 0:
|
|
124
|
+
_, k = _leb(data, k)
|
|
125
|
+
elif kind == 1:
|
|
126
|
+
k += 1
|
|
127
|
+
_, k = _limits(data, k)
|
|
128
|
+
elif kind == 2:
|
|
129
|
+
_, k = _limits(data, k)
|
|
130
|
+
elif kind == 3:
|
|
131
|
+
k += 2
|
|
132
|
+
elif kind == 4:
|
|
133
|
+
k += 1
|
|
134
|
+
_, k = _leb(data, k)
|
|
135
|
+
else:
|
|
136
|
+
raise _Bad
|
|
137
|
+
info["imports"].append({"module": mod, "name": nm, "kind": _KINDS.get(kind, str(kind))})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def printable_strings(data: bytes, limit: int = 20000) -> list[tuple[int, str]]:
|
|
141
|
+
out = []
|
|
142
|
+
for m in _PRINTABLE.finditer(data):
|
|
143
|
+
out.append((m.start(), m.group().decode("ascii")))
|
|
144
|
+
if len(out) >= limit:
|
|
145
|
+
break
|
|
146
|
+
return out
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Passive candidate-host inventory and assessment-specific approvals; no network probing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, time, hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from jscope.workspace.assessment import normalize_domain
|
|
6
|
+
from jscope.workspace.common import WorkspaceError, _atomic_write
|
|
7
|
+
|
|
8
|
+
def discover_candidates(root_domain: str, host_files=(), existing=()):
|
|
9
|
+
root=normalize_domain(root_domain); candidates={}
|
|
10
|
+
def add(value,source):
|
|
11
|
+
try:
|
|
12
|
+
host=normalize_domain(value)
|
|
13
|
+
if host != root and not host.endswith('.'+root):
|
|
14
|
+
candidates.setdefault(str(value), {'hostname':host,'state':'excluded','reason':'outside-root-domain','provenance':source,'discovered_at':time.time()})
|
|
15
|
+
else:
|
|
16
|
+
candidates.setdefault(host, {'hostname':host,'state':'known','reason':None,'provenance':source,'discovered_at':time.time()})
|
|
17
|
+
except WorkspaceError as e:
|
|
18
|
+
candidates.setdefault(str(value), {'hostname':None,'state':'excluded','reason':'invalid-hostname','detail':str(e),'provenance':source,'discovered_at':time.time()})
|
|
19
|
+
for h in existing: add(h,'workspace-knowledge')
|
|
20
|
+
for filename in host_files:
|
|
21
|
+
p=Path(filename)
|
|
22
|
+
for line in p.read_text(encoding='utf-8').splitlines():
|
|
23
|
+
val=line.strip()
|
|
24
|
+
if val and not val.startswith('#'): add(val, f'host-file:{p.name}')
|
|
25
|
+
add(root,'root-domain')
|
|
26
|
+
return list(candidates.values())
|
|
27
|
+
|
|
28
|
+
def persist_candidates(manifest_path, candidates):
|
|
29
|
+
p=Path(manifest_path)
|
|
30
|
+
try: data=json.loads(p.read_text(encoding='utf-8'))
|
|
31
|
+
except (OSError,ValueError) as e: raise WorkspaceError('assessment manifest missing or corrupt; refusing discovery update') from e
|
|
32
|
+
if data.get('schema')!='jscope-assessment/1' or not data.get('assessment_id') or not data.get('root_domain'):
|
|
33
|
+
raise WorkspaceError('invalid assessment manifest')
|
|
34
|
+
hosts=data.setdefault('hosts',{})
|
|
35
|
+
for c in candidates:
|
|
36
|
+
h=c.get('hostname')
|
|
37
|
+
if not h or c.get('state') != 'known': continue
|
|
38
|
+
prior=hosts.get(h)
|
|
39
|
+
if prior:
|
|
40
|
+
prior.setdefault('provenance',[])
|
|
41
|
+
if c['provenance'] not in prior['provenance']: prior['provenance'].append(c['provenance'])
|
|
42
|
+
else:
|
|
43
|
+
hosts[h]={'state':'known','discovery_source':c['provenance'],'discovered_at':c['discovered_at']}
|
|
44
|
+
data.setdefault('discovery',[]).append({'at':time.time(),'candidate_count':len(candidates),'candidates':candidates})
|
|
45
|
+
_atomic_write(p,data); return data
|
|
46
|
+
|
|
47
|
+
def approve_hosts(manifest_path, hostnames):
|
|
48
|
+
p=Path(manifest_path)
|
|
49
|
+
try: data=json.loads(p.read_text(encoding='utf-8'))
|
|
50
|
+
except (OSError,ValueError) as e: raise WorkspaceError('assessment manifest missing or corrupt; approval denied') from e
|
|
51
|
+
if data.get('schema')!='jscope-assessment/1' or not data.get('assessment_id'): raise WorkspaceError('invalid assessment manifest; approval denied')
|
|
52
|
+
root=normalize_domain(data['root_domain']); normalized=[]
|
|
53
|
+
for raw in hostnames:
|
|
54
|
+
h=normalize_domain(raw)
|
|
55
|
+
if h != root and not h.endswith('.'+root): raise WorkspaceError(f'approval denied: {h} is outside root domain')
|
|
56
|
+
if h not in data.get('hosts',{}): raise WorkspaceError(f'approval denied: {h} is not a known candidate')
|
|
57
|
+
normalized.append(h)
|
|
58
|
+
if not normalized: raise WorkspaceError('no hosts specified for approval')
|
|
59
|
+
now=time.time(); decision={'at':now,'assessment_id':data['assessment_id'],'hosts':sorted(set(normalized)),'decision':'approved'}
|
|
60
|
+
data.setdefault('approval_history',[]).append(decision)
|
|
61
|
+
auth=data.setdefault('authorization',{}); auth['approved_hosts']=sorted(set(auth.get('approved_hosts',[]))|set(normalized)); auth['approved_at']=now; auth['fresh_for_this_run']=True
|
|
62
|
+
for h in normalized: data['hosts'][h]['state']='approved'
|
|
63
|
+
data['status']='approved'; _atomic_write(p,data); return decision
|