Module refinery.lib.scripts.ps1.deobfuscation.simplify

PowerShell syntax normalization transforms.

Expand source code Browse git
"""
PowerShell syntax normalization transforms.
"""
from __future__ import annotations

from refinery.lib.scripts import Node, Transformer, set_value
from refinery.lib.scripts.ps1.analysis.cache import model_cache
from refinery.lib.scripts.ps1.analysis.commands import CommandKind, Ps1CommandModel
from refinery.lib.scripts.ps1.analysis.dataflow import Ps1VariableFlow
from refinery.lib.scripts.ps1.analysis.separator import coerced_text_at
from refinery.lib.scripts.ps1.ast import get_command_name
from refinery.lib.scripts.ps1.data import (
    ALL_PARAMETER_NAMES,
    KNOWN_PS_OPERATORS,
    KNOWN_PS_SWITCHES,
    PS1_KNOWN_VARIABLES,
    SIMPLE_IDENTIFIER,
    TYPE_ARG_COMMANDS,
)
from refinery.lib.scripts.ps1.deobfuscation.helpers import (
    is_bare_command_name,
    make_string_literal,
)
from refinery.lib.scripts.ps1.deobfuscation.substitution import substitute_field, substitute_list
from refinery.lib.scripts.ps1.deobfuscation.typenames import canonical_type_name
from refinery.lib.scripts.ps1.model import (
    Ps1BinaryExpression,
    Ps1CastExpression,
    Ps1ClassDefinition,
    Ps1CommandArgument,
    Ps1CommandArgumentKind,
    Ps1CommandInvocation,
    Ps1ExpandableString,
    Ps1ExpressionStatement,
    Ps1FunctionDefinition,
    Ps1HereString,
    Ps1IntegerLiteral,
    Ps1InvokeMember,
    Ps1MemberAccess,
    Ps1ParenExpression,
    Ps1RealLiteral,
    Ps1ScopeModifier,
    Ps1Script,
    Ps1StringLiteral,
    Ps1SubExpression,
    Ps1TypeExpression,
    Ps1UnaryExpression,
    Ps1Variable,
)
from refinery.lib.scripts.ps1.token import _strip_backtick_noop
from refinery.lib.scripts.win32const import DEFAULT_ENVIRONMENT_TEMPLATE

_KNOWN_ENV_NAMES: dict[str, str] = {
    name.lower(): name for name in DEFAULT_ENVIRONMENT_TEMPLATE
}


def _has_wildcard(name: str) -> bool:
    return any(c in name for c in '*?[')


