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.
Files changed (81) hide show
  1. jscope/__init__.py +1 -0
  2. jscope/__main__.py +10 -0
  3. jscope/ai/__init__.py +0 -0
  4. jscope/ai/explain.py +180 -0
  5. jscope/ai/providers.py +71 -0
  6. jscope/analyzers/__init__.py +0 -0
  7. jscope/analyzers/auth_analyzer.py +170 -0
  8. jscope/analyzers/authorization_analyzer.py +196 -0
  9. jscope/analyzers/base.py +56 -0
  10. jscope/analyzers/common.py +78 -0
  11. jscope/analyzers/dataflow_analyzer.py +192 -0
  12. jscope/analyzers/endpoint_analyzer.py +150 -0
  13. jscope/analyzers/framework_detector.py +82 -0
  14. jscope/analyzers/graphql_analyzer.py +198 -0
  15. jscope/analyzers/graphql_schema_analyzer.py +132 -0
  16. jscope/analyzers/html_analyzer.py +196 -0
  17. jscope/analyzers/manager.py +114 -0
  18. jscope/analyzers/openapi_analyzer.py +278 -0
  19. jscope/analyzers/router_analyzer.py +502 -0
  20. jscope/analyzers/secret_analyzer.py +99 -0
  21. jscope/analyzers/service_worker_analyzer.py +142 -0
  22. jscope/analyzers/source_map_analyzer.py +119 -0
  23. jscope/analyzers/storage_analyzer.py +125 -0
  24. jscope/analyzers/wasm_analyzer.py +139 -0
  25. jscope/assessment_engine.py +285 -0
  26. jscope/assessment_reporting.py +71 -0
  27. jscope/benchmark/__init__.py +1 -0
  28. jscope/benchmark/fixtures.py +105 -0
  29. jscope/benchmark/runner.py +155 -0
  30. jscope/cli.py +1241 -0
  31. jscope/config.py +72 -0
  32. jscope/core/__init__.py +0 -0
  33. jscope/core/artifacts.py +139 -0
  34. jscope/core/cache.py +198 -0
  35. jscope/core/classification.py +56 -0
  36. jscope/core/evidence.py +411 -0
  37. jscope/core/graph.py +196 -0
  38. jscope/core/redact.py +67 -0
  39. jscope/crawler/__init__.py +0 -0
  40. jscope/crawler/crawler.py +176 -0
  41. jscope/dataflow/__init__.py +1 -0
  42. jscope/dataflow/engine.py +1684 -0
  43. jscope/dataflow/modules.py +205 -0
  44. jscope/dataflow/rules.py +118 -0
  45. jscope/downloader/__init__.py +0 -0
  46. jscope/downloader/downloader.py +116 -0
  47. jscope/parsing/__init__.py +0 -0
  48. jscope/parsing/ast_engine.py +322 -0
  49. jscope/parsing/graphql_schema.py +153 -0
  50. jscope/parsing/html.py +55 -0
  51. jscope/parsing/queries.py +385 -0
  52. jscope/parsing/wasm.py +146 -0
  53. jscope/passive_discovery.py +63 -0
  54. jscope/pipeline.py +196 -0
  55. jscope/plugin/__init__.py +0 -0
  56. jscope/plugin/_worker.py +199 -0
  57. jscope/plugin/loader.py +225 -0
  58. jscope/plugin/sandbox.py +207 -0
  59. jscope/reporting/__init__.py +0 -0
  60. jscope/reporting/formats.py +208 -0
  61. jscope/reporting/human.py +137 -0
  62. jscope/reporting/json_report.py +35 -0
  63. jscope/reporting/priority.py +116 -0
  64. jscope/runtime/__init__.py +0 -0
  65. jscope/runtime/observe.py +397 -0
  66. jscope/runtime/prober.py +259 -0
  67. jscope/runtime/scope.py +288 -0
  68. jscope/security_transport.py +246 -0
  69. jscope/verify/__init__.py +1 -0
  70. jscope/verify/engine.py +290 -0
  71. jscope/workspace/__init__.py +0 -0
  72. jscope/workspace/assessment.py +133 -0
  73. jscope/workspace/common.py +25 -0
  74. jscope/workspace/diff.py +143 -0
  75. jscope/workspace/store.py +206 -0
  76. jscope-0.4.2.dist-info/METADATA +180 -0
  77. jscope-0.4.2.dist-info/RECORD +81 -0
  78. jscope-0.4.2.dist-info/WHEEL +5 -0
  79. jscope-0.4.2.dist-info/entry_points.txt +2 -0
  80. jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
  81. jscope-0.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1684 @@
