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,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
|