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

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, Function constructor, constructor chains, and indirect invocation via setTimeout and setInterval.

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