class Ps1Simplifications(Transformer):

    def __init__(self):
        super().__init__()
        self._commands: Ps1CommandModel | None = None
        self._flow: Ps1VariableFlow | None = None
        self._entry = False

    def visit(self, node: Node):
        """
        The models are captured once at the root and dropped again when the walk they were captured
        for ends, for the reason
        `refinery.lib.scripts.ps1.deobfuscation.typenames.VariableTypeAwareTransformer` states: a
        second walk over a tree the first one rewrote enters on the guarded arm and would otherwise
        be answered from the first walk's graphs.
        """
        if self._entry or not isinstance(node, Ps1Script):
            return super().visit(node)
        self._entry = True
        try:
            cache = model_cache(self, node)
            self._commands = cache.commands
            self._flow = cache.variable_flow
            return super().visit(node)
        finally:
            self._entry = False
            self._commands = None
            self._flow = None

    def visit_Ps1Variable(self, node: Ps1Variable):
        self.generic_visit(node)
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        if node.braced and SIMPLE_IDENTIFIER.match(node.name):
            set_value(node, 'braced', False)
            self.mark_changed()
        canonical = PS1_KNOWN_VARIABLES.get(node.name.lower())
        if canonical is not None and canonical != node.name:
            set_value(node, 'name', canonical)
            self.mark_changed()
        if node.scope == Ps1ScopeModifier.ENV:
            canonical = _KNOWN_ENV_NAMES.get(node.name.lower())
            if canonical is not None and canonical != node.name:
                set_value(node, 'name', canonical)
                self.mark_changed()
        return None

    def visit_Ps1FunctionDefinition(self, node: Ps1FunctionDefinition):
        self.generic_visit(node)
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        return None

    def visit_Ps1ClassDefinition(self, node: Ps1ClassDefinition):
        self.generic_visit(node)
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        return None

    def visit_Ps1ParenExpression(self, node: Ps1ParenExpression):
        self.generic_visit(node)
        inner = node.expression
        if isinstance(inner, (Ps1StringLiteral, Ps1HereString, Ps1IntegerLiteral, Ps1RealLiteral, Ps1TypeExpression)):
            return inner
        return None

    def visit_Ps1SubExpression(self, node: Ps1SubExpression):
        self.generic_visit(node)
        if isinstance(node.parent, Ps1ExpandableString):
            return None
        if len(node.body) == 1:
            stmt = node.body[0]
            if isinstance(stmt, Ps1ExpressionStatement):
                inner = stmt.expression
                if isinstance(inner, (
                    Ps1Variable,
                    Ps1StringLiteral,
                    Ps1IntegerLiteral,
                    Ps1RealLiteral,
                    Ps1TypeExpression,
                    Ps1CastExpression,
                )):
                    return inner
        return None

    def visit_Ps1ExpandableString(self, node: Ps1ExpandableString):
        """
        An expandable string every part of which is a constant, written as the plain string it
        produces.

        What a subexpression contributes is what the value it holds *renders* to and not the way it
        was written — measured, `"$(0xFF)"` is `255` and `"$([char]65)"` is `A` — which is the same
        question `refinery.lib.scripts.ps1.deobfuscation.constants` answers for a variable
        substituted into one of these. A collection contributes its elements separated by `$OFS`,
        which is why the question is asked at the string rather than of the value alone.
        """
        self.generic_visit(node)
        if self._flow is None:
            return None
        parts: list[str] = []
        for p in node.parts:
            if isinstance(p, Ps1StringLiteral):
                parts.append(p.value)
                continue
            if isinstance(p, Ps1SubExpression) and len(p.body) == 1:
                stmt = p.body[0]
                if isinstance(stmt, Ps1ExpressionStatement) and stmt.expression is not None:
                    sv = coerced_text_at(stmt.expression, node, self._flow)
                    if sv is not None:
                        parts.append(sv)
                        continue
            return None
        return make_string_literal(''.join(parts))

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self.generic_visit(node)
        self._normalize_member(node)
        return None

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self.generic_visit(node)
        self._normalize_member(node)
        return None

    def _normalize_member(self, node: Ps1MemberAccess | Ps1InvokeMember):
        if not isinstance(node.member, Ps1StringLiteral):
            return
        name = node.member.value
        if node.member.raw and node.member.raw[0] == '"' and '`' in node.member.raw:
            name = _strip_backtick_noop(node.member.raw[1:-1])
        if SIMPLE_IDENTIFIER.match(name):
            set_value(node, 'member', name)
            self.mark_changed()

    def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
        self.generic_visit(node)
        normalized = KNOWN_PS_OPERATORS.get(node.operator.lower(), node.operator)
        if normalized != node.operator:
            set_value(node, 'operator', normalized)
            self.mark_changed()
        return None

    def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
        self.generic_visit(node)
        normalized = KNOWN_PS_OPERATORS.get(node.operator.lower(), node.operator)
        if normalized != node.operator:
            set_value(node, 'operator', normalized)
            self.mark_changed()
        return None

    def visit_Ps1CommandArgument(self, node: Ps1CommandArgument):
        self.generic_visit(node)
        if node.kind in (Ps1CommandArgumentKind.SWITCH, Ps1CommandArgumentKind.NAMED):
            if '`' in node.name:
                set_value(node, 'name', _strip_backtick_noop(node.name))
                self.mark_changed()
            name_lower = node.name.lower()
            normalized = KNOWN_PS_OPERATORS.get(name_lower)
            if normalized is None:
                normalized = KNOWN_PS_SWITCHES.get(name_lower)
            if normalized is None:
                bare = name_lower.lstrip('-')
                if bare != name_lower:
                    canonical = ALL_PARAMETER_NAMES.get(bare)
                    if canonical is not None:
                        normalized = F'-{canonical}'
            if normalized is not None and normalized != node.name:
                set_value(node, 'name', normalized)
                self.mark_changed()
        return None

    def visit_Ps1TypeExpression(self, node: Ps1TypeExpression):
        self._normalize_type_field(node, 'name')
        return None

    def visit_Ps1CastExpression(self, node: Ps1CastExpression):
        self.generic_visit(node)
        self._normalize_type_field(node, 'type_name')
        return None

    def _normalize_type_field(self, node: Node, attr: str) -> None:
        spelled = getattr(node, attr)
        normalized = self._normalize_type_name(spelled)
        if normalized != spelled:
            set_value(node, attr, normalized)

    def _normalize_type_name(self, name: str) -> str:
        canonical = canonical_type_name(name)
        if canonical is not None and canonical != name:
            self.mark_changed()
            return canonical
        return name

    def _operator_is_noise(self, node: Ps1CommandInvocation) -> bool:
        """
        Whether dropping the invocation operator from `node` leaves the same program. `&` always
        does: it only forces command position. `.` does not — it runs the target in the *caller's*
        scope, so a script file or a function dot-sourced this way writes its definitions, variables
        and type-system changes here rather than into a child scope. A compiled cmdlet has no such
        body and cannot tell the two apart, so the dot may be dropped only from a name the command
        model resolves to a cmdlet — never from one a `function`/`filter`, a `function:`/`alias:`
        assignment, or an unresolved `Set-Alias` has taken over, each of which the model reports as
        something other than a cmdlet.

        The world reads the surviving dot as its evidence that off-tree code runs
        (`refinery.lib.scripts.ps1.analysis.world.runs_another_script_file`), so dropping it from
        `. helper` did not merely change scope: the world, rebuilt from the stripped tree, then read
        closed and every grant in the script fired.
        """
        if node.invocation_operator == '&':
            return True
        if self._commands is None:
            return False
        return self._commands.denotation(node).kind is CommandKind.CMDLET

    def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
        self.generic_visit(node)
        old_name = node.name
        if isinstance(node.name, Ps1ParenExpression) and node.name.expression is not None:
            inner = node.name.expression
            if isinstance(inner, Ps1StringLiteral):
                substitute_field(node, 'name', inner)
            elif isinstance(inner, Ps1CommandInvocation):
                c = get_command_name(inner)
                if c is not None and c.lower() in ('gcm', 'get-command'):
                    if len(inner.arguments) == 1:
                        arg = inner.arguments[0]
                        if isinstance(arg, Ps1CommandArgument):
                            arg = arg.value
                        if isinstance(arg, Ps1ParenExpression):
                            arg = arg.expression
                        # Only resolve a concrete command name; a wildcard pattern such as
                        # `gcm i*e-e*` must not be substituted verbatim as the command name.
                        if isinstance(arg, Ps1StringLiteral) and not _has_wildcard(arg.value):
                            substitute_field(node, 'name', arg)
        if node.name is not old_name:
            self.mark_changed()
        if node.name and isinstance(node.name, Ps1StringLiteral):
            if '`' in node.name.value:
                stripped = _strip_backtick_noop(node.name.value)
                substitute_field(node, 'name', Ps1StringLiteral(
                    offset=node.name.offset,
                    value=stripped,
                    raw=stripped,
                ))
                self.mark_changed()
            if is_bare_command_name(node.name.value) and node.name.raw != node.name.value:
                substitute_field(node, 'name', Ps1StringLiteral(
                    offset=node.name.offset,
                    value=node.name.value,
                    raw=node.name.value,
                ))
                self.mark_changed()
        if node.invocation_operator in ('&', '.'):
            if isinstance(node.name, Ps1StringLiteral):
                name_val = node.name.value
                if (
                    (SIMPLE_IDENTIFIER.match(name_val) or '-' in name_val)
                    and is_bare_command_name(name_val)
                    and not _has_wildcard(name_val)
                    and self._operator_is_noise(node)
                ):
                    substitute_field(node, 'name', Ps1StringLiteral(
                        offset=node.name.offset,
                        value=name_val,
                        raw=name_val,
                    ))
                    set_value(node, 'invocation_operator', '')
                    self.mark_changed()
        if (c := get_command_name(node)) and c.lower() in TYPE_ARG_COMMANDS:
            self._normalize_first_positional_type_arg(node)
        return None

    def _normalize_first_positional_type_arg(self, node: Ps1CommandInvocation):
        for arg in node.arguments:
            if isinstance(arg, Ps1CommandArgument):
                if arg.kind == Ps1CommandArgumentKind.NAMED:
                    if arg.name.lstrip('-').lower() == 'class' and isinstance(arg.value, Ps1StringLiteral):
                        normalized = self._normalize_type_name(arg.value.value)
                        if normalized != arg.value.value:
                            substitute_field(arg, 'value', Ps1StringLiteral(
                                offset=arg.value.offset, value=normalized, raw=normalized))
                    continue
                if arg.kind != Ps1CommandArgumentKind.POSITIONAL:
                    continue
                if isinstance(arg.value, Ps1StringLiteral):
                    normalized = self._normalize_type_name(arg.value.value)
                    if normalized != arg.value.value:
                        substitute_field(arg, 'value', Ps1StringLiteral(
                            offset=arg.value.offset, value=normalized, raw=normalized))
                return
            if isinstance(arg, Ps1StringLiteral):
                normalized = self._normalize_type_name(arg.value)
                if normalized != arg.value:
                    arguments = list(node.arguments)
                    arguments[arguments.index(arg)] = Ps1StringLiteral(
                        offset=arg.offset, value=normalized, raw=normalized)
                    substitute_list(node, 'arguments', arguments)
                return

