Module refinery.lib.scripts.js.deobfuscation.reflection
Inline reflectively executed JavaScript code: eval, Function constructor, constructor chains, and setTimeout/setInterval with string arguments. An obfuscator which wraps the entire program in
Function(param, code)(proxyObject)
is handled as a special case with automatic proxy object resolution.
Expand source code Browse git
"""
Inline reflectively executed JavaScript code: eval, Function constructor, constructor chains, and
setTimeout/setInterval with string arguments. An obfuscator which wraps the entire program in
Function(param, code)(proxyObject)
is handled as a special case with automatic proxy object resolution.
"""
from __future__ import annotations
import enum
from typing import Callable, NamedTuple
from refinery.lib.scripts import (
Expression,
Node,
_clone_node,
_replace_in_parent,
is_well_formed,
)
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import side_effect_free
from refinery.lib.scripts.js.analysis.model import (
FUNCTION_NODES,
REFLECTIVE_INTRINSICS,
SYNC_EVAL_NAMES,
TIMER_NAMES,
Binding,
BindingKind,
Scope,
SemanticModel,
build_semantic_model,
crosses_dynamic_scope,
enclosing_function,
is_member_write_target,
is_simple_assignment_target,
name_uses_in_scope,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
ScriptLevelTransformer,
access_key,
get_body,
property_key,
references_receiver_this,
remove_declarator,
rewrite_receiver_this_to_global,
string_value,
walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.options import module_execution
from refinery.lib.scripts.js.deobfuscation.strict_divergence import diverges_under_strict
from refinery.lib.scripts.js.model import (
JsArrowFunctionExpression,
JsAssignmentExpression,
JsAwaitExpression,
JsBlockStatement,
JsCallExpression,
JsExpressionStatement,
JsFunctionExpression,
JsIdentifier,
JsMemberExpression,
JsNewExpression,
JsObjectExpression,
JsProperty,
JsPropertyKind,
JsReturnStatement,
JsScript,
JsSequenceExpression,
JsStringLiteral,
JsUnaryExpression,
JsVariableDeclarator,
Statement,
strip_parens,
wraps_return,
)
from refinery.lib.scripts.js.strict import (
collect_strict_violations,
declares_use_strict,
strict_mode_at,
)
_REFLECTIVE_CALLEE_NAMES = REFLECTIVE_INTRINSICS | TIMER_NAMES | SYNC_EVAL_NAMES
class ReflectedScope(enum.Enum):
"""
The execution scope of reflectively evaluated code, which decides how its free names, `this`, and
top-level declarations must be treated when the code is inlined at its call site. A
`Function`-constructed function and indirect `eval`/string-timer code run in the global sloppy
scope; a direct `eval` runs in the caller's scope, which is the inline site itself, so its
references and `this` are already correct there and only its declarations need care.
"""
FUNCTION_CONSTRUCTOR = enum.auto()
GLOBAL_EVAL = enum.auto()
DIRECT_EVAL = enum.auto()
def _try_parse(code: str, *, top_level_await: bool, strict: bool) -> JsScript | None:
"""
The tree the reflected code spells, or `None` where it spells no program. Inlining is the one
place a parse has to be believed rather than merely used: what comes back is printed into the
file around it, so text the parser did not read would be printed as source it never agreed to,
and a literal the code left open would run on into whatever follows it at the call site.
Recovery makes the parser total, so raising is not the test. The test is whether the tree is
well formed, which is precisely the domain over which printing it back means what it said. A
payload cut off in the middle of a construct is the case that makes the difference: the parser
finishes it by writing the token it was waiting for, so `x = f(1, 2` reads as a call that runs,
and only the repair the parser records keeps that from being spliced into the file as though it
had been written whole.
Well formed is not the whole of it. A text can spell a tree the printer reproduces exactly and
still be one the language refuses to read — a repeated parameter where the grammar wants a unique
list, an accessor of the wrong arity, a Use Strict Directive under a parameter list that may hold
none. Evaluated, such a text is a `SyntaxError` the call site catches and the program carries on
from; spliced into the file, it takes the whole file down with it, and nothing runs at all. So it
is refused here, which leaves the `eval` or `Function` call standing to throw exactly what it threw
before.
*strict* is the mode at the destination, and it is the mode the text has to be legal in, whichever
mode it would have run in where it stood. A body a `Function` constructor builds runs sloppy in the
global scope, but inlining it puts its text where the destination's mode governs; a direct `eval`
already runs in the destination's mode, and a text that mode refuses is a `SyntaxError` the call
site catches and carries on from. The two arrive by different routes at the same requirement, which
is why one seed answers for every surface.
Module-only syntax is refused for the same reason and needs no mode to decide it. Every surface
that reaches here evaluates its text as a Script, where an `import` or `export` declaration is a
`SyntaxError` the call site catches; spliced into the file it is a `SyntaxError` the file cannot
survive, and where the host does load the file as a module it is a declaration the program never
made. It is read off the mark the parser left rather than walked for again, so this gate and the
mode `refinery.lib.scripts.js.strict.strict_mode_at` reads for the same tree cannot part
company.
Refusing is free: the `eval` or `Function` call is left standing to throw exactly what it threw
before. Whether such a body would additionally *behave* differently at a strict destination is a
separate question, and one only the surfaces that run sloppy have to ask; `diverges_under_strict`
owns it.
"""
try:
from refinery.lib.scripts.js.parser import JsParser
parsed = JsParser(code, top_level_await=top_level_await).parse()
except Exception:
return None
if not parsed.body or not is_well_formed(parsed):
return None
if parsed.module:
return None
if collect_strict_violations(parsed, strict=strict):
return None
return parsed
def _site_in_async_function(site: Node) -> bool:
"""
Whether *site* sits inside an `async` function, so a direct `eval` there runs where `await` is an
operator. Global-scope reflected code (indirect `eval`, a `Function` body, a string call) runs in
the global sloppy scope instead, where `await` is an ordinary identifier and never an operator.
"""
func = enclosing_function(site)
return isinstance(func, FUNCTION_NODES) and func.is_async
def _try_eval_string_arg(node: Expression, model: SemanticModel) -> str | None:
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
JsInterpreter,
_ThrowSignal,
)
try:
result = JsInterpreter(model=model).eval_expression(node)
except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError):
return None
if isinstance(result, str):
return result
return None
def _extract_eval_code(
node: JsCallExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the code string from a direct `eval("code")` / `(eval)("code")`. The callee must be the
free global `eval`; a locally-shadowed `eval` names an ordinary value whose call is left intact.
"""
if free_global_name(node.callee) != 'eval':
return None
if len(node.arguments) != 1:
return None
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
def _extract_indirect_eval_code(
node: JsCallExpression,
read_effect: Callable[[Node], bool] | None = None,
*,
alias_name: Callable[[Expression | None], str | None],
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the code string from indirect eval patterns:
- `(0, eval)("code")`
- `window.eval("code")` / `globalThis.eval("code")` / `window['eval']("code")`
Inlining discards the comma-sequence prefix, so it is admitted only when dropping it is
side-effect free; *read_effect* rejects a prefix read that resolves through a `with` body's dynamic
scope (firing a getter or throwing), which the model-free check cannot see. *free_global_name*
confirms the sequence tail is the free global `eval` and *alias_name* resolves a global-object-alias
member to the intrinsic it names, both declining a shadowed name or a dynamic scope.
"""
if len(node.arguments) != 1:
return None
callee = strip_parens(node.callee) if node.callee is not None else None
if isinstance(callee, JsSequenceExpression):
exprs = callee.expressions
if len(exprs) >= 2 and free_global_name(exprs[-1]) == 'eval':
if all(side_effect_free(e, read_effect=read_effect) for e in exprs[:-1]):
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
if alias_name(node.callee) == 'eval':
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
return None
def _extract_string_call_code(
node: JsCallExpression,
names: frozenset[str],
*,
alias_name: Callable[[Expression | None], str | None],
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the code string a named global string-call evaluates — a deferred timer
(`setTimeout("code", ...)`, `setInterval`, `setImmediate`) or a synchronous global eval
(`execScript("code")`) — whether the global is named directly or through a global-object alias
(`window.setTimeout("code", ...)`), both of which reach the same evaluating global. *names* selects
which globals qualify. The callee must denote the free global: *free_global_name* resolves a bare
name and *alias_name* a global-object-alias member, each declining a locally shadowed name or a
dynamic scope.
"""
if node.callee is None:
return None
name = free_global_name(node.callee) or alias_name(node.callee)
if name not in names:
return None
if not node.arguments:
return None
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
def _extract_function_body_code(
constructor_call: JsCallExpression | JsNewExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the body code string from Function constructor calls:
Function("code")
Function("a", "b", "code")
new Function("code")
The callee must be the free global `Function`; a locally-shadowed `Function` names an ordinary
value and is left alone. The last string argument is the function body; preceding string arguments
are parameter names (ignored for now).
"""
if free_global_name(constructor_call.callee) != 'Function':
return None
args = constructor_call.arguments
if not args:
return None
last = args[-1]
body = string_value(last) or eval_string(last)
if body is None:
return None
if not all(isinstance(a, JsStringLiteral) for a in args[:-1]):
return None
return body
def _denotes_function_constructor(
expr: Expression | None, read_effect: Callable[[Node], bool] | None = None,
) -> bool:
"""
Whether *expr* evaluates to the `Function` intrinsic, reached by `.constructor` navigation from a
side-effect-free base. `Function` is what the reflective `Function("code")` idiom calls, so a callee
that denotes it under another spelling constructs a function from the same code. Two spellings reach
it:
<function literal>.constructor (a plain function or arrow literal)
<literal>.constructor.constructor (any side-effect-free base)
A plain function or arrow literal's own `.constructor` is `Function`, since every ordinary function
is an instance of `Function`; an `async` or generator literal is refused, its `.constructor` being
`AsyncFunction` or `GeneratorFunction`, which build a coroutine or generator body rather than the
plain function `Function` builds. Any value's `.constructor.constructor` is `Function`, because the
first hop yields that value's constructor — itself a function — whose own `.constructor` is
`Function`. Inlining discards the evaluation of the base, so it must be side-effect free; a function
literal always is, and for the double hop *read_effect* rejects a bare-identifier base that resolves
through a `with` body's dynamic scope (firing a getter or throwing), which the model-free check
cannot see.
"""
if not isinstance(expr, JsMemberExpression) or access_key(expr) != 'constructor':
return False
base = strip_parens(expr.object)
if base is None:
return False
if isinstance(base, (JsFunctionExpression, JsArrowFunctionExpression)):
return not wraps_return(base)
if isinstance(base, JsMemberExpression) and access_key(base) == 'constructor':
inner = base.object
return inner is not None and side_effect_free(inner, read_effect=read_effect)
return False
def _extract_constructor_chain_code(
ctor_call: Node,
read_effect: Callable[[Node], bool] | None = None,
*,
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the body code from a constructor-navigation call that constructs a function:
(function() {}).constructor("code")
"".constructor.constructor("code")
[].constructor.constructor("code")
*ctor_call* is the construction itself (the call to the navigated `Function` intrinsic), not its
later invocation; its callee must denote `Function` (`_denotes_function_constructor`).
"""
if not isinstance(ctor_call, JsCallExpression):
return None
if not _denotes_function_constructor(ctor_call.callee, read_effect):
return None
if len(ctor_call.arguments) != 1:
return None
return string_value(ctor_call.arguments[0]) or eval_string(ctor_call.arguments[0])
def _function_constructor_body(
ctor_call: Node,
read_effect: Callable[[Node], bool] | None = None,
*,
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> tuple[str, bool] | None:
"""
Given the construction *ctor_call* itself — `Function("code")`, `new Function("code")`, or a
`<literal>.constructor…("code")` navigation — return its body code together with whether the
construction binds parameters (a leading string argument to the `Function` form). Returns `None`
when *ctor_call* is not such a construction. The caller decides how the constructed function is
invoked and ORs in whether that invocation passes arguments, since a body that binds either a
parameter or a call argument cannot be inlined.
"""
if isinstance(ctor_call, (JsCallExpression, JsNewExpression)):
code = _extract_function_body_code(
ctor_call, free_global_name=free_global_name, eval_string=eval_string)
if code is not None:
return code, len(ctor_call.arguments) > 1
chain = _extract_constructor_chain_code(ctor_call, read_effect, eval_string=eval_string)
if chain is not None:
return chain, False
return None
def _extract_getter_target(func: Expression | None) -> str | JsUnaryExpression | None:
"""
Extract the value returned by a getter. Expected patterns:
- `{ return <identifier>; }` -> returns the identifier name as `str`
- a `typeof` expression -> returns a `refinery.lib.scripts.js.model.JsUnaryExpression` clone
"""
if not isinstance(func, JsFunctionExpression):
return None
if func.body is None or not isinstance(func.body, JsBlockStatement):
return None
body = func.body.body
if len(body) != 1:
return None
stmt = body[0]
if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
return None
arg = stmt.argument
if isinstance(arg, JsIdentifier):
return arg.name
if (
isinstance(arg, JsUnaryExpression)
and arg.operator == 'typeof'
and isinstance(arg.operand, JsIdentifier)
):
return arg
return None
def _extract_setter_target(func: Expression | None) -> str | None:
"""
Extract the global assigned in a setter. Expected pattern:
{ return <global> = <param>; }
where the function has exactly one parameter.
"""
if not isinstance(func, JsFunctionExpression):
return None
if len(func.params) != 1 or not isinstance(func.params[0], JsIdentifier):
return None
param_name = func.params[0].name
if func.body is None or not isinstance(func.body, JsBlockStatement):
return None
body = func.body.body
if len(body) != 1:
return None
stmt = body[0]
if isinstance(stmt, JsReturnStatement):
expr = stmt.argument
elif isinstance(stmt, JsExpressionStatement):
expr = stmt.expression
else:
return None
if not isinstance(expr, JsAssignmentExpression) or expr.operator != '=':
return None
if not isinstance(expr.left, JsIdentifier):
return None
if not isinstance(expr.right, JsIdentifier) or expr.right.name != param_name:
return None
return expr.left.name
class _ProxyMapping(NamedTuple):
getters: dict[str, str | JsUnaryExpression]
setters: dict[str, str]
def _build_proxy_mapping(
obj: JsObjectExpression,
) -> _ProxyMapping | None:
"""
Build getter and setter mappings from a pack proxy object. Returns `(getters, setters)` or
`None` if any property is malformed.
"""
getters: dict[str, str | JsUnaryExpression] = {}
setters: dict[str, str] = {}
for prop in obj.properties:
if not isinstance(prop, JsProperty):
return None
key = property_key(prop)
if key is None:
return None
if prop.kind == JsPropertyKind.GET:
target = _extract_getter_target(prop.value)
if target is None:
return None
getters[key] = target
elif prop.kind == JsPropertyKind.SET:
target = _extract_setter_target(prop.value)
if target is None:
return None
setters[key] = target
else:
return None
return _ProxyMapping(getters, setters)
def _substitute_proxy_accesses(
parsed: JsScript,
param_name: str,
getters: dict[str, str | JsUnaryExpression],
setters: dict[str, str],
) -> bool:
"""
Replace all `param[key]` accesses in the parsed code with the resolved globals from the proxy
mapping. A plain read resolves to the getter target and a simple `key = v` write to the setter
target; a compound, update, or delete access reads via the getter AND writes via the setter, which
no single global substitution preserves, so it makes resolution fail. Returns `True` if every
access was resolved successfully.
"""
for node in list(parsed.walk()):
if not isinstance(node, JsMemberExpression):
continue
if not isinstance(node.object, JsIdentifier) or node.object.name != param_name:
continue
key = access_key(node)
if key is None:
return False
if is_simple_assignment_target(node):
if key not in setters:
return False
_replace_in_parent(node, JsIdentifier(name=setters[key]))
elif is_member_write_target(node):
return False
else:
if key not in getters:
return False
target = getters[key]
if isinstance(target, str):
_replace_in_parent(node, JsIdentifier(name=target))
else:
_replace_in_parent(node, _clone_node(target))
return True
def _try_unpack_function_constructor(
node: JsCallExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
) -> list[Statement] | None:
"""
Unpack an immediately-invoked `Function` constructor whose single argument is a proxy object
with getter/setter properties that redirect to global variables:
Function("p", "p.abc = p.def(p.ghi)")(
{get abc() { return x }, set abc(v) { x = v }, get def() { return y }, ...}
)
Parses the code string, resolves all `p.key` accesses through the proxy mapping back to their
original global identifiers, and returns the recovered statement list. Returns `None` if the node
does not match — including when the inner callee is not the free global `Function` — or if any
proxy access cannot be resolved.
"""
inner = node.callee
if not isinstance(inner, JsCallExpression):
return None
if free_global_name(inner.callee) != 'Function':
return None
if len(node.arguments) != 1 or not isinstance(node.arguments[0], JsObjectExpression):
return None
proxy_obj = node.arguments[0]
inner_args = inner.arguments
if len(inner_args) == 1:
param_name = ''
code = string_value(inner_args[0])
elif len(inner_args) == 2:
param_name = string_value(inner_args[0])
code = string_value(inner_args[1])
if param_name is None:
return None
else:
return None
if code is None:
return None
mapping = _build_proxy_mapping(proxy_obj)
if mapping is None:
return None
getters, setters = mapping
parsed = _try_parse(code, top_level_await=False, strict=strict_mode_at(node))
if parsed is None:
return None
if param_name and not _substitute_proxy_accesses(parsed, param_name, getters, setters):
return None
return list(parsed.body)
def _is_pack_shaped(
node: JsCallExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
) -> bool:
"""
Return `True` when the call has the shape of a pack pattern: the callee is a free-global `Function()`
call and the outer argument is an object expression. When this shape is detected, the generic
function-body extraction should be skipped to avoid inlining code with unresolved proxy references.
The callee is identified through the model, so a locally shadowed `Function` is not mistaken for the
intrinsic.
"""
inner = node.callee
if not isinstance(inner, JsCallExpression) or inner.callee is None:
return False
if free_global_name(inner.callee) != 'Function':
return False
return len(node.arguments) == 1 and isinstance(node.arguments[0], JsObjectExpression)
def _has_top_level_await(stmts: list[Statement]) -> bool:
"""
Return `True` if any `refinery.lib.scripts.js.model.JsAwaitExpression` in `stmts` is at the top
level, i.e. not inside a nested function boundary.
"""
return any(isinstance(n, JsAwaitExpression) for s in stmts for n in walk_scope(s))
def _has_top_level_return(stmts: list[Statement]) -> bool:
"""
Whether *stmts* — an evaluated code string's body — has a `return` at its own top level, outside any
nested function. A `return` outside a function is a SyntaxError in `eval` and string-timer code, so
such a body throws when evaluated and must not be inlined as if it produced a value or ran to
completion. The `Function` constructor is exempt: its body is a real function body, where a
top-level `return` is the function's own return.
"""
return any(isinstance(n, JsReturnStatement) for s in stmts for n in walk_scope(s))
def _references_new_target(root: Node) -> bool:
"""
Whether *root* reads the `new.target` meta-property, which the parser models as a member access
whose object is the reserved word `new`. A `Function`-constructed function is invoked as a call,
so its `new.target` is always `undefined`; splicing the body into a real function would rebind
`new.target` to the caller's, so a body that reads it cannot be inlined.
"""
for node in root.walk():
if (
isinstance(node, JsMemberExpression)
and isinstance(node.object, JsIdentifier)
and node.object.name == 'new'
):
return True
return False
def _body_free_names(body_model: SemanticModel, parsed: JsScript) -> set[str]:
"""
The names *parsed* reads or writes without binding them locally — the names a
`Function`-constructed body resolves against the global scope. A name bound inside the body is
excluded (inlining carries its binding along), as is a property name or key; an implicit-global
write the body performs is included, since it targets a global rather than a local binding.
"""
free: set[str] = set()
for ident in parsed.walk():
if not isinstance(ident, JsIdentifier) or not body_model.is_reference(ident):
continue
binding = body_model.resolve(ident)
if binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL:
free.add(ident.name)
return free
def _body_declared_names(body_model: SemanticModel) -> set[str]:
"""
The names a `Function`-constructed body declares at its top level — the `var`, function, `let`,
`const`, and `class` bindings that inlining would hoist into the caller's scope. Implicit globals
are excluded: those are writes to globals, covered by the free-name check rather than introduced as
new bindings.
"""
return {
name for name, binding in body_model.root_scope.bindings.items()
if binding.kind is not BindingKind.IMPLICIT_GLOBAL
}
def _hoist_path_is_clear(names: set[str], site_scope: Scope, var_scope: Scope) -> bool:
"""
Whether each hoisted `var`/function name can rise from the call site to *var_scope* without
crossing a lexical binding of the same name. A `var` spliced into a block still hoists to the
enclosing function or script, but it is a redeclaration SyntaxError if any block it passes
through — from the site's own scope up to, but not including, *var_scope* — lexically binds the
same name. Conflicts with a binding declared directly in *var_scope* are already caught by the
capture check.
"""
scope: Scope | None = site_scope
while scope is not None and scope is not var_scope:
if any(name in scope.bindings for name in names):
return False
scope = scope.parent
return True
def _inlined_declarations_safe(
body_model: SemanticModel,
root_model: SemanticModel,
site_scope: Scope,
) -> bool:
"""
Whether the names a `Function`-constructed body declares at its top level can be introduced at the
call site without capturing an identifier already meaningful there. Such declarations are local to
the constructed function; inlining lifts `var` and function declarations into the caller's function
or script scope and `let`/`const`/`class` into the caller's immediate block, where a same-named
reference, an inherited binding, or a redeclaration would silently rebind to the inlined declaration
or produce a duplicate lexical declaration. Each name is checked against the scope it would actually
land in.
"""
bindings = body_model.root_scope.bindings
hoisted = {name for name, binding in bindings.items() if binding.is_hoisted}
lexical = {name for name, binding in bindings.items() if binding.is_lexical}
if hoisted:
var_scope = site_scope.var_scope
if var_scope is None or root_model.would_capture(hoisted, var_scope):
return False
if not _hoist_path_is_clear(hoisted, site_scope, var_scope):
return False
if lexical and root_model.would_capture(lexical, site_scope):
return False
return True
class JsReflectionInlining(ScriptLevelTransformer):
"""
Inline reflective code execution: `eval`, `Function` constructor, constructor chains, and
indirect invocation via `setTimeout` and `setInterval`.
"""
_read_effect: Callable[[Node], bool]
_alias_name: Callable[[Expression | None], str | None]
_free_global: Callable[[Expression | None], str | None]
_eval_string: Callable[[Expression | None], str | None]
_pending_retire: dict[int, Binding]
_retire_consumed: dict[int, int]
_retire_binding: dict[int, Binding]
def _process_script(self, node: JsScript) -> None:
"""
Inline every reflective site in the script, holding the semantic model for the whole pass.
Each inline splices in code that was a string, so this pass *can* reveal facts its held model
predates — `eval('Math.floor = f')` makes a write visible that no pre-inline model could see. What
makes holding the model sound is the precondition rather than the absence of such reveals: this
transform only ever does work on a script that has a reflective surface, and `has_reflection_surface`
being true withdraws trust from every intrinsic (see
`refinery.lib.scripts.js.analysis.effects.EffectModel.trusted_intrinsic`). No fold against a
built-in can be admitted anywhere inside this window, so a write revealed here cannot be acted on
before the pin is released and the model rebuilt. Inlining can only turn that flag off, never on,
which leaves the held answer the stricter one.
Should this transform ever run on a script with no reflective surface, or should that flag stop
gating intrinsic trust, this argument does not hold and the pin must be reconsidered.
"""
with model_cache(self, node).pinned():
self._read_effect = self._dynamic_read_effect(node)
self._alias_name = self._alias_member_name(node)
self._free_global = self._free_global_name(node)
self._eval_string = self._string_argument_value(node)
self._pending_retire = {}
self._retire_consumed = {}
self._retire_binding = {}
self._inline_statements(node)
self._inline_expressions(node)
self._lower_timers(node)
self._retire_consumed_temporaries()
def _note_retirement(self, site: Node, binding: Binding | None) -> None:
"""
Record that inlining the reflective call at *site* would retire the single-use temporary
*binding* — the local whose sole value is the `Function` construction the call invokes. The note
is provisional: it is keyed by the site and only acted on once `_confirm_retirement` sees the
inlining committed, so a resolution the caller declines (a body that could not be reduced to an
expression, a statement `_sanitize_inlined_body` rejects) retires nothing.
"""
if binding is not None:
self._pending_retire[id(site)] = binding
def _confirm_retirement(self, site: Node) -> None:
"""
Acknowledge that the inlining at *site* was committed, counting the read of its temporary that
the inlining consumed. A temporary read at every site by such a committed inlining is retired by
`_retire_consumed_temporaries`; one still read elsewhere is not.
"""
binding = self._pending_retire.pop(id(site), None)
if binding is None:
return
self._retire_consumed[id(binding)] = self._retire_consumed.get(id(binding), 0) + 1
self._retire_binding[id(binding)] = binding
def _retire_consumed_temporaries(self) -> None:
"""
Drop the declarator of each single-assignment temporary whose every read was a `Function`
construction invocation this pass inlined. The construction is side-effect-free precisely
because the inlining succeeded — `_resolve_reflected_body` parses the code and declines a body
it cannot, so a construction whose body was inlined provably parses and cannot throw — which is
the judgment `refinery.lib.scripts.js.analysis.effects.EffectModel` withholds from an intrinsic
under a live reflection surface and only this pass, having parsed the code, can make.
"""
for bid, binding in self._retire_binding.items():
if self._retire_consumed.get(bid, 0) != len(binding.reads):
continue
if binding.exported or binding.dynamic_refs or len(binding.declarations) != 1:
continue
declarator = binding.declarations[0].parent
if not isinstance(declarator, JsVariableDeclarator) or declarator.init is None:
continue
remove_declarator(declarator)
self.mark_changed()
def _dynamic_read_effect(self, root: JsScript) -> Callable[[Node], bool]:
"""
A predicate reporting whether reading a node crosses a `with` body's dynamic scope, resolved
against *root*'s current model. Threaded into the reflective-inlining safety checks so a read
that may fire a `with` object's getter or throw is never dropped as if it were pure. Resolved
lazily through the shared cache, so a script with no reflective site builds no model.
"""
return lambda node: model_cache(self, root).model.read_has_dynamic_effect(node)
def _alias_member_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
"""
A resolver reporting the intrinsic a global-object-alias member names — `window.eval` yields
`'eval'`, `globalThis['setTimeout']` yields `'setTimeout'` — or `None` when the base is not the
real, unshadowed global object. A local `window` (a parameter, a `var`, a `with`-object
property) names an ordinary object whose member is not the reflective intrinsic and must not be
inlined; the model's shadow- and dynamic-scope-aware check is the single source of that judgment.
Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
"""
def resolve(callee: Expression | None) -> str | None:
if callee is None:
return None
member = strip_parens(callee)
if not isinstance(member, JsMemberExpression):
return None
model = model_cache(self, root).model
if model.scope_of(member) is None:
return None
return model.global_alias_member_name(
member, module_scope=module_execution(self.options))
return resolve
def _free_global_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
"""
A resolver reporting the reflective intrinsic a bare callee identifier denotes — `eval` yields
`'eval'`, `Function` yields `'Function'`, a timer or `execScript` its own name — or `None`. Only
a name that could name such a callee is resolved; any other identifier is declined before any
model lookup, since no caller acts on a non-reflective name. A local binding (a parameter, a
`var`, a `with`-object property) of the name is an ordinary value, not the intrinsic, and must
not drive an inline; the model resolves a reference to its binding for a shadow and to `None` for
a free global, and `read_has_dynamic_effect` rejects a name read through a dynamic scope.
Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
"""
def resolve(callee: Expression | None) -> str | None:
if callee is None:
return None
ident = strip_parens(callee)
if not isinstance(ident, JsIdentifier) or ident.name not in _REFLECTIVE_CALLEE_NAMES:
return None
model = model_cache(self, root).model
if model.scope_of(ident) is None:
return None
if model.resolve(ident) is None and not model.read_has_dynamic_effect(ident):
return ident.name
return None
return resolve
def _string_argument_value(self, root: JsScript) -> Callable[[Expression | None], str | None]:
"""
A resolver folding an argument expression to the string it denotes — `atob('...')` to the code
it decodes — or `None`. The interpreter is given *root*'s semantic model, because a call it
answers from the built-in registry is the built-in only where nothing has bound that name; the
effect model is not built, since none of the questions asked here are about effects. Resolved
lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
"""
def resolve(node: Expression | None) -> str | None:
if node is None:
return None
model = model_cache(self, root).model
if model.scope_of(node) is None:
return None
return _try_eval_string_arg(node, model)
return resolve
def _inline_statements(self, root: JsScript) -> None:
for container in list(root.walk()):
body = get_body(container)
if body is None:
continue
i = 0
while i < len(body):
original = body[i]
parsed = self._try_resolve_statement(original, root, container is root)
if parsed is None:
i += 1
continue
parsed = self._sanitize_inlined_body(parsed)
if parsed is None:
i += 1
continue
for stmt in parsed:
stmt.parent = container
body[i:i + 1] = parsed
self._confirm_retirement(original)
self.mark_changed()
i += len(parsed)
@staticmethod
def _sanitize_inlined_body(stmts: list[Statement]) -> list[Statement] | None:
"""
Adapt a reflective body's statements for the statement position they replace, where the call's
return value is discarded and no `return` may escape into the container. A trailing `return x`
becomes the bare expression `x` (its value was already being thrown away) and a trailing
valueless `return` is dropped. Any other `return` — before the last statement, or nested in the
control flow of any statement (an `if`, loop, or `try`) rather than at the body's own top level —
declines the inlining (`None`), since its early exit cannot be reproduced at statement position
without reordering and declining is always sound. `walk_scope` finds a nested `return` without
descending into a nested function, whose own `return` stays with it. This holds for every
container, not only the script: a `return` spliced into a function body would return from that
enclosing function, and into the script would be a syntax error.
"""
if not stmts:
return stmts
trailing = stmts[-1] if isinstance(stmts[-1], JsReturnStatement) else None
for stmt in stmts[:-1] if trailing is not None else stmts:
if any(isinstance(node, JsReturnStatement) for node in walk_scope(stmt)):
return None
if trailing is None:
return stmts
if trailing.argument is not None:
return [*stmts[:-1], JsExpressionStatement(expression=trailing.argument)]
return stmts[:-1]
def _inline_expressions(self, root: JsScript) -> None:
for node in list(root.walk()):
if not isinstance(node, JsCallExpression):
continue
if isinstance(node.parent, JsExpressionStatement):
continue
replacement = self._try_resolve_expression(node, root)
if replacement is None:
continue
_replace_in_parent(node, replacement)
self._confirm_retirement(node)
self.mark_changed()
def _lower_timers(self, root: JsScript) -> None:
"""
Rewrite a string-argument timer — `setTimeout("code", delay)`, `setInterval`, and their
`setImmediate`/global-alias variants — into a deferred function call
`setTimeout(function () { code }, delay)`, so the evaluated code is deobfuscated without changing
when or how often it runs. Unlike the eval and constructor paths, a timer is not inlined at the
call site: its value is a handle and its execution is deferred, so only its code string is
lowered. `execScript` is not a timer — it evaluates synchronously — so it is inlined in place by
`_try_resolve_statement` instead of lowered here.
"""
for node in list(root.walk()):
if isinstance(node, JsCallExpression):
self._try_lower_timer(node, root)
def _try_lower_timer(self, node: JsCallExpression, root: JsScript) -> None:
"""
Replace a string timer's code argument with a function wrapping the parsed code, when that code
runs safely in the global scope the timer would give it. The wrapper is defined at the call site,
so it is held to the same global-scope safety as an indirect eval — its `this` is rewritten to
`globalThis`, its free names must still denote the same global, and a top-level declaration
(whose global or transient environment a local function cannot reproduce) or a `return`/`await`
that a plain function body cannot host declines the lowering, leaving the string timer intact.
"""
code = _extract_string_call_code(
node,
TIMER_NAMES,
alias_name=self._alias_name,
free_global_name=self._free_global,
eval_string=self._eval_string,
)
if code is None:
return
resolved = self._resolve_reflected_body(
code, node, root, ReflectedScope.GLOBAL_EVAL, at_global_scope=False,
)
if resolved is None or _has_top_level_await(resolved.body):
return
block = JsBlockStatement(body=resolved.body)
wrapper = JsFunctionExpression(params=[], body=block)
block.parent = wrapper
for stmt in resolved.body:
stmt.parent = block
_replace_in_parent(node.arguments[0], wrapper)
self.mark_changed()
def _try_resolve_statement(
self, stmt: Statement, root: JsScript, at_global_scope: bool,
) -> list[Statement] | None:
"""
Resolve a statement-position reflective call to the statements it should become, or `None`. A
`Function`-constructor pack, a direct or indirect `eval`, and a `Function` body are handled by
`_resolve_reflected_call`; `execScript("code")` runs its code synchronously in the global scope
and discards the value, so at statement position it is replaced by that code inlined in place. An
`await`-ed call is not a plain call expression here, so it is left for the expression pass, which
rewrites the `eval` inside `await eval("expr")` to `await (expr)` without dropping the `await`.
"""
if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
return None
node = stmt.expression
if not isinstance(node, JsCallExpression):
return None
sync = _extract_string_call_code(
node,
SYNC_EVAL_NAMES,
alias_name=self._alias_name,
free_global_name=self._free_global,
eval_string=self._eval_string,
)
if sync is not None:
parsed = self._resolve_reflected_body(
sync, stmt, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
)
if parsed is None or _has_top_level_await(parsed.body):
return None
return parsed.body
pack_result = _try_unpack_function_constructor(
node, free_global_name=self._free_global)
if pack_result is not None:
return pack_result
if _is_pack_shaped(node, free_global_name=self._free_global):
return None
resolved = self._resolve_reflected_call(node, stmt, root, at_global_scope)
if resolved is None:
return None
return resolved[1].body
def _try_resolve_expression(self, node: JsCallExpression, root: JsScript) -> Expression | None:
resolved = self._resolve_reflected_call(node, node, root, at_global_scope=False)
if resolved is None:
return None
scope, parsed = resolved
body = parsed.body
if len(body) != 1:
return None
stmt = body[0]
if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
if isinstance(stmt, JsReturnStatement) and stmt.argument is not None:
return stmt.argument
return None
if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None:
return stmt.expression
return None
def _resolve_reflected_call(
self,
node: JsCallExpression,
site: Node,
root: JsScript,
at_global_scope: bool,
) -> tuple[ReflectedScope, JsScript] | None:
"""
Dispatch a reflective call to the safety gate for its execution scope, pairing the resolved body
with that scope or returning `None` to decline. A `Function` constructor or constructor chain is
a fresh global-scope function; a direct `eval` runs in the caller's scope; an indirect `eval`
runs in the global scope. A string timer is not inlined here: its value is a handle, not the
code's completion value, and its deferred execution is preserved instead by `_lower_timers`.
"""
read_effect = self._read_effect
alias_name = self._alias_name
free_global_name = self._free_global
resolved = self._resolved_constructor_call(node, root)
if resolved is not None:
ctor_call, retire = resolved
body = _function_constructor_body(
ctor_call, read_effect, free_global_name=free_global_name,
eval_string=self._eval_string)
if body is not None:
code, ctor_binds = body
parsed = self._resolve_reflected_body(
code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope,
binds=ctor_binds or bool(node.arguments),
)
if parsed is not None:
self._note_retirement(site, retire)
return ReflectedScope.FUNCTION_CONSTRUCTOR, parsed
return None
direct = _extract_eval_code(
node, free_global_name=free_global_name, eval_string=self._eval_string)
if direct is not None:
parsed = self._resolve_reflected_body(
direct, site, root, ReflectedScope.DIRECT_EVAL, at_global_scope,
)
return (ReflectedScope.DIRECT_EVAL, parsed) if parsed is not None else None
code = _extract_indirect_eval_code(
node, read_effect, alias_name=alias_name, free_global_name=free_global_name,
eval_string=self._eval_string)
if code is not None:
parsed = self._resolve_reflected_body(
code, site, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
)
return (ReflectedScope.GLOBAL_EVAL, parsed) if parsed is not None else None
return None
def _resolved_constructor_call(
self, node: JsCallExpression, root: JsScript,
) -> tuple[Node, Binding | None] | None:
"""
The `Function` construction that *node* invokes, paired with the single-use temporary to retire
once its sole read is inlined (or `None` to retire nothing). For the immediate forms —
`Function("code")()`, `new Function(...)()`, `(function(){}).constructor("code")()` — the
construction is `node`'s own callee. When the callee is a bare identifier, the construction is
the value the name provably holds (`SemanticModel.singular_value`, which already declines a
reassigned or dynamically rebindable binding), taken only where that value is established before
*node* (`DominanceModel.binding_established_before`) so the invocation cannot read it out of its
temporal dead zone. The body is inlined at *node*, never the construction relocated, so a
`Function` reference in the initializer keeps its original scope; retiring the dead temporary is
left to `_retire_consumed_temporaries` once every read is accounted for.
"""
callee = strip_parens(node.callee)
if isinstance(callee, (JsCallExpression, JsNewExpression)):
return callee, None
if not isinstance(callee, JsIdentifier):
return None
cache = model_cache(self, root)
binding = cache.model.resolve(callee)
value = strip_parens(cache.model.singular_value(binding))
if not isinstance(value, (JsCallExpression, JsNewExpression)):
return None
if not cache.dominance.binding_established_before(binding, node):
return None
return value, binding
def _resolve_reflected_body(
self,
code: str,
site: Node,
root: JsScript,
scope: ReflectedScope,
at_global_scope: bool,
*,
binds: bool = False,
) -> JsScript | None:
"""
Parse reflectively evaluated *code* and decide whether inlining its body at *site* preserves
meaning, given the `ReflectedScope` it runs in. Global-scope code — a `Function`-constructed
body or indirect `eval`/string-timer code — must run in the global sloppy mode it would have: a
strict context at *site* declines a body that would diverge under strict mode
(`diverges_under_strict`), as does a `"use strict"` prologue; every receiver `this` becomes
`globalThis`; and a body reading `arguments`, `super`, or `new.target`, or a free
name that no longer denotes the same global at *site* — including one a `with` on the path could
capture — declines. Direct `eval` runs in the caller's scope, which is *site* itself, so its
references and `this` are already correct there and only the checks below apply. A top-level
`return` is a SyntaxError in evaluated code, so an eval body with one declines. Declaration
handling is delegated to `_reflected_declarations_safe`. Anything not provably safe is left
intact (returns `None`) — declining is always sound.
"""
if binds:
return None
resolves_globally = scope is not ReflectedScope.DIRECT_EVAL
top_level_await = not resolves_globally and _site_in_async_function(site)
site_is_strict = strict_mode_at(site)
parsed = _try_parse(code, top_level_await=top_level_await, strict=site_is_strict)
if parsed is None:
return None
if declares_use_strict(parsed) and (resolves_globally or not site_is_strict):
return None
if resolves_globally:
rewrite_receiver_this_to_global(parsed)
if references_receiver_this(parsed) or _references_new_target(parsed):
return None
if scope is not ReflectedScope.FUNCTION_CONSTRUCTOR and _has_top_level_return(parsed.body):
return None
body_model = build_semantic_model(parsed)
if resolves_globally and site_is_strict and diverges_under_strict(parsed, body_model):
return None
free = _body_free_names(body_model, parsed)
if resolves_globally and 'arguments' in free:
return None
declared = _body_declared_names(body_model)
if not free and not declared:
return parsed
root_model = model_cache(self, root).model
site_scope = root_model.scope_of(site)
if site_scope is None:
return None
if resolves_globally and free:
if crosses_dynamic_scope(site_scope):
return None
for name in free:
binding = root_model.lookup(name, site_scope)
if binding is not None and not root_model.reaches_global_object(
binding, module_scope=module_execution(self.options),
):
return None
if declared and not self._reflected_declarations_safe(
body_model, root_model, site_scope, site, scope, at_global_scope,
):
return None
return parsed
def _reflected_declarations_safe(
self,
body_model: SemanticModel,
root_model: SemanticModel,
site_scope: Scope,
site: Node,
scope: ReflectedScope,
at_global_scope: bool,
) -> bool:
"""
Whether the top-level declarations of a reflected body can be reproduced by inlining it at the
call site. A `Function`-constructed body's declarations are local to the created function and
lift into the caller's scopes (`_inlined_declarations_safe`); evaluated code declares in its
execution scope and is handled by `_eval_declarations_safe`.
"""
if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
return _inlined_declarations_safe(body_model, root_model, site_scope)
return self._eval_declarations_safe(
body_model, root_model, site_scope, site, scope, at_global_scope,
)
def _eval_declarations_safe(
self,
body_model: SemanticModel,
root_model: SemanticModel,
site_scope: Scope,
site: Node,
scope: ReflectedScope,
at_global_scope: bool,
) -> bool:
"""
Whether an `eval` body's top-level declarations can be inlined at the call site. A
`let`/`const`/`class` lives in a declarative environment discarded when the evaluation
returns, so a persistent inlined binding differs only if a name it declares is referenced
outside the body; it is declined exactly when introducing it at the site would capture such a
reference. A `var` or function persists: under indirect eval it becomes a global-object
property, reproducible only at top-level script scope and never under the module model; under
direct eval it lands in the caller's variable scope, but never under a strict direct eval,
whose `var` stays local to the eval. Such a declaration hoists to the head of its variable
scope, so it is inlined only when the eval site strictly dominates every reference to the name
already there — one that runs before it or shares its statement, or reads the name through a
closure, would be rebound.
"""
root = root_model.root
bindings = body_model.root_scope.bindings
lexical = {name for name, binding in bindings.items() if binding.is_lexical}
if lexical and root_model.would_capture(lexical, site_scope):
return False
hoisted = {name for name, binding in bindings.items() if binding.is_hoisted}
if not hoisted:
return True
if scope is ReflectedScope.GLOBAL_EVAL:
if module_execution(self.options) or not at_global_scope:
return False
elif strict_mode_at(site) or declares_use_strict(body_model.root):
return False
var_scope = site_scope.var_scope
if var_scope is None:
return False
dominance = model_cache(self, root).dominance
return all(
dominance.strictly_dominates(site, node)
for node in name_uses_in_scope(hoisted, var_scope)
)
Classes
class ReflectedScope (*args, **kwds)-
The execution scope of reflectively evaluated code, which decides how its free names,
this, and top-level declarations must be treated when the code is inlined at its call site. AFunction-constructed function and indirecteval/string-timer code run in the global sloppy scope; a directevalruns in the caller's scope, which is the inline site itself, so its references andthisare already correct there and only its declarations need care.Expand source code Browse git
class ReflectedScope(enum.Enum): """ The execution scope of reflectively evaluated code, which decides how its free names, `this`, and top-level declarations must be treated when the code is inlined at its call site. A `Function`-constructed function and indirect `eval`/string-timer code run in the global sloppy scope; a direct `eval` runs in the caller's scope, which is the inline site itself, so its references and `this` are already correct there and only its declarations need care. """ FUNCTION_CONSTRUCTOR = enum.auto() GLOBAL_EVAL = enum.auto() DIRECT_EVAL = enum.auto()Ancestors
- enum.Enum
Class variables
var FUNCTION_CONSTRUCTOR-
The type of the None singleton.
var GLOBAL_EVAL-
The type of the None singleton.
var DIRECT_EVAL-
The type of the None singleton.
class JsReflectionInlining-
Inline reflective code execution:
eval,Functionconstructor, constructor chains, and indirect invocation viasetTimeoutandsetInterval.Expand source code Browse git
class JsReflectionInlining(ScriptLevelTransformer): """ Inline reflective code execution: `eval`, `Function` constructor, constructor chains, and indirect invocation via `setTimeout` and `setInterval`. """ _read_effect: Callable[[Node], bool] _alias_name: Callable[[Expression | None], str | None] _free_global: Callable[[Expression | None], str | None] _eval_string: Callable[[Expression | None], str | None] _pending_retire: dict[int, Binding] _retire_consumed: dict[int, int] _retire_binding: dict[int, Binding] def _process_script(self, node: JsScript) -> None: """ Inline every reflective site in the script, holding the semantic model for the whole pass. Each inline splices in code that was a string, so this pass *can* reveal facts its held model predates — `eval('Math.floor = f')` makes a write visible that no pre-inline model could see. What makes holding the model sound is the precondition rather than the absence of such reveals: this transform only ever does work on a script that has a reflective surface, and `has_reflection_surface` being true withdraws trust from every intrinsic (see `refinery.lib.scripts.js.analysis.effects.EffectModel.trusted_intrinsic`). No fold against a built-in can be admitted anywhere inside this window, so a write revealed here cannot be acted on before the pin is released and the model rebuilt. Inlining can only turn that flag off, never on, which leaves the held answer the stricter one. Should this transform ever run on a script with no reflective surface, or should that flag stop gating intrinsic trust, this argument does not hold and the pin must be reconsidered. """ with model_cache(self, node).pinned(): self._read_effect = self._dynamic_read_effect(node) self._alias_name = self._alias_member_name(node) self._free_global = self._free_global_name(node) self._eval_string = self._string_argument_value(node) self._pending_retire = {} self._retire_consumed = {} self._retire_binding = {} self._inline_statements(node) self._inline_expressions(node) self._lower_timers(node) self._retire_consumed_temporaries() def _note_retirement(self, site: Node, binding: Binding | None) -> None: """ Record that inlining the reflective call at *site* would retire the single-use temporary *binding* — the local whose sole value is the `Function` construction the call invokes. The note is provisional: it is keyed by the site and only acted on once `_confirm_retirement` sees the inlining committed, so a resolution the caller declines (a body that could not be reduced to an expression, a statement `_sanitize_inlined_body` rejects) retires nothing. """ if binding is not None: self._pending_retire[id(site)] = binding def _confirm_retirement(self, site: Node) -> None: """ Acknowledge that the inlining at *site* was committed, counting the read of its temporary that the inlining consumed. A temporary read at every site by such a committed inlining is retired by `_retire_consumed_temporaries`; one still read elsewhere is not. """ binding = self._pending_retire.pop(id(site), None) if binding is None: return self._retire_consumed[id(binding)] = self._retire_consumed.get(id(binding), 0) + 1 self._retire_binding[id(binding)] = binding def _retire_consumed_temporaries(self) -> None: """ Drop the declarator of each single-assignment temporary whose every read was a `Function` construction invocation this pass inlined. The construction is side-effect-free precisely because the inlining succeeded — `_resolve_reflected_body` parses the code and declines a body it cannot, so a construction whose body was inlined provably parses and cannot throw — which is the judgment `refinery.lib.scripts.js.analysis.effects.EffectModel` withholds from an intrinsic under a live reflection surface and only this pass, having parsed the code, can make. """ for bid, binding in self._retire_binding.items(): if self._retire_consumed.get(bid, 0) != len(binding.reads): continue if binding.exported or binding.dynamic_refs or len(binding.declarations) != 1: continue declarator = binding.declarations[0].parent if not isinstance(declarator, JsVariableDeclarator) or declarator.init is None: continue remove_declarator(declarator) self.mark_changed() def _dynamic_read_effect(self, root: JsScript) -> Callable[[Node], bool]: """ A predicate reporting whether reading a node crosses a `with` body's dynamic scope, resolved against *root*'s current model. Threaded into the reflective-inlining safety checks so a read that may fire a `with` object's getter or throw is never dropped as if it were pure. Resolved lazily through the shared cache, so a script with no reflective site builds no model. """ return lambda node: model_cache(self, root).model.read_has_dynamic_effect(node) def _alias_member_name(self, root: JsScript) -> Callable[[Expression | None], str | None]: """ A resolver reporting the intrinsic a global-object-alias member names — `window.eval` yields `'eval'`, `globalThis['setTimeout']` yields `'setTimeout'` — or `None` when the base is not the real, unshadowed global object. A local `window` (a parameter, a `var`, a `with`-object property) names an ordinary object whose member is not the reflective intrinsic and must not be inlined; the model's shadow- and dynamic-scope-aware check is the single source of that judgment. Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`. """ def resolve(callee: Expression | None) -> str | None: if callee is None: return None member = strip_parens(callee) if not isinstance(member, JsMemberExpression): return None model = model_cache(self, root).model if model.scope_of(member) is None: return None return model.global_alias_member_name( member, module_scope=module_execution(self.options)) return resolve def _free_global_name(self, root: JsScript) -> Callable[[Expression | None], str | None]: """ A resolver reporting the reflective intrinsic a bare callee identifier denotes — `eval` yields `'eval'`, `Function` yields `'Function'`, a timer or `execScript` its own name — or `None`. Only a name that could name such a callee is resolved; any other identifier is declined before any model lookup, since no caller acts on a non-reflective name. A local binding (a parameter, a `var`, a `with`-object property) of the name is an ordinary value, not the intrinsic, and must not drive an inline; the model resolves a reference to its binding for a shadow and to `None` for a free global, and `read_has_dynamic_effect` rejects a name read through a dynamic scope. Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`. """ def resolve(callee: Expression | None) -> str | None: if callee is None: return None ident = strip_parens(callee) if not isinstance(ident, JsIdentifier) or ident.name not in _REFLECTIVE_CALLEE_NAMES: return None model = model_cache(self, root).model if model.scope_of(ident) is None: return None if model.resolve(ident) is None and not model.read_has_dynamic_effect(ident): return ident.name return None return resolve def _string_argument_value(self, root: JsScript) -> Callable[[Expression | None], str | None]: """ A resolver folding an argument expression to the string it denotes — `atob('...')` to the code it decodes — or `None`. The interpreter is given *root*'s semantic model, because a call it answers from the built-in registry is the built-in only where nothing has bound that name; the effect model is not built, since none of the questions asked here are about effects. Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`. """ def resolve(node: Expression | None) -> str | None: if node is None: return None model = model_cache(self, root).model if model.scope_of(node) is None: return None return _try_eval_string_arg(node, model) return resolve def _inline_statements(self, root: JsScript) -> None: for container in list(root.walk()): body = get_body(container) if body is None: continue i = 0 while i < len(body): original = body[i] parsed = self._try_resolve_statement(original, root, container is root) if parsed is None: i += 1 continue parsed = self._sanitize_inlined_body(parsed) if parsed is None: i += 1 continue for stmt in parsed: stmt.parent = container body[i:i + 1] = parsed self._confirm_retirement(original) self.mark_changed() i += len(parsed) @staticmethod def _sanitize_inlined_body(stmts: list[Statement]) -> list[Statement] | None: """ Adapt a reflective body's statements for the statement position they replace, where the call's return value is discarded and no `return` may escape into the container. A trailing `return x` becomes the bare expression `x` (its value was already being thrown away) and a trailing valueless `return` is dropped. Any other `return` — before the last statement, or nested in the control flow of any statement (an `if`, loop, or `try`) rather than at the body's own top level — declines the inlining (`None`), since its early exit cannot be reproduced at statement position without reordering and declining is always sound. `walk_scope` finds a nested `return` without descending into a nested function, whose own `return` stays with it. This holds for every container, not only the script: a `return` spliced into a function body would return from that enclosing function, and into the script would be a syntax error. """ if not stmts: return stmts trailing = stmts[-1] if isinstance(stmts[-1], JsReturnStatement) else None for stmt in stmts[:-1] if trailing is not None else stmts: if any(isinstance(node, JsReturnStatement) for node in walk_scope(stmt)): return None if trailing is None: return stmts if trailing.argument is not None: return [*stmts[:-1], JsExpressionStatement(expression=trailing.argument)] return stmts[:-1] def _inline_expressions(self, root: JsScript) -> None: for node in list(root.walk()): if not isinstance(node, JsCallExpression): continue if isinstance(node.parent, JsExpressionStatement): continue replacement = self._try_resolve_expression(node, root) if replacement is None: continue _replace_in_parent(node, replacement) self._confirm_retirement(node) self.mark_changed() def _lower_timers(self, root: JsScript) -> None: """ Rewrite a string-argument timer — `setTimeout("code", delay)`, `setInterval`, and their `setImmediate`/global-alias variants — into a deferred function call `setTimeout(function () { code }, delay)`, so the evaluated code is deobfuscated without changing when or how often it runs. Unlike the eval and constructor paths, a timer is not inlined at the call site: its value is a handle and its execution is deferred, so only its code string is lowered. `execScript` is not a timer — it evaluates synchronously — so it is inlined in place by `_try_resolve_statement` instead of lowered here. """ for node in list(root.walk()): if isinstance(node, JsCallExpression): self._try_lower_timer(node, root) def _try_lower_timer(self, node: JsCallExpression, root: JsScript) -> None: """ Replace a string timer's code argument with a function wrapping the parsed code, when that code runs safely in the global scope the timer would give it. The wrapper is defined at the call site, so it is held to the same global-scope safety as an indirect eval — its `this` is rewritten to `globalThis`, its free names must still denote the same global, and a top-level declaration (whose global or transient environment a local function cannot reproduce) or a `return`/`await` that a plain function body cannot host declines the lowering, leaving the string timer intact. """ code = _extract_string_call_code( node, TIMER_NAMES, alias_name=self._alias_name, free_global_name=self._free_global, eval_string=self._eval_string, ) if code is None: return resolved = self._resolve_reflected_body( code, node, root, ReflectedScope.GLOBAL_EVAL, at_global_scope=False, ) if resolved is None or _has_top_level_await(resolved.body): return block = JsBlockStatement(body=resolved.body) wrapper = JsFunctionExpression(params=[], body=block) block.parent = wrapper for stmt in resolved.body: stmt.parent = block _replace_in_parent(node.arguments[0], wrapper) self.mark_changed() def _try_resolve_statement( self, stmt: Statement, root: JsScript, at_global_scope: bool, ) -> list[Statement] | None: """ Resolve a statement-position reflective call to the statements it should become, or `None`. A `Function`-constructor pack, a direct or indirect `eval`, and a `Function` body are handled by `_resolve_reflected_call`; `execScript("code")` runs its code synchronously in the global scope and discards the value, so at statement position it is replaced by that code inlined in place. An `await`-ed call is not a plain call expression here, so it is left for the expression pass, which rewrites the `eval` inside `await eval("expr")` to `await (expr)` without dropping the `await`. """ if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None: return None node = stmt.expression if not isinstance(node, JsCallExpression): return None sync = _extract_string_call_code( node, SYNC_EVAL_NAMES, alias_name=self._alias_name, free_global_name=self._free_global, eval_string=self._eval_string, ) if sync is not None: parsed = self._resolve_reflected_body( sync, stmt, root, ReflectedScope.GLOBAL_EVAL, at_global_scope, ) if parsed is None or _has_top_level_await(parsed.body): return None return parsed.body pack_result = _try_unpack_function_constructor( node, free_global_name=self._free_global) if pack_result is not None: return pack_result if _is_pack_shaped(node, free_global_name=self._free_global): return None resolved = self._resolve_reflected_call(node, stmt, root, at_global_scope) if resolved is None: return None return resolved[1].body def _try_resolve_expression(self, node: JsCallExpression, root: JsScript) -> Expression | None: resolved = self._resolve_reflected_call(node, node, root, at_global_scope=False) if resolved is None: return None scope, parsed = resolved body = parsed.body if len(body) != 1: return None stmt = body[0] if scope is ReflectedScope.FUNCTION_CONSTRUCTOR: if isinstance(stmt, JsReturnStatement) and stmt.argument is not None: return stmt.argument return None if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None: return stmt.expression return None def _resolve_reflected_call( self, node: JsCallExpression, site: Node, root: JsScript, at_global_scope: bool, ) -> tuple[ReflectedScope, JsScript] | None: """ Dispatch a reflective call to the safety gate for its execution scope, pairing the resolved body with that scope or returning `None` to decline. A `Function` constructor or constructor chain is a fresh global-scope function; a direct `eval` runs in the caller's scope; an indirect `eval` runs in the global scope. A string timer is not inlined here: its value is a handle, not the code's completion value, and its deferred execution is preserved instead by `_lower_timers`. """ read_effect = self._read_effect alias_name = self._alias_name free_global_name = self._free_global resolved = self._resolved_constructor_call(node, root) if resolved is not None: ctor_call, retire = resolved body = _function_constructor_body( ctor_call, read_effect, free_global_name=free_global_name, eval_string=self._eval_string) if body is not None: code, ctor_binds = body parsed = self._resolve_reflected_body( code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope, binds=ctor_binds or bool(node.arguments), ) if parsed is not None: self._note_retirement(site, retire) return ReflectedScope.FUNCTION_CONSTRUCTOR, parsed return None direct = _extract_eval_code( node, free_global_name=free_global_name, eval_string=self._eval_string) if direct is not None: parsed = self._resolve_reflected_body( direct, site, root, ReflectedScope.DIRECT_EVAL, at_global_scope, ) return (ReflectedScope.DIRECT_EVAL, parsed) if parsed is not None else None code = _extract_indirect_eval_code( node, read_effect, alias_name=alias_name, free_global_name=free_global_name, eval_string=self._eval_string) if code is not None: parsed = self._resolve_reflected_body( code, site, root, ReflectedScope.GLOBAL_EVAL, at_global_scope, ) return (ReflectedScope.GLOBAL_EVAL, parsed) if parsed is not None else None return None def _resolved_constructor_call( self, node: JsCallExpression, root: JsScript, ) -> tuple[Node, Binding | None] | None: """ The `Function` construction that *node* invokes, paired with the single-use temporary to retire once its sole read is inlined (or `None` to retire nothing). For the immediate forms — `Function("code")()`, `new Function(...)()`, `(function(){}).constructor("code")()` — the construction is `node`'s own callee. When the callee is a bare identifier, the construction is the value the name provably holds (`SemanticModel.singular_value`, which already declines a reassigned or dynamically rebindable binding), taken only where that value is established before *node* (`DominanceModel.binding_established_before`) so the invocation cannot read it out of its temporal dead zone. The body is inlined at *node*, never the construction relocated, so a `Function` reference in the initializer keeps its original scope; retiring the dead temporary is left to `_retire_consumed_temporaries` once every read is accounted for. """ callee = strip_parens(node.callee) if isinstance(callee, (JsCallExpression, JsNewExpression)): return callee, None if not isinstance(callee, JsIdentifier): return None cache = model_cache(self, root) binding = cache.model.resolve(callee) value = strip_parens(cache.model.singular_value(binding)) if not isinstance(value, (JsCallExpression, JsNewExpression)): return None if not cache.dominance.binding_established_before(binding, node): return None return value, binding def _resolve_reflected_body( self, code: str, site: Node, root: JsScript, scope: ReflectedScope, at_global_scope: bool, *, binds: bool = False, ) -> JsScript | None: """ Parse reflectively evaluated *code* and decide whether inlining its body at *site* preserves meaning, given the `ReflectedScope` it runs in. Global-scope code — a `Function`-constructed body or indirect `eval`/string-timer code — must run in the global sloppy mode it would have: a strict context at *site* declines a body that would diverge under strict mode (`diverges_under_strict`), as does a `"use strict"` prologue; every receiver `this` becomes `globalThis`; and a body reading `arguments`, `super`, or `new.target`, or a free name that no longer denotes the same global at *site* — including one a `with` on the path could capture — declines. Direct `eval` runs in the caller's scope, which is *site* itself, so its references and `this` are already correct there and only the checks below apply. A top-level `return` is a SyntaxError in evaluated code, so an eval body with one declines. Declaration handling is delegated to `_reflected_declarations_safe`. Anything not provably safe is left intact (returns `None`) — declining is always sound. """ if binds: return None resolves_globally = scope is not ReflectedScope.DIRECT_EVAL top_level_await = not resolves_globally and _site_in_async_function(site) site_is_strict = strict_mode_at(site) parsed = _try_parse(code, top_level_await=top_level_await, strict=site_is_strict) if parsed is None: return None if declares_use_strict(parsed) and (resolves_globally or not site_is_strict): return None if resolves_globally: rewrite_receiver_this_to_global(parsed) if references_receiver_this(parsed) or _references_new_target(parsed): return None if scope is not ReflectedScope.FUNCTION_CONSTRUCTOR and _has_top_level_return(parsed.body): return None body_model = build_semantic_model(parsed) if resolves_globally and site_is_strict and diverges_under_strict(parsed, body_model): return None free = _body_free_names(body_model, parsed) if resolves_globally and 'arguments' in free: return None declared = _body_declared_names(body_model) if not free and not declared: return parsed root_model = model_cache(self, root).model site_scope = root_model.scope_of(site) if site_scope is None: return None if resolves_globally and free: if crosses_dynamic_scope(site_scope): return None for name in free: binding = root_model.lookup(name, site_scope) if binding is not None and not root_model.reaches_global_object( binding, module_scope=module_execution(self.options), ): return None if declared and not self._reflected_declarations_safe( body_model, root_model, site_scope, site, scope, at_global_scope, ): return None return parsed def _reflected_declarations_safe( self, body_model: SemanticModel, root_model: SemanticModel, site_scope: Scope, site: Node, scope: ReflectedScope, at_global_scope: bool, ) -> bool: """ Whether the top-level declarations of a reflected body can be reproduced by inlining it at the call site. A `Function`-constructed body's declarations are local to the created function and lift into the caller's scopes (`_inlined_declarations_safe`); evaluated code declares in its execution scope and is handled by `_eval_declarations_safe`. """ if scope is ReflectedScope.FUNCTION_CONSTRUCTOR: return _inlined_declarations_safe(body_model, root_model, site_scope) return self._eval_declarations_safe( body_model, root_model, site_scope, site, scope, at_global_scope, ) def _eval_declarations_safe( self, body_model: SemanticModel, root_model: SemanticModel, site_scope: Scope, site: Node, scope: ReflectedScope, at_global_scope: bool, ) -> bool: """ Whether an `eval` body's top-level declarations can be inlined at the call site. A `let`/`const`/`class` lives in a declarative environment discarded when the evaluation returns, so a persistent inlined binding differs only if a name it declares is referenced outside the body; it is declined exactly when introducing it at the site would capture such a reference. A `var` or function persists: under indirect eval it becomes a global-object property, reproducible only at top-level script scope and never under the module model; under direct eval it lands in the caller's variable scope, but never under a strict direct eval, whose `var` stays local to the eval. Such a declaration hoists to the head of its variable scope, so it is inlined only when the eval site strictly dominates every reference to the name already there — one that runs before it or shares its statement, or reads the name through a closure, would be rebound. """ root = root_model.root bindings = body_model.root_scope.bindings lexical = {name for name, binding in bindings.items() if binding.is_lexical} if lexical and root_model.would_capture(lexical, site_scope): return False hoisted = {name for name, binding in bindings.items() if binding.is_hoisted} if not hoisted: return True if scope is ReflectedScope.GLOBAL_EVAL: if module_execution(self.options) or not at_global_scope: return False elif strict_mode_at(site) or declares_use_strict(body_model.root): return False var_scope = site_scope.var_scope if var_scope is None: return False dominance = model_cache(self, root).dominance return all( dominance.strictly_dominates(site, node) for node in name_uses_in_scope(hoisted, var_scope) )Ancestors
Inherited members