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,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST Engine
|
|
3
|
+
==========
|
|
4
|
+
|
|
5
|
+
Thin, cached wrapper around tree-sitter's JavaScript grammar.
|
|
6
|
+
|
|
7
|
+
Design notes (see design-brainstorm.md §9 "Performance"):
|
|
8
|
+
- "Parse once, analyze many": ASTs are cached by content hash, so
|
|
9
|
+
re-running with a different analyzer config never re-parses unchanged
|
|
10
|
+
files.
|
|
11
|
+
- Bounded worst-case behavior: files above `max_bytes` are skipped with a
|
|
12
|
+
clear "skipped: size limit" marker rather than risking a hang on a
|
|
13
|
+
pathological minified vendor bundle.
|
|
14
|
+
|
|
15
|
+
This module intentionally exposes a small, high-level query surface
|
|
16
|
+
(`find_calls`, `resolve_string`, etc.) rather than leaking raw tree-sitter
|
|
17
|
+
node types everywhere else in the codebase -- analyzers should not need to
|
|
18
|
+
know tree-sitter's grammar quirks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import tree_sitter_javascript as _tsjs
|
|
30
|
+
from tree_sitter import Language, Parser
|
|
31
|
+
|
|
32
|
+
_JS_LANGUAGE = Language(_tsjs.language())
|
|
33
|
+
_parser = Parser(_JS_LANGUAGE)
|
|
34
|
+
|
|
35
|
+
# TypeScript / TSX grammars are optional at import time: if the wheel is missing the engine still works for
|
|
36
|
+
# JavaScript and reports every .ts/.tsx file as "skipped: grammar unavailable" instead of silently mis-parsing it.
|
|
37
|
+
_PARSERS: dict[str, Parser] = {"javascript": _parser}
|
|
38
|
+
try:
|
|
39
|
+
import tree_sitter_typescript as _tsts
|
|
40
|
+
_PARSERS["typescript"] = Parser(Language(_tsts.language_typescript()))
|
|
41
|
+
_PARSERS["tsx"] = Parser(Language(_tsts.language_tsx()))
|
|
42
|
+
except Exception: # noqa: BLE001 (ImportError or ABI mismatch)
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
TS_AVAILABLE = "typescript" in _PARSERS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def language_for_path(path: str) -> str:
|
|
49
|
+
"""Grammar name for a path: .ts/.mts/.cts -> typescript, .tsx -> tsx, anything else -> javascript."""
|
|
50
|
+
low = path.lower().split("#")[0]
|
|
51
|
+
if low.endswith((".ts", ".mts", ".cts")):
|
|
52
|
+
return "typescript"
|
|
53
|
+
if low.endswith(".tsx"):
|
|
54
|
+
return "tsx"
|
|
55
|
+
return "javascript"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def count_syntax_errors(root) -> int:
|
|
59
|
+
"""Number of ERROR / MISSING nodes under `root` (0 == the grammar parsed the whole file cleanly)."""
|
|
60
|
+
n, stack = 0, [root]
|
|
61
|
+
while stack:
|
|
62
|
+
node = stack.pop()
|
|
63
|
+
if node.type == "ERROR" or node.is_missing:
|
|
64
|
+
n += 1
|
|
65
|
+
if node.has_error:
|
|
66
|
+
stack.extend(node.children)
|
|
67
|
+
return n
|
|
68
|
+
|
|
69
|
+
DEFAULT_MAX_BYTES = 5 * 1024 * 1024 # 5MB: generous but bounded
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CallSite:
|
|
74
|
+
"""A normalized view of a call expression, e.g. fetch(...), axios.get(...)."""
|
|
75
|
+
|
|
76
|
+
callee: str # dotted callee, e.g. "fetch" or "axios.get"
|
|
77
|
+
args_raw: list[str] # raw source text of each argument
|
|
78
|
+
args_resolved: list["ResolvedValue"]
|
|
79
|
+
line: int
|
|
80
|
+
node: Any = field(repr=False, default=None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ResolvedValue:
|
|
85
|
+
"""Result of trying to resolve an AST node to a concrete value."""
|
|
86
|
+
|
|
87
|
+
value: str | None # the resolved string, or None if unresolvable
|
|
88
|
+
is_literal: bool # True if it was a raw string literal
|
|
89
|
+
resolved_via_symbol: bool # True if resolution required following a const/let binding
|
|
90
|
+
is_placeholder: bool # True if it looks like an unsubstituted build placeholder
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ParsedFile:
|
|
95
|
+
path: str
|
|
96
|
+
source: bytes
|
|
97
|
+
content_hash: str
|
|
98
|
+
tree: Any = field(repr=False, default=None)
|
|
99
|
+
skipped_reason: str | None = None
|
|
100
|
+
origin: str = "disk" # "disk" | "sourcemap" (recovered original source)
|
|
101
|
+
language: str = "javascript" # grammar used: javascript | typescript | tsx
|
|
102
|
+
syntax_errors: int = 0 # ERROR/MISSING nodes: > 0 means this file was only PARTIALLY analyzed
|
|
103
|
+
memo: dict = field(default_factory=dict, repr=False) # per-file query cache (see parsing/queries.py)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def text(self) -> str:
|
|
107
|
+
return self.source.decode("utf-8", errors="replace")
|
|
108
|
+
|
|
109
|
+
def line_of(self, node) -> int:
|
|
110
|
+
return node.start_point[0] + 1
|
|
111
|
+
|
|
112
|
+
def node_text(self, node) -> str:
|
|
113
|
+
return self.source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ASTEngine:
|
|
117
|
+
"""Parses JS files with a content-hash keyed cache.
|
|
118
|
+
|
|
119
|
+
Cache is in-memory for this process by default; `cache_dir`, if given,
|
|
120
|
+
also persists parsed-source hashes to disk so `jscope analyze` on an
|
|
121
|
+
unchanged project directory can skip re-parsing across runs (this is
|
|
122
|
+
also what powers cheap incremental/differential analysis later).
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, cache_dir: str | None = None, max_bytes: int = DEFAULT_MAX_BYTES):
|
|
126
|
+
self._cache: dict[str, ParsedFile] = {}
|
|
127
|
+
self.cache_dir = Path(cache_dir) if cache_dir else None
|
|
128
|
+
self.max_bytes = max_bytes
|
|
129
|
+
if self.cache_dir:
|
|
130
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def hash_bytes(data: bytes) -> str:
|
|
134
|
+
return hashlib.sha256(data).hexdigest()[:16]
|
|
135
|
+
|
|
136
|
+
def parse_file(self, path: str) -> ParsedFile:
|
|
137
|
+
data = Path(path).read_bytes()
|
|
138
|
+
return self.parse_bytes(data, path=path)
|
|
139
|
+
|
|
140
|
+
def parse_bytes(self, data: bytes, path: str = "<memory>") -> ParsedFile:
|
|
141
|
+
content_hash = self.hash_bytes(data)
|
|
142
|
+
lang = language_for_path(path)
|
|
143
|
+
key = content_hash if lang == "javascript" else f"{content_hash}:{lang}"
|
|
144
|
+
if key in self._cache:
|
|
145
|
+
cached = self._cache[key]
|
|
146
|
+
# Re-tag with the current path for reporting purposes, keep the parsed tree.
|
|
147
|
+
return ParsedFile(
|
|
148
|
+
path=path, source=cached.source, content_hash=content_hash,
|
|
149
|
+
tree=cached.tree, skipped_reason=cached.skipped_reason,
|
|
150
|
+
language=cached.language, syntax_errors=cached.syntax_errors,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if len(data) > self.max_bytes:
|
|
154
|
+
pf = ParsedFile(path=path, source=data, content_hash=content_hash, language=lang,
|
|
155
|
+
tree=None, skipped_reason=f"skipped: size limit ({len(data)} bytes)")
|
|
156
|
+
self._cache[key] = pf
|
|
157
|
+
return pf
|
|
158
|
+
|
|
159
|
+
parser = _PARSERS.get(lang)
|
|
160
|
+
if parser is None:
|
|
161
|
+
pf = ParsedFile(path=path, source=data, content_hash=content_hash, language=lang, tree=None,
|
|
162
|
+
skipped_reason=f"skipped: {lang} grammar unavailable (pip install tree-sitter-typescript)")
|
|
163
|
+
self._cache[key] = pf
|
|
164
|
+
return pf
|
|
165
|
+
|
|
166
|
+
tree = parser.parse(data)
|
|
167
|
+
pf = ParsedFile(path=path, source=data, content_hash=content_hash, tree=tree, language=lang,
|
|
168
|
+
syntax_errors=count_syntax_errors(tree.root_node) if tree.root_node.has_error else 0)
|
|
169
|
+
self._cache[key] = pf
|
|
170
|
+
return pf
|
|
171
|
+
|
|
172
|
+
# -- query helpers ----------------------------------------------------
|
|
173
|
+
|
|
174
|
+
_CALLEE_CANDIDATES = {
|
|
175
|
+
"fetch", "axios", "axios.get", "axios.post", "axios.put", "axios.delete",
|
|
176
|
+
"axios.patch", "$.ajax", "$.get", "$.post", "XMLHttpRequest",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def walk(self, node):
|
|
180
|
+
yield node
|
|
181
|
+
for child in node.children:
|
|
182
|
+
yield from self.walk(child)
|
|
183
|
+
|
|
184
|
+
def _callee_dotted_name(self, pf: ParsedFile, node) -> str | None:
|
|
185
|
+
"""Given a call_expression's `function` child, reconstruct a dotted
|
|
186
|
+
name like 'axios.get' or 'fetch'."""
|
|
187
|
+
if node.type == "identifier":
|
|
188
|
+
return pf.node_text(node)
|
|
189
|
+
if node.type == "member_expression":
|
|
190
|
+
obj = node.child_by_field_name("object")
|
|
191
|
+
prop = node.child_by_field_name("property")
|
|
192
|
+
if obj is None or prop is None:
|
|
193
|
+
return None
|
|
194
|
+
obj_name = self._callee_dotted_name(pf, obj)
|
|
195
|
+
if obj_name is None:
|
|
196
|
+
return None
|
|
197
|
+
return f"{obj_name}.{pf.node_text(prop)}"
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def _local_bindings(self, pf: ParsedFile, root) -> dict[str, Any]:
|
|
201
|
+
"""Very small const/let/var propagation: name -> value node, scoped
|
|
202
|
+
to the whole file (module-level heuristic, intentionally simple --
|
|
203
|
+
see design-brainstorm.md §12 on scoping this down honestly rather
|
|
204
|
+
than overclaiming full data-flow)."""
|
|
205
|
+
bindings: dict[str, Any] = {}
|
|
206
|
+
for node in self.walk(root):
|
|
207
|
+
if node.type in ("variable_declarator",):
|
|
208
|
+
name_node = node.child_by_field_name("name")
|
|
209
|
+
value_node = node.child_by_field_name("value")
|
|
210
|
+
if name_node is not None and value_node is not None and name_node.type == "identifier":
|
|
211
|
+
bindings[pf.node_text(name_node)] = value_node
|
|
212
|
+
return bindings
|
|
213
|
+
|
|
214
|
+
def resolve_value(self, pf: ParsedFile, node, bindings: dict[str, Any] | None = None,
|
|
215
|
+
_depth: int = 0) -> ResolvedValue:
|
|
216
|
+
if bindings is None:
|
|
217
|
+
bindings = {}
|
|
218
|
+
if _depth > 5 or node is None:
|
|
219
|
+
return ResolvedValue(None, False, False, False)
|
|
220
|
+
|
|
221
|
+
if node.type == "string":
|
|
222
|
+
raw = pf.node_text(node).strip("'\"`")
|
|
223
|
+
return ResolvedValue(raw, is_literal=(_depth == 0), resolved_via_symbol=(_depth > 0),
|
|
224
|
+
is_placeholder=False)
|
|
225
|
+
|
|
226
|
+
if node.type == "template_string":
|
|
227
|
+
raw = pf.node_text(node).strip("`")
|
|
228
|
+
# if it contains ${...} substitutions we can't statically resolve,
|
|
229
|
+
# keep the raw template shape -- still useful signal.
|
|
230
|
+
return ResolvedValue(raw, is_literal=(_depth == 0), resolved_via_symbol=(_depth > 0),
|
|
231
|
+
is_placeholder=False)
|
|
232
|
+
|
|
233
|
+
if node.type == "identifier":
|
|
234
|
+
name = pf.node_text(node)
|
|
235
|
+
if name in bindings:
|
|
236
|
+
return self.resolve_value(pf, bindings[name], bindings, _depth + 1)
|
|
237
|
+
return ResolvedValue(None, False, False, False)
|
|
238
|
+
|
|
239
|
+
if node.type == "member_expression":
|
|
240
|
+
text = pf.node_text(node)
|
|
241
|
+
if text.startswith("process.env."):
|
|
242
|
+
return ResolvedValue(text, is_literal=False, resolved_via_symbol=False, is_placeholder=True)
|
|
243
|
+
return ResolvedValue(None, False, False, False)
|
|
244
|
+
|
|
245
|
+
if node.type == "binary_expression":
|
|
246
|
+
# naive string-concat resolution: left + right, only if both sides resolve
|
|
247
|
+
left = node.child_by_field_name("left")
|
|
248
|
+
right = node.child_by_field_name("right")
|
|
249
|
+
lval = self.resolve_value(pf, left, bindings, _depth + 1)
|
|
250
|
+
rval = self.resolve_value(pf, right, bindings, _depth + 1)
|
|
251
|
+
if lval.value is not None and rval.value is not None:
|
|
252
|
+
return ResolvedValue(lval.value + rval.value, is_literal=False,
|
|
253
|
+
resolved_via_symbol=True, is_placeholder=False)
|
|
254
|
+
return ResolvedValue(None, False, False, False)
|
|
255
|
+
|
|
256
|
+
return ResolvedValue(None, False, False, False)
|
|
257
|
+
|
|
258
|
+
def find_calls(self, pf: ParsedFile) -> list[CallSite]:
|
|
259
|
+
"""Find call expressions whose callee matches a known HTTP-call shape."""
|
|
260
|
+
if pf.tree is None:
|
|
261
|
+
return []
|
|
262
|
+
root = pf.tree.root_node
|
|
263
|
+
bindings = self._local_bindings(pf, root)
|
|
264
|
+
sites: list[CallSite] = []
|
|
265
|
+
|
|
266
|
+
for node in self.walk(root):
|
|
267
|
+
if node.type != "call_expression":
|
|
268
|
+
continue
|
|
269
|
+
fn_node = node.child_by_field_name("function")
|
|
270
|
+
if fn_node is None:
|
|
271
|
+
continue
|
|
272
|
+
dotted = self._callee_dotted_name(pf, fn_node)
|
|
273
|
+
if dotted is None:
|
|
274
|
+
continue
|
|
275
|
+
# match exact known callees, or a trailing method on a known object (axios.*, $.* )
|
|
276
|
+
base = dotted.split(".")[0]
|
|
277
|
+
if dotted not in self._CALLEE_CANDIDATES and base not in ("axios", "$"):
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
args_node = node.child_by_field_name("arguments")
|
|
281
|
+
args_raw: list[str] = []
|
|
282
|
+
args_resolved: list[ResolvedValue] = []
|
|
283
|
+
if args_node is not None:
|
|
284
|
+
for child in args_node.children:
|
|
285
|
+
if child.type in ("(", ")", ","):
|
|
286
|
+
continue
|
|
287
|
+
args_raw.append(pf.node_text(child))
|
|
288
|
+
args_resolved.append(self.resolve_value(pf, child, bindings))
|
|
289
|
+
|
|
290
|
+
sites.append(CallSite(
|
|
291
|
+
callee=dotted, args_raw=args_raw, args_resolved=args_resolved,
|
|
292
|
+
line=pf.line_of(node), node=node,
|
|
293
|
+
))
|
|
294
|
+
return sites
|
|
295
|
+
|
|
296
|
+
def find_string_literals(self, pf: ParsedFile):
|
|
297
|
+
"""Yield (node, text, line) for every string/template literal in the file."""
|
|
298
|
+
if pf.tree is None:
|
|
299
|
+
return
|
|
300
|
+
for node in self.walk(pf.tree.root_node):
|
|
301
|
+
if node.type == "string":
|
|
302
|
+
yield node, pf.node_text(node).strip("'\""), pf.line_of(node)
|
|
303
|
+
elif node.type == "template_string":
|
|
304
|
+
yield node, pf.node_text(node).strip("`"), pf.line_of(node)
|
|
305
|
+
|
|
306
|
+
def is_in_dead_branch(self, pf: ParsedFile, node) -> bool:
|
|
307
|
+
"""Heuristic: walk up from `node`; if any ancestor if_statement's
|
|
308
|
+
condition is a literal falsy value, treat as dead code."""
|
|
309
|
+
current = node.parent
|
|
310
|
+
while current is not None:
|
|
311
|
+
if current.type == "if_statement":
|
|
312
|
+
cond = current.child_by_field_name("condition")
|
|
313
|
+
if cond is not None:
|
|
314
|
+
text = pf.node_text(cond).strip()
|
|
315
|
+
if text.startswith("(") and text.endswith(")"):
|
|
316
|
+
text = text[1:-1].strip()
|
|
317
|
+
if text in ("false", "0", "'false'", '"false"'):
|
|
318
|
+
return True
|
|
319
|
+
if "NODE_ENV" in text and "development" in text:
|
|
320
|
+
return True
|
|
321
|
+
current = current.parent
|
|
322
|
+
return False
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Minimal, dependency-free GraphQL schema reader (SDL + introspection JSON) -> a common dict shape.
|
|
2
|
+
|
|
3
|
+
{"roots": {"query": "Query", "mutation": "Mutation", "subscription": None},
|
|
4
|
+
"types": {"Query": {"kind": "type", "fields": {"user": {"args": {"id": "ID!"}, "type": "User", "directives": []}}}}}
|
|
5
|
+
|
|
6
|
+
It is deliberately a *reader*, not a validator: it extracts what the offline-recon use case needs (root fields, argument
|
|
7
|
+
types, directives) and tolerates what it does not understand rather than rejecting the document.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_TOKEN = re.compile(r'"""[\s\S]*?"""|"(?:[^"\\]|\\.)*"|#[^\n]*|[A-Za-z_]\w*|-?\d+(?:\.\d+)?|\.\.\.|[!:(){}\[\]@=,|&$]')
|
|
16
|
+
_KINDS = {"type", "interface", "input", "enum", "union", "scalar", "directive", "schema", "extend", "fragment"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tokens(text: str) -> list[str]:
|
|
20
|
+
return [t for t in _TOKEN.findall(text) if not (t.startswith("#") or t.startswith('"') or t == ",")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_type(toks: list[str], i: int) -> tuple[str, int]:
|
|
24
|
+
out = ""
|
|
25
|
+
while i < len(toks) and toks[i] == "[":
|
|
26
|
+
out, i = out + "[", i + 1
|
|
27
|
+
if i < len(toks):
|
|
28
|
+
out, i = out + toks[i], i + 1
|
|
29
|
+
while i < len(toks) and toks[i] in ("]", "!"):
|
|
30
|
+
out, i = out + toks[i], i + 1
|
|
31
|
+
return out, i
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _skip_parens(toks: list[str], i: int) -> int:
|
|
35
|
+
depth = 0
|
|
36
|
+
while i < len(toks):
|
|
37
|
+
if toks[i] == "(":
|
|
38
|
+
depth += 1
|
|
39
|
+
elif toks[i] == ")":
|
|
40
|
+
depth -= 1
|
|
41
|
+
if depth == 0:
|
|
42
|
+
return i + 1
|
|
43
|
+
i += 1
|
|
44
|
+
return i
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _directives(toks: list[str], i: int) -> tuple[list[str], int]:
|
|
48
|
+
names = []
|
|
49
|
+
while i < len(toks) and toks[i] == "@":
|
|
50
|
+
i += 1
|
|
51
|
+
if i < len(toks):
|
|
52
|
+
names.append(toks[i])
|
|
53
|
+
i += 1
|
|
54
|
+
if i < len(toks) and toks[i] == "(":
|
|
55
|
+
i = _skip_parens(toks, i)
|
|
56
|
+
return names, i
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_sdl(text: str) -> dict[str, Any]:
|
|
60
|
+
toks = _tokens(text)
|
|
61
|
+
types: dict[str, dict] = {}
|
|
62
|
+
roots: dict[str, str | None] = {"query": None, "mutation": None, "subscription": None}
|
|
63
|
+
i, n = 0, len(toks)
|
|
64
|
+
while i < n:
|
|
65
|
+
t = toks[i]
|
|
66
|
+
if t == "extend":
|
|
67
|
+
i += 1
|
|
68
|
+
continue
|
|
69
|
+
if t == "schema":
|
|
70
|
+
i += 1
|
|
71
|
+
_, i = _directives(toks, i)
|
|
72
|
+
if i < n and toks[i] == "{":
|
|
73
|
+
i += 1
|
|
74
|
+
while i < n and toks[i] != "}":
|
|
75
|
+
if toks[i] in roots and i + 2 < n and toks[i + 1] == ":":
|
|
76
|
+
roots[toks[i]] = toks[i + 2]
|
|
77
|
+
i += 3
|
|
78
|
+
else:
|
|
79
|
+
i += 1
|
|
80
|
+
i += 1
|
|
81
|
+
continue
|
|
82
|
+
if t in ("type", "interface", "input") and i + 1 < n:
|
|
83
|
+
kind, name, i = t, toks[i + 1], i + 2
|
|
84
|
+
if i < n and toks[i] == "implements":
|
|
85
|
+
while i < n and toks[i] not in ("{", "@"):
|
|
86
|
+
i += 1
|
|
87
|
+
_, i = _directives(toks, i)
|
|
88
|
+
fields: dict[str, dict] = {}
|
|
89
|
+
if i < n and toks[i] == "{":
|
|
90
|
+
i += 1
|
|
91
|
+
while i < n and toks[i] != "}":
|
|
92
|
+
fname = toks[i]
|
|
93
|
+
i += 1
|
|
94
|
+
args: dict[str, str] = {}
|
|
95
|
+
if i < n and toks[i] == "(":
|
|
96
|
+
j, end = i + 1, _skip_parens(toks, i)
|
|
97
|
+
while j < end - 1:
|
|
98
|
+
if j + 1 < end and toks[j + 1] == ":" and re.match(r"[A-Za-z_]", toks[j]):
|
|
99
|
+
aname = toks[j]
|
|
100
|
+
atype, j = _parse_type(toks, j + 2)
|
|
101
|
+
args[aname] = atype
|
|
102
|
+
if j < end and toks[j] == "=": # default value: skip one token
|
|
103
|
+
j += 2
|
|
104
|
+
_, j = _directives(toks, j)
|
|
105
|
+
else:
|
|
106
|
+
j += 1
|
|
107
|
+
i = end
|
|
108
|
+
ftype = ""
|
|
109
|
+
if i < n and toks[i] == ":":
|
|
110
|
+
ftype, i = _parse_type(toks, i + 1)
|
|
111
|
+
dirs, i = _directives(toks, i)
|
|
112
|
+
if kind == "input": # input fields look like `name: Type = default @d`
|
|
113
|
+
if i < n and toks[i] == "=":
|
|
114
|
+
i += 2
|
|
115
|
+
fields[fname] = {"args": args, "type": ftype, "directives": dirs}
|
|
116
|
+
types.setdefault(name, {"kind": kind, "fields": {}})["fields"].update(fields)
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
i += 1
|
|
120
|
+
for r, default in (("query", "Query"), ("mutation", "Mutation"), ("subscription", "Subscription")):
|
|
121
|
+
if roots[r] is None and default in types:
|
|
122
|
+
roots[r] = default
|
|
123
|
+
return {"roots": roots, "types": types}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _unwrap(t: dict | None) -> str:
|
|
127
|
+
if not t:
|
|
128
|
+
return ""
|
|
129
|
+
kind, name = t.get("kind"), t.get("name")
|
|
130
|
+
if kind == "NON_NULL":
|
|
131
|
+
return _unwrap(t.get("ofType")) + "!"
|
|
132
|
+
if kind == "LIST":
|
|
133
|
+
return "[" + _unwrap(t.get("ofType")) + "]"
|
|
134
|
+
return name or ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_introspection(doc: dict) -> dict[str, Any]:
|
|
138
|
+
schema = (doc.get("data") or doc)["__schema"]
|
|
139
|
+
roots = {"query": (schema.get("queryType") or {}).get("name"),
|
|
140
|
+
"mutation": (schema.get("mutationType") or {}).get("name"),
|
|
141
|
+
"subscription": (schema.get("subscriptionType") or {}).get("name")}
|
|
142
|
+
types: dict[str, dict] = {}
|
|
143
|
+
for t in schema.get("types", []):
|
|
144
|
+
name = t.get("name") or ""
|
|
145
|
+
if name.startswith("__"):
|
|
146
|
+
continue
|
|
147
|
+
fields = {}
|
|
148
|
+
for f in (t.get("fields") or t.get("inputFields") or []):
|
|
149
|
+
fields[f["name"]] = {"args": {a["name"]: _unwrap(a.get("type")) for a in f.get("args", []) or []},
|
|
150
|
+
"type": _unwrap(f.get("type")), "directives": [],
|
|
151
|
+
"deprecated": bool(f.get("isDeprecated"))}
|
|
152
|
+
types[name] = {"kind": str(t.get("kind", "OBJECT")).lower(), "fields": fields}
|
|
153
|
+
return {"roots": roots, "types": types}
|
jscope/parsing/html.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""HTML / Vue SFC helpers (Phase 4).
|
|
2
|
+
|
|
3
|
+
Inline `<script>` bodies (and a `.vue` file's `<script>` block) are turned into ordinary ParsedFiles so EVERY existing
|
|
4
|
+
analyzer (endpoints, secrets, auth, storage, GraphQL...) runs on them unchanged. Source is left-padded with newlines so
|
|
5
|
+
reported line numbers match the original document.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
|
|
12
|
+
from jscope.core.artifacts import KIND_VUE, Artifact
|
|
13
|
+
from jscope.parsing.ast_engine import ASTEngine, ParsedFile
|
|
14
|
+
|
|
15
|
+
_JS_TYPES = {"", "module", "text/javascript", "application/javascript", "text/babel", "application/x-javascript",
|
|
16
|
+
"text/ecmascript", "application/ecmascript"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def soup(art: Artifact) -> BeautifulSoup:
|
|
20
|
+
if "soup" not in art.memo:
|
|
21
|
+
art.memo["soup"] = BeautifulSoup(art.text, "html.parser")
|
|
22
|
+
return art.memo["soup"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def script_tags(art: Artifact):
|
|
26
|
+
"""(tag, is_executable_js, lang) for every <script>."""
|
|
27
|
+
out = []
|
|
28
|
+
for tag in soup(art).find_all("script"):
|
|
29
|
+
typ = (tag.get("type") or "").strip().lower()
|
|
30
|
+
lang = (tag.get("lang") or "js").strip().lower()
|
|
31
|
+
out.append((tag, typ in _JS_TYPES, lang))
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_inline_scripts(art: Artifact, ast: ASTEngine) -> tuple[list[ParsedFile], list[str]]:
|
|
36
|
+
files: list[ParsedFile] = []
|
|
37
|
+
diags: list[str] = []
|
|
38
|
+
n = 0
|
|
39
|
+
for tag, is_js, lang in script_tags(art):
|
|
40
|
+
if tag.get("src") and art.kind != KIND_VUE:
|
|
41
|
+
continue # external script: it is its own file
|
|
42
|
+
body = tag.string if tag.string is not None else "".join(tag.strings)
|
|
43
|
+
if not is_js or not body or not body.strip():
|
|
44
|
+
continue
|
|
45
|
+
if lang not in ("js", "javascript", "jsx"):
|
|
46
|
+
diags.append(f"[html] {art.path}: <script lang=\"{lang}\"> not analyzed (JavaScript grammar only)")
|
|
47
|
+
continue
|
|
48
|
+
n += 1
|
|
49
|
+
line = getattr(tag, "sourceline", None) or 1
|
|
50
|
+
padded = ("\n" * (line - 1) + body).encode("utf-8")
|
|
51
|
+
suffix = "#script" if art.kind == KIND_VUE else f"#inline-{n}"
|
|
52
|
+
pf = ast.parse_bytes(padded, path=art.path + suffix)
|
|
53
|
+
pf.origin = "vue-sfc" if art.kind == KIND_VUE else "html-inline"
|
|
54
|
+
files.append(pf)
|
|
55
|
+
return files, diags
|