Classes

class Ps1Simplifications

In-place tree rewriter. Each visit method may return a replacement node or None to keep the original. Tracks whether any transformation was applied via the changed flag.

When a models cache is attached by the pipeline, setting changed truthy invalidates it, so a transform that mutates the tree never leaves a stale model behind for the next consumer.

Expand source code Browse git
class Ps1Simplifications(Transformer):

    def __init__(self):
        super().__init__()
        self._commands: Ps1CommandModel | None = None
        self._flow: Ps1VariableFlow | None = None
        self._entry = False

    def visit(self, node: Node):
        """
        The models are captured once at the root and dropped again when the walk they were captured
        for ends, for the reason
        `refinery.lib.scripts.ps1.deobfuscation.typenames.VariableTypeAwareTransformer` states: a
        second walk over a tree the first one rewrote enters on the guarded arm and would otherwise
        be answered from the first walk's graphs.
        """
        if self._entry or not isinstance(node, Ps1Script):
            return super().visit(node)
        self._entry = True
        try:
            cache = model_cache(self, node)
            self._commands = cache.commands
            self._flow = cache.variable_flow
            return super().visit(node)
        finally:
            self._entry = False
            self._commands = None
            self._flow = None

    def visit_Ps1Variable(self, node: Ps1Variable):
        self.generic_visit(node)
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        if node.braced and SIMPLE_IDENTIFIER.match(node.name):
            set_value(node, 'braced', False)
            self.mark_changed()
        canonical = PS1_KNOWN_VARIABLES.get(node.name.lower())
        if canonical is not None and canonical != node.name:
            set_value(node, 'name', canonical)
            self.mark_changed()
        if node.scope == Ps1ScopeModifier.ENV:
            canonical = _KNOWN_ENV_NAMES.get(node.name.lower())
            if canonical is not None and canonical != node.name:
                set_value(node, 'name', canonical)
                self.mark_changed()
        return None

    def visit_Ps1FunctionDefinition(self, node: Ps1FunctionDefinition):
        self.generic_visit(node)
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        return None

    def visit_Ps1ClassDefinition(self, node: Ps1ClassDefinition):
        self.generic_visit(node)
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        return None

    def visit_Ps1ParenExpression(self, node: Ps1ParenExpression):
        self.generic_visit(node)
        inner = node.expression
        if isinstance(inner, (Ps1StringLiteral, Ps1HereString, Ps1IntegerLiteral, Ps1RealLiteral, Ps1TypeExpression)):
            return inner
        return None

    def visit_Ps1SubExpression(self, node: Ps1SubExpression):
        self.generic_visit(node)
        if isinstance(node.parent, Ps1ExpandableString):
            return None
        if len(node.body) == 1:
            stmt = node.body[0]
            if isinstance(stmt, Ps1ExpressionStatement):
                inner = stmt.expression
                if isinstance(inner, (
                    Ps1Variable,
                    Ps1StringLiteral,
                    Ps1IntegerLiteral,
                    Ps1RealLiteral,
                    Ps1TypeExpression,
                    Ps1CastExpression,
                )):
                    return inner
        return None

    def visit_Ps1ExpandableString(self, node: Ps1ExpandableString):
        """
        An expandable string every part of which is a constant, written as the plain string it
        produces.

        What a subexpression contributes is what the value it holds *renders* to and not the way it
        was written — measured, `"$(0xFF)"` is `255` and `"$([char]65)"` is `A` — which is the same
        question `refinery.lib.scripts.ps1.deobfuscation.constants` answers for a variable
        substituted into one of these. A collection contributes its elements separated by `$OFS`,
        which is why the question is asked at the string rather than of the value alone.
        """
        self.generic_visit(node)
        if self._flow is None:
            return None
        parts: list[str] = []
        for p in node.parts:
            if isinstance(p, Ps1StringLiteral):
                parts.append(p.value)
                continue
            if isinstance(p, Ps1SubExpression) and len(p.body) == 1:
                stmt = p.body[0]
                if isinstance(stmt, Ps1ExpressionStatement) and stmt.expression is not None:
                    sv = coerced_text_at(stmt.expression, node, self._flow)
                    if sv is not None:
                        parts.append(sv)
                        continue
            return None
        return make_string_literal(''.join(parts))

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self.generic_visit(node)
        self._normalize_member(node)
        return None

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self.generic_visit(node)
        self._normalize_member(node)
        return None

    def _normalize_member(self, node: Ps1MemberAccess | Ps1InvokeMember):
        if not isinstance(node.member, Ps1StringLiteral):
            return
        name = node.member.value
        if node.member.raw and node.member.raw[0] == '"' and '`' in node.member.raw:
            name = _strip_backtick_noop(node.member.raw[1:-1])
        if SIMPLE_IDENTIFIER.match(name):
            set_value(node, 'member', name)
            self.mark_changed()

    def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
        self.generic_visit(node)
        normalized = KNOWN_PS_OPERATORS.get(node.operator.lower(), node.operator)
        if normalized != node.operator:
            set_value(node, 'operator', normalized)
            self.mark_changed()
        return None

    def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
        self.generic_visit(node)
        normalized = KNOWN_PS_OPERATORS.get(node.operator.lower(), node.operator)
        if normalized != node.operator:
            set_value(node, 'operator', normalized)
            self.mark_changed()
        return None

    def visit_Ps1CommandArgument(self, node: Ps1CommandArgument):
        self.generic_visit(node)
        if node.kind in (Ps1CommandArgumentKind.SWITCH, Ps1CommandArgumentKind.NAMED):
            if '`' in node.name:
                set_value(node, 'name', _strip_backtick_noop(node.name))
                self.mark_changed()
            name_lower = node.name.lower()
            normalized = KNOWN_PS_OPERATORS.get(name_lower)
            if normalized is None:
                normalized = KNOWN_PS_SWITCHES.get(name_lower)
            if normalized is None:
                bare = name_lower.lstrip('-')
                if bare != name_lower:
                    canonical = ALL_PARAMETER_NAMES.get(bare)
                    if canonical is not None:
                        normalized = F'-{canonical}'
            if normalized is not None and normalized != node.name:
                set_value(node, 'name', normalized)
                self.mark_changed()
        return None

    def visit_Ps1TypeExpression(self, node: Ps1TypeExpression):
        self._normalize_type_field(node, 'name')
        return None

    def visit_Ps1CastExpression(self, node: Ps1CastExpression):
        self.generic_visit(node)
        self._normalize_type_field(node, 'type_name')
        return None

    def _normalize_type_field(self, node: Node, attr: str) -> None:
        spelled = getattr(node, attr)
        normalized = self._normalize_type_name(spelled)
        if normalized != spelled:
            set_value(node, attr, normalized)

    def _normalize_type_name(self, name: str) -> str:
        canonical = canonical_type_name(name)
        if canonical is not None and canonical != name:
            self.mark_changed()
            return canonical
        return name

    def _operator_is_noise(self, node: Ps1CommandInvocation) -> bool:
        """
        Whether dropping the invocation operator from `node` leaves the same program. `&` always
        does: it only forces command position. `.` does not — it runs the target in the *caller's*
        scope, so a script file or a function dot-sourced this way writes its definitions, variables
        and type-system changes here rather than into a child scope. A compiled cmdlet has no such
        body and cannot tell the two apart, so the dot may be dropped only from a name the command
        model resolves to a cmdlet — never from one a `function`/`filter`, a `function:`/`alias:`
        assignment, or an unresolved `Set-Alias` has taken over, each of which the model reports as
        something other than a cmdlet.

        The world reads the surviving dot as its evidence that off-tree code runs
        (`refinery.lib.scripts.ps1.analysis.world.runs_another_script_file`), so dropping it from
        `. helper` did not merely change scope: the world, rebuilt from the stripped tree, then read
        closed and every grant in the script fired.
        """
        if node.invocation_operator == '&':
            return True
        if self._commands is None:
            return False
        return self._commands.denotation(node).kind is CommandKind.CMDLET

    def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
        self.generic_visit(node)
        old_name = node.name
        if isinstance(node.name, Ps1ParenExpression) and node.name.expression is not None:
            inner = node.name.expression
            if isinstance(inner, Ps1StringLiteral):
                substitute_field(node, 'name', inner)
            elif isinstance(inner, Ps1CommandInvocation):
                c = get_command_name(inner)
                if c is not None and c.lower() in ('gcm', 'get-command'):
                    if len(inner.arguments) == 1:
                        arg = inner.arguments[0]
                        if isinstance(arg, Ps1CommandArgument):
                            arg = arg.value
                        if isinstance(arg, Ps1ParenExpression):
                            arg = arg.expression
                        # Only resolve a concrete command name; a wildcard pattern such as
                        # `gcm i*e-e*` must not be substituted verbatim as the command name.
                        if isinstance(arg, Ps1StringLiteral) and not _has_wildcard(arg.value):
                            substitute_field(node, 'name', arg)
        if node.name is not old_name:
            self.mark_changed()
        if node.name and isinstance(node.name, Ps1StringLiteral):
            if '`' in node.name.value:
                stripped = _strip_backtick_noop(node.name.value)
                substitute_field(node, 'name', Ps1StringLiteral(
                    offset=node.name.offset,
                    value=stripped,
                    raw=stripped,
                ))
                self.mark_changed()
            if is_bare_command_name(node.name.value) and node.name.raw != node.name.value:
                substitute_field(node, 'name', Ps1StringLiteral(
                    offset=node.name.offset,
                    value=node.name.value,
                    raw=node.name.value,
                ))
                self.mark_changed()
        if node.invocation_operator in ('&', '.'):
            if isinstance(node.name, Ps1StringLiteral):
                name_val = node.name.value
                if (
                    (SIMPLE_IDENTIFIER.match(name_val) or '-' in name_val)
                    and is_bare_command_name(name_val)
                    and not _has_wildcard(name_val)
                    and self._operator_is_noise(node)
                ):
                    substitute_field(node, 'name', Ps1StringLiteral(
                        offset=node.name.offset,
                        value=name_val,
                        raw=name_val,
                    ))
                    set_value(node, 'invocation_operator', '')
                    self.mark_changed()
        if (c := get_command_name(node)) and c.lower() in TYPE_ARG_COMMANDS:
            self._normalize_first_positional_type_arg(node)
        return None

    def _normalize_first_positional_type_arg(self, node: Ps1CommandInvocation):
        for arg in node.arguments:
            if isinstance(arg, Ps1CommandArgument):
                if arg.kind == Ps1CommandArgumentKind.NAMED:
                    if arg.name.lstrip('-').lower() == 'class' and isinstance(arg.value, Ps1StringLiteral):
                        normalized = self._normalize_type_name(arg.value.value)
                        if normalized != arg.value.value:
                            substitute_field(arg, 'value', Ps1StringLiteral(
                                offset=arg.value.offset, value=normalized, raw=normalized))
                    continue
                if arg.kind != Ps1CommandArgumentKind.POSITIONAL:
                    continue
                if isinstance(arg.value, Ps1StringLiteral):
                    normalized = self._normalize_type_name(arg.value.value)
                    if normalized != arg.value.value:
                        substitute_field(arg, 'value', Ps1StringLiteral(
                            offset=arg.value.offset, value=normalized, raw=normalized))
                return
            if isinstance(arg, Ps1StringLiteral):
                normalized = self._normalize_type_name(arg.value)
                if normalized != arg.value:
                    arguments = list(node.arguments)
                    arguments[arguments.index(arg)] = Ps1StringLiteral(
                        offset=arg.offset, value=normalized, raw=normalized)
                    substitute_list(node, 'arguments', arguments)
                return