1
+ """Interprocedural, inter-file taint engine (FR-01).
2
+
3
+ Design in one paragraph: every file is *executed abstractly* once (module scope), then every top-level function and every
4
+ closure created along the way is executed with clean parameters so sources found inside them are seen. Whenever execution
5
+ reaches a call whose callee resolves to project code (local function, import/require, dynamic import, callback passed
6
+ along) the callee is executed with the actual argument values -- context-sensitively, memoised by (function, argument
7
+ taint identities), with depth and recursion limits. Values carry taint (source + the steps taken), applied mitigations and
8
+ explicit *assumptions* (e.g. "passed through unresolved call foo()"). Nothing unresolved is silently guessed: it either
9
+ becomes an assumption on the flow or a recorded limitation.
10
+
11
+ A flow is only ever a *potential* flow. The engine never claims exploitability; verification (jscope.verify) does that.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Callable
20
+
21
+ from jscope.dataflow import rules as R
22
+ from jscope.dataflow.modules import ModuleResolver
23
+ from jscope.dataflow.rules import Mitigation
24
+ from jscope.parsing.ast_engine import ParsedFile
25
+
26
+ MAX_DEPTH = 14
27
+ MAX_STEPS_PER_FILE = 400_000
28
+ MAX_PATH_STEPS = 40
29
+ _FN_TYPES = {"function_declaration", "function_expression", "function", "arrow_function", "generator_function",
30
+ "generator_function_declaration", "method_definition"}
31
+ _WRAPPERS = {"parenthesized_expression", "as_expression", "satisfies_expression", "non_null_expression",
32
+ "type_assertion", "instantiation_expression", "await_expression", "yield_expression"}
33
+ _TERMINATORS = {"return_statement", "throw_statement", "break_statement", "continue_statement"}
34
+
35
+
36
+ # ======================================================================================== model
37
+ @dataclass(frozen=True)
38
+ class Taint:
39
+ kind: str
40
+ file: str
41
+ line: int
42
+ snippet: str
43
+ steps: tuple = () # ((file, line, text), ...)
44
+ mitigations: tuple = () # tuple[Mitigation]
45
+ assumptions: tuple = () # tuple[str]
46
+
47
+ @property
48
+ def ident(self) -> tuple:
49
+ return (self.kind, self.file, self.line, tuple(m.name for m in self.mitigations), self.assumptions)
50
+
51
+ def step(self, file: str, line: int, text: str) -> "Taint":
52
+ if self.steps and self.steps[-1][2] == text and self.steps[-1][0] == file and self.steps[-1][1] == line:
53
+ return self
54
+ if len(self.steps) >= MAX_PATH_STEPS:
55
+ return self
56
+ return Taint(self.kind, self.file, self.line, self.snippet, self.steps + ((file, line, text),),
57
+ self.mitigations, self.assumptions)
58
+
59
+ def mitigate(self, m: Mitigation) -> "Taint":
60
+ if any(x.name == m.name for x in self.mitigations):
61
+ return self
62
+ return Taint(self.kind, self.file, self.line, self.snippet, self.steps, self.mitigations + (m,), self.assumptions)
63
+
64
+ def assume(self, text: str) -> "Taint":
65
+ if text in self.assumptions:
66
+ return self
67
+ return Taint(self.kind, self.file, self.line, self.snippet, self.steps, self.mitigations,
68
+ self.assumptions + (text,))
69
+
70
+
71
+ class Val:
72
+ """Abstract value: taint set plus optional structure (object fields, callable, module namespace, external ref)."""
73
+ __slots__ = ("t", "props", "fn", "ns", "ext", "imp", "lits", "pyfn")
74
+
75
+ def __init__(self, t=frozenset(), props=None, fn=None, ns=None, ext=None, imp=None, lits=None, pyfn=None):
76
+ self.t: frozenset = t if isinstance(t, frozenset) else frozenset(t)
77
+ self.props: dict[str, Val] | None = props
78
+ self.fn: FnVal | None = fn
79
+ self.ns: str | None = ns
80
+ self.ext: tuple | None = ext
81
+ self.imp: tuple | None = imp
82
+ self.lits: tuple | None = lits # literal string array contents (allow-list detection)
83
+ self.pyfn: Callable | None = pyfn
84
+
85
+ @property
86
+ def tainted(self) -> bool:
87
+ return bool(self.t)
88
+
89
+ def all_taints(self) -> frozenset:
90
+ out = set(self.t)
91
+ for v in (self.props or {}).values():
92
+ out |= v.all_taints()
93
+ return frozenset(out)
94
+
95
+ def map_taints(self, f: Callable[[Taint], Taint]) -> "Val":
96
+ return Val(frozenset(f(x) for x in self.t),
97
+ {k: v.map_taints(f) for k, v in self.props.items()} if self.props is not None else None,
98
+ self.fn, self.ns, self.ext, self.imp, self.lits, self.pyfn)
99
+
100
+
101
+ CLEAN = Val()
102
+
103
+
104
+ def join(a: Val | None, b: Val | None) -> Val:
105
+ if a is None:
106
+ return b if b is not None else CLEAN
107
+ if b is None or a is b:
108
+ return a
109
+ props = None
110
+ if a.props is not None or b.props is not None:
111
+ props = {}
112
+ for k in set(a.props or {}) | set(b.props or {}):
113
+ props[k] = join((a.props or {}).get(k), (b.props or {}).get(k))
114
+ return Val(a.t | b.t, props, a.fn or b.fn, a.ns or b.ns, a.ext or b.ext, a.imp or b.imp,
115
+ a.lits if a.lits == b.lits else None, a.pyfn or b.pyfn)
116
+
117
+
118
+ def union_vals(vals: list[Val]) -> Val:
119
+ t: set = set()
120
+ for v in vals:
121
+ t |= v.all_taints()
122
+ return Val(frozenset(t)) if t else CLEAN
123
+
124
+
125
+ @dataclass
126
+ class FnVal:
127
+ node: Any
128
+ file: str
129
+ scope: "Scope"
130
+ name: str = "<anonymous>"
131
+ key: tuple = ()
132
+
133
+
134
+ class Scope:
135
+ __slots__ = ("vars", "parent")
136
+
137
+ def __init__(self, parent: "Scope | None" = None):
138
+ self.vars: dict[str, Val] = {}
139
+ self.parent = parent
140
+
141
+ def lookup(self, name: str) -> Val | None:
142
+ s: Scope | None = self
143
+ while s is not None:
144
+ if name in s.vars:
145
+ return s.vars[name]
146
+ s = s.parent
147
+ return None
148
+
149
+ def declare(self, name: str, v: Val) -> None:
150
+ self.vars[name] = v
151
+
152
+ def assign(self, name: str, v: Val, weak: bool) -> None:
153
+ s: Scope | None = self
154
+ while s is not None:
155
+ if name in s.vars:
156
+ s.vars[name] = join(s.vars[name], v) if weak else v
157
+ return
158
+ s = s.parent
159
+ self.vars[name] = v
160
+
161
+
162
+ @dataclass
163
+ class Frame:
164
+ file: str
165
+ fname: str = "<module>"
166
+ returns: list = field(default_factory=list)
167
+ dead: bool = False
168
+ cond: int = 0
169
+ depth: int = 0
170
+
171
+
172
+ @dataclass
173
+ class Flow:
174
+ source_kind: str
175
+ source_file: str
176
+ source_line: int
177
+ source_snippet: str
178
+ sink_class: str
179
+ sink_name: str
180
+ sink_file: str
181
+ sink_line: int
182
+ sink_snippet: str
183
+ steps: tuple
184
+ status: str # candidate | uncertain | sanitized | unreachable
185
+ mitigations: tuple = ()
186
+ assumptions: tuple = ()
187
+ files: tuple = () # every file the path touches (source, steps, sink)
188
+ root: str = "" # file whose root analysis discovered this flow (incremental cache attribution)
189
+
190
+ @property
191
+ def key(self) -> tuple:
192
+ return (self.source_kind, self.source_file, self.source_line, self.sink_file, self.sink_line, self.sink_name)
193
+
194
+ @property
195
+ def cross_file(self) -> bool:
196
+ return len(set(self.files)) > 1
197
+
198
+
199
+ @dataclass
200
+ class Limitation:
201
+ file: str
202
+ line: int
203
+ kind: str
204
+ message: str
205
+
206
+
207
+ @dataclass
208
+ class ModuleState:
209
+ path: str
210
+ scope: Scope
211
+ exports: dict[str, Val] = field(default_factory=dict)
212
+ stars: list[str] = field(default_factory=list)
213
+ status: str = "new" # new | running | done
214
+ packages: set = field(default_factory=set)
215
+
216
+
217
+ _STATUS_RANK = {"candidate": 0, "uncertain": 1, "sanitized": 2, "unreachable": 3}
218
+
219
+
220
+ # ======================================================================================== engine
221
+ class DataFlowEngine:
222
+ def __init__(self, files: list[ParsedFile], resolver: ModuleResolver | None = None,
223
+ vue_templates: list[tuple] | None = None):
224
+ self.files: dict[str, ParsedFile] = {pf.path: pf for pf in files if pf.tree is not None and not pf.skipped_reason}
225
+ keys = [(pf.path.split("#")[0] if pf.origin == "vue-sfc" else pf.path) for pf in self.files.values()]
226
+ paths = list(self.files)
227
+ self._alias_to_real = {k: p for k, p in zip(keys, paths)}
228
+ self.resolver = resolver or ModuleResolver(keys)
229
+ self.modules: dict[str, ModuleState] = {}
230
+ self.flows: dict[tuple, Flow] = {}
231
+ self.limitations: list[Limitation] = []
232
+ self._lim_seen: set = set()
233
+ self.pending: list[FnVal] = []
234
+ self._pending_seen: set = set()
235
+ self._memo: dict[tuple, Val] = {}
236
+ self._stack: list[tuple] = []
237
+ self._analyzed_clean: set = set()
238
+ self.steps_used: dict[str, int] = {}
239
+ self.truncated_files: set[str] = set()
240
+ self.import_edges: dict[str, set[str]] = {} # file -> project files it imports (for incremental)
241
+ self.unresolved_imports: list[tuple] = [] # (file, line, spec, reason)
242
+ self.vue_templates = vue_templates or []
243
+ self.vue_state: dict[tuple, set] = {}
244
+ self.root_files: set[str] = set()
245
+ self.attr: str = "" # file currently being analysed as a root (cache attribution)
246
+ self.isolate_roots = False # True => memo cleared per root, so each root's flows are self-contained
247
+ self.suppressed: set[str] = set() # roots whose results come from the cache: their flows are not re-reported
248
+
249
+ # ------------------------------------------------------------------ helpers
250
+ def lim(self, file: str, line: int, kind: str, msg: str) -> None:
251
+ k = (file, line, kind, msg)
252
+ if k not in self._lim_seen:
253
+ self._lim_seen.add(k)
254
+ self.limitations.append(Limitation(file, line, kind, msg))
255
+
256
+ def tick(self, file: str) -> bool:
257
+ n = self.steps_used.get(file, 0) + 1
258
+ self.steps_used[file] = n
259
+ if n > MAX_STEPS_PER_FILE:
260
+ if file not in self.truncated_files:
261
+ self.truncated_files.add(file)
262
+ self.lim(file, 1, "budget", f"analysis budget exceeded ({MAX_STEPS_PER_FILE} steps); results for this file are partial")
263
+ return False
264
+ return True
265
+
266
+ @staticmethod
267
+ def named(node) -> list:
268
+ return [c for c in node.children if c.is_named and c.type != "comment"]
269
+
270
+ def txt(self, pf: ParsedFile, node) -> str:
271
+ return pf.node_text(node)
272
+
273
+ def unwrap(self, node):
274
+ while node is not None and node.type in _WRAPPERS:
275
+ ch = self.named(node)
276
+ if not ch:
277
+ return node
278
+ node = ch[-1] if node.type == "type_assertion" else ch[0]
279
+ return node
280
+
281
+ def dotted(self, pf: ParsedFile, node) -> str | None:
282
+ node = self.unwrap(node)
283
+ if node is None:
284
+ return None
285
+ t = node.type
286
+ if t in ("identifier", "property_identifier", "shorthand_property_identifier"):
287
+ return self.txt(pf, node)
288
+ if t == "this":
289
+ return "this"
290
+ if t == "member_expression":
291
+ o = self.dotted(pf, node.child_by_field_name("object"))
292
+ p = node.child_by_field_name("property")
293
+ if o is None or p is None:
294
+ return None
295
+ return f"{o}.{self.txt(pf, p)}"
296
+ if t == "call_expression": # `a.b().c` -> "a.b().c" shape used for jQuery receiver detection
297
+ f = self.dotted(pf, node.child_by_field_name("function"))
298
+ return f"{f}()" if f else None
299
+ return None
300
+
301
+ def str_value(self, pf: ParsedFile, node) -> str | None:
302
+ node = self.unwrap(node)
303
+ if node is None:
304
+ return None
305
+ if node.type == "string":
306
+ frags = [self.txt(pf, c) for c in node.children if c.type == "string_fragment"]
307
+ if frags or len(node.children) == 2:
308
+ return "".join(frags)
309
+ return self.txt(pf, node)[1:-1]
310
+ if node.type == "template_string" and all(c.type != "template_substitution" for c in node.children):
311
+ return self.txt(pf, node).strip("`")
312
+ return None
313
+
314
+ def real_path(self, key: str) -> str | None:
315
+ return self._alias_to_real.get(key) or (key if key in self.files else None)
316
+
317
+ # ------------------------------------------------------------------ module execution
318
+ def get_module(self, path: str) -> ModuleState | None:
319
+ path = self.real_path(path)
320
+ if path is None or path not in self.files:
321
+ return None
322
+ m = self.modules.get(path)
323
+ if m is None:
324
+ m = ModuleState(path=path, scope=Scope())
325
+ self.modules[path] = m
326
+ if m.status == "new":
327
+ m.status = "running"
328
+ prev_attr, self.attr = self.attr, path
329
+ try:
330
+ pf = self.files[path]
331
+ frame = Frame(path)
332
+ root = pf.tree.root_node
333
+ self.hoist(pf, root, m.scope, m)
334
+ self.exec_block(pf, root, m.scope, frame, m)
335
+ self.finish_cjs(m)
336
+ finally:
337
+ self.attr = prev_attr
338
+ m.status = "done"
339
+ return m
340
+
341
+ def finish_cjs(self, m: ModuleState) -> None:
342
+ d = m.exports.get("default")
343
+ if d is not None and d.props:
344
+ for k, v in d.props.items():
345
+ m.exports.setdefault(k, v)
346
+
347
+ def hoist(self, pf: ParsedFile, block, scope: Scope, m: ModuleState | None) -> None:
348
+ for c in self.named(block):
349
+ node = c
350
+ exported = False
351
+ if c.type == "export_statement":
352
+ decl = c.child_by_field_name("declaration")
353
+ if decl is None:
354
+ continue
355
+ node, exported = decl, True
356
+ if node.type in ("function_declaration", "generator_function_declaration"):
357
+ nm = node.child_by_field_name("name")
358
+ if nm is not None:
359
+ name = self.txt(pf, nm)
360
+ fv = self.make_fn(pf, node, scope, name)
361
+ scope.declare(name, Val(fn=fv))
362
+ if exported and m is not None:
363
+ m.exports[name] = scope.vars[name]
364
+ if "default" in self.txt(pf, c)[:25]:
365
+ m.exports["default"] = scope.vars[name]
366
+
367
+ def make_fn(self, pf: ParsedFile, node, scope: Scope, name: str) -> FnVal:
368
+ return FnVal(node, pf.path, scope, name, (pf.path, node.start_byte))
369
+
370
+ # ------------------------------------------------------------------ statements
371
+ def exec_block(self, pf, block, scope, frame, m=None) -> None:
372
+ for c in self.named(block):
373
+ if not self.tick(pf.path):
374
+ return
375
+ self.exec_stmt(pf, c, scope, frame, m)
376
+
377
+ def exec_stmt(self, pf, node, scope, frame, m=None) -> None:
378
+ t = node.type
379
+ if t == "expression_statement":
380
+ for c in self.named(node):
381
+ self.eval(pf, c, scope, frame)
382
+ elif t in ("lexical_declaration", "variable_declaration"):
383
+ for d in self.named(node):
384
+ if d.type == "variable_declarator":
385
+ self.exec_declarator(pf, d, scope, frame, m)
386
+ elif t == "return_statement":
387
+ ch = self.named(node)
388
+ v = self.eval(pf, ch[0], scope, frame) if ch else CLEAN
389
+ frame.returns.append(v)
390
+ elif t == "statement_block":
391
+ inner = Scope(scope)
392
+ self.hoist(pf, node, inner, None)
393
+ self.exec_block(pf, node, inner, frame)
394
+ elif t == "if_statement":
395
+ self.exec_if(pf, node, scope, frame)
396
+ elif t in ("for_statement", "while_statement", "do_statement"):
397
+ self.exec_loop(pf, node, scope, frame)
398
+ elif t == "for_in_statement":
399
+ self.exec_for_in(pf, node, scope, frame)
400
+ elif t == "switch_statement":
401
+ self.eval_opt(pf, node.child_by_field_name("value"), scope, frame)
402
+ body = node.child_by_field_name("body")
403
+ frame.cond += 1
404
+ if body is not None:
405
+ for case in self.named(body):
406
+ val_node = case.child_by_field_name("value")
407
+ for st in self.named(case):
408
+ if val_node is not None and st.id == val_node.id:
409
+ continue
410
+ self.exec_stmt(pf, st, scope, frame)
411
+ frame.cond -= 1
412
+ elif t == "try_statement":
413
+ body = node.child_by_field_name("body")
414
+ if body is not None:
415
+ self.exec_stmt(pf, body, scope, frame)
416
+ h = node.child_by_field_name("handler")
417
+ if h is not None:
418
+ hs = Scope(scope)
419
+ p = h.child_by_field_name("parameter")
420
+ if p is not None:
421
+ self.bind_pattern(pf, p, CLEAN, hs, frame, declare=True)
422
+ hb = h.child_by_field_name("body")
423
+ frame.cond += 1
424
+ if hb is not None:
425
+ self.exec_stmt(pf, hb, hs, frame)
426
+ frame.cond -= 1
427
+ f = node.child_by_field_name("finalizer")
428
+ if f is not None:
429
+ fb = f.child_by_field_name("body")
430
+ if fb is not None:
431
+ self.exec_stmt(pf, fb, scope, frame)
432
+ elif t == "throw_statement":
433
+ for c in self.named(node):
434
+ self.eval(pf, c, scope, frame)
435
+ elif t == "labeled_statement":
436
+ b = node.child_by_field_name("body")
437
+ if b is not None:
438
+ self.exec_stmt(pf, b, scope, frame)
439
+ elif t in ("function_declaration", "generator_function_declaration"):
440
+ nm = node.child_by_field_name("name")
441
+ if nm is not None and scope.lookup(self.txt(pf, nm)) is None:
442
+ scope.declare(self.txt(pf, nm), Val(fn=self.make_fn(pf, node, scope, self.txt(pf, nm))))
443
+ nm_s = self.txt(pf, nm) if nm is not None else ""
444
+ v = scope.lookup(nm_s)
445
+ if v is not None and v.fn is not None:
446
+ self.register_pending(v.fn)
447
+ elif t == "class_declaration":
448
+ self.exec_class(pf, node, scope, frame)
449
+ elif t == "export_statement":
450
+ self.exec_export(pf, node, scope, frame, m)
451
+ elif t == "import_statement":
452
+ self.exec_import(pf, node, scope, frame, m)
453
+ # interface / type / enum / ambient declarations carry no runtime flow: ignored on purpose
454
+
455
+ def eval_opt(self, pf, node, scope, frame) -> Val:
456
+ return self.eval(pf, node, scope, frame) if node is not None else CLEAN
457
+
458
+ def exec_declarator(self, pf, d, scope, frame, m=None) -> None:
459
+ name_node = d.child_by_field_name("name")
460
+ value = d.child_by_field_name("value")
461
+ v = self.eval(pf, value, scope, frame) if value is not None else CLEAN
462
+ if value is not None and name_node is not None and name_node.type == "identifier":
463
+ v = self.add_step(v, pf, d, f"assigned to `{self.txt(pf, name_node)}`")
464
+ if v.fn is not None and v.fn.name == "<anonymous>":
465
+ v.fn.name = self.txt(pf, name_node)
466
+ if name_node is not None:
467
+ self.bind_pattern(pf, name_node, v, scope, frame, declare=True, m=m)
468
+
469
+ def add_step(self, v: Val, pf, node, text: str) -> Val:
470
+ if not v.t and not v.props:
471
+ return v
472
+ line = pf.line_of(node)
473
+ return v.map_taints(lambda x: x.step(pf.path, line, text))
474
+
475
+ def bind_pattern(self, pf, pat, v: Val, scope, frame, declare=False, m=None) -> None:
476
+ t = pat.type
477
+ if t in ("required_parameter", "optional_parameter"):
478
+ p = pat.child_by_field_name("pattern")
479
+ dflt = pat.child_by_field_name("value")
480
+ if p is not None:
481
+ vv = join(v, self.eval(pf, dflt, scope, frame)) if dflt is not None else v
482
+ self.bind_pattern(pf, p, vv, scope, frame, declare, m)
483
+ return
484
+ if t == "identifier":
485
+ name = self.txt(pf, pat)
486
+ if declare:
487
+ scope.declare(name, v)
488
+ else:
489
+ scope.assign(name, v, frame.cond > 0)
490
+ self.note_vue_state(pf, name, v)
491
+ return
492
+ if t == "assignment_pattern" or t == "object_assignment_pattern":
493
+ left, right = pat.child_by_field_name("left"), pat.child_by_field_name("right")
494
+ vv = join(v, self.eval(pf, right, scope, frame)) if right is not None else v
495
+ if left is not None:
496
+ if left.type == "shorthand_property_identifier_pattern":
497
+ scope.declare(self.txt(pf, left), vv)
498
+ else:
499
+ self.bind_pattern(pf, left, vv, scope, frame, declare, m)
500
+ return
501
+ if t == "rest_pattern":
502
+ ch = self.named(pat)
503
+ if ch:
504
+ self.bind_pattern(pf, ch[0], Val(v.all_taints()), scope, frame, declare, m)
505
+ return
506
+ if t == "object_pattern":
507
+ for c in self.named(pat):
508
+ if c.type == "shorthand_property_identifier_pattern":
509
+ name = self.txt(pf, c)
510
+ sub = self.member_of(v, name)
511
+ (scope.declare if declare else (lambda n, x: scope.assign(n, x, frame.cond > 0)))(name, sub)
512
+ self.note_vue_state(pf, name, sub)
513
+ elif c.type == "pair_pattern":
514
+ k, val = c.child_by_field_name("key"), c.child_by_field_name("value")
515
+ key = self.txt(pf, k).strip("'\"") if k is not None else ""
516
+ if val is not None:
517
+ self.bind_pattern(pf, val, self.member_of(v, key), scope, frame, declare, m)
518
+ elif c.type in ("object_assignment_pattern", "assignment_pattern"):
519
+ left, right = c.child_by_field_name("left"), c.child_by_field_name("right")
520
+ if left is not None:
521
+ key = self.txt(pf, left)
522
+ sub = self.member_of(v, key)
523
+ if right is not None:
524
+ sub = join(sub, self.eval(pf, right, scope, frame))
525
+ scope.declare(key, sub)
526
+ elif c.type == "rest_pattern":
527
+ ch = self.named(c)
528
+ if ch:
529
+ self.bind_pattern(pf, ch[0], Val(v.all_taints()), scope, frame, declare, m)
530
+ return
531
+ if t == "array_pattern":
532
+ for c in self.named(pat):
533
+ self.bind_pattern(pf, c, Val(v.all_taints()), scope, frame, declare, m)
534
+ return
535
+ if t == "member_expression" or t == "subscript_expression":
536
+ self.assign_member(pf, pat, v, scope, frame)
537
+
538
+ def note_vue_state(self, pf, name: str, v: Val) -> None:
539
+ if pf.origin == "vue-sfc":
540
+ ts = v.all_taints()
541
+ if ts:
542
+ self.vue_state.setdefault((pf.path, name), set()).update(ts)
543
+
544
+ def member_of(self, v: Val, name: str) -> Val:
545
+ if v.ns is not None:
546
+ return self.resolve_export_val(v.ns, name)
547
+ if v.imp is not None:
548
+ v = self.force(v)
549
+ if v.ns is not None:
550
+ return self.resolve_export_val(v.ns, name)
551
+ if v.ext is not None:
552
+ return Val(v.t, ext=(v.ext[0], name))
553
+ if v.props is not None:
554
+ if name in v.props:
555
+ return v.props[name]
556
+ if not v.t:
557
+ return CLEAN
558
+ return Val(v.t) if v.t else CLEAN
559
+
560
+ # ------------------------------------------------------------------ if / loops / validators
561
+ def exec_if(self, pf, node, scope, frame) -> None:
562
+ cond = node.child_by_field_name("condition")
563
+ cons = node.child_by_field_name("consequence")
564
+ alt_wrap = node.child_by_field_name("alternative")
565
+ alt = None
566
+ if alt_wrap is not None:
567
+ ch = self.named(alt_wrap)
568
+ alt = ch[0] if alt_wrap.type == "else_clause" and ch else alt_wrap
569
+ cond_expr = self.unwrap(cond) if cond is not None else None
570
+ self.eval_opt(pf, cond_expr, scope, frame)
571
+ const = self.const_truth(pf, cond_expr)
572
+ v_true = self.validations(pf, cond_expr, True, scope)
573
+ v_false = self.validations(pf, cond_expr, False, scope)
574
+
575
+ def run(branch, validations, dead):
576
+ if branch is None:
577
+ return
578
+ saved = self.apply_validations(validations, scope, frame)
579
+ old_dead = frame.dead
580
+ frame.dead = frame.dead or dead
581
+ frame.cond += 1
582
+ self.exec_stmt(pf, branch, scope, frame)
583
+ frame.cond -= 1
584
+ frame.dead = old_dead
585
+ self.restore_validations(saved, scope)
586
+
587
+ run(cons, v_true, const is False)
588
+ run(alt, v_false, const is True)
589
+ if cons is not None and self.terminates(cons):
590
+ self.apply_validations(v_false, scope, frame) # `if (!ok(x)) return;` => x validated afterwards
591
+ if alt is not None and self.terminates(alt):
592
+ self.apply_validations(v_true, scope, frame)
593
+
594
+ def terminates(self, node) -> bool:
595
+ if node.type in _TERMINATORS:
596
+ return True
597
+ if node.type == "statement_block":
598
+ ch = self.named(node)
599
+ return bool(ch) and ch[-1].type in _TERMINATORS
600
+ return False
601
+
602
+ def const_truth(self, pf, node) -> bool | None:
603
+ if node is None:
604
+ return None
605
+ text = self.txt(pf, node).strip()
606
+ if text in ("false", "0", "null", "undefined", "''", '""', "!1"):
607
+ return False
608
+ if text in ("true", "1", "!0"):
609
+ return True
610
+ if "NODE_ENV" in text and ("development" in text or "test" in text) and "!" not in text.replace("!==", "").replace("!=", ""):
611
+ return False # dev-only branch: not shipped in production bundles
612
+ return None
613
+
614
+ def exec_loop(self, pf, node, scope, frame) -> None:
615
+ ls = Scope(scope)
616
+ for fld in ("initializer",):
617
+ init = node.child_by_field_name(fld)
618
+ if init is not None:
619
+ self.exec_stmt(pf, init, ls, frame) if init.type in ("lexical_declaration", "variable_declaration", "expression_statement") else self.eval(pf, init, ls, frame)
620
+ cond = node.child_by_field_name("condition")
621
+ body = node.child_by_field_name("body")
622
+ frame.cond += 1
623
+ for _ in range(2): # twice: lets loop-carried taint reach the top of the body
624
+ self.eval_opt(pf, cond, ls, frame)
625
+ if body is not None:
626
+ self.exec_stmt(pf, body, ls, frame)
627
+ inc = node.child_by_field_name("increment")
628
+ self.eval_opt(pf, inc, ls, frame)
629
+ frame.cond -= 1
630
+
631
+ def exec_for_in(self, pf, node, scope, frame) -> None:
632
+ ls = Scope(scope)
633
+ right = self.eval_opt(pf, node.child_by_field_name("right"), scope, frame)
634
+ left = node.child_by_field_name("left")
635
+ if left is not None:
636
+ if left.type in ("lexical_declaration", "variable_declaration"):
637
+ for d in self.named(left):
638
+ nm = d.child_by_field_name("name") if d.type == "variable_declarator" else d
639
+ if nm is not None:
640
+ self.bind_pattern(pf, nm, Val(right.all_taints()), ls, frame, declare=True)
641
+ else:
642
+ self.bind_pattern(pf, left, Val(right.all_taints()), ls, frame, declare=True)
643
+ body = node.child_by_field_name("body")
644
+ frame.cond += 1
645
+ for _ in range(2):
646
+ if body is not None:
647
+ self.exec_stmt(pf, body, ls, frame)
648
+ frame.cond -= 1
649
+
650
+ # validators -----------------------------------------------------------------------------------
651
+ def validations(self, pf, cond, truthy: bool, scope) -> list[tuple[str, Mitigation]]:
652
+ """Variables proven validated when `cond` evaluates to `truthy`, as [(variable, Mitigation)]."""
653
+ cond = self.unwrap(cond) if cond is not None else None
654
+ if cond is None:
655
+ return []
656
+ t = cond.type
657
+ if t == "unary_expression":
658
+ op = cond.child_by_field_name("operator")
659
+ if op is not None and op.type == "!":
660
+ return self.validations(pf, cond.child_by_field_name("argument"), not truthy, scope)
661
+ return []
662
+ if t == "binary_expression":
663
+ op = cond.child_by_field_name("operator").type
664
+ left, right = cond.child_by_field_name("left"), cond.child_by_field_name("right")
665
+ if op == "&&":
666
+ return (self.validations(pf, left, True, scope) + self.validations(pf, right, True, scope)) if truthy else []
667
+ if op == "||":
668
+ return [] if truthy else (self.validations(pf, left, False, scope) + self.validations(pf, right, False, scope))
669
+ if op in ("===", "==", "!==", "!="):
670
+ eq_when_true = op in ("===", "==")
671
+ if eq_when_true != truthy:
672
+ return [] # this leaf being true/false says nothing positive
673
+ return self.equality_validation(pf, left, right)
674
+ return []
675
+ if t == "call_expression" and truthy:
676
+ return self.call_validation(pf, cond, scope)
677
+ return []
678
+
679
+ def equality_validation(self, pf, left, right) -> list[tuple[str, Mitigation]]:
680
+ out = []
681
+ for a, b in ((left, right), (right, left)):
682
+ a, b = self.unwrap(a), self.unwrap(b)
683
+ if a is None or b is None:
684
+ continue
685
+ da = self.dotted(pf, a)
686
+ if da and da.endswith(".origin"):
687
+ out.append((da.split(".")[0], Mitigation("origin check", effective=R.ALL, note="postMessage origin compared to a fixed value (trust boundary)")))
688
+ elif a.type == "identifier" and self.str_value(pf, b) is not None:
689
+ out.append((self.txt(pf, a), Mitigation("equals literal", effective=R.ALL, note="value is compared to a constant")))
690
+ elif a.type == "unary_expression" and self.txt(pf, a).startswith("typeof") and self.str_value(pf, b) in ("number", "boolean"):
691
+ arg = a.child_by_field_name("argument")
692
+ if arg is not None and arg.type == "identifier":
693
+ out.append((self.txt(pf, arg), Mitigation("typeof number", effective=R.ALL, note="type check")))
694
+ return out
695
+
696
+ def call_validation(self, pf, call, scope) -> list[tuple[str, Mitigation]]:
697
+ fn = self.unwrap(call.child_by_field_name("function"))
698
+ args = call.child_by_field_name("arguments")
699
+ argn = self.named(args) if args is not None and args.type == "arguments" else []
700
+ if fn is None:
701
+ return []
702
+ name = self.dotted(pf, fn) or ""
703
+ prop_n = fn.child_by_field_name("property") if fn.type == "member_expression" else None
704
+ last = self.txt(pf, prop_n) if prop_n is not None else name.split(".")[-1]
705
+ first = self.unwrap(argn[0]) if argn else None
706
+ if last == "test" and fn.type == "member_expression" and first is not None and first.type == "identifier":
707
+ recv = self.unwrap(fn.child_by_field_name("object"))
708
+ if recv is not None and recv.type == "regex":
709
+ src = self.txt(pf, recv)
710
+ body = src[1:src.rfind("/")]
711
+ if R.regex_is_strict_allowlist(body):
712
+ return [(self.txt(pf, first), Mitigation("strict regex allow-list", effective=R.ALL, note=f"/{body}/ is anchored and alphanumeric-only"))]
713
+ return [(self.txt(pf, first), Mitigation("regex.test()", uncertain=R.ALL, note="validated by a regex that JScope cannot prove strict"))]
714
+ return [(self.txt(pf, first), Mitigation("validator call", uncertain=R.ALL, note=f"{name}() used as a guard; its strictness is unknown"))]
715
+ if last in ("includes", "has") and fn.type == "member_expression" and first is not None and first.type == "identifier":
716
+ recv_v = self.eval(pf, fn.child_by_field_name("object"), scope, Frame(pf.path))
717
+ if recv_v.lits:
718
+ return [(self.txt(pf, first), Mitigation("allow-list", effective=R.ALL, note="value must equal one of a fixed list of literals"))]
719
+ return [(self.txt(pf, first), Mitigation("membership check", uncertain=R.ALL, note="checked against a list JScope could not resolve"))]
720
+ if last in ("isInteger", "isFinite", "isSafeInteger") and first is not None and first.type == "identifier":
721
+ return [(self.txt(pf, first), Mitigation("numeric check", effective=R.ALL))]
722
+ if last in ("startsWith", "endsWith", "indexOf") and fn.type == "member_expression" and first is not None:
723
+ recv = self.unwrap(fn.child_by_field_name("object"))
724
+ if recv is not None and recv.type == "identifier":
725
+ return [(self.txt(pf, recv), Mitigation(f"{last}() guard", uncertain=frozenset({"navigation"}),
726
+ note="prefix/suffix checks do not stop `//host` or `javascript:` tricks"))]
727
+ if name.endswith("isValid") or re.search(r"^(is|validate|check|assert)[A-Z_]", last or ""):
728
+ for a in argn[:1]:
729
+ a = self.unwrap(a)
730
+ if a is not None and a.type == "identifier":
731
+ return [(self.txt(pf, a), Mitigation(f"{last}() guard", uncertain=R.ALL, note="named like a validator; behaviour not verified"))]
732
+ return []
733
+
734
+ def apply_validations(self, vals: list[tuple[str, Mitigation]], scope: Scope, frame: Frame) -> dict:
735
+ saved = {}
736
+ for var, mit in vals:
737
+ cur = scope.lookup(var)
738
+ if cur is None or not cur.all_taints():
739
+ continue
740
+ cur = self.force(cur)
741
+ new = cur.map_taints(lambda x, mit=mit: x.mitigate(mit))
742
+ saved[var] = (cur, new)
743
+ s: Scope | None = scope
744
+ while s is not None:
745
+ if var in s.vars:
746
+ s.vars[var] = new
747
+ break
748
+ s = s.parent
749
+ return saved
750
+
751
+ def restore_validations(self, saved: dict, scope: Scope) -> None:
752
+ for var, (old, new) in saved.items():
753
+ s: Scope | None = scope
754
+ while s is not None:
755
+ if var in s.vars:
756
+ if s.vars[var] is new:
757
+ s.vars[var] = old
758
+ break
759
+ s = s.parent
760
+
761
+ # ------------------------------------------------------------------ classes / exports / imports
762
+ def exec_class(self, pf, node, scope, frame) -> None:
763
+ body = node.child_by_field_name("body")
764
+ nm = node.child_by_field_name("name")
765
+ cls_name = self.txt(pf, nm) if nm is not None else "<class>"
766
+ props: dict[str, Val] = {}
767
+ if body is not None:
768
+ for c in self.named(body):
769
+ if c.type == "method_definition":
770
+ mn = c.child_by_field_name("name")
771
+ name = self.txt(pf, mn) if mn is not None else "<method>"
772
+ fv = self.make_fn(pf, c, scope, f"{cls_name}.{name}")
773
+ props[name] = Val(fn=fv)
774
+ self.register_pending(fv)
775
+ elif c.type in ("field_definition", "public_field_definition"):
776
+ v = c.child_by_field_name("value")
777
+ if v is not None:
778
+ self.eval(pf, v, scope, frame)
779
+ if nm is not None:
780
+ scope.declare(cls_name, Val(props=props))
781
+
782
+ def exec_export(self, pf, node, scope, frame, m) -> None:
783
+ if m is None:
784
+ return
785
+ decl = node.child_by_field_name("declaration")
786
+ value = node.child_by_field_name("value")
787
+ source = node.child_by_field_name("source")
788
+ text_head = self.txt(pf, node)[:40]
789
+ is_default = bool(re.match(r"export\s+default\b", text_head))
790
+ if decl is not None:
791
+ self.exec_stmt(pf, decl, scope, frame, m)
792
+ names: list[str] = []
793
+ if decl.type in ("lexical_declaration", "variable_declaration"):
794
+ for d in self.named(decl):
795
+ nm = d.child_by_field_name("name") if d.type == "variable_declarator" else None
796
+ if nm is not None and nm.type == "identifier":
797
+ names.append(self.txt(pf, nm))
798
+ elif nm is not None:
799
+ names += [x for x in re.findall(r"[A-Za-z_$][\w$]*", self.txt(pf, nm))]
800
+ else:
801
+ nm = decl.child_by_field_name("name")
802
+ if nm is not None:
803
+ names.append(self.txt(pf, nm))
804
+ for n in names:
805
+ v = scope.lookup(n)
806
+ if v is not None:
807
+ m.exports[n] = v
808
+ if is_default:
809
+ m.exports["default"] = v
810
+ return
811
+ if value is not None:
812
+ m.exports["default"] = self.eval(pf, value, scope, frame)
813
+ return
814
+ clause = next((c for c in self.named(node) if c.type == "export_clause"), None)
815
+ target = None
816
+ if source is not None:
817
+ spec = self.str_value(pf, source)
818
+ res = self.resolver.resolve(spec or "", pf.path)
819
+ self.note_import(pf, node, spec or "", res)
820
+ target = res.path
821
+ if clause is not None:
822
+ for sp in self.named(clause):
823
+ if sp.type != "export_specifier":
824
+ continue
825
+ n = sp.child_by_field_name("name")
826
+ a = sp.child_by_field_name("alias")
827
+ local = self.txt(pf, n) if n is not None else ""
828
+ exported = self.txt(pf, a) if a is not None else local
829
+ if target:
830
+ m.exports[exported] = Val(imp=(target, local))
831
+ else:
832
+ v = scope.lookup(local)
833
+ if v is not None:
834
+ m.exports[exported] = v
835
+ elif target and not clause:
836
+ m.stars.append(target) # `export * from './x'`
837
+
838
+ def note_import(self, pf, node, spec: str, res) -> None:
839
+ if res.status == "resolved" and res.path:
840
+ real = self.real_path(res.path)
841
+ if real:
842
+ self.import_edges.setdefault(pf.path, set()).add(real)
843
+ elif res.status == "unresolved":
844
+ self.unresolved_imports.append((pf.path, pf.line_of(node), spec, res.reason))
845
+ self.lim(pf.path, pf.line_of(node), "unresolved-import", f"import '{spec}' not resolved: {res.reason}")
846
+
847
+ def exec_import(self, pf, node, scope, frame, m) -> None:
848
+ src = node.child_by_field_name("source")
849
+ spec = self.str_value(pf, src) if src is not None else None
850
+ if spec is None:
851
+ return
852
+ res = self.resolver.resolve(spec, pf.path)
853
+ self.note_import(pf, node, spec, res)
854
+ if m is not None:
855
+ m.packages.add(spec)
856
+ clause = next((c for c in self.named(node) if c.type == "import_clause"), None)
857
+ if clause is None:
858
+ return
859
+
860
+ def binding(imported: str) -> Val:
861
+ if res.status == "resolved" and res.path and self.real_path(res.path):
862
+ return Val(imp=(self.real_path(res.path), imported))
863
+ return Val(ext=(spec, imported))
864
+
865
+ for c in self.named(clause):
866
+ if c.type == "identifier":
867
+ scope.declare(self.txt(pf, c), binding("default"))
868
+ elif c.type == "namespace_import":
869
+ ids = [x for x in self.named(c) if x.type == "identifier"]
870
+ if ids:
871
+ if res.status == "resolved" and res.path and self.real_path(res.path):
872
+ scope.declare(self.txt(pf, ids[0]), Val(ns=self.real_path(res.path)))
873
+ else:
874
+ scope.declare(self.txt(pf, ids[0]), Val(ext=(spec, None)))
875
+ elif c.type == "named_imports":
876
+ for sp in self.named(c):
877
+ if sp.type != "import_specifier":
878
+ continue
879
+ n = sp.child_by_field_name("name")
880
+ a = sp.child_by_field_name("alias")
881
+ nm = self.txt(pf, n) if n is not None else ""
882
+ scope.declare(self.txt(pf, a) if a is not None else nm, binding(nm))
883
+
884
+ def force(self, v: Val) -> Val:
885
+ """Resolve a lazy import binding to the exporting module's value (running that module if needed)."""
886
+ guard = 0
887
+ while v.imp is not None and guard < 20:
888
+ guard += 1
889
+ path, name = v.imp
890
+ nv = self.resolve_export_val(path, name)
891
+ v = nv
892
+ return v
893
+
894
+ def resolve_export_val(self, path: str, name: str, _seen: set | None = None) -> Val:
895
+ _seen = _seen or set()
896
+ if (path, name) in _seen:
897
+ return CLEAN
898
+ _seen.add((path, name))
899
+ m = self.modules.get(path)
900
+ if m is not None and m.status == "running":
901
+ v = m.exports.get(name) or m.scope.vars.get(name) # circular import: whatever is already defined
902
+ if v is not None:
903
+ return v
904
+ self.lim(path, 1, "circular-import", f"circular import: '{name}' requested before its module finished executing")
905
+ return CLEAN
906
+ m = self.get_module(path)
907
+ if m is None:
908
+ return CLEAN
909
+ if name in m.exports:
910
+ v = m.exports[name]
911
+ return self.force(v) if v.imp is not None else v
912
+ if name == "default" and "default" not in m.exports:
913
+ return Val(ns=path) # CJS-style `import x from` of a namespace-ish module
914
+ for star in m.stars:
915
+ v = self.resolve_export_val(star, name, _seen)
916
+ if v is not CLEAN:
917
+ return v
918
+ return CLEAN
919
+
920
+ # ------------------------------------------------------------------ expressions
921
+ def eval(self, pf, node, scope, frame) -> Val:
922
+ if node is None:
923
+ return CLEAN
924
+ if not self.tick(pf.path):
925
+ return CLEAN
926
+ t = node.type
927
+ if t in _WRAPPERS:
928
+ ch = self.named(node)
929
+ if not ch:
930
+ return CLEAN
931
+ return self.eval(pf, ch[-1] if t == "type_assertion" else ch[0], scope, frame)
932
+ if t == "identifier":
933
+ v = scope.lookup(self.txt(pf, node))
934
+ if v is None:
935
+ return CLEAN
936
+ return self.force(v) if v.imp is not None else v
937
+ if t in ("string", "number", "true", "false", "null", "undefined", "regex", "this", "super", "property_identifier",
938
+ "shorthand_property_identifier", "template_literal_type"):
939
+ return CLEAN
940
+ if t == "template_string":
941
+ parts = [self.eval(pf, c.child_by_field_name("expression") or (self.named(c)[0] if self.named(c) else None), scope, frame)
942
+ for c in node.children if c.type == "template_substitution"]
943
+ return union_vals(parts)
944
+ if t == "member_expression":
945
+ return self.eval_member(pf, node, scope, frame)
946
+ if t == "subscript_expression":
947
+ obj = self.eval(pf, node.child_by_field_name("object"), scope, frame)
948
+ idx = node.child_by_field_name("index")
949
+ iv = self.eval_opt(pf, idx, scope, frame)
950
+ key = self.str_value(pf, idx) if idx is not None else None
951
+ od = self.dotted(pf, node.child_by_field_name("object"))
952
+ if od in R.STORAGE_OBJECTS or (od or "").replace("window.", "") in R.STORAGE_OBJECTS:
953
+ return self.make_source(pf, node, "storage")
954
+ if key is not None and obj.props is not None and key in obj.props:
955
+ return obj.props[key]
956
+ return Val(obj.all_taints()) if obj.all_taints() else CLEAN
957
+ if t == "call_expression":
958
+ return self.eval_call(pf, node, scope, frame)
959
+ if t == "new_expression":
960
+ return self.eval_new(pf, node, scope, frame)
961
+ if t == "binary_expression":
962
+ op = node.child_by_field_name("operator").type
963
+ l, r = node.child_by_field_name("left"), node.child_by_field_name("right")
964
+ lv, rv = self.eval(pf, l, scope, frame), self.eval(pf, r, scope, frame)
965
+ if op in ("+", "||", "??", "&&"):
966
+ return union_vals([lv, rv]) if (lv.all_taints() or rv.all_taints()) else (lv if op != "+" else CLEAN)
967
+ return CLEAN
968
+ if t == "unary_expression":
969
+ self.eval_opt(pf, node.child_by_field_name("argument"), scope, frame)
970
+ return CLEAN
971
+ if t == "update_expression":
972
+ return CLEAN
973
+ if t == "ternary_expression":
974
+ self.eval_opt(pf, node.child_by_field_name("condition"), scope, frame)
975
+ frame.cond += 1
976
+ a = self.eval_opt(pf, node.child_by_field_name("consequence"), scope, frame)
977
+ b = self.eval_opt(pf, node.child_by_field_name("alternative"), scope, frame)
978
+ frame.cond -= 1
979
+ return join(a, b)
980
+ if t == "assignment_expression":
981
+ return self.eval_assign(pf, node, scope, frame)
982
+ if t == "augmented_assignment_expression":
983
+ left, right = node.child_by_field_name("left"), node.child_by_field_name("right")
984
+ rv = self.eval(pf, right, scope, frame)
985
+ lv = self.eval(pf, left, scope, frame)
986
+ nv = union_vals([lv, rv]) if (lv.all_taints() or rv.all_taints()) else CLEAN
987
+ if left is not None:
988
+ self.assign_target(pf, left, nv, scope, frame, weak=True)
989
+ return nv
990
+ if t == "object":
991
+ return self.eval_object(pf, node, scope, frame)
992
+ if t == "array":
993
+ vals, lits = [], []
994
+ for c in self.named(node):
995
+ if c.type == "spread_element":
996
+ ch = self.named(c)
997
+ vals.append(self.eval(pf, ch[0], scope, frame) if ch else CLEAN)
998
+ lits = None if lits is not None else lits
999
+ continue
1000
+ vals.append(self.eval(pf, c, scope, frame))
1001
+ if lits is not None:
1002
+ s = self.str_value(pf, c)
1003
+ if s is None:
1004
+ lits = None
1005
+ else:
1006
+ lits.append(s)
1007
+ v = union_vals(vals)
1008
+ return Val(v.t, lits=tuple(lits) if lits else None) if (v.t or lits) else CLEAN
1009
+ if t in ("arrow_function", "function_expression", "function", "generator_function"):
1010
+ fv = self.make_fn(pf, node, scope, "<anonymous>")
1011
+ self.register_pending(fv)
1012
+ return Val(fn=fv)
1013
+ if t == "sequence_expression":
1014
+ last = CLEAN
1015
+ for c in self.named(node):
1016
+ last = self.eval(pf, c, scope, frame)
1017
+ return last
1018
+ if t == "spread_element":
1019
+ ch = self.named(node)
1020
+ return self.eval(pf, ch[0], scope, frame) if ch else CLEAN
1021
+ if t in ("jsx_element", "jsx_self_closing_element", "jsx_fragment"):
1022
+ return self.eval_jsx(pf, node, scope, frame)
1023
+ if t == "jsx_expression":
1024
+ ch = self.named(node)
1025
+ return self.eval(pf, ch[0], scope, frame) if ch else CLEAN
1026
+ if t == "class":
1027
+ self.exec_class(pf, node, scope, frame)
1028
+ return CLEAN
1029
+ if t == "pair":
1030
+ return self.eval_opt(pf, node.child_by_field_name("value"), scope, frame)
1031
+ # unknown expression kind: evaluate children so nested calls/sinks are still seen
1032
+ vals = [self.eval(pf, c, scope, frame) for c in self.named(node) if c.type not in ("type_annotation",)]
1033
+ return union_vals(vals) if vals else CLEAN
1034
+
1035
+ # sources --------------------------------------------------------------------------------------
1036
+ def make_source(self, pf, node, kind: str, snippet: str | None = None) -> Val:
1037
+ line = pf.line_of(node)
1038
+ snip = (snippet or self.txt(pf, node)).replace("\n", " ")[:90]
1039
+ return Val(frozenset({Taint(kind, pf.path, line, snip)}))
1040
+
1041
+ def eval_member(self, pf, node, scope, frame) -> Val:
1042
+ d = self.dotted(pf, node)
1043
+ obj_node = node.child_by_field_name("object")
1044
+ prop = node.child_by_field_name("property")
1045
+ pname = self.txt(pf, prop) if prop is not None else ""
1046
+ if d:
1047
+ nd = re.sub(r"^(window|self|globalThis|top|parent)\.", "", d) if not d.startswith("window.location") else d
1048
+ m = R.URL_MEMBER.match(d)
1049
+ if m and scope.lookup("location") is None:
1050
+ return self.make_source(pf, node, R.URL_KIND[m.group(1)])
1051
+ if d in ("document.URL", "document.documentURI", "window.document.URL"):
1052
+ return self.make_source(pf, node, "url")
1053
+ if nd in ("document.referrer",):
1054
+ return self.make_source(pf, node, "referrer")
1055
+ if nd == "document.cookie":
1056
+ return self.make_source(pf, node, "cookie")
1057
+ if d in ("window.name",):
1058
+ return self.make_source(pf, node, "window_name")
1059
+ root = d.split(".")[0]
1060
+ if root in R.STORAGE_OBJECTS and d.count(".") == 1 and scope.lookup(root) is None and pname not in ("getItem", "setItem", "removeItem", "clear", "key", "length"):
1061
+ return self.make_source(pf, node, "storage")
1062
+ if pname == "value" and obj_node is not None:
1063
+ od = self.dotted(pf, obj_node) or ""
1064
+ ot = self.txt(pf, obj_node)
1065
+ if od.endswith(".target") or od.endswith(".current") or od == "target" or R.FORM_CALL_RE.search(ot):
1066
+ return self.make_source(pf, node, "form_input")
1067
+ if pname == "responseText" and obj_node is not None:
1068
+ return self.make_source(pf, node, "network_response")
1069
+ obj = self.eval(pf, obj_node, scope, frame)
1070
+ obj = self.force(obj) if obj.imp is not None else obj
1071
+ if pname == "length":
1072
+ return CLEAN
1073
+ return self.member_of(obj, pname)
1074
+
1075
+ # assignments ----------------------------------------------------------------------------------
1076
+ def eval_assign(self, pf, node, scope, frame) -> Val:
1077
+ left, right = node.child_by_field_name("left"), node.child_by_field_name("right")
1078
+ rv = self.eval(pf, right, scope, frame)
1079
+ if left is not None and left.type == "identifier":
1080
+ rv = self.add_step(rv, pf, node, f"assigned to `{self.txt(pf, left)}`")
1081
+ if rv.fn is not None and rv.fn.name == "<anonymous>":
1082
+ rv.fn.name = self.txt(pf, left)
1083
+ if left is not None:
1084
+ self.assign_target(pf, left, rv, scope, frame, weak=False)
1085
+ return rv
1086
+
1087
+ def assign_target(self, pf, left, v: Val, scope, frame, weak: bool) -> None:
1088
+ left = self.unwrap(left)
1089
+ if left is None:
1090
+ return
1091
+ if left.type == "identifier":
1092
+ scope.assign(self.txt(pf, left), v, frame.cond > 0 or weak)
1093
+ self.note_vue_state(pf, self.txt(pf, left), v)
1094
+ elif left.type in ("member_expression", "subscript_expression"):
1095
+ self.assign_member(pf, left, v, scope, frame)
1096
+ elif left.type in ("object_pattern", "array_pattern"):
1097
+ self.bind_pattern(pf, left, v, scope, frame, declare=False)
1098
+
1099
+ def assign_member(self, pf, left, v: Val, scope, frame) -> None:
1100
+ d = self.dotted(pf, left) or ""
1101
+ prop = left.child_by_field_name("property")
1102
+ pname = self.txt(pf, prop) if prop is not None and left.type == "member_expression" else None
1103
+ obj_node = left.child_by_field_name("object")
1104
+ # --- CommonJS exports
1105
+ m = self.modules.get(pf.path)
1106
+ if m is not None and pname is not None:
1107
+ if d == "module.exports":
1108
+ m.exports["default"] = v
1109
+ return
1110
+ if d.startswith(("module.exports.", "exports.")) and d.count(".") <= 2:
1111
+ m.exports[pname] = v
1112
+ return
1113
+ # --- sinks on assignment
1114
+ if pname in R.HTML_ASSIGN_PROPS:
1115
+ self.report(pf, left, "html", f".{pname} = ...", v, frame, scope)
1116
+ elif pname == "src" and obj_node is not None and re.search(r"(?i)script", self.txt(pf, obj_node)):
1117
+ self.report(pf, left, "code", "script.src = ...", v, frame, scope)
1118
+ elif R.NAV_ASSIGN_DOTTED.match(d) and (pname in (None, "href") or d.endswith("location")):
1119
+ self.report(pf, left, "navigation", f"{d} = ...", v, frame, scope)
1120
+ elif pname == "onmessage" and v.fn is not None:
1121
+ self.call_message_handler(pf, left, v, scope, frame)
1122
+ if pname == "innerText" or pname == "textContent":
1123
+ return # safe sinks: text, not markup
1124
+ # --- record property on tracked objects (field-sensitive for locals)
1125
+ if obj_node is not None:
1126
+ on = self.unwrap(obj_node)
1127
+ if on is not None and on.type == "identifier":
1128
+ name = self.txt(pf, on)
1129
+ cur = scope.lookup(name)
1130
+ key = pname if pname is not None else self.str_value(pf, left.child_by_field_name("index"))
1131
+ if cur is not None and key is not None and cur.imp is None and cur.ns is None and cur.ext is None:
1132
+ props = dict(cur.props) if cur.props is not None else {}
1133
+ props[key] = join(props.get(key), v) if frame.cond > 0 else v
1134
+ nv = Val(cur.t, props, cur.fn, None, None, None, cur.lits, cur.pyfn)
1135
+ scope.assign(name, nv, False)
1136
+ elif cur is None and key is not None:
1137
+ scope.assign(name, Val(props={key: v}), False)
1138
+ elif on is not None and on.type == "this" and pname is not None:
1139
+ self.note_vue_state(pf, pname, v)
1140
+ if pname == "value" and obj_node is not None:
1141
+ on = self.unwrap(obj_node)
1142
+ if on is not None and on.type == "identifier":
1143
+ self.note_vue_state(pf, self.txt(pf, on), v) # Vue ref: `x.value = ...`
1144
+
1145
+ def eval_object(self, pf, node, scope, frame) -> Val:
1146
+ props: dict[str, Val] = {}
1147
+ t: set = set()
1148
+ for c in self.named(node):
1149
+ if c.type == "pair":
1150
+ k, val = c.child_by_field_name("key"), c.child_by_field_name("value")
1151
+ key = self.str_value(pf, k) if k is not None and k.type in ("string",) else (self.txt(pf, k) if k is not None and k.type in ("property_identifier", "number", "identifier") else None)
1152
+ vv = self.eval_opt(pf, val, scope, frame)
1153
+ if key is None:
1154
+ t |= vv.all_taints()
1155
+ else:
1156
+ props[key] = vv
1157
+ if val is not None and val.type in _FN_TYPES and vv.fn is not None and vv.fn.name == "<anonymous>":
1158
+ vv.fn.name = key
1159
+ elif c.type == "shorthand_property_identifier":
1160
+ name = self.txt(pf, c)
1161
+ sv = scope.lookup(name) or CLEAN
1162
+ props[name] = self.force(sv) if sv.imp is not None else sv
1163
+ elif c.type == "method_definition":
1164
+ mn = c.child_by_field_name("name")
1165
+ name = self.txt(pf, mn) if mn is not None else "<method>"
1166
+ fv = self.make_fn(pf, c, scope, name)
1167
+ props[name] = Val(fn=fv)
1168
+ self.register_pending(fv)
1169
+ elif c.type == "spread_element":
1170
+ ch = self.named(c)
1171
+ sv = self.eval(pf, ch[0], scope, frame) if ch else CLEAN
1172
+ if sv.props:
1173
+ for k2, v2 in sv.props.items():
1174
+ props[k2] = v2
1175
+ t |= set(sv.t)
1176
+ v = Val(frozenset(t), props)
1177
+ return v
1178
+
1179
+ def eval_jsx(self, pf, node, scope, frame) -> Val:
1180
+ for n in self.walk_jsx(node):
1181
+ if n.type == "jsx_attribute":
1182
+ ch = self.named(n)
1183
+ name = self.txt(pf, ch[0]) if ch else ""
1184
+ val = ch[1] if len(ch) > 1 else None
1185
+ if val is not None and val.type == "jsx_expression":
1186
+ inner = self.named(val)
1187
+ expr = inner[0] if inner else None
1188
+ if name == "dangerouslySetInnerHTML" and expr is not None:
1189
+ obj = self.eval(pf, expr, scope, frame)
1190
+ h = obj.props.get("__html") if obj.props is not None else None
1191
+ h = h if h is not None else Val(obj.all_taints())
1192
+ self.report(pf, n, "html", "dangerouslySetInnerHTML", h, frame, scope)
1193
+ else:
1194
+ self.eval_opt(pf, expr, scope, frame)
1195
+ elif n.type == "jsx_expression" and n.parent is not None and n.parent.type != "jsx_attribute":
1196
+ ch = self.named(n)
1197
+ if ch:
1198
+ self.eval(pf, ch[0], scope, frame)
1199
+ return CLEAN
1200
+
1201
+ def walk_jsx(self, node):
1202
+ stack = [node]
1203
+ while stack:
1204
+ n = stack.pop()
1205
+ yield n
1206
+ if n.type == "jsx_expression" and (n.parent is None or n.parent.type != "jsx_attribute"):
1207
+ continue # evaluated as a whole by the caller; don't walk into JS twice
1208
+ stack.extend(reversed(n.children))
1209
+
1210
+ # ------------------------------------------------------------------ calls
1211
+ def register_pending(self, fv: FnVal) -> None:
1212
+ if fv.key not in self._pending_seen:
1213
+ self._pending_seen.add(fv.key)
1214
+ self.pending.append(fv)
1215
+
1216
+ def eval_args(self, pf, args_node, scope, frame) -> list[Val]:
1217
+ out: list[Val] = []
1218
+ if args_node is None:
1219
+ return out
1220
+ if args_node.type == "template_string":
1221
+ return [self.eval(pf, args_node, scope, frame)]
1222
+ for c in self.named(args_node):
1223
+ out.append(self.eval(pf, c, scope, frame))
1224
+ return out
1225
+
1226
+ def arg_nodes(self, args_node) -> list:
1227
+ if args_node is None or args_node.type == "template_string":
1228
+ return []
1229
+ return self.named(args_node)
1230
+
1231
+ def eval_new(self, pf, node, scope, frame) -> Val:
1232
+ ctor = node.child_by_field_name("constructor")
1233
+ args_node = node.child_by_field_name("arguments")
1234
+ args = self.eval_args(pf, args_node, scope, frame)
1235
+ name = self.dotted(pf, ctor) or ""
1236
+ if name in ("Function", "window.Function"):
1237
+ if args:
1238
+ self.report(pf, node, "code", "new Function(...)", args[-1], frame, scope)
1239
+ return CLEAN
1240
+ if name == "Promise" and args and args[0].fn is not None:
1241
+ collected: list[Val] = []
1242
+ res = Val(pyfn=lambda a: (collected.append(a[0] if a else CLEAN), CLEAN)[1])
1243
+ self.call_fn(args[0].fn, [res, Val(pyfn=lambda a: CLEAN)], node, frame, pf)
1244
+ return union_vals(collected)
1245
+ if name == "XMLHttpRequest":
1246
+ return CLEAN
1247
+ cv = self.eval(pf, ctor, scope, frame)
1248
+ cv = self.force(cv) if cv.imp is not None else cv
1249
+ base = name.split(".")[-1]
1250
+ if cv.props is not None and ctor is not None and ctor.type == "identifier":
1251
+ return Val(props=dict(cv.props)) # user class: instance exposes its methods
1252
+ if base in R.PASSTHROUGH_CTORS:
1253
+ return union_vals(args)
1254
+ ts = union_vals(args)
1255
+ if ts.t:
1256
+ return Val(frozenset(x.assume(f"passed through unresolved constructor new {base}()") for x in ts.t))
1257
+ return CLEAN
1258
+
1259
+ def callee_names(self, pf, fn_node, scope) -> list[str]:
1260
+ raw = self.dotted(pf, fn_node) or ""
1261
+ names = [raw] if raw else []
1262
+ if raw:
1263
+ root = raw.split(".")[0]
1264
+ rv = scope.lookup(root)
1265
+ if rv is not None and rv.ext is not None and rv.ext[0]:
1266
+ pkg, member = rv.ext
1267
+ rest = raw.split(".", 1)[1] if "." in raw else ""
1268
+ parts = [pkg] + ([member] if member and member != "default" else []) + ([rest] if rest else [])
1269
+ names.append(".".join(parts))
1270
+ if member and member != "default":
1271
+ names.append(".".join([member] + ([rest] if rest else [])))
1272
+ return names
1273
+
1274
+ def eval_call(self, pf, node, scope, frame) -> Val:
1275
+ fn_node = self.unwrap(node.child_by_field_name("function"))
1276
+ args_node = node.child_by_field_name("arguments")
1277
+ line = pf.line_of(node)
1278
+ # --- dynamic import() ---------------------------------------------------------
1279
+ if fn_node is not None and fn_node.type == "import":
1280
+ spec_nodes = self.arg_nodes(args_node)
1281
+ spec = self.str_value(pf, spec_nodes[0]) if spec_nodes else None
1282
+ if spec is None:
1283
+ self.lim(pf.path, line, "dynamic-import", "import() with a non-literal specifier cannot be resolved statically")
1284
+ return CLEAN
1285
+ res = self.resolver.resolve(spec, pf.path)
1286
+ self.note_import(pf, node, spec, res)
1287
+ if res.status == "resolved" and res.path and self.real_path(res.path):
1288
+ return Val(ns=self.real_path(res.path))
1289
+ return Val(ext=(spec, None))
1290
+ args = self.eval_args(pf, args_node, scope, frame)
1291
+ argn = self.arg_nodes(args_node)
1292
+ if fn_node is None:
1293
+ return CLEAN
1294
+ names = self.callee_names(pf, fn_node, scope)
1295
+ raw = names[0] if names else ""
1296
+ last = raw.split(".")[-1] if raw else (self.txt(pf, fn_node.child_by_field_name("property")) if fn_node.type == "member_expression" and fn_node.child_by_field_name("property") is not None else "")
1297
+ recv_node = fn_node.child_by_field_name("object") if fn_node.type == "member_expression" else None
1298
+ recv_text = self.txt(pf, recv_node) if recv_node is not None else ""
1299
+
1300
+ # --- require() ----------------------------------------------------------------
1301
+ if raw == "require" and scope.lookup("require") is None and argn:
1302
+ spec = self.str_value(pf, argn[0])
1303
+ if spec is None:
1304
+ self.lim(pf.path, line, "dynamic-require", "require() with a non-literal specifier cannot be resolved statically")
1305
+ return CLEAN
1306
+ m = self.modules.get(pf.path)
1307
+ if m is not None:
1308
+ m.packages.add(spec)
1309
+ res = self.resolver.resolve(spec, pf.path)
1310
+ self.note_import(pf, node, spec, res)
1311
+ if res.status == "resolved" and res.path and self.real_path(res.path):
1312
+ return Val(ns=self.real_path(res.path))
1313
+ return Val(ext=(spec, None))
1314
+
1315
+ # --- sinks --------------------------------------------------------------------
1316
+ sink = self.match_sink_call(pf, node, raw, last, recv_text, args, argn, scope)
1317
+ if sink:
1318
+ for cls, label, idx in sink:
1319
+ if idx == "all":
1320
+ for a in args:
1321
+ self.report(pf, node, cls, label, a, frame, scope)
1322
+ elif idx < len(args):
1323
+ self.report(pf, node, cls, label, args[idx], frame, scope)
1324
+
1325
+ # --- sanitizers ---------------------------------------------------------------
1326
+ for nm in names:
1327
+ for pat, mit in R.SANITIZERS:
1328
+ if pat.search(nm):
1329
+ target = args[0] if args else CLEAN
1330
+ if not target.all_taints() and last != "sanitize":
1331
+ return CLEAN
1332
+ if mit.effective >= R.ALL:
1333
+ return CLEAN
1334
+ return target.map_taints(lambda x, mit=mit: x.mitigate(mit))
1335
+ if R.DB_ESCAPERS.search(nm) and args:
1336
+ return args[0].map_taints(lambda x: x.mitigate(Mitigation("SQL escaping", effective=frozenset({"sql"}))))
1337
+ if last == "replace" and recv_node is not None and len(args) >= 1:
1338
+ rv = self.eval(pf, recv_node, scope, frame)
1339
+ if rv.all_taints():
1340
+ return rv.map_taints(lambda x: x.mitigate(R.PARTIAL_REPLACE))
1341
+
1342
+ # --- sources as calls ---------------------------------------------------------
1343
+ if last in ("getItem",) and recv_text.replace("window.", "") in R.STORAGE_OBJECTS:
1344
+ return self.make_source(pf, node, "storage")
1345
+ if last == "val" and R.JQUERY_RECEIVER.match(recv_text or ""):
1346
+ return self.make_source(pf, node, "form_input")
1347
+ if raw in R.NETWORK_CALLEES or raw.startswith(R.NETWORK_PREFIX) or (last in ("get", "post", "put", "patch", "request") and recv_text in ("axios", "$", "$http", "http", "api", "client")):
1348
+ v = self.make_source(pf, node, "network_response")
1349
+ if raw in ("$.get", "$.post", "$.getJSON") and len(args) > 1:
1350
+ for a in args[1:]:
1351
+ if a.fn is not None:
1352
+ self.call_fn(a.fn, [v], node, frame, pf)
1353
+ if raw == "$.ajax" and args and args[0].props and args[0].props.get("success") is not None and args[0].props["success"].fn is not None:
1354
+ self.call_fn(args[0].props["success"].fn, [v], node, frame, pf)
1355
+ return v
1356
+ if last == "addEventListener" and argn and (self.str_value(pf, argn[0]) == "message") and len(args) > 1 and args[1].fn is not None:
1357
+ self.call_message_handler(pf, node, args[1], scope, frame)
1358
+ return CLEAN
1359
+
1360
+ # --- promise & collection combinators ----------------------------------------
1361
+ if recv_node is not None and last in ("then", "catch", "finally") and args:
1362
+ rv = self.eval(pf, recv_node, scope, frame)
1363
+ rv = self.force(rv) if rv.imp is not None else rv
1364
+ res_vals = [rv] if last != "catch" else [rv]
1365
+ for a in args[:2]:
1366
+ if a.fn is not None:
1367
+ pass_in = rv if rv.ns is None else rv
1368
+ out = self.call_fn(a.fn, [pass_in], node, frame, pf)
1369
+ res_vals.append(out)
1370
+ elif a.pyfn is not None:
1371
+ a.pyfn([rv])
1372
+ return union_vals(res_vals) if any(x.all_taints() for x in res_vals) else (rv if rv.ns is not None else CLEAN)
1373
+ if recv_node is not None and last in ("map", "forEach", "filter", "find", "some", "every", "flatMap", "reduce") and args and args[0].fn is not None:
1374
+ rv = self.eval(pf, recv_node, scope, frame)
1375
+ elem = Val(rv.all_taints())
1376
+ cb_args = [elem, CLEAN, CLEAN] if last != "reduce" else [CLEAN, elem]
1377
+ out = self.call_fn(args[0].fn, cb_args, node, frame, pf)
1378
+ if last in ("forEach",):
1379
+ return CLEAN
1380
+ if last in ("filter", "find", "some", "every"):
1381
+ return elem if last in ("filter", "find") else CLEAN
1382
+ return union_vals([out, elem]) if last == "reduce" else out
1383
+ if raw in ("setTimeout", "setInterval", "queueMicrotask", "requestAnimationFrame", "setImmediate") and args and args[0].fn is not None:
1384
+ self.call_fn(args[0].fn, [], node, frame, pf)
1385
+ return CLEAN
1386
+ if raw in ("useEffect", "useLayoutEffect", "useCallback_", "$", "jQuery") and args and args[0].fn is not None:
1387
+ self.call_fn(args[0].fn, [], node, frame, pf)
1388
+ return CLEAN
1389
+ if raw in R.CLEAN_FUNCS:
1390
+ return CLEAN
1391
+
1392
+ # --- project-internal callee --------------------------------------------------
1393
+ callee = self.resolve_callee(pf, fn_node, scope, frame, raw)
1394
+ if callee is not None:
1395
+ if callee.fn is not None:
1396
+ return self.call_fn(callee.fn, args, node, frame, pf)
1397
+ if callee.pyfn is not None:
1398
+ return callee.pyfn(args)
1399
+ # --- known pass-through / unresolved -----------------------------------------
1400
+ recv_val = self.eval(pf, recv_node, scope, frame) if recv_node is not None else CLEAN
1401
+ if recv_node is not None and recv_val.ns is not None:
1402
+ ex = self.member_of(recv_val, last)
1403
+ ex = self.force(ex) if ex.imp is not None else ex
1404
+ if ex.fn is not None:
1405
+ return self.call_fn(ex.fn, args, node, frame, pf)
1406
+ recv_all = recv_val.all_taints()
1407
+ arg_all = union_vals(args).t
1408
+ if not recv_all and not arg_all:
1409
+ return CLEAN
1410
+ if raw in R.PASSTHROUGH_FUNCS or last in R.PASS_METHODS or any(n in R.PASSTHROUGH_FUNCS for n in names):
1411
+ return Val(recv_all | arg_all)
1412
+ desc = raw or last or "<expr>"
1413
+ if fn_node.type == "member_expression" and (self.dotted(pf, fn_node) is None):
1414
+ self.lim(pf.path, line, "computed-call", "call on a dynamically computed receiver/property; flow through it is assumed")
1415
+ a_txt = f"passed through unresolved call {desc}()"
1416
+ out = frozenset(x.assume(a_txt) for x in (recv_all | arg_all))
1417
+ self.lim(pf.path, line, "unresolved-call", f"tainted value flows through {desc}(), whose behaviour is unknown")
1418
+ return Val(out)
1419
+
1420
+ def resolve_callee(self, pf, fn_node, scope, frame, raw: str) -> Val | None:
1421
+ if fn_node.type == "identifier":
1422
+ v = scope.lookup(self.txt(pf, fn_node))
1423
+ if v is None:
1424
+ return None
1425
+ v = self.force(v) if v.imp is not None else v
1426
+ return v if (v.fn or v.pyfn) else None
1427
+ if fn_node.type == "member_expression":
1428
+ obj = self.eval(pf, fn_node.child_by_field_name("object"), scope, frame)
1429
+ obj = self.force(obj) if obj.imp is not None else obj
1430
+ prop = fn_node.child_by_field_name("property")
1431
+ pname = self.txt(pf, prop) if prop is not None else ""
1432
+ mv = self.member_of(obj, pname)
1433
+ mv = self.force(mv) if mv.imp is not None else mv
1434
+ return mv if (mv.fn or mv.pyfn) else None
1435
+ if fn_node.type in ("arrow_function", "function_expression", "function"):
1436
+ fv = self.make_fn(pf, fn_node, scope, "<iife>")
1437
+ return Val(fn=fv)
1438
+ if fn_node.type == "call_expression":
1439
+ inner = self.eval(pf, fn_node, scope, frame)
1440
+ return inner if inner.fn else None
1441
+ return None
1442
+
1443
+ def match_sink_call(self, pf, node, raw, last, recv_text, args, argn, scope):
1444
+ out = []
1445
+ recv_clean = recv_text.replace("window.", "")
1446
+ if last in ("write", "writeln") and recv_clean in ("document",):
1447
+ out.append(("html", f"document.{last}()", "all"))
1448
+ elif last == "insertAdjacentHTML":
1449
+ out.append(("html", "insertAdjacentHTML()", 1))
1450
+ elif last == "createContextualFragment":
1451
+ out.append(("html", "createContextualFragment()", 0))
1452
+ elif raw in ("eval", "window.eval", "globalThis.eval"):
1453
+ out.append(("code", "eval()", 0))
1454
+ elif raw in ("setTimeout", "setInterval", "window.setTimeout", "window.setInterval") and args and args[0].fn is None:
1455
+ out.append(("code", f"{raw}(<string>)", 0))
1456
+ elif raw == "Function":
1457
+ out.append(("code", "Function()", len(args) - 1 if args else 0))
1458
+ elif last in ("assign", "replace") and recv_clean in ("location", "document.location") and args:
1459
+ out.append(("navigation", f"location.{last}()", 0))
1460
+ elif raw in ("window.open", "open") and args and scope.lookup("open") is None:
1461
+ out.append(("navigation", "window.open()", 0))
1462
+ elif last in R.JQUERY_HTML_METHODS and R.JQUERY_RECEIVER.match(recv_text or "") and args:
1463
+ out.append(("html", f"jQuery .{last}()", 0))
1464
+ elif last == "setAttribute" and len(argn) >= 2:
1465
+ an = (self.str_value(pf, argn[0]) or "").lower()
1466
+ if an.startswith("on") or an == "srcdoc":
1467
+ out.append(("html" if an == "srcdoc" else "code", f"setAttribute('{an}')", 1))
1468
+ elif an in ("href", "src", "action", "formaction"):
1469
+ out.append(("navigation", f"setAttribute('{an}')", 1))
1470
+ m = self.modules.get(pf.path)
1471
+ pkgs = m.packages if m is not None else set()
1472
+ if last in R.CMD_FUNCS or raw in R.CMD_FUNCS:
1473
+ if pkgs & R.CMD_PACKAGES or any(n.split(".")[0] in R.CMD_PACKAGES for n in self.callee_names(pf, self.unwrap(node.child_by_field_name("function")), scope)):
1474
+ out.append(("command", f"{raw or last}()", 0))
1475
+ if last in R.DB_METHODS and pkgs & R.DB_PACKAGES and args:
1476
+ out.append(("sql", f".{last}()", 0))
1477
+ return out
1478
+
1479
+ # ------------------------------------------------------------------ function execution
1480
+ def call_message_handler(self, pf, node, handler: Val, scope, frame) -> None:
1481
+ if handler.fn is None:
1482
+ return
1483
+ src = self.make_source(pf, node, "postmessage", snippet="message event .data")
1484
+ ev = Val(props={"data": src, "origin": CLEAN, "source": CLEAN})
1485
+ self.call_fn(handler.fn, [ev], node, frame, pf)
1486
+
1487
+ def call_fn(self, fn: FnVal, args: list[Val], call_node, frame: Frame, call_pf: ParsedFile) -> Val:
1488
+ if fn.file not in self.files:
1489
+ return CLEAN
1490
+ if frame.depth >= MAX_DEPTH:
1491
+ self.lim(call_pf.path, call_pf.line_of(call_node), "depth", f"call depth limit ({MAX_DEPTH}) reached at {fn.name}(); flow beyond is not tracked")
1492
+ return union_vals(args)
1493
+ if fn.key in self._stack:
1494
+ self.lim(call_pf.path, call_pf.line_of(call_node), "recursion", f"recursive call to {fn.name}(); summarised by argument taint")
1495
+ return union_vals(args)
1496
+ ident = tuple(tuple(sorted(x.ident for x in a.all_taints())) for a in args)
1497
+ key = (fn.key, ident)
1498
+ if key in self._memo:
1499
+ return self._memo[key]
1500
+ self._memo[key] = CLEAN # cycle guard for mutually recursive contexts
1501
+ pf = self.files[fn.file]
1502
+ node = fn.node
1503
+ # wrap args with call-site steps
1504
+ line = call_pf.line_of(call_node)
1505
+ wrapped = [self.add_step(a, call_pf, call_node, f"passed as argument to {fn.name}()") if a.all_taints() else a for a in args]
1506
+ sc = Scope(fn.scope)
1507
+ params = node.child_by_field_name("parameters")
1508
+ single = node.child_by_field_name("parameter")
1509
+ new_frame = Frame(fn.file, fn.name, [], frame.dead, 0, frame.depth + 1)
1510
+ if single is not None:
1511
+ self.bind_pattern(pf, single, wrapped[0] if wrapped else CLEAN, sc, new_frame, declare=True)
1512
+ elif params is not None:
1513
+ for i, p in enumerate(self.named(params)):
1514
+ self.bind_pattern(pf, p, wrapped[i] if i < len(wrapped) else CLEAN, sc, new_frame, declare=True)
1515
+ self._stack.append(fn.key)
1516
+ try:
1517
+ body = node.child_by_field_name("body")
1518
+ if body is not None:
1519
+ if body.type == "statement_block":
1520
+ self.hoist(pf, body, sc, None)
1521
+ self.exec_block(pf, body, sc, new_frame)
1522
+ else:
1523
+ new_frame.returns.append(self.eval(pf, body, sc, new_frame))
1524
+ finally:
1525
+ self._stack.pop()
1526
+ ret = join_all(new_frame.returns)
1527
+ if ret.all_taints():
1528
+ ret = self.add_step(ret, pf, node, f"returned from {fn.name}()")
1529
+ self._memo[key] = ret
1530
+ return ret
1531
+
1532
+ # ------------------------------------------------------------------ reporting
1533
+ def report(self, pf, node, sink_class: str, label: str, v: Val, frame: Frame, scope: Scope) -> None:
1534
+ taints = v.all_taints()
1535
+ if not taints or (self.attr and self.attr in self.suppressed):
1536
+ return
1537
+ line = pf.line_of(node)
1538
+ snippet = self.txt(pf, node).replace("\n", " ")[:110]
1539
+ dead = frame.dead or self.is_dead(pf, node)
1540
+ for t in taints:
1541
+ status, applied = self.judge(t, sink_class)
1542
+ if dead:
1543
+ status = "unreachable"
1544
+ steps = t.steps + ((pf.path, line, f"reaches {R.sink_label(sink_class)} sink {label}"),)
1545
+ files = tuple(dict.fromkeys([t.file] + [s[0] for s in steps]))
1546
+ fl = Flow(t.kind, t.file, t.line, t.snippet, sink_class, label, pf.path, line, snippet, steps, status,
1547
+ tuple(t.mitigations), t.assumptions, files, self.attr)
1548
+ self.keep(fl)
1549
+
1550
+ def keep(self, fl: Flow) -> None:
1551
+ """Insert a flow, keeping the worst-case status; ties broken deterministically so results never depend on
1552
+ discovery order (required for incremental == clean)."""
1553
+ old = self.flows.get(fl.key)
1554
+ if old is None or self._order(fl) < self._order(old):
1555
+ self.flows[fl.key] = fl
1556
+
1557
+ @staticmethod
1558
+ def _order(f: Flow) -> tuple:
1559
+ return (_STATUS_RANK[f.status], len(f.steps), repr(f.steps), f.assumptions, tuple(m.name for m in f.mitigations))
1560
+
1561
+ @staticmethod
1562
+ def judge(t: Taint, sink_class: str) -> tuple[str, tuple]:
1563
+ eff = [m for m in t.mitigations if sink_class in m.effective]
1564
+ if eff:
1565
+ return "sanitized", tuple(eff)
1566
+ unc = [m for m in t.mitigations if sink_class in m.uncertain]
1567
+ if unc:
1568
+ return "uncertain", tuple(unc)
1569
+ return "candidate", ()
1570
+
1571
+ def is_dead(self, pf, node) -> bool:
1572
+ cur = node.parent
1573
+ while cur is not None:
1574
+ if cur.type == "if_statement":
1575
+ cond = cur.child_by_field_name("condition")
1576
+ cons = cur.child_by_field_name("consequence")
1577
+ alt = cur.child_by_field_name("alternative")
1578
+ tr = self.const_truth(pf, self.unwrap(cond)) if cond is not None else None
1579
+ if tr is False and cons is not None and cons.start_byte <= node.start_byte <= cons.end_byte:
1580
+ return True
1581
+ if tr is True and alt is not None and alt.start_byte <= node.start_byte <= alt.end_byte:
1582
+ return True
1583
+ cur = cur.parent
1584
+ return False
1585
+
1586
+ # ------------------------------------------------------------------ roots
1587
+ def run(self, only_files: set[str] | None = None) -> None:
1588
+ targets = sorted(p for p in self.files if (only_files is None or p in only_files))
1589
+ if only_files is None and not self.isolate_roots:
1590
+ for path in sorted(self.files):
1591
+ self.get_module(path) # module bodies first: exports must exist before callers need them
1592
+ for path in targets:
1593
+ self.get_module(path)
1594
+ for path in targets:
1595
+ self.root_files.add(path)
1596
+ self.attr = path
1597
+ if self.isolate_roots:
1598
+ self._memo.clear()
1599
+ self.run_roots(path)
1600
+ self.attr = ""
1601
+ self.drain_pending(targets)
1602
+ self.check_vue_templates()
1603
+
1604
+ def run_roots(self, path: str) -> None:
1605
+ pf = self.files[path]
1606
+ m = self.modules.get(path)
1607
+ if m is None:
1608
+ return
1609
+ for fv in self.top_level_functions(pf, m):
1610
+ self.analyze_clean(fv, pf)
1611
+
1612
+ def top_level_functions(self, pf, m) -> list[FnVal]:
1613
+ out: list[FnVal] = []
1614
+ seen: set = set()
1615
+
1616
+ def visit(node):
1617
+ for c in node.children:
1618
+ if c.type in ("function_declaration", "generator_function_declaration"):
1619
+ nm = c.child_by_field_name("name")
1620
+ name = self.txt(pf, nm) if nm is not None else "<fn>"
1621
+ v = m.scope.vars.get(name)
1622
+ fv = v.fn if v is not None and v.fn is not None and v.fn.node.id == c.id else self.make_fn(pf, c, m.scope, name)
1623
+ if fv.key not in seen:
1624
+ seen.add(fv.key)
1625
+ out.append(fv)
1626
+ elif c.type in ("export_statement", "statement_block", "expression_statement", "program"):
1627
+ visit(c)
1628
+ visit(pf.tree.root_node)
1629
+ for name, v in list(m.scope.vars.items()):
1630
+ if v.fn is not None and v.fn.file == pf.path and v.fn.key not in seen:
1631
+ seen.add(v.fn.key)
1632
+ out.append(v.fn)
1633
+ for pv in (v.props or {}).values():
1634
+ if pv.fn is not None and pv.fn.file == pf.path and pv.fn.key not in seen:
1635
+ seen.add(pv.fn.key)
1636
+ out.append(pv.fn)
1637
+ return out
1638
+
1639
+ def analyze_clean(self, fv: FnVal, pf=None) -> None:
1640
+ k = (fv.key, "clean")
1641
+ if k in self._analyzed_clean or fv.file not in self.files:
1642
+ return
1643
+ self._analyzed_clean.add(k)
1644
+ pf = self.files[fv.file]
1645
+ n = len(fv.node.child_by_field_name("parameters").named_children) if fv.node.child_by_field_name("parameters") is not None else 1
1646
+ self.call_fn(fv, [CLEAN] * n, fv.node, Frame(fv.file), pf)
1647
+
1648
+ def drain_pending(self, targets: list[str]) -> None:
1649
+ guard = 0
1650
+ tset = set(targets)
1651
+ while self.pending and guard < 200_000:
1652
+ guard += 1
1653
+ fv = self.pending.pop(0)
1654
+ if fv.file in tset:
1655
+ self.attr = fv.file
1656
+ if self.isolate_roots:
1657
+ self._memo.clear()
1658
+ self.analyze_clean(fv)
1659
+ self.attr = ""
1660
+
1661
+ # ------------------------------------------------------------------ Vue templates
1662
+ def check_vue_templates(self) -> None:
1663
+ for script_path, art_path, line, expr in self.vue_templates:
1664
+ root = re.match(r"[A-Za-z_$][\w$]*", expr.strip())
1665
+ if not root:
1666
+ continue
1667
+ ts = self.vue_state.get((script_path, root.group(0)))
1668
+ if not ts:
1669
+ continue
1670
+ if script_path in self.suppressed:
1671
+ continue
1672
+ for t in ts:
1673
+ status, applied = self.judge(t, "html")
1674
+ steps = t.steps + ((art_path, line, f"rendered by v-html=\"{expr.strip()}\" in the template"),)
1675
+ self.keep(Flow(t.kind, t.file, t.line, t.snippet, "html", "v-html", art_path, line, f'v-html="{expr.strip()}"',
1676
+ steps, status, tuple(applied), t.assumptions, tuple(dict.fromkeys([t.file] + [s[0] for s in steps])),
1677
+ script_path))
1678
+
1679
+
1680
+ def join_all(vals: list[Val]) -> Val:
1681
+ out: Val | None = None
1682
+ for v in vals:
1683
+ out = join(out, v)
1684
+ return out if out is not None else CLEAN