Module refinery.lib.scripts.js.deobfuscation.singleuse

Unwrap a function the whole script exists to call once.

A one-shot wrapper is a function declaration at the script's top level whose only reference is a bare statement, at that same top level, invoking it. The body is the payload; the declaration and the invocation are the machinery. This pass moves the body to the invocation and deletes both in one edit, one wrapper per pass invocation, so the pipeline re-runs it while any wrapper remains.

Expand source code Browse git
"""
Unwrap a function the whole script exists to call once.

A one-shot wrapper is a function declaration at the script's top level whose only reference is a
bare statement, at that same top level, invoking it. The body is the payload; the declaration and
the invocation are the machinery. This pass moves the body to the invocation and deletes both in
one edit, one wrapper per pass invocation, so the pipeline re-runs it while any wrapper remains.
"""
from __future__ import annotations

from refinery.lib.scripts import set_body
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    is_direct_eval_call,
    own_arguments_binding,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    a_host_reaches_the_binding,
    body_returns_undefined,
    inlined_declarations_safe,
    nothing_still_names,
    preserve_script_end_value,
    reaches_script_completion,
    references_new_target,
    references_receiver_this,
    sanitize_inlined_body,
    walk_scope,
)
from refinery.lib.scripts.js.model import (
    JsCallExpression,
    JsExpressionStatement,
    JsFunctionDeclaration,
    JsScript,
    Statement,
)
from refinery.lib.scripts.js.options import (
    is_host_entrypoint,
    preserves_script_return,
    runs_as_module,
)
from refinery.lib.scripts.js.strict import declares_use_strict


class JsSingleUseFunctionInliner(ScriptLevelTransformer):
    """
    Move the body of a top-level function the script invokes exactly once to that invocation. A
    plain call at statement position discards the completion value, so the move keeps every
    observable the body produces; what it changes is only where the body's own bindings live, and
    the move is admitted exactly where that changes nothing a name can tell.
    """

    def _process_script(self, node: JsScript) -> None:
        """
        Unwrap at most one wrapper per pass, holding the models pinned: the loop stops at the
        first edit, so no model answer is read after the rewrite it would describe.
        """
        cache = model_cache(self, node)
        with cache.pinned():
            for statement in node.body:
                if not isinstance(statement, JsFunctionDeclaration):
                    continue
                if self._unwrap(statement, node, cache):
                    return

    def _unwrap(self, declaration: JsFunctionDeclaration, root: JsScript, cache: ModelCache) -> bool:
        """
        Whether *declaration* is a one-shot wrapper this pass unwrapped, performing the edit and
        reporting it. Every gate refuses rather than repairs: the only safe move is the one that
        changes nothing a name can tell.

        The reference gates hold the shape — the name is read exactly once, as the callee of a
        bare top-level call, written nowhere, exported nowhere, reached by no host the analyst
        named, and named by nothing outside the declaration and the invocation, so nothing
        survives the edit that could miss the function. The tampering gate holds the multiplicity
        — the one invocation executes at most once, so the moved body runs exactly as often as
        the call did. The body gates hold what a splice at statement position cannot reproduce —
        a receiver, `arguments`, `new.target`, a strict prologue, a nested `return`, or a direct
        `eval` that resolves in the wrapper's scope rather than the script's — and the arguments
        of the call must be droppable, since the call evaluates them and nothing reads them. The
        declaration gate holds the move itself: every binding the body declares lands in the
        script's own scope without capturing or colliding, no opaque surface stands anywhere
        that could re-invoke the wrapper by name once it is gone, and none of the landed names
        is one the analyst declared a host reaches — a name the script declares shadows what the
        host put there, which a binding inside the wrapper never did.
        """
        model = cache.model
        if (
            declaration.id is None
            or declaration.body is None
            or declaration.params
            or declaration.generator
            or declaration.is_async
        ):
            return False
        binding = model.binding_of(declaration.id)
        if (
            binding is None
            or binding.exported
            or binding.declarations != [declaration.id]
            or binding.scope is not model.root_scope
        ):
            return False
        invoked = self._single_top_level_invocation(binding, root)
        if invoked is None:
            return False
        call, statement = invoked
        if not nothing_still_names(model, [declaration, statement]):
            return False
        if a_host_reaches_the_binding(model, binding, self.options):
            return False
        if model.reflection_surface_sites(binding):
            return False
        if not cache.tampering.at_most_once(call):
            return False
        if not all(
            cache.effects.is_side_effect_free(
                argument, None,
                call_established=cache.call_established, discarded=True,
                reads_may_throw=True, read_established=cache.read_established,
                coercions_may_write=True,
            )
            for argument in call.arguments
        ):
            return False
        body = declaration.body
        if declares_use_strict(body):
            return False
        if references_receiver_this(body) or references_new_target(body):
            return False
        arguments_binding = own_arguments_binding(model, declaration)
        if arguments_binding is not None and (
            arguments_binding.reads
            or arguments_binding.writes
            or arguments_binding.dynamic_refs
            or arguments_binding.indefinite_writes
        ):
            return False
        if any(
            is_direct_eval_call(node)
            for node in walk_scope(declaration, include_root_body=True)
            if isinstance(node, JsCallExpression)
        ):
            return False
        wrapper_scope = model.function_scope(declaration)
        if wrapper_scope is None:
            return False
        if not inlined_declarations_safe(wrapper_scope, model, model.root_scope):
            return False
        if not runs_as_module(self.options, root) and any(
            is_host_entrypoint(self.options, name)
            for name in wrapper_scope.bindings
        ):
            return False
        statements = sanitize_inlined_body(list(body.body))
        if statements is None:
            return False
        if preserves_script_return(self.options):
            statements = preserve_script_end_value(
                statements,
                returns_undefined=body_returns_undefined(body.body),
                reaches_completion=reaches_script_completion(statement, root),
            )
        self._replace_invocation_with_body(root, declaration, statement, statements)
        self.mark_changed()
        return True

    @staticmethod
    def _single_top_level_invocation(
        binding: Binding, root: JsScript,
    ) -> tuple[JsCallExpression, JsExpressionStatement] | None:
        """
        The one call that invokes *binding*'s function — a plain call, in a bare expression
        statement of the script's own body, through the function's name read exactly once — or
        `None`. The statement position is what makes the move an identity for control flow: an
        invocation written under an `if`, inside a loop, or nested in another expression does not
        run unconditionally at that position, and a `new` expression or a read that hands the
        function elsewhere is not the call this pass replaces.
        """
        if len(binding.reads) != 1 or binding.writes:
            return None
        read = binding.reads[0]
        call = read.parent
        if not isinstance(call, JsCallExpression) or call.callee is not read:
            return None
        statement = call.parent
        if (
            not isinstance(statement, JsExpressionStatement)
            or statement.expression is not call
            or statement.parent is not root
        ):
            return None
        return call, statement

    @staticmethod
    def _replace_invocation_with_body(
        root: JsScript,
        declaration: JsFunctionDeclaration,
        statement: JsExpressionStatement,
        statements: list[Statement],
    ) -> None:
        """
        Splice *statements* where the wrapper's invocation *statement* stands and drop the
        declaration, in one body assignment, so no reader of the tree observes either edit without
        the other. The splice keeps the invocation's position, which is the moment the body already
        runs; the declaration takes no statement with it but its own, so the statements between
        the two keep their distance to the body.

        The comments the wrapper's block carried behind its last statement stand in the payload,
        not on the machinery, so they land behind the spliced statements — on the statement that
        follows them, or on the file's tail where none does — which is the position a parse of
        the spliced text would have given them.
        """
        invoked_at = root.body.index(statement)
        declared_at = root.body.index(declaration)
        kept = [
            other for other in root.body
            if other is not declaration and other is not statement
        ]
        body_lands_at = invoked_at - (1 if declared_at < invoked_at else 0)
        set_body(root, [*kept[:body_lands_at], *statements, *kept[body_lands_at:]])
        trailing = declaration.body.trailing_comments if declaration.body is not None else []
        if trailing:
            follower = body_lands_at + len(statements)
            if follower < len(root.body):
                root.body[follower].leading_comments[:0] = trailing
            else:
                root.trailing_comments[:0] = trailing