Ancestors

Methods

def visit(self, node)

The models are captured once at the root and dropped again when the walk they were captured for ends, for the reason VariableTypeAwareTransformer states: a second walk over a tree the first one rewrote enters on the guarded arm and would otherwise be answered from the first walk's graphs.

Expand source code Browse git
def visit(self, node: Node):
    """
    The models are captured once at the root and dropped again when the walk they were captured
    for ends, for the reason
    `refinery.lib.scripts.ps1.deobfuscation.typenames.VariableTypeAwareTransformer` states: a
    second walk over a tree the first one rewrote enters on the guarded arm and would otherwise
    be answered from the first walk's graphs.
    """
    if self._entry or not isinstance(node, Ps1Script):
        return super().visit(node)
    self._entry = True
    try:
        cache = model_cache(self, node)
        self._commands = cache.commands
        self._flow = cache.variable_flow
        return super().visit(node)
    finally:
        self._entry = False
        self._commands = None
        self._flow = None
def visit_Ps1Variable(self, node)
Expand source code Browse git
def visit_Ps1Variable(self, node: Ps1Variable):
    self.generic_visit(node)
    if '`' in node.name:
        set_value(node, 'name', _strip_backtick_noop(node.name))
        self.mark_changed()
    if node.braced and SIMPLE_IDENTIFIER.match(node.name):
        set_value(node, 'braced', False)
        self.mark_changed()
    canonical = PS1_KNOWN_VARIABLES.get(node.name.lower())
    if canonical is not None and canonical != node.name:
        set_value(node, 'name', canonical)
        self.mark_changed()
    if node.scope == Ps1ScopeModifier.ENV:
        canonical = _KNOWN_ENV_NAMES.get(node.name.lower())
        if canonical is not None and canonical != node.name:
            set_value(node, 'name', canonical)
            self.mark_changed()
    return None
