Module refinery.lib.scripts.ps1.analysis.faults
Where a terminating error raised at a point in a PowerShell script goes.
The routing itself is already in the control-flow graph:
refinery.lib.scripts.ps1.analysis.cfg joins every statement of a guarded region to the handler
that may be offered its errors, chains catch clauses and trap sets in the order the engine
consults them, and edges to the body exit where the error gets past all of them. What is here is
the reading of that graph — which handlers a point reaches, whether the error may leave the body,
and the transpose, which points a handler is reachable from.
Three questions, and the difference between them is the whole of this module. Does an error
raised here reach a handler that acts is a property of a position, and it is what a pass asks
before it empties a guarded body. Would deleting this statement change which handler runs is a
property of a statement, and it additionally needs to know whether the statement can raise at
all — fault_is_observed() asks that one, because the
predicate that answers it lives there. Is this handler still reachable from anything that raises
is the transpose, and it is what a pass asks before deleting a trap, where the statement being
removed cannot itself raise and every error it re-routes belongs to something else.
A body boundary is where this stops. The graphs are per body, so an error that leaves a function
reaches whatever guards the call, and no graph here holds both. leaves_the_body reports that
rather than guessing, and a caller reads it as unknown wherever a caller might be guarding.
Expand source code Browse git
"""
Where a terminating error raised at a point in a PowerShell script goes.
The routing itself is already in the control-flow graph:
`refinery.lib.scripts.ps1.analysis.cfg` joins every statement of a guarded region to the handler
that may be offered its errors, chains `catch` clauses and `trap` sets in the order the engine
consults them, and edges to the body exit where the error gets past all of them. What is here is
the reading of that graph — which handlers a point reaches, whether the error may leave the body,
and the transpose, which points a handler is reachable from.
**Three questions, and the difference between them is the whole of this module.** *Does an error
raised here reach a handler that acts* is a property of a **position**, and it is what a pass asks
before it empties a guarded body. *Would deleting this statement change which handler runs* is a
property of a **statement**, and it additionally needs to know whether the statement can raise at
all — `refinery.lib.scripts.ps1.analysis.effects.fault_is_observed` asks that one, because the
predicate that answers it lives there. *Is this handler still reachable from anything that raises*
is the **transpose**, and it is what a pass asks before deleting a `trap`, where the statement being
removed cannot itself raise and every error it re-routes belongs to something else.
A body boundary is where this stops. The graphs are per body, so an error that leaves a function
reaches whatever guards the *call*, and no graph here holds both. `leaves_the_body` reports that
rather than guessing, and a caller reads it as *unknown* wherever a caller might be guarding.
"""
from __future__ import annotations
from typing import Iterator, NamedTuple
from refinery.lib.scripts import Node, tree_root
from refinery.lib.scripts.analysis.cfg import CfgNode, ControlFlowGraph, ControlFlowModel
from refinery.lib.scripts.ps1.ast import (
argument_text,
assignment_target_variables,
binding_key,
binds_parameter,
bound_argument_value,
resolve_command_name,
string_value,
)
from refinery.lib.scripts.ps1.data import COMMON_PARAMETERS
from refinery.lib.scripts.ps1.model import (
Ps1AssignmentExpression,
Ps1BreakStatement,
Ps1CatchClause,
Ps1CommandArgument,
Ps1CommandArgumentKind,
Ps1CommandInvocation,
Ps1ContinueStatement,
Ps1IntegerLiteral,
Ps1RealLiteral,
Ps1Script,
Ps1ThrowStatement,
Ps1TrapStatement,
Ps1Variable,
)
#: The names the `-ErrorAction` parameter answers to, lowercased and without the dash: the parameter
#: itself and every alias the collected command data records for it. The alias is subscripted rather
#: than defaulted, so that a collected surface which stops carrying this parameter fails the load
#: rather than silently dropping the spelling, as `refinery.lib.scripts.ps1.data` does for its own
#: derived sets.
_ERROR_ACTION_NAME = 'erroraction'
_ERROR_ACTION = frozenset((_ERROR_ACTION_NAME, *COMMON_PARAMETERS[_ERROR_ACTION_NAME]))
#: Every name a *common* parameter answers to, so that an abbreviation of one can be told from an
#: abbreviation that reaches several. A command carrying `-ErrorAction` carries the whole set of
#: them — that is what makes a command advanced — so a prefix reaching another member of this set
#: reaches it on the very commands the question is about.
_COMMON_SURFACES = frozenset(
surface
for parameter, aliases in COMMON_PARAMETERS.items()
for surface in (parameter, *aliases)
)
#: The `ActionPreference` member that makes an error terminating, and the number that member is.
#: `Stop` and `1` are the two spellings the corpus measures directly.
#:
#: Neither is read literally. The ordinal is read as a *number* rather than as a numeral, because
#: what selects the member is its value and `0x1`, `01` and `1` all denote it. The name is read by
#: prefix, because 5.1 resolves `-ErrorAction St` to `Stop`.
#:
#: An **ambiguous** member prefix is a third thing, and the reading costs recall rather than
#: correctness. `-ErrorAction S` reaches `SilentlyContinue`, `Stop` and `Suspend`, and 5.1 answers
#: it with a `ParameterBindingException` — `CannotConvertArgumentNoMessage`, measured — so the
#: command never runs at all and the error it reports instead is statement-terminating: the script
#: carries on. Reading the prefix as `Stop` keeps a handler over that, which is the safe direction
#: and is what `TestPs1AnAmbiguousActionPrefixKeepsTheTrapOverIt` records.
_STOP = 'stop'
_STOP_ORDINAL = 1
#: The automatic variable that decides what a command does with an error it reports. Set to `Stop`
#: it makes every one of them terminating — a failing cast included, which is otherwise reported
#: and stepped over.
_ERROR_ACTION_PREFERENCE = 'erroractionpreference'
class Ps1FaultRouting(NamedTuple):
"""
Where a terminating error raised at one point may go: the handlers it may be offered to, in no
particular order, and whether it may get past all of them and leave the body.
`handlers` holds `Ps1CatchClause` and `Ps1TrapStatement` nodes and is a *may* set — a type
filter is matched by inheritance at run time, so a clause that cannot be shown to miss is
reported alongside the one after it.
The two fields answer different questions and a caller reads both. A handler that acts makes the
error observable however the rest of the routing looks; `leaves_the_body` beside a `trap` is the
escalation that ends the body, and beside nothing at all it is the ordinary unhandled error that
5.1 reports and steps over.
"""
handlers: tuple[Ps1CatchClause | Ps1TrapStatement, ...]
leaves_the_body: bool
#: What the graphs place nothing for, and therefore claim nothing about. Every query answers `None`
#: for a node it cannot place, and every caller reads that as *the error may go anywhere*.
UNPLACED = None
def _binds_the_error_action(written: str) -> bool:
"""
Whether the parameter name *written* at a call site binds `-ErrorAction` and nothing else.
5.1 binds a parameter by any prefix of its name that no other parameter of the command answers
to, which `refinery.lib.scripts.ps1.ast.binds_parameter` reads. What it cannot read on its own
is the *ambiguity*, and here that is decidable: every command carrying `-ErrorAction` carries
every common parameter, so a prefix reaching a second common name reaches it on exactly the
commands this asks about. `-e`, `-er` and `-erro` all reach `-ErrorVariable` as well and are
measured not to stop; `-errora` reaches this one alone and is measured to stop.
Refusing an ambiguous prefix is also what keeps a native command out: `powershell.exe -e <b64>`
hands `-e` to a program rather than to a parameter binder, and reading it as an error action
pins every handler in the script around the commonest shape in this project's corpus.
"""
reached = {surface for surface in _COMMON_SURFACES if binds_parameter(written, surface)}
return bool(reached) and reached <= _ERROR_ACTION
def _rethrows(handler: Ps1TrapStatement) -> bool:
"""
Whether *handler* disposes of an error by re-raising it, which a `trap` spells as an unlabelled
`break` — measured: `& { trap { break }; [int]'a' }` ends the script where the same block
without the handler reports the cast and carries on.
A `break` written inside a loop of the trap's own body leaves that loop instead and is read here
as a rethrow all the same, because the statement list is read rather than the control flow. That
keeps a handler rather than dropping one, and the exact reading is the builder's
`refinery.lib.scripts.ps1.analysis.cfg` already makes for the block the `trap` is written in.
"""
body = handler.body.body if handler.body is not None else ()
return any(
isinstance(statement, Ps1BreakStatement) and statement.label is None
for statement in body
)
def _selects_stop(value: Node | None) -> bool:
"""
Whether *value* selects `Stop`, as the argument of `-ErrorAction` or as what a write to
`$ErrorActionPreference` stores. A value this cannot read is read as selecting it: the question
decides whether a handler is load bearing, and a value computed at run time may be anything.
A numeral is read for the number it denotes rather than for the text it is written as, because
what selects the member is the ordinal and `0x1`, `01` and `1` all denote it.
`refinery.lib.scripts.ps1.ast.argument_text` deliberately answers a numeral's spelling, which
is the right reading where a bare word names something and the wrong one here. A name is
matched by prefix, for the same reason a parameter name is.
"""
if isinstance(value, (Ps1IntegerLiteral, Ps1RealLiteral)):
return value.value == _STOP_ORDINAL
text = argument_text(value)
if text is None:
return True
text = text.strip().lower()
return bool(text) and (text == str(_STOP_ORDINAL) or _STOP.startswith(text))
def _stops_on_error(command: Ps1CommandInvocation) -> bool:
"""
Whether *command* carries `-ErrorAction Stop`, which turns every error it reports into one that
ends the script wherever nothing handles it.
The parameter and its argument are two arguments rather than one: 5.1 binds a bare `-Name value`
pair by position and only the `-Name:value` spelling arrives as a single named argument, so both
shapes are read here. A parameter written last with nothing after it binds no value at all,
which 5.1 rejects, and is read as `Stop` for the same reason an unreadable value is.
Every occurrence is read rather than the first, so that a name bound by a prefix 5.1 would in
fact reject cannot hide the parameter written after it — reading such a name as this one is
then the over-approximation the name set claims it is, rather than a verdict on the command. A
command carrying a splat is read as carrying the parameter for the same reason: the names it
binds are in a table this cannot see into.
"""
arguments = [
argument for argument in command.arguments
if isinstance(argument, Ps1CommandArgument)
]
for index, argument in enumerate(arguments):
value = argument.value
if isinstance(value, Ps1Variable) and value.splatted:
return True
if not _binds_the_error_action(argument.name):
continue
if argument.kind is not Ps1CommandArgumentKind.NAMED:
following = arguments[index + 1] if index + 1 < len(arguments) else None
value = None
if following is not None and following.kind is Ps1CommandArgumentKind.POSITIONAL:
value = following.value
if _selects_stop(value):
return True
return False
def _writes_stop_to_the_preference(node: Node) -> bool:
"""
Whether *node* assigns `$ErrorActionPreference` a value that may be `Stop`.
The target is read through `refinery.lib.scripts.ps1.ast.assignment_target_variables`, so that
a type-constrained, parenthesized or multi-assignment target is the same write as a bare one,
and keyed through `refinery.lib.scripts.ps1.ast.binding_key`, so that the unrelated
process-global `$env:ErrorActionPreference` is not read as this variable.
"""
if not isinstance(node, Ps1AssignmentExpression):
return False
if not any(
binding_key(variable) == _ERROR_ACTION_PREFERENCE
for variable in assignment_target_variables(node.target)
):
return False
return _selects_stop(node.value)
#: The commands that arm strict mode, in the spelling `resolve_command_name` answers with. Two
#: rather than one, because two commands write the same engine slot: `Set-StrictMode -Version`
#: writes the scope it stands in and `Set-PSDebug -Strict` writes the global scope, and 5.1
#: documents the second as the first at version 1. Neither carries an alias in the collected
#: surface, and every other way of arming either arrives as a string these are matched inside.
_STRICT_MODE_COMMANDS = frozenset({
'set-psdebug',
'set-strictmode',
})
def _arms_strict_mode(node: Node) -> bool:
"""
Whether *node* may turn a read of a variable that was never set into an error.
A command is the spelling that matters, resolved the deny-list way through
`refinery.lib.scripts.ps1.ast.resolve_command_name`, so that a module- or scope-qualified
spelling of one arms strict mode as the bare word does. The argument is not read: a
`Set-StrictMode -Off` and a `Set-PSDebug -Trace 1` are armings here like every other spelling,
and what that costs is the recall of a script that names either command for another purpose.
A string value need only *contain* a name, which is the asymmetry
`refinery.lib.scripts.ps1.analysis.worldflow._names_own_path` makes and that
`a_stop_may_be_in_force` makes beside it. `Invoke-Expression 'Set-StrictMode -Version 1'`
arms it as surely as writing the command does, and a script that spells either name anywhere is
read as arming it — the direction that refuses a removal rather than granting one.
"""
if (
isinstance(node, Ps1CommandInvocation)
and resolve_command_name(node) in _STRICT_MODE_COMMANDS
):
return True
written = string_value(node)
if written is None:
return False
written = written.lower()
return any(command in written for command in _STRICT_MODE_COMMANDS)
def _arms_strict_mode_v2(node: Node) -> bool:
"""
Whether *node* may arm strict mode at version 2 or above, under which the object adapter's
faked `Count` and `Length` on a scalar or `$null` become terminating errors rather than a
value. This is narrower than `_arms_strict_mode`: `Set-PSDebug -Strict` is documented as
`Set-StrictMode -Version 1`, where those fakes still read, so only `Set-StrictMode` can arm the
version this asks about.
The argument *is* read here, unlike in `_arms_strict_mode`, because version 1 and version 2 are
the two poles of the question. Only a `-Version` that is provably the integer `1` is read as
not arming version 2; every other spelling — a higher or non-constant version, `Latest`, `-Off`,
or no readable value — is read as arming it, the direction that refuses a fold rather than
granting one. A string value need only *contain* the command name, the way
`_arms_strict_mode` reads one, since the version inside it cannot be told apart.
"""
if isinstance(node, Ps1CommandInvocation):
if resolve_command_name(node) != 'set-strictmode':
return False
version = bound_argument_value(node, 'version')
return not (isinstance(version, Ps1IntegerLiteral) and version.value == 1)
written = string_value(node)
if written is None or 'set-strictmode' not in written.lower():
return False
parent = node.parent
if isinstance(parent, Ps1CommandInvocation) and parent.name is node:
# A bareword command name parses as a string literal too, so a genuine `Set-StrictMode`
# invocation reaches this the way a concealed one does. Its version is read on the
# invocation above; reading the version-less name here would refuse every `-Version 1`.
return False
return True
#: The automatic variables through which a `Stop` can be armed for commands that did not ask for
#: one: the preference itself, and the table that binds `-ErrorAction` into every command that takes
#: it. Spelled as names rather than as assignment shapes because `a_stop_may_be_in_force` asks
#: whether either is *touched* at all, however it is spelled.
_STOP_BEARING_NAMES = frozenset({
_ERROR_ACTION_PREFERENCE,
'psdefaultparametervalues',
})
def a_stop_may_be_in_force(root: Node) -> bool:
"""
Whether anything in *root* may make an error a command reports into a terminating one — the
strict counterpart of the whole-script question `Ps1FaultReach` asks itself, and a different
question from it.
That one reads an assignment of `Stop` to `$ErrorActionPreference`, and reads it *laxly* on
purpose: it decides whether a handler may be removed, where a missed arming keeps a handler that
could have gone and costs recall on junk. Two spellings it is known to miss are ledgered as
behaviour defects — `New-Variable ErrorActionPreference Stop -Force`, and a `-ErrorAction` entry
written into `$PSDefaultParameterValues`.
A caller asking whether a *statement completed* cannot inherit those. Reading a script as arming
nothing where it does says a command that in fact raised ran to its end, and a value it was going
to establish is then resolved through at every use below it — which rewrites calls a run never
makes. So this asks the wider question: does the script touch either name at all, in any way,
including naming one in a string that a command could set it through. What that costs is the
recall of every script that so much as mentions the preference, which is a shape worth refusing.
A variable occurrence must carry the name exactly; a string value need only *contain* one,
which is the same asymmetry `refinery.lib.scripts.ps1.analysis.worldflow._names_own_path`
makes, and for the same reason. A provider path spells the name inside a larger word, and
`Set-Item Variable:ErrorActionPreference Stop` arms it as surely as the assignment does — as
does a payload handed to `Invoke-Expression`. Matching the whole literal instead reads a script
that really arms `Stop` as arming nothing, which is the one direction that rewrites a call the
run does not make.
"""
for node in root.walk():
if isinstance(node, Ps1Variable) and binding_key(node) in _STOP_BEARING_NAMES:
return True
written = string_value(node)
if written is not None and any(
name in written.lower() for name in _STOP_BEARING_NAMES
):
return True
return False
def ends_the_script(element: Node) -> bool:
"""
Whether an error raised at *element* stops the script, rather than being reported and stepped
over, where no handler takes it.
PowerShell has two kinds of error a `trap` sees, and they are disposed of differently where
there is no `trap` — which is the whole reason this question is worth asking. A
**statement-terminating** error ends the statement it was raised in and the next statement runs:
a failing cast, a division by zero, a member access on `$null`, an exception out of a .NET
method, an unresolved command name. A **terminating** error ends the script, and only `throw`
and a command told to stop raise one. Both halves are measured.
`exit` is neither, and is deliberately absent: it ends the script by an exception no `trap`
catches, so a handler over one disposes of nothing and reading `exit` as a raise would keep a
handler no run reaches. `trap { 'T' }; exit 3` writes neither `T` nor anything else, which the
executable corpus cannot hold as a row because a snippet that exits takes the measuring host
with it.
The whole subtree is read, because a construct raises wherever its parts do:
1..2 | ForEach-Object { throw }
ends the script although the `throw` stands in a body of its own. That reaches into a `function`
definition written inside the statement as well, whose `throw` runs only once something calls
it — an over-approximation, and one that keeps a handler rather than dropping it.
A `trap` inside the subtree that re-raises is read the same way, because it *converts*: an error
a nested block would have reported and stepped over ends the script once such a handler takes
it. See `_rethrows`.
"""
for node in element.walk():
if isinstance(node, Ps1ThrowStatement):
return True
if isinstance(node, Ps1TrapStatement) and _rethrows(node):
return True
if isinstance(node, Ps1CommandInvocation) and _stops_on_error(node):
return True
return False
def handler_acts(handler: Ps1CatchClause | Ps1TrapStatement) -> bool:
"""
Whether running *handler* changes what the script does.
An empty `catch { }` swallows the error and lets execution resume after the construct, so a
script that never raises reaches the same next statement — which is why an obfuscator writes one
and why removing what it guards costs nothing. A `trap` is the same rule with one more spelling:
a body holding only an unlabelled `break` or `continue` decides how the error is disposed of and
emits nothing, while a body holding anything else — a call, an assignment, or a bare value the
engine writes to the output stream — is a handler whose running is observable.
"""
body = handler.body.body if handler.body is not None else ()
if isinstance(handler, Ps1CatchClause):
return bool(body)
for statement in body:
if isinstance(statement, (Ps1BreakStatement, Ps1ContinueStatement)):
if statement.label is not None:
return True
continue
return True
return False
def _handled_in_the_body(routing: Ps1FaultRouting) -> bool:
"""
Whether a handler of the body the error was raised in disposes of it in a way a run can see: a
`catch` or `trap` that acts, or a `trap` set the error may get past, which 5.1 answers by ending
the body rather than by reporting the error and stepping over it.
A `catch` that misses does not end the body — the sharp asymmetry between the two keywords — so
the escalation reading is keyed to the `trap` and not to the escape.
"""
if any(handler_acts(handler) for handler in routing.handlers):
return True
return routing.leaves_the_body and any(
isinstance(handler, Ps1TrapStatement) for handler in routing.handlers
)
def _escapes(routing: Ps1FaultRouting) -> bool:
"""
Whether an error routed like *routing* gets past every handler of the body it was raised in. A
handler set it cannot leave settles it there; an empty set settles nothing, which the graphs
spell as no handlers and no exceptional edge out of the body.
"""
return not routing.handlers or routing.leaves_the_body
class Ps1FaultReach:
"""
The fault routing of one script, read off its control-flow graphs.
Built over `refinery.lib.scripts.analysis.cfg.ControlFlowModel` and nothing else: where an error
goes is decided by the graph the builder already wired, so this needs no semantic model, no call
graph and no world. It is a view rather than a solver — every answer is one walk over the
exceptional edges, memoized per node for the life of the model, and the model itself is
discarded whenever the tree moves.
"""
def __init__(self, control_flow: ControlFlowModel):
self._control_flow = control_flow
self._forward: dict[int, Ps1FaultRouting] = {}
self._backward: dict[int, bool] = {}
self._handled: set[int] | None = None
self._ending: dict[int, bool] = {}
self._stopping: bool | None = None
self._strict: bool | None = None
self._strict_v2: bool | None = None
def routing_at(self, node: Node) -> Ps1FaultRouting | None:
"""
Where an error raised at *node* may go, or `None` when the graphs place *node* nowhere —
a body they do not descend into, or an expression evaluated at no point they model.
"""
located = self._control_flow.locate(node)
if located is None:
return UNPLACED
return self._routing(*located)
def points_in(self, node: Node) -> Iterator[Node]:
"""
The points inside *node* at which the graphs say something is evaluated, *node* itself
included where it is one.
Deleting a construct deletes everything it holds, so the errors it stops raising are its
body's as well as its own — and each is at a position of its own, since a `try` nested in it
routes its body's errors to its own `catch`. What the graphs place is exactly the list of
such positions, which is why it is read off them rather than guessed from the node types: a
`for` loop is three points and a body, an `if` is one point per test, and neither construct
has a node standing for the statement as a whole.
A construct that yields nothing at all is one the graphs model nowhere, and a caller reads
that as a subtree it cannot judge rather than as one that raises nothing.
"""
for inner in node.walk():
if self._control_flow.node_of(inner) is not None:
yield inner
def escapes_the_body(self, node: Node) -> bool:
"""
Whether an error raised at *node* gets past every handler written in the body *node* stands
in, so that where it goes next is decided by whatever ran that body and not by anything the
body itself says.
A position the graphs place nowhere settles nothing and escapes nothing; `False` is the
answer that leaves such a node to the position question, which reads an unplaced node as
observed.
"""
located = self._control_flow.locate(node)
if located is None:
return False
routing = self._routing(*located)
return not _handled_in_the_body(routing) and _escapes(routing)
def observed_at(self, node: Node) -> bool:
"""
Whether an error raised at *node* changes which code runs: some handler it reaches acts, a
`trap` set may decline it and end the body, or the graphs place *node* nowhere.
This is the question about the **position** and not about the statement standing there —
it answers the same for a statement that cannot raise at all, which is what a caller
weighing whether a guarded body may be emptied wants to know.
What the body itself decides is `_handled_in_the_body`; what is left over once the error
gets past it is this.
**An error that gets past a function's own handlers is the caller's**, and no graph here
holds both ends of a call. Measured: the same function whose error is reported and stepped
over when called at script scope abandons its remaining statements and runs the `catch` of a
`try` written around the *call*, however many bodies deep the raise is. So a raise in a body
something may call is refused wherever the script holds a handler that acts — and granted
where it holds none, since a `catch` that is not written cannot be the one guarding the
call.
"""
located = self._control_flow.locate(node)
if located is None:
return True
graph, start = located
return self._reading_is_observed(graph, self._routing(graph, start))
def _reading_is_observed(self, graph: ControlFlowGraph, routing: Ps1FaultRouting) -> bool:
"""
Whether an error routed like *routing* out of *graph* changes what runs. The one reading of
a routing there is, so that the position question, the arrival question `_observed_from`
asks of a fallback, and anything later that reads a routing cannot answer it three ways.
"""
if _handled_in_the_body(routing):
return True
if not _escapes(routing):
return False
return not isinstance(graph.owner, Ps1Script) and self._handled_elsewhere(graph)
def _handled_elsewhere(self, body: ControlFlowGraph) -> bool:
"""
Whether this script writes a handler that acts in some body other than *body*. What guards
a call is unknowable from the called body's graph, and this is what settles it in the
direction of a grant: a handler written in the very body that raises is one the error has
already got past, and a script with none anywhere else has no call site the raise could
matter at.
A named block is not a body of its own here, which is why the comparison is per graph: a
`trap` in `begin` and a raise in `process` share one script block, and neither guards the
other.
"""
if self._handled is None:
self._handled = {
id(graph)
for graph in self._control_flow.graphs.values()
if any(
isinstance(node.element, (Ps1CatchClause, Ps1TrapStatement))
and handler_acts(node.element)
for node in graph.nodes
)
}
return bool(self._handled - {id(body)})
def leaves_the_body(self, node: Node) -> bool:
"""
Whether an error raised at *node* may get past every handler of the body it is in, so that
where it goes next is a question about the caller. `True` for a node the graphs cannot
place, which is the same answer for the same reason.
"""
routing = self.routing_at(node)
return routing is None or routing.leaves_the_body
def removing_a_handler_is_observed(self, handler: Node) -> bool:
"""
Whether deleting *handler* may change which code runs — the transpose, and the question
asked before a `trap` is deleted.
A `trap` cannot itself raise, so the forward question answers nothing about removing one:
what changes is where the errors of *other* statements go. Two things have to be true for
that to matter, and asking only one gets it wrong in either direction.
**Something has to still reach it.** Walking the exceptional edges backwards from the
handler's own node finds exactly the statements whose errors it may be offered, and a
handler nothing reaches is one whose deletion re-routes nothing — which is how a `trap` left
behind by the removal of the only raise in its block becomes removable in turn. The walk
crosses other handlers, because a `catch` that misses hands the error on and the statement
that raised it is behind that clause rather than at it.
**What the handler itself emits is deliberately not asked here**, and it is a hole: a body
that writes runs exactly when the handler is offered an error, so `trap { 'h' }` beside a
raise puts `h` on the output stream where an unhandled error writes only its record — and
this reports it removable. Asking `handler_acts` of the handler under test closes it and
costs the injected-noise shape `refinery.lib.scripts.ps1.deobfuscation.deadcode`
deliberately drops, because `_raisers` counts every statement the block offers rather than
every statement that may raise. The two are one question and it is not settled here.
**And what it would fall back to has to act.** Deleting a handler sends its errors to
whatever the graph records as its fallback, so the question is asked again there: an empty
`catch` beside it swallows exactly as it did, and a `trap` at script scope with nothing
outside it lets an error be reported and stepped over, which is what an unhandled error
already did. A fallback the graph does not record is refused, because a handler whose
counterfactual is unknown is one whose removal cannot be judged.
**A `trap` set that may decline is load bearing for declining**, not only for handling. It
is what turns an error 5.1 would report and step over into one that ends the body, so
deleting it starts running everything written after the raise:
trap [System.IO.IOException] { }
guards nothing and is still the whole reason a script stops where it does.
"""
located = self._control_flow.locate(handler)
if located is None:
return True
graph, start = located
if start.element is not handler:
# The climb left the handler behind, so the node this would answer about stands for the
# statement around it and every question below reads the wrong element.
return True
remembered = self._backward.get(id(start))
if remembered is None:
remembered = self._backward[id(start)] = self._removal_matters(graph, start)
return remembered
def _removal_matters(self, graph: ControlFlowGraph, start: CfgNode) -> bool:
raisers = self._raisers(graph, start)
if not raisers:
return False
if self._stops_on_every_error():
return True
if any(self._ends_the_script(raiser) for raiser in raisers):
return True
element = start.element
routing = self._routing(graph, start)
if isinstance(element, Ps1TrapStatement) and routing.leaves_the_body:
return True
if any(handler_acts(handler) for handler in routing.handlers):
return True
fallback = graph.fallback_of(start)
if fallback is None:
return True
return self._observed_from(graph, fallback)
def _observed_from(self, graph: ControlFlowGraph, arrival: CfgNode) -> bool:
"""
Whether an error arriving at *arrival* changes what runs: *arrival* is a handler that acts,
or the routing onward from it is one `_reading_is_observed` calls observable — including the
body boundary, because an error that gets past *arrival* is the caller's exactly as one
raised at a position is.
"""
element = arrival.element
if isinstance(element, (Ps1CatchClause, Ps1TrapStatement)) and handler_acts(element):
return True
return self._reading_is_observed(graph, self._routing(graph, arrival))
def _routing(self, graph: ControlFlowGraph, start: CfgNode) -> Ps1FaultRouting:
"""
The routing out of *start*, remembered for the life of this model. Every reader goes through
here so that a node's closure is walked once however many questions are asked about it.
"""
remembered = self._forward.get(id(start))
if remembered is None:
remembered = self._forward[id(start)] = self._route(graph, start)
return remembered
def _route(self, graph: ControlFlowGraph, start: CfgNode) -> Ps1FaultRouting:
handlers: list[Ps1CatchClause | Ps1TrapStatement] = []
leaves = False
for node in self._exceptional_closure(graph, start, forward=True):
if node is graph.exit:
leaves = True
elif isinstance(node.element, (Ps1CatchClause, Ps1TrapStatement)):
handlers.append(node.element)
return Ps1FaultRouting(tuple(handlers), leaves)
def _raisers(self, graph: ControlFlowGraph, start: CfgNode) -> list[Node]:
"""
The statements whose errors *start* may be offered, which is the backward walk over the
exceptional edges. They are reported rather than counted, because what would become of an
error if this handler were gone is a question about the statement that raises it.
"""
return [
node.element
for node in self._exceptional_closure(graph, start, forward=False)
if node.element is not None
and not isinstance(node.element, (Ps1CatchClause, Ps1TrapStatement))
]
def _ends_the_script(self, element: Node) -> bool:
"""
`ends_the_script` for *element*, remembered for the life of this model. Every handler judged
against the same block is offered the same statements, so the subtree behind each one is
read once however many removals ask about it.
"""
remembered = self._ending.get(id(element))
if remembered is None:
remembered = self._ending[id(element)] = ends_the_script(element)
return remembered
@property
def _script(self) -> Node | None:
"""
The tree the graphs were built over, read off a graph's owner rather than taken as an
argument: a graph owner is a node of that tree, and a whole-script fact has to be asked of
the whole script rather than of the statements the graphs happen to place.
"""
for graph in self._control_flow.graphs.values():
return tree_root(graph.owner)
return None
def _stops_on_every_error(self) -> bool:
"""
Whether this script writes `Stop` to `$ErrorActionPreference` anywhere at all, which makes
every error a command reports a terminating one.
Position is deliberately not asked. The preference is a variable of the session rather than
of a block, so a write in one body governs a raise in another and a write inside a branch
governs everything that runs after it; a script that arms it at all is therefore read as
arming it throughout. That direction keeps handlers, and what it costs — junk `trap` beside
a script that arms `Stop` — is a shape an obfuscator has no reason to emit.
The tree is read rather than the graphs. What the graphs place is statements, and their
elements nest, so a walk per placed element reads an inner subtree once per level of
nesting around it and still misses what stands at no point they model — a `param` block
default among it.
"""
if self._stopping is None:
root = self._script
self._stopping = root is not None and any(
_writes_stop_to_the_preference(node) for node in root.walk()
)
return self._stopping
def strict_mode_may_be_in_force(self) -> bool:
"""
Whether this script may arm strict mode anywhere at all, which makes reading a variable that
was never set an error instead of a `$null`.
Two consumers, both reading it as the one model of whether the script arms strict mode.
`refinery.lib.scripts.ps1.analysis.effects.expression_cannot_fault` is the single place
deciding whether a bare variable read can raise before a removal site deletes it;
`refinery.lib.scripts.ps1.deobfuscation.constants.Ps1NullVariableInlining` stands its whole
pass down where this holds, because a never-assigned read it would rewrite to `$null` is a
terminating error under strict mode and the value would decide a branch the script never
reaches. Measured on 5.1: under the default semantics `$unset | ForEach-Object { [void]$_ }`
writes nothing and the script runs on, so removing it is invisible; under
`Set-StrictMode -Version 1` the same line raises a statement-terminating error that a
`catch` and a `trap` both take, so removing it is exactly what
`refinery.lib.scripts.ps1.analysis.effects.fault_is_observed` exists to refuse.
Position is not asked, and the reason is not the one `_stops_on_every_error` gives. Which
scopes an arming covers is not one rule: `Set-StrictMode` writes the scope it stands in and
`Set-PSDebug -Strict` writes the global one, so the first arms nothing outside the function
it is written in and the second arms everything that runs anywhere afterwards. Reading the
whole script is what covers both without deciding which was meant. `Set-StrictMode -Off` is
not distinguished from an arming either, since reading the argument buys back only the
recall of a script that turns strict mode off again.
**What this cannot see is an arming that is not in the script.** Strict mode is resolved by
walking the scope chain to the global scope, so a script dot-sourced from a session that
armed it — a profile, a stage-1 loader, an analyst's console — runs strict while spelling
nothing. Nothing readable says whether that happened, so the grant this feeds assumes the
entry scope runs the default semantics, the way
`refinery.lib.scripts.ps1.analysis.worldflow` assumes a leak does not re-run the statements
above it. A fragment carved out of a larger script is the case where the assumption is worth
doubting.
**The empty pole is the opposite of the sibling's, and it is why this is not a copy of it.**
`_stops_on_every_error` answers `False` where the graphs place no script, which is safe
because a missed arming there only keeps a handler. This one grants a *removal*, so a script
the graphs hold nothing of has to refuse it rather than read as running under the lax
default.
"""
if self._strict is None:
root = self._script
self._strict = root is None or any(
_arms_strict_mode(node) for node in root.walk()
)
return self._strict
def strict_mode_v2_may_be_in_force(self) -> bool:
"""
Whether this script may arm strict mode at version 2 or above, under which the `Count` and
`Length` the object adapter fakes onto a scalar or `$null` raise `PropertyNotFoundStrict`
rather than answering. Folding one of those to its value would stand a number where the
script terminated and decide a branch it never reaches, so the fold in
`refinery.lib.scripts.ps1.deobfuscation.folding` stands down where this holds and keeps the
real members — a `String`'s own `Length`, an array's own `Count` — that read on regardless.
This is the version-sensitive sibling of `strict_mode_may_be_in_force`: that one refuses a
removal wherever any strict mode is armed, because reading an unset variable raises under
version 1 too, and so treats `Set-PSDebug -Strict` and every `-Version` alike. The fake
adapter members survive version 1, so this asks the narrower question, and everything the
two share — position not asked, an arming outside the script not seen, an empty script
refusing — is shared for the reasons written there.
"""
if self._strict_v2 is None:
root = self._script
self._strict_v2 = root is None or any(
_arms_strict_mode_v2(node) for node in root.walk()
)
return self._strict_v2
@staticmethod
def _exceptional_closure(
graph: ControlFlowGraph, start: CfgNode, *, forward: bool,
) -> Iterator[CfgNode]:
"""
The nodes reachable from *start* over exceptional edges alone, in either direction, without
*start* itself. One sweep rather than a walk per edge kind: the graph marks an edge by the
pair of nodes it joins, so the direction decides only which end of the pair the walk holds.
"""
seen = {id(start)}
stack = [start]
while stack:
current = stack.pop()
adjacent = current.successors if forward else current.predecessors
for node in adjacent:
edge = (current, node) if forward else (node, current)
if not graph.is_exceptional(*edge) or id(node) in seen:
continue
seen.add(id(node))
yield node
stack.append(node)
def build_fault_reach(control_flow: ControlFlowModel) -> Ps1FaultReach:
"""
The `Ps1FaultReach` over one script's control-flow graphs.
"""
return Ps1FaultReach(control_flow)
Global variables
var UNPLACED-
What the graphs place nothing for, and therefore claim nothing about. Every query answers
Nonefor a node it cannot place, and every caller reads that as the error may go anywhere.
Functions
def a_stop_may_be_in_force(root)-
Whether anything in root may make an error a command reports into a terminating one — the strict counterpart of the whole-script question
Ps1FaultReachasks itself, and a different question from it.That one reads an assignment of
Stopto$ErrorActionPreference, and reads it laxly on purpose: it decides whether a handler may be removed, where a missed arming keeps a handler that could have gone and costs recall on junk. Two spellings it is known to miss are ledgered as behaviour defects —New-Variable ErrorActionPreference Stop -Force, and a-ErrorActionentry written into$PSDefaultParameterValues.A caller asking whether a statement completed cannot inherit those. Reading a script as arming nothing where it does says a command that in fact raised ran to its end, and a value it was going to establish is then resolved through at every use below it — which rewrites calls a run never makes. So this asks the wider question: does the script touch either name at all, in any way, including naming one in a string that a command could set it through. What that costs is the recall of every script that so much as mentions the preference, which is a shape worth refusing.
A variable occurrence must carry the name exactly; a string value need only contain one, which is the same asymmetry
refinery.lib.scripts.ps1.analysis.worldflow._names_own_pathmakes, and for the same reason. A provider path spells the name inside a larger word, andSet-Item Variable:ErrorActionPreference Stoparms it as surely as the assignment does — as does a payload handed toInvoke-Expression. Matching the whole literal instead reads a script that really armsStopas arming nothing, which is the one direction that rewrites a call the run does not make.Expand source code Browse git
def a_stop_may_be_in_force(root: Node) -> bool: """ Whether anything in *root* may make an error a command reports into a terminating one — the strict counterpart of the whole-script question `Ps1FaultReach` asks itself, and a different question from it. That one reads an assignment of `Stop` to `$ErrorActionPreference`, and reads it *laxly* on purpose: it decides whether a handler may be removed, where a missed arming keeps a handler that could have gone and costs recall on junk. Two spellings it is known to miss are ledgered as behaviour defects — `New-Variable ErrorActionPreference Stop -Force`, and a `-ErrorAction` entry written into `$PSDefaultParameterValues`. A caller asking whether a *statement completed* cannot inherit those. Reading a script as arming nothing where it does says a command that in fact raised ran to its end, and a value it was going to establish is then resolved through at every use below it — which rewrites calls a run never makes. So this asks the wider question: does the script touch either name at all, in any way, including naming one in a string that a command could set it through. What that costs is the recall of every script that so much as mentions the preference, which is a shape worth refusing. A variable occurrence must carry the name exactly; a string value need only *contain* one, which is the same asymmetry `refinery.lib.scripts.ps1.analysis.worldflow._names_own_path` makes, and for the same reason. A provider path spells the name inside a larger word, and `Set-Item Variable:ErrorActionPreference Stop` arms it as surely as the assignment does — as does a payload handed to `Invoke-Expression`. Matching the whole literal instead reads a script that really arms `Stop` as arming nothing, which is the one direction that rewrites a call the run does not make. """ for node in root.walk(): if isinstance(node, Ps1Variable) and binding_key(node) in _STOP_BEARING_NAMES: return True written = string_value(node) if written is not None and any( name in written.lower() for name in _STOP_BEARING_NAMES ): return True return False def ends_the_script(element)-
Whether an error raised at element stops the script, rather than being reported and stepped over, where no handler takes it.
PowerShell has two kinds of error a
trapsees, and they are disposed of differently where there is notrap— which is the whole reason this question is worth asking. A statement-terminating error ends the statement it was raised in and the next statement runs: a failing cast, a division by zero, a member access on$null, an exception out of a .NET method, an unresolved command name. A terminating error ends the script, and onlythrowand a command told to stop raise one. Both halves are measured.exitis neither, and is deliberately absent: it ends the script by an exception notrapcatches, so a handler over one disposes of nothing and readingexitas a raise would keep a handler no run reaches.trap { 'T' }; exit 3writes neitherTnor anything else, which the executable corpus cannot hold as a row because a snippet that exits takes the measuring host with it.The whole subtree is read, because a construct raises wherever its parts do:
1..2 | ForEach-Object { throw }ends the script although the
throwstands in a body of its own. That reaches into afunctiondefinition written inside the statement as well, whosethrowruns only once something calls it — an over-approximation, and one that keeps a handler rather than dropping it.A
trapinside the subtree that re-raises is read the same way, because it converts: an error a nested block would have reported and stepped over ends the script once such a handler takes it. See_rethrows.Expand source code Browse git
def ends_the_script(element: Node) -> bool: """ Whether an error raised at *element* stops the script, rather than being reported and stepped over, where no handler takes it. PowerShell has two kinds of error a `trap` sees, and they are disposed of differently where there is no `trap` — which is the whole reason this question is worth asking. A **statement-terminating** error ends the statement it was raised in and the next statement runs: a failing cast, a division by zero, a member access on `$null`, an exception out of a .NET method, an unresolved command name. A **terminating** error ends the script, and only `throw` and a command told to stop raise one. Both halves are measured. `exit` is neither, and is deliberately absent: it ends the script by an exception no `trap` catches, so a handler over one disposes of nothing and reading `exit` as a raise would keep a handler no run reaches. `trap { 'T' }; exit 3` writes neither `T` nor anything else, which the executable corpus cannot hold as a row because a snippet that exits takes the measuring host with it. The whole subtree is read, because a construct raises wherever its parts do: 1..2 | ForEach-Object { throw } ends the script although the `throw` stands in a body of its own. That reaches into a `function` definition written inside the statement as well, whose `throw` runs only once something calls it — an over-approximation, and one that keeps a handler rather than dropping it. A `trap` inside the subtree that re-raises is read the same way, because it *converts*: an error a nested block would have reported and stepped over ends the script once such a handler takes it. See `_rethrows`. """ for node in element.walk(): if isinstance(node, Ps1ThrowStatement): return True if isinstance(node, Ps1TrapStatement) and _rethrows(node): return True if isinstance(node, Ps1CommandInvocation) and _stops_on_error(node): return True return False def handler_acts(handler)-
Whether running handler changes what the script does.
An empty
catch { }swallows the error and lets execution resume after the construct, so a script that never raises reaches the same next statement — which is why an obfuscator writes one and why removing what it guards costs nothing. Atrapis the same rule with one more spelling: a body holding only an unlabelledbreakorcontinuedecides how the error is disposed of and emits nothing, while a body holding anything else — a call, an assignment, or a bare value the engine writes to the output stream — is a handler whose running is observable.Expand source code Browse git
def handler_acts(handler: Ps1CatchClause | Ps1TrapStatement) -> bool: """ Whether running *handler* changes what the script does. An empty `catch { }` swallows the error and lets execution resume after the construct, so a script that never raises reaches the same next statement — which is why an obfuscator writes one and why removing what it guards costs nothing. A `trap` is the same rule with one more spelling: a body holding only an unlabelled `break` or `continue` decides how the error is disposed of and emits nothing, while a body holding anything else — a call, an assignment, or a bare value the engine writes to the output stream — is a handler whose running is observable. """ body = handler.body.body if handler.body is not None else () if isinstance(handler, Ps1CatchClause): return bool(body) for statement in body: if isinstance(statement, (Ps1BreakStatement, Ps1ContinueStatement)): if statement.label is not None: return True continue return True return False def build_fault_reach(control_flow)-
The
Ps1FaultReachover one script's control-flow graphs.Expand source code Browse git
def build_fault_reach(control_flow: ControlFlowModel) -> Ps1FaultReach: """ The `Ps1FaultReach` over one script's control-flow graphs. """ return Ps1FaultReach(control_flow)
Classes
class Ps1FaultRouting (handlers, leaves_the_body)-
Where a terminating error raised at one point may go: the handlers it may be offered to, in no particular order, and whether it may get past all of them and leave the body.
handlersholdsPs1CatchClauseandPs1TrapStatementnodes and is a may set — a type filter is matched by inheritance at run time, so a clause that cannot be shown to miss is reported alongside the one after it.The two fields answer different questions and a caller reads both. A handler that acts makes the error observable however the rest of the routing looks;
leaves_the_bodybeside atrapis the escalation that ends the body, and beside nothing at all it is the ordinary unhandled error that 5.1 reports and steps over.Expand source code Browse git
class Ps1FaultRouting(NamedTuple): """ Where a terminating error raised at one point may go: the handlers it may be offered to, in no particular order, and whether it may get past all of them and leave the body. `handlers` holds `Ps1CatchClause` and `Ps1TrapStatement` nodes and is a *may* set — a type filter is matched by inheritance at run time, so a clause that cannot be shown to miss is reported alongside the one after it. The two fields answer different questions and a caller reads both. A handler that acts makes the error observable however the rest of the routing looks; `leaves_the_body` beside a `trap` is the escalation that ends the body, and beside nothing at all it is the ordinary unhandled error that 5.1 reports and steps over. """ handlers: tuple[Ps1CatchClause | Ps1TrapStatement, ...] leaves_the_body: boolAncestors
- builtins.tuple
Instance variables
var handlers-
Alias for field number 0
Expand source code Browse git
class Ps1FaultRouting(NamedTuple): """ Where a terminating error raised at one point may go: the handlers it may be offered to, in no particular order, and whether it may get past all of them and leave the body. `handlers` holds `Ps1CatchClause` and `Ps1TrapStatement` nodes and is a *may* set — a type filter is matched by inheritance at run time, so a clause that cannot be shown to miss is reported alongside the one after it. The two fields answer different questions and a caller reads both. A handler that acts makes the error observable however the rest of the routing looks; `leaves_the_body` beside a `trap` is the escalation that ends the body, and beside nothing at all it is the ordinary unhandled error that 5.1 reports and steps over. """ handlers: tuple[Ps1CatchClause | Ps1TrapStatement, ...] leaves_the_body: bool var leaves_the_body-
Alias for field number 1
Expand source code Browse git
class Ps1FaultRouting(NamedTuple): """ Where a terminating error raised at one point may go: the handlers it may be offered to, in no particular order, and whether it may get past all of them and leave the body. `handlers` holds `Ps1CatchClause` and `Ps1TrapStatement` nodes and is a *may* set — a type filter is matched by inheritance at run time, so a clause that cannot be shown to miss is reported alongside the one after it. The two fields answer different questions and a caller reads both. A handler that acts makes the error observable however the rest of the routing looks; `leaves_the_body` beside a `trap` is the escalation that ends the body, and beside nothing at all it is the ordinary unhandled error that 5.1 reports and steps over. """ handlers: tuple[Ps1CatchClause | Ps1TrapStatement, ...] leaves_the_body: bool
class Ps1FaultReach (control_flow)-
The fault routing of one script, read off its control-flow graphs.
Built over
ControlFlowModeland nothing else: where an error goes is decided by the graph the builder already wired, so this needs no semantic model, no call graph and no world. It is a view rather than a solver — every answer is one walk over the exceptional edges, memoized per node for the life of the model, and the model itself is discarded whenever the tree moves.Expand source code Browse git
class Ps1FaultReach: """ The fault routing of one script, read off its control-flow graphs. Built over `refinery.lib.scripts.analysis.cfg.ControlFlowModel` and nothing else: where an error goes is decided by the graph the builder already wired, so this needs no semantic model, no call graph and no world. It is a view rather than a solver — every answer is one walk over the exceptional edges, memoized per node for the life of the model, and the model itself is discarded whenever the tree moves. """ def __init__(self, control_flow: ControlFlowModel): self._control_flow = control_flow self._forward: dict[int, Ps1FaultRouting] = {} self._backward: dict[int, bool] = {} self._handled: set[int] | None = None self._ending: dict[int, bool] = {} self._stopping: bool | None = None self._strict: bool | None = None self._strict_v2: bool | None = None def routing_at(self, node: Node) -> Ps1FaultRouting | None: """ Where an error raised at *node* may go, or `None` when the graphs place *node* nowhere — a body they do not descend into, or an expression evaluated at no point they model. """ located = self._control_flow.locate(node) if located is None: return UNPLACED return self._routing(*located) def points_in(self, node: Node) -> Iterator[Node]: """ The points inside *node* at which the graphs say something is evaluated, *node* itself included where it is one. Deleting a construct deletes everything it holds, so the errors it stops raising are its body's as well as its own — and each is at a position of its own, since a `try` nested in it routes its body's errors to its own `catch`. What the graphs place is exactly the list of such positions, which is why it is read off them rather than guessed from the node types: a `for` loop is three points and a body, an `if` is one point per test, and neither construct has a node standing for the statement as a whole. A construct that yields nothing at all is one the graphs model nowhere, and a caller reads that as a subtree it cannot judge rather than as one that raises nothing. """ for inner in node.walk(): if self._control_flow.node_of(inner) is not None: yield inner def escapes_the_body(self, node: Node) -> bool: """ Whether an error raised at *node* gets past every handler written in the body *node* stands in, so that where it goes next is decided by whatever ran that body and not by anything the body itself says. A position the graphs place nowhere settles nothing and escapes nothing; `False` is the answer that leaves such a node to the position question, which reads an unplaced node as observed. """ located = self._control_flow.locate(node) if located is None: return False routing = self._routing(*located) return not _handled_in_the_body(routing) and _escapes(routing) def observed_at(self, node: Node) -> bool: """ Whether an error raised at *node* changes which code runs: some handler it reaches acts, a `trap` set may decline it and end the body, or the graphs place *node* nowhere. This is the question about the **position** and not about the statement standing there — it answers the same for a statement that cannot raise at all, which is what a caller weighing whether a guarded body may be emptied wants to know. What the body itself decides is `_handled_in_the_body`; what is left over once the error gets past it is this. **An error that gets past a function's own handlers is the caller's**, and no graph here holds both ends of a call. Measured: the same function whose error is reported and stepped over when called at script scope abandons its remaining statements and runs the `catch` of a `try` written around the *call*, however many bodies deep the raise is. So a raise in a body something may call is refused wherever the script holds a handler that acts — and granted where it holds none, since a `catch` that is not written cannot be the one guarding the call. """ located = self._control_flow.locate(node) if located is None: return True graph, start = located return self._reading_is_observed(graph, self._routing(graph, start)) def _reading_is_observed(self, graph: ControlFlowGraph, routing: Ps1FaultRouting) -> bool: """ Whether an error routed like *routing* out of *graph* changes what runs. The one reading of a routing there is, so that the position question, the arrival question `_observed_from` asks of a fallback, and anything later that reads a routing cannot answer it three ways. """ if _handled_in_the_body(routing): return True if not _escapes(routing): return False return not isinstance(graph.owner, Ps1Script) and self._handled_elsewhere(graph) def _handled_elsewhere(self, body: ControlFlowGraph) -> bool: """ Whether this script writes a handler that acts in some body other than *body*. What guards a call is unknowable from the called body's graph, and this is what settles it in the direction of a grant: a handler written in the very body that raises is one the error has already got past, and a script with none anywhere else has no call site the raise could matter at. A named block is not a body of its own here, which is why the comparison is per graph: a `trap` in `begin` and a raise in `process` share one script block, and neither guards the other. """ if self._handled is None: self._handled = { id(graph) for graph in self._control_flow.graphs.values() if any( isinstance(node.element, (Ps1CatchClause, Ps1TrapStatement)) and handler_acts(node.element) for node in graph.nodes ) } return bool(self._handled - {id(body)}) def leaves_the_body(self, node: Node) -> bool: """ Whether an error raised at *node* may get past every handler of the body it is in, so that where it goes next is a question about the caller. `True` for a node the graphs cannot place, which is the same answer for the same reason. """ routing = self.routing_at(node) return routing is None or routing.leaves_the_body def removing_a_handler_is_observed(self, handler: Node) -> bool: """ Whether deleting *handler* may change which code runs — the transpose, and the question asked before a `trap` is deleted. A `trap` cannot itself raise, so the forward question answers nothing about removing one: what changes is where the errors of *other* statements go. Two things have to be true for that to matter, and asking only one gets it wrong in either direction. **Something has to still reach it.** Walking the exceptional edges backwards from the handler's own node finds exactly the statements whose errors it may be offered, and a handler nothing reaches is one whose deletion re-routes nothing — which is how a `trap` left behind by the removal of the only raise in its block becomes removable in turn. The walk crosses other handlers, because a `catch` that misses hands the error on and the statement that raised it is behind that clause rather than at it. **What the handler itself emits is deliberately not asked here**, and it is a hole: a body that writes runs exactly when the handler is offered an error, so `trap { 'h' }` beside a raise puts `h` on the output stream where an unhandled error writes only its record — and this reports it removable. Asking `handler_acts` of the handler under test closes it and costs the injected-noise shape `refinery.lib.scripts.ps1.deobfuscation.deadcode` deliberately drops, because `_raisers` counts every statement the block offers rather than every statement that may raise. The two are one question and it is not settled here. **And what it would fall back to has to act.** Deleting a handler sends its errors to whatever the graph records as its fallback, so the question is asked again there: an empty `catch` beside it swallows exactly as it did, and a `trap` at script scope with nothing outside it lets an error be reported and stepped over, which is what an unhandled error already did. A fallback the graph does not record is refused, because a handler whose counterfactual is unknown is one whose removal cannot be judged. **A `trap` set that may decline is load bearing for declining**, not only for handling. It is what turns an error 5.1 would report and step over into one that ends the body, so deleting it starts running everything written after the raise: trap [System.IO.IOException] { } guards nothing and is still the whole reason a script stops where it does. """ located = self._control_flow.locate(handler) if located is None: return True graph, start = located if start.element is not handler: # The climb left the handler behind, so the node this would answer about stands for the # statement around it and every question below reads the wrong element. return True remembered = self._backward.get(id(start)) if remembered is None: remembered = self._backward[id(start)] = self._removal_matters(graph, start) return remembered def _removal_matters(self, graph: ControlFlowGraph, start: CfgNode) -> bool: raisers = self._raisers(graph, start) if not raisers: return False if self._stops_on_every_error(): return True if any(self._ends_the_script(raiser) for raiser in raisers): return True element = start.element routing = self._routing(graph, start) if isinstance(element, Ps1TrapStatement) and routing.leaves_the_body: return True if any(handler_acts(handler) for handler in routing.handlers): return True fallback = graph.fallback_of(start) if fallback is None: return True return self._observed_from(graph, fallback) def _observed_from(self, graph: ControlFlowGraph, arrival: CfgNode) -> bool: """ Whether an error arriving at *arrival* changes what runs: *arrival* is a handler that acts, or the routing onward from it is one `_reading_is_observed` calls observable — including the body boundary, because an error that gets past *arrival* is the caller's exactly as one raised at a position is. """ element = arrival.element if isinstance(element, (Ps1CatchClause, Ps1TrapStatement)) and handler_acts(element): return True return self._reading_is_observed(graph, self._routing(graph, arrival)) def _routing(self, graph: ControlFlowGraph, start: CfgNode) -> Ps1FaultRouting: """ The routing out of *start*, remembered for the life of this model. Every reader goes through here so that a node's closure is walked once however many questions are asked about it. """ remembered = self._forward.get(id(start)) if remembered is None: remembered = self._forward[id(start)] = self._route(graph, start) return remembered def _route(self, graph: ControlFlowGraph, start: CfgNode) -> Ps1FaultRouting: handlers: list[Ps1CatchClause | Ps1TrapStatement] = [] leaves = False for node in self._exceptional_closure(graph, start, forward=True): if node is graph.exit: leaves = True elif isinstance(node.element, (Ps1CatchClause, Ps1TrapStatement)): handlers.append(node.element) return Ps1FaultRouting(tuple(handlers), leaves) def _raisers(self, graph: ControlFlowGraph, start: CfgNode) -> list[Node]: """ The statements whose errors *start* may be offered, which is the backward walk over the exceptional edges. They are reported rather than counted, because what would become of an error if this handler were gone is a question about the statement that raises it. """ return [ node.element for node in self._exceptional_closure(graph, start, forward=False) if node.element is not None and not isinstance(node.element, (Ps1CatchClause, Ps1TrapStatement)) ] def _ends_the_script(self, element: Node) -> bool: """ `ends_the_script` for *element*, remembered for the life of this model. Every handler judged against the same block is offered the same statements, so the subtree behind each one is read once however many removals ask about it. """ remembered = self._ending.get(id(element)) if remembered is None: remembered = self._ending[id(element)] = ends_the_script(element) return remembered @property def _script(self) -> Node | None: """ The tree the graphs were built over, read off a graph's owner rather than taken as an argument: a graph owner is a node of that tree, and a whole-script fact has to be asked of the whole script rather than of the statements the graphs happen to place. """ for graph in self._control_flow.graphs.values(): return tree_root(graph.owner) return None def _stops_on_every_error(self) -> bool: """ Whether this script writes `Stop` to `$ErrorActionPreference` anywhere at all, which makes every error a command reports a terminating one. Position is deliberately not asked. The preference is a variable of the session rather than of a block, so a write in one body governs a raise in another and a write inside a branch governs everything that runs after it; a script that arms it at all is therefore read as arming it throughout. That direction keeps handlers, and what it costs — junk `trap` beside a script that arms `Stop` — is a shape an obfuscator has no reason to emit. The tree is read rather than the graphs. What the graphs place is statements, and their elements nest, so a walk per placed element reads an inner subtree once per level of nesting around it and still misses what stands at no point they model — a `param` block default among it. """ if self._stopping is None: root = self._script self._stopping = root is not None and any( _writes_stop_to_the_preference(node) for node in root.walk() ) return self._stopping def strict_mode_may_be_in_force(self) -> bool: """ Whether this script may arm strict mode anywhere at all, which makes reading a variable that was never set an error instead of a `$null`. Two consumers, both reading it as the one model of whether the script arms strict mode. `refinery.lib.scripts.ps1.analysis.effects.expression_cannot_fault` is the single place deciding whether a bare variable read can raise before a removal site deletes it; `refinery.lib.scripts.ps1.deobfuscation.constants.Ps1NullVariableInlining` stands its whole pass down where this holds, because a never-assigned read it would rewrite to `$null` is a terminating error under strict mode and the value would decide a branch the script never reaches. Measured on 5.1: under the default semantics `$unset | ForEach-Object { [void]$_ }` writes nothing and the script runs on, so removing it is invisible; under `Set-StrictMode -Version 1` the same line raises a statement-terminating error that a `catch` and a `trap` both take, so removing it is exactly what `refinery.lib.scripts.ps1.analysis.effects.fault_is_observed` exists to refuse. Position is not asked, and the reason is not the one `_stops_on_every_error` gives. Which scopes an arming covers is not one rule: `Set-StrictMode` writes the scope it stands in and `Set-PSDebug -Strict` writes the global one, so the first arms nothing outside the function it is written in and the second arms everything that runs anywhere afterwards. Reading the whole script is what covers both without deciding which was meant. `Set-StrictMode -Off` is not distinguished from an arming either, since reading the argument buys back only the recall of a script that turns strict mode off again. **What this cannot see is an arming that is not in the script.** Strict mode is resolved by walking the scope chain to the global scope, so a script dot-sourced from a session that armed it — a profile, a stage-1 loader, an analyst's console — runs strict while spelling nothing. Nothing readable says whether that happened, so the grant this feeds assumes the entry scope runs the default semantics, the way `refinery.lib.scripts.ps1.analysis.worldflow` assumes a leak does not re-run the statements above it. A fragment carved out of a larger script is the case where the assumption is worth doubting. **The empty pole is the opposite of the sibling's, and it is why this is not a copy of it.** `_stops_on_every_error` answers `False` where the graphs place no script, which is safe because a missed arming there only keeps a handler. This one grants a *removal*, so a script the graphs hold nothing of has to refuse it rather than read as running under the lax default. """ if self._strict is None: root = self._script self._strict = root is None or any( _arms_strict_mode(node) for node in root.walk() ) return self._strict def strict_mode_v2_may_be_in_force(self) -> bool: """ Whether this script may arm strict mode at version 2 or above, under which the `Count` and `Length` the object adapter fakes onto a scalar or `$null` raise `PropertyNotFoundStrict` rather than answering. Folding one of those to its value would stand a number where the script terminated and decide a branch it never reaches, so the fold in `refinery.lib.scripts.ps1.deobfuscation.folding` stands down where this holds and keeps the real members — a `String`'s own `Length`, an array's own `Count` — that read on regardless. This is the version-sensitive sibling of `strict_mode_may_be_in_force`: that one refuses a removal wherever any strict mode is armed, because reading an unset variable raises under version 1 too, and so treats `Set-PSDebug -Strict` and every `-Version` alike. The fake adapter members survive version 1, so this asks the narrower question, and everything the two share — position not asked, an arming outside the script not seen, an empty script refusing — is shared for the reasons written there. """ if self._strict_v2 is None: root = self._script self._strict_v2 = root is None or any( _arms_strict_mode_v2(node) for node in root.walk() ) return self._strict_v2 @staticmethod def _exceptional_closure( graph: ControlFlowGraph, start: CfgNode, *, forward: bool, ) -> Iterator[CfgNode]: """ The nodes reachable from *start* over exceptional edges alone, in either direction, without *start* itself. One sweep rather than a walk per edge kind: the graph marks an edge by the pair of nodes it joins, so the direction decides only which end of the pair the walk holds. """ seen = {id(start)} stack = [start] while stack: current = stack.pop() adjacent = current.successors if forward else current.predecessors for node in adjacent: edge = (current, node) if forward else (node, current) if not graph.is_exceptional(*edge) or id(node) in seen: continue seen.add(id(node)) yield node stack.append(node)Methods
def routing_at(self, node)-
Where an error raised at node may go, or
Nonewhen the graphs place node nowhere — a body they do not descend into, or an expression evaluated at no point they model.Expand source code Browse git
def routing_at(self, node: Node) -> Ps1FaultRouting | None: """ Where an error raised at *node* may go, or `None` when the graphs place *node* nowhere — a body they do not descend into, or an expression evaluated at no point they model. """ located = self._control_flow.locate(node) if located is None: return UNPLACED return self._routing(*located) def points_in(self, node)-
The points inside node at which the graphs say something is evaluated, node itself included where it is one.
Deleting a construct deletes everything it holds, so the errors it stops raising are its body's as well as its own — and each is at a position of its own, since a
trynested in it routes its body's errors to its owncatch. What the graphs place is exactly the list of such positions, which is why it is read off them rather than guessed from the node types: aforloop is three points and a body, anifis one point per test, and neither construct has a node standing for the statement as a whole.A construct that yields nothing at all is one the graphs model nowhere, and a caller reads that as a subtree it cannot judge rather than as one that raises nothing.
Expand source code Browse git
def points_in(self, node: Node) -> Iterator[Node]: """ The points inside *node* at which the graphs say something is evaluated, *node* itself included where it is one. Deleting a construct deletes everything it holds, so the errors it stops raising are its body's as well as its own — and each is at a position of its own, since a `try` nested in it routes its body's errors to its own `catch`. What the graphs place is exactly the list of such positions, which is why it is read off them rather than guessed from the node types: a `for` loop is three points and a body, an `if` is one point per test, and neither construct has a node standing for the statement as a whole. A construct that yields nothing at all is one the graphs model nowhere, and a caller reads that as a subtree it cannot judge rather than as one that raises nothing. """ for inner in node.walk(): if self._control_flow.node_of(inner) is not None: yield inner def escapes_the_body(self, node)-
Whether an error raised at node gets past every handler written in the body node stands in, so that where it goes next is decided by whatever ran that body and not by anything the body itself says.
A position the graphs place nowhere settles nothing and escapes nothing;
Falseis the answer that leaves such a node to the position question, which reads an unplaced node as observed.Expand source code Browse git
def escapes_the_body(self, node: Node) -> bool: """ Whether an error raised at *node* gets past every handler written in the body *node* stands in, so that where it goes next is decided by whatever ran that body and not by anything the body itself says. A position the graphs place nowhere settles nothing and escapes nothing; `False` is the answer that leaves such a node to the position question, which reads an unplaced node as observed. """ located = self._control_flow.locate(node) if located is None: return False routing = self._routing(*located) return not _handled_in_the_body(routing) and _escapes(routing) def observed_at(self, node)-
Whether an error raised at node changes which code runs: some handler it reaches acts, a
trapset may decline it and end the body, or the graphs place node nowhere.This is the question about the position and not about the statement standing there — it answers the same for a statement that cannot raise at all, which is what a caller weighing whether a guarded body may be emptied wants to know.
What the body itself decides is
_handled_in_the_body; what is left over once the error gets past it is this.An error that gets past a function's own handlers is the caller's, and no graph here holds both ends of a call. Measured: the same function whose error is reported and stepped over when called at script scope abandons its remaining statements and runs the
catchof atrywritten around the call, however many bodies deep the raise is. So a raise in a body something may call is refused wherever the script holds a handler that acts — and granted where it holds none, since acatchthat is not written cannot be the one guarding the call.Expand source code Browse git
def observed_at(self, node: Node) -> bool: """ Whether an error raised at *node* changes which code runs: some handler it reaches acts, a `trap` set may decline it and end the body, or the graphs place *node* nowhere. This is the question about the **position** and not about the statement standing there — it answers the same for a statement that cannot raise at all, which is what a caller weighing whether a guarded body may be emptied wants to know. What the body itself decides is `_handled_in_the_body`; what is left over once the error gets past it is this. **An error that gets past a function's own handlers is the caller's**, and no graph here holds both ends of a call. Measured: the same function whose error is reported and stepped over when called at script scope abandons its remaining statements and runs the `catch` of a `try` written around the *call*, however many bodies deep the raise is. So a raise in a body something may call is refused wherever the script holds a handler that acts — and granted where it holds none, since a `catch` that is not written cannot be the one guarding the call. """ located = self._control_flow.locate(node) if located is None: return True graph, start = located return self._reading_is_observed(graph, self._routing(graph, start)) def leaves_the_body(self, node)-
Whether an error raised at node may get past every handler of the body it is in, so that where it goes next is a question about the caller.
Truefor a node the graphs cannot place, which is the same answer for the same reason.Expand source code Browse git
def leaves_the_body(self, node: Node) -> bool: """ Whether an error raised at *node* may get past every handler of the body it is in, so that where it goes next is a question about the caller. `True` for a node the graphs cannot place, which is the same answer for the same reason. """ routing = self.routing_at(node) return routing is None or routing.leaves_the_body def removing_a_handler_is_observed(self, handler)-
Whether deleting handler may change which code runs — the transpose, and the question asked before a
trapis deleted.A
trapcannot itself raise, so the forward question answers nothing about removing one: what changes is where the errors of other statements go. Two things have to be true for that to matter, and asking only one gets it wrong in either direction.Something has to still reach it. Walking the exceptional edges backwards from the handler's own node finds exactly the statements whose errors it may be offered, and a handler nothing reaches is one whose deletion re-routes nothing — which is how a
trapleft behind by the removal of the only raise in its block becomes removable in turn. The walk crosses other handlers, because acatchthat misses hands the error on and the statement that raised it is behind that clause rather than at it.What the handler itself emits is deliberately not asked here, and it is a hole: a body that writes runs exactly when the handler is offered an error, so
trap { 'h' }beside a raise putshon the output stream where an unhandled error writes only its record — and this reports it removable. Askinghandler_acts()of the handler under test closes it and costs the injected-noise shaperefinery.lib.scripts.ps1.deobfuscation.deadcodedeliberately drops, because_raiserscounts every statement the block offers rather than every statement that may raise. The two are one question and it is not settled here.And what it would fall back to has to act. Deleting a handler sends its errors to whatever the graph records as its fallback, so the question is asked again there: an empty
catchbeside it swallows exactly as it did, and atrapat script scope with nothing outside it lets an error be reported and stepped over, which is what an unhandled error already did. A fallback the graph does not record is refused, because a handler whose counterfactual is unknown is one whose removal cannot be judged.A
trapset that may decline is load bearing for declining, not only for handling. It is what turns an error 5.1 would report and step over into one that ends the body, so deleting it starts running everything written after the raise:trap [System.IO.IOException] { }guards nothing and is still the whole reason a script stops where it does.
Expand source code Browse git
def removing_a_handler_is_observed(self, handler: Node) -> bool: """ Whether deleting *handler* may change which code runs — the transpose, and the question asked before a `trap` is deleted. A `trap` cannot itself raise, so the forward question answers nothing about removing one: what changes is where the errors of *other* statements go. Two things have to be true for that to matter, and asking only one gets it wrong in either direction. **Something has to still reach it.** Walking the exceptional edges backwards from the handler's own node finds exactly the statements whose errors it may be offered, and a handler nothing reaches is one whose deletion re-routes nothing — which is how a `trap` left behind by the removal of the only raise in its block becomes removable in turn. The walk crosses other handlers, because a `catch` that misses hands the error on and the statement that raised it is behind that clause rather than at it. **What the handler itself emits is deliberately not asked here**, and it is a hole: a body that writes runs exactly when the handler is offered an error, so `trap { 'h' }` beside a raise puts `h` on the output stream where an unhandled error writes only its record — and this reports it removable. Asking `handler_acts` of the handler under test closes it and costs the injected-noise shape `refinery.lib.scripts.ps1.deobfuscation.deadcode` deliberately drops, because `_raisers` counts every statement the block offers rather than every statement that may raise. The two are one question and it is not settled here. **And what it would fall back to has to act.** Deleting a handler sends its errors to whatever the graph records as its fallback, so the question is asked again there: an empty `catch` beside it swallows exactly as it did, and a `trap` at script scope with nothing outside it lets an error be reported and stepped over, which is what an unhandled error already did. A fallback the graph does not record is refused, because a handler whose counterfactual is unknown is one whose removal cannot be judged. **A `trap` set that may decline is load bearing for declining**, not only for handling. It is what turns an error 5.1 would report and step over into one that ends the body, so deleting it starts running everything written after the raise: trap [System.IO.IOException] { } guards nothing and is still the whole reason a script stops where it does. """ located = self._control_flow.locate(handler) if located is None: return True graph, start = located if start.element is not handler: # The climb left the handler behind, so the node this would answer about stands for the # statement around it and every question below reads the wrong element. return True remembered = self._backward.get(id(start)) if remembered is None: remembered = self._backward[id(start)] = self._removal_matters(graph, start) return remembered def strict_mode_may_be_in_force(self)-
Whether this script may arm strict mode anywhere at all, which makes reading a variable that was never set an error instead of a
$null.Two consumers, both reading it as the one model of whether the script arms strict mode.
expression_cannot_fault()is the single place deciding whether a bare variable read can raise before a removal site deletes it;Ps1NullVariableInliningstands its whole pass down where this holds, because a never-assigned read it would rewrite to$nullis a terminating error under strict mode and the value would decide a branch the script never reaches. Measured on 5.1: under the default semantics$unset | ForEach-Object { [void]$_ }writes nothing and the script runs on, so removing it is invisible; underSet-StrictMode -Version 1the same line raises a statement-terminating error that acatchand atrapboth take, so removing it is exactly whatfault_is_observed()exists to refuse.Position is not asked, and the reason is not the one
_stops_on_every_errorgives. Which scopes an arming covers is not one rule:Set-StrictModewrites the scope it stands in andSet-PSDebug -Strictwrites the global one, so the first arms nothing outside the function it is written in and the second arms everything that runs anywhere afterwards. Reading the whole script is what covers both without deciding which was meant.Set-StrictMode -Offis not distinguished from an arming either, since reading the argument buys back only the recall of a script that turns strict mode off again.What this cannot see is an arming that is not in the script. Strict mode is resolved by walking the scope chain to the global scope, so a script dot-sourced from a session that armed it — a profile, a stage-1 loader, an analyst's console — runs strict while spelling nothing. Nothing readable says whether that happened, so the grant this feeds assumes the entry scope runs the default semantics, the way
refinery.lib.scripts.ps1.analysis.worldflowassumes a leak does not re-run the statements above it. A fragment carved out of a larger script is the case where the assumption is worth doubting.The empty pole is the opposite of the sibling's, and it is why this is not a copy of it.
_stops_on_every_erroranswersFalsewhere the graphs place no script, which is safe because a missed arming there only keeps a handler. This one grants a removal, so a script the graphs hold nothing of has to refuse it rather than read as running under the lax default.Expand source code Browse git
def strict_mode_may_be_in_force(self) -> bool: """ Whether this script may arm strict mode anywhere at all, which makes reading a variable that was never set an error instead of a `$null`. Two consumers, both reading it as the one model of whether the script arms strict mode. `refinery.lib.scripts.ps1.analysis.effects.expression_cannot_fault` is the single place deciding whether a bare variable read can raise before a removal site deletes it; `refinery.lib.scripts.ps1.deobfuscation.constants.Ps1NullVariableInlining` stands its whole pass down where this holds, because a never-assigned read it would rewrite to `$null` is a terminating error under strict mode and the value would decide a branch the script never reaches. Measured on 5.1: under the default semantics `$unset | ForEach-Object { [void]$_ }` writes nothing and the script runs on, so removing it is invisible; under `Set-StrictMode -Version 1` the same line raises a statement-terminating error that a `catch` and a `trap` both take, so removing it is exactly what `refinery.lib.scripts.ps1.analysis.effects.fault_is_observed` exists to refuse. Position is not asked, and the reason is not the one `_stops_on_every_error` gives. Which scopes an arming covers is not one rule: `Set-StrictMode` writes the scope it stands in and `Set-PSDebug -Strict` writes the global one, so the first arms nothing outside the function it is written in and the second arms everything that runs anywhere afterwards. Reading the whole script is what covers both without deciding which was meant. `Set-StrictMode -Off` is not distinguished from an arming either, since reading the argument buys back only the recall of a script that turns strict mode off again. **What this cannot see is an arming that is not in the script.** Strict mode is resolved by walking the scope chain to the global scope, so a script dot-sourced from a session that armed it — a profile, a stage-1 loader, an analyst's console — runs strict while spelling nothing. Nothing readable says whether that happened, so the grant this feeds assumes the entry scope runs the default semantics, the way `refinery.lib.scripts.ps1.analysis.worldflow` assumes a leak does not re-run the statements above it. A fragment carved out of a larger script is the case where the assumption is worth doubting. **The empty pole is the opposite of the sibling's, and it is why this is not a copy of it.** `_stops_on_every_error` answers `False` where the graphs place no script, which is safe because a missed arming there only keeps a handler. This one grants a *removal*, so a script the graphs hold nothing of has to refuse it rather than read as running under the lax default. """ if self._strict is None: root = self._script self._strict = root is None or any( _arms_strict_mode(node) for node in root.walk() ) return self._strict def strict_mode_v2_may_be_in_force(self)-
Whether this script may arm strict mode at version 2 or above, under which the
CountandLengththe object adapter fakes onto a scalar or$nullraisePropertyNotFoundStrictrather than answering. Folding one of those to its value would stand a number where the script terminated and decide a branch it never reaches, so the fold inrefinery.lib.scripts.ps1.deobfuscation.foldingstands down where this holds and keeps the real members — aString's ownLength, an array's ownCount— that read on regardless.This is the version-sensitive sibling of
strict_mode_may_be_in_force: that one refuses a removal wherever any strict mode is armed, because reading an unset variable raises under version 1 too, and so treatsSet-PSDebug -Strictand every-Versionalike. The fake adapter members survive version 1, so this asks the narrower question, and everything the two share — position not asked, an arming outside the script not seen, an empty script refusing — is shared for the reasons written there.Expand source code Browse git
def strict_mode_v2_may_be_in_force(self) -> bool: """ Whether this script may arm strict mode at version 2 or above, under which the `Count` and `Length` the object adapter fakes onto a scalar or `$null` raise `PropertyNotFoundStrict` rather than answering. Folding one of those to its value would stand a number where the script terminated and decide a branch it never reaches, so the fold in `refinery.lib.scripts.ps1.deobfuscation.folding` stands down where this holds and keeps the real members — a `String`'s own `Length`, an array's own `Count` — that read on regardless. This is the version-sensitive sibling of `strict_mode_may_be_in_force`: that one refuses a removal wherever any strict mode is armed, because reading an unset variable raises under version 1 too, and so treats `Set-PSDebug -Strict` and every `-Version` alike. The fake adapter members survive version 1, so this asks the narrower question, and everything the two share — position not asked, an arming outside the script not seen, an empty script refusing — is shared for the reasons written there. """ if self._strict_v2 is None: root = self._script self._strict_v2 = root is None or any( _arms_strict_mode_v2(node) for node in root.walk() ) return self._strict_v2