Classes

class JsSingleUseFunctionInliner

Move the body of a top-level function the script invokes exactly once to that invocation. A plain call at statement position discards the completion value, so the move keeps every observable the body produces; what it changes is only where the body's own bindings live, and the move is admitted exactly where that changes nothing a name can tell.

Expand source code Browse git
class JsSingleUseFunctionInliner(ScriptLevelTransformer):
    """
    Move the body of a top-level function the script invokes exactly once to that invocation. A
    plain call at statement position discards the completion value, so the move keeps every
    observable the body produces; what it changes is only where the body's own bindings live, and
    the move is admitted exactly where that changes nothing a name can tell.
    """

    def _process_script(self, node: JsScript) -> None:
        """
        Unwrap at most one wrapper per pass, holding the models pinned: the loop stops at the
        first edit, so no model answer is read after the rewrite it would describe.
        """
        cache = model_cache(self, node)
        with cache.pinned():
            for statement in node.body:
                if not isinstance(statement, JsFunctionDeclaration):
                    continue
                if self._unwrap(statement, node, cache):
                    return

    def _unwrap(self, declaration: JsFunctionDeclaration, root: JsScript, cache: ModelCache) -> bool:
        """
        Whether *declaration* is a one-shot wrapper this pass unwrapped, performing the edit and
        reporting it. Every gate refuses rather than repairs: the only safe move is the one that
        changes nothing a name can tell.

        The reference gates hold the shape — the name is read exactly once, as the callee of a
        bare top-level call, written nowhere, exported nowhere, reached by no host the analyst
        named, and named by nothing outside the declaration and the invocation, so nothing
        survives the edit that could miss the function. The tampering gate holds the multiplicity
        — the one invocation executes at most once, so the moved body runs exactly as often as
        the call did. The body gates hold what a splice at statement position cannot reproduce —
        a receiver, `arguments`, `new.target`, a strict prologue, a nested `return`, or a direct
        `eval` that resolves in the wrapper's scope rather than the script's — and the arguments
        of the call must be droppable, since the call evaluates them and nothing reads them. The
        declaration gate holds the move itself: every binding the body declares lands in the
        script's own scope without capturing or colliding, no opaque surface stands anywhere
        that could re-invoke the wrapper by name once it is gone, and none of the landed names
        is one the analyst declared a host reaches — a name the script declares shadows what the
        host put there, which a binding inside the wrapper never did.
        """
        model = cache.model
        if (
            declaration.id is None
            or declaration.body is None
            or declaration.params
            or declaration.generator
            or declaration.is_async
        ):
            return False
        binding = model.binding_of(declaration.id)
        if (
            binding is None
            or binding.exported
            or binding.declarations != [declaration.id]
            or binding.scope is not model.root_scope
        ):
            return False
        invoked = self._single_top_level_invocation(binding, root)
        if invoked is None:
            return False
        call, statement = invoked
        if not nothing_still_names(model, [declaration, statement]):
            return False
        if a_host_reaches_the_binding(model, binding, self.options):
            return False
        if model.reflection_surface_sites(binding):
            return False
        if not cache.tampering.at_most_once(call):
            return False
        if not all(
            cache.effects.is_side_effect_free(
                argument, None,
                call_established=cache.call_established, discarded=True,
                reads_may_throw=True, read_established=cache.read_established,
                coercions_may_write=True,
            )
            for argument in call.arguments
        ):
            return False
        body = declaration.body
        if declares_use_strict(body):
            return False
        if references_receiver_this(body) or references_new_target(body):
            return False
        arguments_binding = own_arguments_binding(model, declaration)
        if arguments_binding is not None and (
            arguments_binding.reads
            or arguments_binding.writes
            or arguments_binding.dynamic_refs
            or arguments_binding.indefinite_writes
        ):
            return False
        if any(
            is_direct_eval_call(node)
            for node in walk_scope(declaration, include_root_body=True)
            if isinstance(node, JsCallExpression)
        ):
            return False
        wrapper_scope = model.function_scope(declaration)
        if wrapper_scope is None:
            return False
        if not inlined_declarations_safe(wrapper_scope, model, model.root_scope):
            return False
        if not runs_as_module(self.options, root) and any(
            is_host_entrypoint(self.options, name)
            for name in wrapper_scope.bindings
        ):
            return False
        statements = sanitize_inlined_body(list(body.body))
        if statements is None:
            return False
        if preserves_script_return(self.options):
            statements = preserve_script_end_value(
                statements,
                returns_undefined=body_returns_undefined(body.body),
                reaches_completion=reaches_script_completion(statement, root),
            )
        self._replace_invocation_with_body(root, declaration, statement, statements)
        self.mark_changed()
        return True

    @staticmethod
    def _single_top_level_invocation(
        binding: Binding, root: JsScript,
    ) -> tuple[JsCallExpression, JsExpressionStatement] | None:
        """
        The one call that invokes *binding*'s function — a plain call, in a bare expression
        statement of the script's own body, through the function's name read exactly once — or
        `None`. The statement position is what makes the move an identity for control flow: an
        invocation written under an `if`, inside a loop, or nested in another expression does not
        run unconditionally at that position, and a `new` expression or a read that hands the
        function elsewhere is not the call this pass replaces.
        """
        if len(binding.reads) != 1 or binding.writes:
            return None
        read = binding.reads[0]
        call = read.parent
        if not isinstance(call, JsCallExpression) or call.callee is not read:
            return None
        statement = call.parent
        if (
            not isinstance(statement, JsExpressionStatement)
            or statement.expression is not call
            or statement.parent is not root
        ):
            return None
        return call, statement

    @staticmethod
    def _replace_invocation_with_body(
        root: JsScript,
        declaration: JsFunctionDeclaration,
        statement: JsExpressionStatement,
        statements: list[Statement],
    ) -> None:
        """
        Splice *statements* where the wrapper's invocation *statement* stands and drop the
        declaration, in one body assignment, so no reader of the tree observes either edit without
        the other. The splice keeps the invocation's position, which is the moment the body already
        runs; the declaration takes no statement with it but its own, so the statements between
        the two keep their distance to the body.

        The comments the wrapper's block carried behind its last statement stand in the payload,
        not on the machinery, so they land behind the spliced statements — on the statement that
        follows them, or on the file's tail where none does — which is the position a parse of
        the spliced text would have given them.
        """
        invoked_at = root.body.index(statement)
        declared_at = root.body.index(declaration)
        kept = [
            other for other in root.body
            if other is not declaration and other is not statement
        ]
        body_lands_at = invoked_at - (1 if declared_at < invoked_at else 0)
        set_body(root, [*kept[:body_lands_at], *statements, *kept[body_lands_at:]])
        trailing = declaration.body.trailing_comments if declaration.body is not None else []
        if trailing:
            follower = body_lands_at + len(statements)
            if follower < len(root.body):
                root.body[follower].leading_comments[:0] = trailing
            else:
                root.trailing_comments[:0] = trailing

Ancestors

Inherited members