def visit_Ps1FunctionDefinition(self, node)
Expand source code Browse git
def visit_Ps1FunctionDefinition(self, node: Ps1FunctionDefinition):
    self.generic_visit(node)
    if '`' in node.name:
        set_value(node, 'name', _strip_backtick_noop(node.name))
        self.mark_changed()
    return None
def visit_Ps1ClassDefinition(self, node)
Expand source code Browse git
def visit_Ps1ClassDefinition(self, node: Ps1ClassDefinition):
    self.generic_visit(node)
    if '`' in node.name:
        set_value(node, 'name', _strip_backtick_noop(node.name))
        self.mark_changed()
    return None
def visit_Ps1ParenExpression(self, node)
Expand source code Browse git
def visit_Ps1ParenExpression(self, node: Ps1ParenExpression):
    self.generic_visit(node)
    inner = node.expression
    if isinstance(inner, (Ps1StringLiteral, Ps1HereString, Ps1IntegerLiteral, Ps1RealLiteral, Ps1TypeExpression)):
        return inner
    return None
def visit_Ps1SubExpression(self, node)
Expand source code Browse git
def visit_Ps1SubExpression(self, node: Ps1SubExpression):
    self.generic_visit(node)
    if isinstance(node.parent, Ps1ExpandableString):
        return None
    if len(node.body) == 1:
        stmt = node.body[0]
        if isinstance(stmt, Ps1ExpressionStatement):
            inner = stmt.expression
            if isinstance(inner, (
                Ps1Variable,
                Ps1StringLiteral,
                Ps1IntegerLiteral,
                Ps1RealLiteral,
                Ps1TypeExpression,
                Ps1CastExpression,
            )):
                return inner
    return None
def visit_Ps1ExpandableString(self, node)

An expandable string every part of which is a constant, written as the plain string it produces.

What a subexpression contributes is what the value it holds renders to and not the way it was written — measured, "$(0xFF)" is 255 and "$([char]65)" is A — which is the same question refinery.lib.scripts.ps1.deobfuscation.constants answers for a variable substituted into one of these. A collection contributes its elements separated by $OFS, which is why the question is asked at the string rather than of the value alone.

Expand source code Browse git
def visit_Ps1ExpandableString(self, node: Ps1ExpandableString):
    """
    An expandable string every part of which is a constant, written as the plain string it
    produces.

    What a subexpression contributes is what the value it holds *renders* to and not the way it
    was written — measured, `"$(0xFF)"` is `255` and `"$([char]65)"` is `A` — which is the same
    question `refinery.lib.scripts.ps1.deobfuscation.constants` answers for a variable
    substituted into one of these. A collection contributes its elements separated by `$OFS`,
    which is why the question is asked at the string rather than of the value alone.
    """
    self.generic_visit(node)
    if self._flow is None:
        return None
    parts: list[str] = []
    for p in node.parts:
        if isinstance(p, Ps1StringLiteral):
            parts.append(p.value)
            continue
        if isinstance(p, Ps1SubExpression) and len(p.body) == 1:
            stmt = p.body[0]
            if isinstance(stmt, Ps1ExpressionStatement) and stmt.expression is not None:
                sv = coerced_text_at(stmt.expression, node, self._flow)
                if sv is not None:
                    parts.append(sv)
                    continue
        return None
    return make_string_literal(''.join(parts))
def visit_Ps1MemberAccess(self, node)
Expand source code Browse git
def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
    self.generic_visit(node)
    self._normalize_member(node)
    return None
def visit_Ps1InvokeMember(self, node)
Expand source code Browse git
def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
    self.generic_visit(node)
    self._normalize_member(node)
    return None
def visit_Ps1BinaryExpression(self, node)
Expand source code Browse git
def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
    self.generic_visit(node)
    normalized = KNOWN_PS_OPERATORS.get(node.operator.lower(), node.operator)
    if normalized != node.operator:
        set_value(node, 'operator', normalized)
        self.mark_changed()
    return None
def visit_Ps1UnaryExpression(self, node)
Expand source code Browse git
def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
    self.generic_visit(node)
    normalized = KNOWN_PS_OPERATORS.get(node.operator.lower(), node.operator)
    if normalized != node.operator:
        set_value(node, 'operator', normalized)
        self.mark_changed()
    return None
def visit_Ps1CommandArgument(self, node)
Expand source code Browse git
def visit_Ps1CommandArgument(self, node: Ps1CommandArgument):
    self.generic_visit(node)
    if node.kind in (Ps1CommandArgumentKind.SWITCH, Ps1CommandArgumentKind.NAMED):
        if '`' in node.name:
            set_value(node, 'name', _strip_backtick_noop(node.name))
            self.mark_changed()
        name_lower = node.name.lower()
        normalized = KNOWN_PS_OPERATORS.get(name_lower)
        if normalized is None:
            normalized = KNOWN_PS_SWITCHES.get(name_lower)
        if normalized is None:
            bare = name_lower.lstrip('-')
            if bare != name_lower:
                canonical = ALL_PARAMETER_NAMES.get(bare)
                if canonical is not None:
                    normalized = F'-{canonical}'
        if normalized is not None and normalized != node.name:
            set_value(node, 'name', normalized)
            self.mark_changed()
    return None
def visit_Ps1TypeExpression(self, node)
Expand source code Browse git
def visit_Ps1TypeExpression(self, node: Ps1TypeExpression):
    self._normalize_type_field(node, 'name')
    return None
def visit_Ps1CastExpression(self, node)
Expand source code Browse git
def visit_Ps1CastExpression(self, node: Ps1CastExpression):
    self.generic_visit(node)
    self._normalize_type_field(node, 'type_name')
    return None
def visit_Ps1CommandInvocation(self, node)
Expand source code Browse git
def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
    self.generic_visit(node)
    old_name = node.name
    if isinstance(node.name, Ps1ParenExpression) and node.name.expression is not None:
        inner = node.name.expression
        if isinstance(inner, Ps1StringLiteral):
            substitute_field(node, 'name', inner)
        elif isinstance(inner, Ps1CommandInvocation):
            c = get_command_name(inner)
            if c is not None and c.lower() in ('gcm', 'get-command'):
                if len(inner.arguments) == 1:
                    arg = inner.arguments[0]
                    if isinstance(arg, Ps1CommandArgument):
                        arg = arg.value
                    if isinstance(arg, Ps1ParenExpression):
                        arg = arg.expression
                    # Only resolve a concrete command name; a wildcard pattern such as
                    # `gcm i*e-e*` must not be substituted verbatim as the command name.
                    if isinstance(arg, Ps1StringLiteral) and not _has_wildcard(arg.value):
                        substitute_field(node, 'name', arg)
    if node.name is not old_name:
        self.mark_changed()
    if node.name and isinstance(node.name, Ps1StringLiteral):
        if '`' in node.name.value:
            stripped = _strip_backtick_noop(node.name.value)
            substitute_field(node, 'name', Ps1StringLiteral(
                offset=node.name.offset,
                value=stripped,
                raw=stripped,
            ))
            self.mark_changed()
        if is_bare_command_name(node.name.value) and node.name.raw != node.name.value:
            substitute_field(node, 'name', Ps1StringLiteral(
                offset=node.name.offset,
                value=node.name.value,
                raw=node.name.value,
            ))
            self.mark_changed()
    if node.invocation_operator in ('&', '.'):
        if isinstance(node.name, Ps1StringLiteral):
            name_val = node.name.value
            if (
                (SIMPLE_IDENTIFIER.match(name_val) or '-' in name_val)
                and is_bare_command_name(name_val)
                and not _has_wildcard(name_val)
                and self._operator_is_noise(node)
            ):
                substitute_field(node, 'name', Ps1StringLiteral(
                    offset=node.name.offset,
                    value=name_val,
                    raw=name_val,
                ))
                set_value(node, 'invocation_operator', '')
                self.mark_changed()
    if (c := get_command_name(node)) and c.lower() in TYPE_ARG_COMMANDS:
        self._normalize_first_positional_type_arg(node)
    return None

Inherited members