Module refinery.lib.scripts.ps1.analysis.callgraph
The call graph of one PowerShell script: which function definitions a command name denotes, which invocations reach that name, and whether the two together are the whole story.
Two questions are asked of it, and they fail in opposite directions, which is why one walk answers
both. Reachability decides whether a definition may be deleted, so a call site the walk misses
deletes live code. Output flow — Ps1OutputFlow —
decides whether a bare value inside a body may be deleted, so a call site the walk misses deletes a
payload. A walk that expanded only bodies already proven reachable would be sound for the first
question and a deletion licence for the second, so the walk here expands every body a name can
denote.
The graph is structure and nothing more. It says who can call what; what a call does with the value it produces is the effect layer's question, asked through here rather than answered here.
Expand source code Browse git
"""
The call graph of one PowerShell script: which function definitions a command name denotes, which
invocations reach that name, and whether the two together are the whole story.
Two questions are asked of it, and they fail in opposite directions, which is why one walk answers
both. *Reachability* decides whether a definition may be deleted, so a call site the walk misses
deletes live code. *Output flow* — `refinery.lib.scripts.ps1.analysis.effects.Ps1OutputFlow` —
decides whether a bare value inside a body may be deleted, so a call site the walk misses deletes a
payload. A walk that expanded only bodies already proven reachable would be sound for the first
question and a deletion licence for the second, so the walk here expands every body a name can
denote.
The graph is structure and nothing more. It says who can call what; what a call *does* with the
value it produces is the effect layer's question, asked through here rather than answered here.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Callable, Mapping, NamedTuple, Sequence
if TYPE_CHECKING:
from refinery.lib.scripts.analysis.dominance import DominatorModel
from refinery.lib.scripts import Node
from refinery.lib.scripts.ps1.analysis.world import (
Ps1TypeWorld,
command_definition_keyword_binding,
runs_code_it_cannot_read,
)
from refinery.lib.scripts.ps1.ast import (
assignment_target_variables,
bound_argument_value,
free_positional_values,
get_command_name,
get_member_name,
get_param_block,
has_wildcard,
implicit_get_retry,
is_execution_context_invoke,
is_opaque_dispatch,
is_scriptblock_invoke,
may_create_a_scriptblock,
member_may_be,
normalize_command_name,
resolve_command_name,
string_value,
)
from refinery.lib.scripts.ps1.data import KNOWN_CMDLETS
from refinery.lib.scripts.ps1.model import (
Ps1AccessKind,
Ps1AssignmentExpression,
Ps1CommandArgument,
Ps1CommandInvocation,
Ps1FunctionDefinition,
Ps1HereString,
Ps1InvokeMember,
Ps1MemberAccess,
Ps1MethodMember,
Ps1ParenExpression,
Ps1Pipeline,
Ps1PipelineElement,
Ps1ScopeModifier,
Ps1Script,
Ps1ScriptBlock,
Ps1StringLiteral,
Ps1Variable,
)
from refinery.lib.scripts.ps1.options import eval_is_trusted
#: The variable namespaces that name a command rather than a value. Kept in step with
#: `refinery.lib.scripts.ps1.analysis.world._IDENTITY_SCOPES`, which this module cannot reuse
#: because the world reports one verdict for the whole script and this one needs the single node.
_IDENTITY_SCOPES = frozenset({
Ps1ScopeModifier.ALIAS,
Ps1ScopeModifier.FUNCTION,
})
#: Commands that hand a name to code outside this file. A `.psm1` exporting a function has call
#: sites in whatever imported it, and no walk over this tree can see them. Read through
#: `refinery.lib.scripts.ps1.ast.resolve_command_name`, which is the deny-list reading of a name:
#: a hit here withholds every name-keyed removal, so a spelling that dodges the table is the
#: dangerous direction. That closes a module qualifier written after the call operator, quoted or
#: not — `& Microsoft.PowerShell.Core\Export-ModuleMember` arrives as one token either way. Written
#: as a bare command statement the lexer splits the name at the backslash and the table is dodged;
#: see `refinery.lib.scripts.ps1.ast.resolve_command_name` for why that hole is the lexer's.
_EXPORTING_COMMANDS = frozenset({
'export-modulemember',
})
#: The .NET attribute type names that, on a function's `param` block, bind command aliases for the
#: function — `[Alias('q')]` and its `Attribute`-suffixed and namespace-qualified spellings. Matched
#: after the namespace is stripped, the way .NET resolves an attribute type name.
_COMMAND_ALIAS_ATTRIBUTES = frozenset({
'alias',
'aliasattribute',
})
def binds_command_identity(node: Node) -> bool:
"""
Whether `node` writes the `function:` or `alias:` namespace, binding a command name to something
the definition scan does not read as a definition and the call scan does not read as a call.
Every name-keyed decision has to treat such a node as an unknown: it can bind the name about to
be deleted, and it can be the only thing that reaches it.
"""
if not isinstance(node, Ps1AssignmentExpression):
return False
return any(
variable.scope in _IDENTITY_SCOPES
for variable in assignment_target_variables(node.target)
)
def _is_class_method(definition: Ps1FunctionDefinition) -> bool:
"""
Whether a function definition is the body of a class method rather than a command. A method is
reached through `$object.Method()` and never through its bare name, so it is neither a
definition a call site can name nor a definition this graph can find the callers of.
"""
return isinstance(definition.parent, Ps1MethodMember)
def _enclosing_function(node: Node) -> str | None:
"""
The key of the **outermost** function definition whose body holds `node`, or `None` when `node`
runs as part of the script itself.
Outermost rather than innermost, because the only reader is `Ps1CallGraph.reachable_names` and a
nested definition is never called by name. `function Outer { function Inner { Payload } }`
credits the call to `Outer`: reading it as `Inner`'s leaves `Payload` reached by nothing, so it
is deleted while `Inner` — which no pass removes, since only a top-level definition is
removable — stands there calling a name the emitted script no longer defines. Attribution to the
enclosing scope is what makes the two answers agree.
A class method anywhere on the way out answers `None` and stops the walk, which is the
conservative direction. A method body runs whenever something constructs or calls into the
class, and this graph cannot see that happen; treating its calls as unconditional keeps every
name they reach reachable, and `Ps1OutputFlow` separately refuses to ground a method body
because no call site ever names it. Carrying on to the enclosing function instead would make
those calls conditional on a name that reaches the *class*, which nothing here can prove.
"""
found: str | None = None
cursor = node.parent
while cursor is not None:
if isinstance(cursor, Ps1FunctionDefinition):
if _is_class_method(cursor):
return None
found = normalize_command_name(cursor.name)
cursor = cursor.parent
return found
class Ps1CallSite(NamedTuple):
"""
One invocation of a command name, paired with the key of the outermost function whose body holds
it — see `_enclosing_function` — which is the edge's source, where the invocation is its target.
"""
invocation: Ps1CommandInvocation
caller: str | None
class Ps1CallGraph:
"""
The verdict of `build_call_graph`. Definitions and call sites are keyed through
`refinery.lib.scripts.ps1.ast.normalize_command_name`, so `function global:F` and a bare call to
`F` meet on one key, and every definition sharing a key is kept: a redefinition shadows the
earlier body only from the point it runs, which is order and scope information no walk over the
tree carries.
"""
def __init__(
self,
definitions: Mapping[str, Sequence[Ps1FunctionDefinition]],
keyword_definitions: Mapping[str, Sequence[Ps1ScriptBlock]],
call_sites: Mapping[str, Sequence[Ps1CallSite]],
unattributed_sites: Sequence[Node],
readable: bool,
exports: bool,
handed_out: frozenset[str] = frozenset(),
hands_out_every: bool = False,
):
self._definitions = dict(definitions)
self._keyword_definitions = dict(keyword_definitions)
self._call_sites = dict(call_sites)
self._unattributed_sites = tuple(unattributed_sites)
self._readable = readable
self._exports = exports
self._handed_out = handed_out
self._hands_out_every = hands_out_every
def hands_out(self, name: str) -> bool:
"""
Whether the script may reach the body of the function *name* other than by calling it by
name: it reads `$function:K`, writes a path into the `function:` drive, asks `Get-Command`
for it, looks a command up through `$ExecutionContext` or `$PSCmdlet`, reads
`$MyInvocation` inside it, or does any of these where the name cannot be read. A body handed
out this way may run wherever something runs a block, so no list of call sites is the list of
places it runs.
"""
return self._hands_out_every or normalize_command_name(name) in self._handed_out
def named_callers(self, body: Node) -> list[Node] | None:
"""
The calls naming the function whose body *body* is, or `None` where those are not all the
calls that may run it beside `unattributed_sites`: *body* is no function's, this graph
cannot be read, the function is a class method or has no name, or the script hands it out.
"""
parent = body.parent
if (
not self._readable
or not isinstance(parent, Ps1FunctionDefinition)
or _is_class_method(parent)
or not parent.name
or self.hands_out(parent.name)
):
return None
return [site.invocation for site in self.call_sites(parent.name)]
def sites_running(self, block: Ps1ScriptBlock, attributed_only: bool = False) -> list[Node] | None:
"""
The calls that may run *block*, or `None` where they cannot all be named. A function body is
run by the calls naming the function (`named_callers`), and, unless *attributed_only*, by
every call in `unattributed_sites`. A script block is run by the `&` or `.` that invokes it
where it is written, and by the `ForEach-Object` or `Where-Object` it is handed to.
"""
parent = block.parent
if isinstance(parent, Ps1FunctionDefinition):
named = self.named_callers(block)
if named is None or attributed_only:
return named
return [*named, *self.unattributed_sites]
invocation = parent.parent if isinstance(parent, Ps1CommandArgument) else parent
if not isinstance(invocation, Ps1CommandInvocation):
return None
if invocation.name is block:
return [invocation] if invocation.invocation_operator in ('&', '.') else None
if resolve_command_name(invocation) in ('foreach-object', 'where-object'):
return [invocation]
return None
@property
def is_readable(self) -> bool:
"""
Whether this tree is the whole story about what a command name denotes and who reaches it.
Six independent things say it is not, and every one of them has to be asked, because none
implies another:
- the command table is open (`Ps1TypeWorld.command_table_closed` is false), so a dot-sourced
file, an imported module, an `iex` or an aliasing cmdlet defines or imports a command the
walk never read. A pure type-system mutation — a `class`, `Add-Type` — is *not* one of
these: it opens the wider `closed_for_the_whole_run` but not the command table, since it
binds no command name, so the graph stays readable beside it;
- an invocation dispatches opaquely (`& $f`), so a name is reached without being written;
- an assignment binds the `function:` or `alias:` namespace, which defines a command under a
spelling the definition scan does not read — and leaves the world closed while doing it,
since `${function:j} = { }` remaps nothing in the type system;
- a function definition carries a function-level `[Alias]` attribute, the declarative form of
the same binding: it names a second command for that function under a spelling the
definition scan, keyed on the function's own name, does not read; see
`_declares_a_command_alias`;
- the script calls `Export-ModuleMember`, which hands a name to a caller outside the file;
- a call written under a qualifier resolves onto a name this script defines; see
`_collides_with_a_definition`.
Every consumer reads this as fail-closed: an unreadable graph keeps more, never less.
The first two rows are the ones
`refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` switches off, being
the two that say a name is reached by code nobody can read; see `build_call_graph`.
The `Export-ModuleMember` row catches the explicit export and not the general case. A
`.psm1` with no export statement exports every function by default, and nothing in a file
says it is a module — the assumption the bare-output default rests on, stated where a
caller can see it in `refinery.units.scripting.dps1`.
"""
return self._readable
@property
def exports_a_name(self) -> bool:
"""
Whether this script hands a command name to code outside the file, which is one of the six
things `is_readable` reads and the only one that says a *definition* has a reader elsewhere.
The distinction is worth the separate name because the two questions have different answers
for the same script. An `Invoke-Expression` opens the type world and could in principle call
anything, and every pass here has long accepted that risk in exchange for resolving the
trampolines obfuscators are built out of. An export is not a risk taken for anything: the
script says in as many words that a caller it cannot see will call this name, so a pass that
deletes the definition deletes a reachable entry point.
"""
return self._exports
def may_run_a_body(self, node: Node) -> bool:
"""
Whether evaluating *node* itself may run a body of this script, or code the script does not
spell, which an error handler around it then guards: a script block, which runs wherever
something runs it; a call that runs or makes one; or a command that dispatches to a target
the source does not spell, runs code nobody can read, or names a function or keyword body
this script defines. Where this graph is not the whole story, a name no cmdlet answers to
may name a body code nobody can read defined; a name a cmdlet answers to is read as that
cmdlet, the risk every pass here takes in exchange for reading through an `iex` at all.
"""
if isinstance(node, Ps1ScriptBlock):
return True
if isinstance(node, Ps1InvokeMember):
return (
is_scriptblock_invoke(node)
or may_create_a_scriptblock(node)
or is_execution_context_invoke(node)
)
if not isinstance(node, Ps1CommandInvocation):
return False
if is_opaque_dispatch(node) or runs_code_it_cannot_read(node):
return True
name = get_command_name(node)
if name is None or self.definitions(name) or self.keyword_definitions(name):
return True
if self._readable:
return False
resolved = resolve_command_name(node)
return resolved is None or resolved not in KNOWN_CMDLETS
def definitions(self, name: str) -> Sequence[Ps1FunctionDefinition]:
"""
Every function definition a call to `name` could reach, in source order.
"""
return self._definitions.get(normalize_command_name(name), ())
def keyword_definitions(self, name: str) -> Sequence[Ps1ScriptBlock]:
"""
The scriptblock bodies a `workflow`/`configuration` statement binds to `name`, in source
order. These define the same command name a `function` does but are not themselves a
`refinery.lib.scripts.ps1.model.Ps1FunctionDefinition`, so a caller weighing whether a name
is inert reads them beside `definitions`: the empty `function` a call names may be shadowed
by a `workflow` whose body acts.
"""
return self._keyword_definitions.get(normalize_command_name(name), ())
def call_sites(self, name: str) -> Sequence[Ps1CallSite]:
"""
Every invocation of `name` standing in this tree, in source order, wherever it sits. This
is what separates the graph from the reachability query below, which reads only the edges it
can reach from the script itself.
"""
return self._call_sites.get(normalize_command_name(name), ())
@property
def unattributed_sites(self) -> Sequence[Node]:
"""
Every node standing in this tree that runs code this analysis cannot read — a dispatch
whose target the source does not spell (`& $f`), a string run as code (`iex $c`), a block
made or run from one — in source order, under either reading of such code: it may call any
function this script defines, and a later step may spell the very call it makes (`$f = 'f1';
& $f`, `iex 'f1'`), so a reader asking where a body may run reads these beside the sites
that name it.
"""
return self._unattributed_sites
@property
def defined_names(self) -> Sequence[str]:
"""
Every key this script defines a function under, in source order of the first definition.
"""
return tuple(self._definitions)
def reachable_names(self) -> frozenset[str]:
"""
Every command name some path from the script itself can reach: the names called outside any
function, then the names those functions call, to a fixpoint. A function whose key is absent
is called from nowhere in this tree.
An unreadable graph answers with every name it saw, defined or called, since a name reached
from outside is reached by a path this cannot trace.
"""
if not self._readable or self._hands_out_every:
return frozenset(self._definitions) | frozenset(self._call_sites)
calls_from: dict[str | None, set[str]] = {}
calls_from[None] = set(self._handed_out)
for name, sites in self._call_sites.items():
for site in sites:
calls_from.setdefault(site.caller, set()).add(name)
reachable = set(calls_from.get(None, ()))
frontier = [name for name in reachable if name in self._definitions]
while frontier:
for name in calls_from.get(frontier.pop(), ()):
if name in reachable:
continue
reachable.add(name)
if name in self._definitions:
frontier.append(name)
return frozenset(reachable)
class Ps1BodyCallers:
"""
The calls that may run each script block of the tree *root*, as
`refinery.lib.scripts.ps1.analysis.dispatch.Ps1CallSites` asks for them: read off the call
graph *calls* hands over, which is asked for only once a question needs it.
"""
def __init__(self, root: Node, calls: Callable[[], Ps1CallGraph]):
self._root = root
self._calls = calls
def bodies(self) -> list[Node]:
return [node for node in self._root.walk() if isinstance(node, Ps1ScriptBlock)]
def sites_running(self, body: Node) -> list[Node] | None:
if not isinstance(body, Ps1ScriptBlock):
return None
return self._calls().sites_running(body, attributed_only=True)
def unattributed_sites(self) -> Sequence[Node]:
return self._calls().unattributed_sites
def names_used_before_defined(
graph: Ps1CallGraph,
dominance: DominatorModel,
) -> frozenset[str]:
"""
The keys with a call site that no definition of the name is guaranteed to have run before. Such
a call names, at the point it stands, whatever bound the name earlier — nothing, at script
scope, where Windows PowerShell 5.1 answers the bare word with a `CommandNotFoundException`.
That error is non-terminating there, so the run carries on and a later call does reach the
body, but the earlier call ran the error rather than the body — measured: `zzqfoo1; function
zzqfoo1 { 'boom' }; zzqfoo1` writes an error record and then `boom`. A pass that folds that
earlier call into the body invents the body's value for a statement that raised, and one that
deletes it with the definition as an inert pair erases the error; which body the call reaches,
and whether it reaches one at all, is order the fold does not carry, so every definition and
call of a name reported here is left alone rather than resolved for the reachable calls alone.
Only a call the definition shares a control-flow graph with is ordered. A call inside another
function's body runs when that body is invoked, an ordering no per-body graph carries, and
dominance across two graphs is always false; reading that as unreached would refuse every helper
that calls another, so a cross-graph call is treated as reached — the direction that keeps a
fold the pass already takes. **Strict** dominance, so a definition and a call sharing one node
do not read as ordered: statement granularity cannot order a use and a definition written on one
line, and the reflexive answer for that pair is the use-before-definition this refuses.
"""
unreached: set[str] = set()
for key in graph.defined_names:
definitions = graph.definitions(key)
for site in graph.call_sites(key):
call = site.invocation
colocated = [d for d in definitions if dominance.locate_pair(d, call) is not None]
if colocated and not any(dominance.strictly_dominates(d, call) for d in colocated):
unreached.add(key)
break
return frozenset(unreached)
def calls_after_a_definition(
graph: Ps1CallGraph,
dominance: DominatorModel,
) -> frozenset[int]:
"""
The identities of the call sites a definition of the name they call certainly ran before: in
the same body, or, for a call in a function body, before every statement outside any function
body through which that body may first be entered. A helper defined at the top and called from
functions the script calls later is one; a call from another function that runs before the
definition is not. A body whose callers cannot all be listed (an unreadable graph, a function
handed out as a value, a class method, a call nested in a block), or that a cycle of calls
reaches, is entered from nowhere this can name. **Strict** dominance, for the reason
`names_used_before_defined` gives.
"""
entries = _BodyEntries(graph)
found: set[int] = set()
for key in graph.defined_names:
definitions = graph.definitions(key)
for site in graph.call_sites(key):
call = site.invocation
if any(_runs_before(definition, call, dominance, entries) for definition in definitions):
found.add(id(call))
return frozenset(found)
def _runs_before(
definition: Ps1FunctionDefinition,
call: Node,
dominance: DominatorModel,
entries: _BodyEntries,
) -> bool:
if dominance.locate_pair(definition, call) is not None:
return dominance.strictly_dominates(definition, call)
body = _enclosing_block(call)
if body is None:
return False
first = entries.of(body)
return bool(first) and all(
dominance.locate_pair(definition, entry) is not None
and dominance.strictly_dominates(definition, entry)
for entry in first
)
def _enclosing_block(node: Node) -> Ps1ScriptBlock | None:
cursor = node.parent
while cursor is not None and not isinstance(cursor, Ps1ScriptBlock):
cursor = cursor.parent
return cursor
class _BodyEntries:
"""
For a function body, the call sites outside every function body through which a run may first
enter it, or `None` where a caller cannot be listed. Computed once per body without
recursion; a body on a cycle of calls has no entry list this keeps, so neither has any body that
a cycle reaches.
"""
def __init__(self, graph: Ps1CallGraph):
self._graph = graph
self._memo: dict[int, frozenset[Node] | None] = {}
def _sites(self, body: Ps1ScriptBlock) -> list[Node] | None:
named = self._graph.named_callers(body)
return None if named is None else [*named, *self._graph.unattributed_sites]
def of(self, body: Ps1ScriptBlock) -> frozenset[Node] | None:
memo = self._memo
if id(body) in memo:
return memo[id(body)]
active: set[int] = set()
stack: list[tuple[Ps1ScriptBlock, list[Node], set[Node] | None]] = []
def push(block: Ps1ScriptBlock) -> bool:
sites = self._sites(block)
if sites is None:
memo[id(block)] = None
return False
active.add(id(block))
stack.append((block, list(sites), set()))
return True
if not push(body):
return None
while stack:
block, pending, found = stack[-1]
if found is not None and pending:
site = pending.pop()
holder = _enclosing_block(site)
if holder is None:
found.add(site)
continue
if id(holder) in memo:
inner = memo[id(holder)]
if inner is None:
stack[-1] = (block, pending, None)
else:
found.update(inner)
continue
if id(holder) in active:
stack[-1] = (block, pending, None)
continue
if not push(holder):
stack[-1] = (block, pending, None)
continue
stack.pop()
active.discard(id(block))
result = None if found is None else frozenset(found)
memo[id(block)] = result
if stack:
outer, outer_pending, outer_found = stack[-1]
if result is None:
stack[-1] = (outer, outer_pending, None)
elif outer_found is not None:
outer_found.update(result)
return memo.get(id(body))
def _collides_with_a_definition(
resolved: Sequence[str],
definitions: Mapping[str, Sequence[Ps1FunctionDefinition]],
) -> bool:
"""
Whether some call keyed under a qualifier resolves onto a name this script defines.
`& 'MyModule\\Qzmr'` keys as `mymodule\\qzmr` while `function Qzmr` keys as `qzmr`, so nothing
matches, the definition reads as uncalled, and deleting it leaves a script calling a name it no
longer defines. `resolved` holds every name a call may run that its written key does not spell,
which is the deny-list reading `refinery.lib.scripts.ps1.ast.resolve_command_name` gives — so an
alias is here too, and `iex $x` beside a `function Invoke-Expression` collides for the same
reason, as does `item` beside a `function Get-Item`, which the implicit `Get-` retry reaches.
**The two arrive under different conditions, because they sit at opposite ends of the
precedence.** An alias is the highest tier and beats a script function, so `iex` reaches
`Invoke-Expression` however the script spells its own definitions. The implicit `Get-` retry is
the lowest, reached only once the function tier has missed, so `item` reaches `Get-Item` only in
a script that defines no `item` of its own — and `build_call_graph` therefore withholds a retry
name when the bare key is one this script defines. The set it withholds against is the one
`defined_names` reports, which is what the command model's own function tier reads, so the two
cannot disagree about which calls the retry is reached from.
**The condition is the collision and not the qualifier.** A quoted executable path is the same
shape: `& 'C:\\tools\\stage2.exe'` keys as the path and resolves to `stage2.exe`. Reading the
backslash itself as the signal would make every script that invokes an executable by path
unreadable, and `& 'C:\\Windows\\Temp\\payload.exe'` is one of the commonest shapes there is;
that is not failing closed, it is switching the analysis off. Asked this way it costs nothing
unless the stripped name is one this tree actually defines.
**This has no PowerShell oracle and is a policy choice.** Real PowerShell errors on
`& 'MyModule\\Qzmr'` when no such module is loaded — it does not reach the local definition — so
the language does not say the definition must survive. What says so is the internal invariant
that no removal leaves a dangling reference, plus a decision to fail closed in front of the
lexer's qualified-name hole rather than behind it. `& 'global:Qzmr'` is a different case and is
oracle-backed: the scope qualifier is stripped by the *key*, so the call and the definition
already meet and nothing here fires.
The call is not filed under the stripped key instead, which would be the other way to make the
two meet. The same list feeds `refinery.lib.scripts.ps1.analysis.effects.Ps1OutputFlow`, where
an extra call site *grounds* a function and licenses deleting what its body writes — so a guess
that keeps a definition alive would buy that at the price of a payload.
"""
return any(name in definitions for name in resolved)
def _declares_a_command_alias(definition: Ps1FunctionDefinition) -> bool:
"""
Whether `definition` carries a function-level `[Alias(...)]` attribute, which binds each name it
lists as a command name for the function the moment the definition runs — a second spelling the
definition scan, keyed on the function's own name, never reads, and one the call scan reaches
under a name no definition of it stands. It is the declarative form of a `Set-Alias`, so the
graph reads it the way `binds_command_identity` reads the imperative `alias:` write: the tree is
no longer the whole story about what a command name denotes.
Only the attributes on the `param` block itself are read. An `[Alias]` on one of the parameters
names a parameter of the function, not the command, and leaves the graph the whole story.
"""
block = get_param_block(definition.body)
if block is None:
return False
return any(
attribute.name.rpartition('.')[2].lower() in _COMMAND_ALIAS_ATTRIBUTES
for attribute in block.attributes
)
def build_call_graph(
root: Ps1Script,
world: Ps1TypeWorld,
options: object | None = None,
) -> Ps1CallGraph:
"""
Walk the whole tree once, recording every function definition, every statically named invocation
with the function that holds it, and every reason the result is not the whole story.
The walk runs in source order so that a consumer removing what it finds removes from the front,
and cannot short-circuit on the first unknown, because the definitions and call sites are still
needed to decide what to keep.
The collision row is decided after the walk rather than during it, because a definition may be
written below the call that resolves onto it and a script is not read top to bottom.
`refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` clears exactly the two
rows that say code nobody can read reaches a name — the open world, through the verdict this is
handed, and the opaque dispatch, here. The other four stand under it: an export and a collision
are written in the script, and so are an assignment into the `function:` namespace and a
function-level `[Alias]` attribute, each a definition under a spelling the scan does not read
rather than a call from outside it.
"""
trusting = eval_is_trusted(options)
definitions: dict[str, list[Ps1FunctionDefinition]] = {}
keyword_definitions: dict[str, list[Ps1ScriptBlock]] = {}
call_sites: dict[str, list[Ps1CallSite]] = {}
unattributed_sites: list[Node] = []
qualified: list[str] = []
retried: dict[str, list[str]] = {}
readable = world.command_table_closed
exports = False
handed_out: set[str] = set()
hands_out_every = False
for node in root.walk_in_order():
if isinstance(node, (Ps1CommandInvocation, Ps1InvokeMember)) and runs_code_it_cannot_read(node):
unattributed_sites.append(node)
handing = _functions_handed_out(node)
if handing is None:
hands_out_every = True
else:
handed_out.update(handing)
if isinstance(node, Ps1FunctionDefinition):
if not _is_class_method(node):
definitions.setdefault(normalize_command_name(node.name), []).append(node)
if _declares_a_command_alias(node):
readable = False
elif isinstance(node, Ps1CommandInvocation):
binding = command_definition_keyword_binding(node)
if binding is not None:
keyword_name, keyword_body = binding
keyword_definitions.setdefault(keyword_name, []).append(keyword_body)
name = get_command_name(node)
if name is None:
if not trusting and is_opaque_dispatch(node):
readable = False
continue
key = normalize_command_name(name)
resolved = resolve_command_name(node)
if resolved in _EXPORTING_COMMANDS:
readable = False
exports = True
if resolved is not None and resolved != key:
qualified.append(resolved)
retry = None if resolved is None else implicit_get_retry(resolved)
if retry is not None:
retried.setdefault(key, []).append(retry)
call_sites.setdefault(key, []).append(Ps1CallSite(node, _enclosing_function(node)))
elif binds_command_identity(node):
readable = False
for key, names in retried.items():
if key not in definitions:
qualified.extend(names)
if _collides_with_a_definition(qualified, definitions):
readable = False
return Ps1CallGraph(
definitions,
keyword_definitions,
call_sites,
unattributed_sites,
readable,
exports,
frozenset(handed_out),
hands_out_every,
)
_COMMAND_LOOKUPS = frozenset({
'getcmdlet',
'getcmdletbytypename',
'getcmdlets',
'getcommand',
'getcommandname',
'getcommands',
})
def _functions_handed_out(node: Node) -> tuple[str, ...] | None:
"""
The keys of the functions *node* may hand out as a value rather than call, or `None` where it
may hand out any of them: a `$function:K` occurrence; a string that names a path into
the `function:` drive; `Get-Command`; a command lookup through `$ExecutionContext` or
`$PSCmdlet`; `$MyInvocation` read inside a function body, which hands out that function. An item
command whose path cannot be read is the residual the world model documents for the same
commands: refusing on it would refuse every script that copies or deletes a file by variable.
"""
if isinstance(node, Ps1Variable):
if node.scope is Ps1ScopeModifier.FUNCTION:
return (normalize_command_name(node.name),)
if node.scope is Ps1ScopeModifier.NONE and node.name.lower() == 'myinvocation':
cursor = node.parent
while cursor is not None:
if isinstance(cursor, Ps1FunctionDefinition):
return (normalize_command_name(cursor.name),)
cursor = cursor.parent
return ()
if isinstance(node, (Ps1StringLiteral, Ps1HereString)):
return _function_paths_in(node.value)
if isinstance(node, Ps1CommandInvocation):
if resolve_command_name(node) == 'get-command' and not _only_a_member_is_read(node):
return _command_looked_up(node)
return ()
if isinstance(node, Ps1InvokeMember) and node.access is Ps1AccessKind.INSTANCE:
if not member_may_be(node.member, _COMMAND_LOOKUPS):
return ()
receiver = node.object
while isinstance(receiver, Ps1MemberAccess):
receiver = receiver.object
if isinstance(receiver, Ps1Variable) and receiver.name.lower() in ('executioncontext', 'pscmdlet'):
return None
return ()
def _command_looked_up(command: Ps1CommandInvocation) -> tuple[str, ...] | None:
"""
The key of the function `Get-Command` *command* looks up by name, or `None` where it may list
any of them: the name is the value bound to `-Name`, written as such or as the first argument
no parameter takes. Every other selection, `-CommandType Function` and `-Verb` among them, and
a splat, whose table this cannot see into, lists every function its filter passes.
"""
for argument in command.arguments:
if not isinstance(argument, Ps1CommandArgument):
return None
value = argument.value
if isinstance(value, Ps1Variable) and value.splatted:
return None
written = bound_argument_value(command, 'name')
if written is None:
written = next(iter(free_positional_values(command, 'get-command')), None)
text = None if written is None else string_value(written)
if text is None or has_wildcard(text):
return None
return (normalize_command_name(text),)
def _only_a_member_is_read(command: Ps1CommandInvocation) -> bool:
"""
Whether what *command* writes out is used only for one member other than the block it carries,
where it stands, as `(Get-Command K).Name` uses it: nothing that runs the body leaves.
"""
cursor: Node = command
parent = cursor.parent
if isinstance(parent, Ps1PipelineElement) and not parent.redirections:
pipeline = parent.parent
if not isinstance(pipeline, Ps1Pipeline) or len(pipeline.elements) != 1:
return False
cursor, parent = pipeline, pipeline.parent
while isinstance(parent, Ps1ParenExpression):
cursor, parent = parent, parent.parent
if not isinstance(parent, Ps1MemberAccess) or parent.object is not cursor:
return False
name = get_member_name(parent.member)
return name is not None and name.lower() != 'scriptblock'
def _function_paths_in(text: str) -> tuple[str, ...] | None:
"""
The keys of the functions the paths into the `function:` drive in *text* name, or `None` where
one of them names no single function.
"""
lowered = text.lower()
found: list[str] = []
start = lowered.find('function:')
while start >= 0:
rest = lowered[start + len('function:'):].lstrip(':\\/')
name = re.split(r'[\s\'"`;,|)}\]]', rest, maxsplit=1)[0]
if not name or has_wildcard(name):
return None
found.append(normalize_command_name(name))
start = lowered.find('function:', start + 1)
return tuple(found)
Functions
def binds_command_identity(node)-
Whether
nodewrites thefunction:oralias:namespace, binding a command name to something the definition scan does not read as a definition and the call scan does not read as a call. Every name-keyed decision has to treat such a node as an unknown: it can bind the name about to be deleted, and it can be the only thing that reaches it.Expand source code Browse git
def binds_command_identity(node: Node) -> bool: """ Whether `node` writes the `function:` or `alias:` namespace, binding a command name to something the definition scan does not read as a definition and the call scan does not read as a call. Every name-keyed decision has to treat such a node as an unknown: it can bind the name about to be deleted, and it can be the only thing that reaches it. """ if not isinstance(node, Ps1AssignmentExpression): return False return any( variable.scope in _IDENTITY_SCOPES for variable in assignment_target_variables(node.target) ) def names_used_before_defined(graph, dominance)-
The keys with a call site that no definition of the name is guaranteed to have run before. Such a call names, at the point it stands, whatever bound the name earlier — nothing, at script scope, where Windows PowerShell 5.1 answers the bare word with a
CommandNotFoundException. That error is non-terminating there, so the run carries on and a later call does reach the body, but the earlier call ran the error rather than the body — measured:zzqfoo1; function zzqfoo1 { 'boom' }; zzqfoo1<code> writes an error record and then </code>boom. A pass that folds that earlier call into the body invents the body's value for a statement that raised, and one that deletes it with the definition as an inert pair erases the error; which body the call reaches, and whether it reaches one at all, is order the fold does not carry, so every definition and call of a name reported here is left alone rather than resolved for the reachable calls alone.Only a call the definition shares a control-flow graph with is ordered. A call inside another function's body runs when that body is invoked, an ordering no per-body graph carries, and dominance across two graphs is always false; reading that as unreached would refuse every helper that calls another, so a cross-graph call is treated as reached — the direction that keeps a fold the pass already takes. Strict dominance, so a definition and a call sharing one node do not read as ordered: statement granularity cannot order a use and a definition written on one line, and the reflexive answer for that pair is the use-before-definition this refuses.
Expand source code Browse git
def names_used_before_defined( graph: Ps1CallGraph, dominance: DominatorModel, ) -> frozenset[str]: """ The keys with a call site that no definition of the name is guaranteed to have run before. Such a call names, at the point it stands, whatever bound the name earlier — nothing, at script scope, where Windows PowerShell 5.1 answers the bare word with a `CommandNotFoundException`. That error is non-terminating there, so the run carries on and a later call does reach the body, but the earlier call ran the error rather than the body — measured: `zzqfoo1; function zzqfoo1 { 'boom' }; zzqfoo1` writes an error record and then `boom`. A pass that folds that earlier call into the body invents the body's value for a statement that raised, and one that deletes it with the definition as an inert pair erases the error; which body the call reaches, and whether it reaches one at all, is order the fold does not carry, so every definition and call of a name reported here is left alone rather than resolved for the reachable calls alone. Only a call the definition shares a control-flow graph with is ordered. A call inside another function's body runs when that body is invoked, an ordering no per-body graph carries, and dominance across two graphs is always false; reading that as unreached would refuse every helper that calls another, so a cross-graph call is treated as reached — the direction that keeps a fold the pass already takes. **Strict** dominance, so a definition and a call sharing one node do not read as ordered: statement granularity cannot order a use and a definition written on one line, and the reflexive answer for that pair is the use-before-definition this refuses. """ unreached: set[str] = set() for key in graph.defined_names: definitions = graph.definitions(key) for site in graph.call_sites(key): call = site.invocation colocated = [d for d in definitions if dominance.locate_pair(d, call) is not None] if colocated and not any(dominance.strictly_dominates(d, call) for d in colocated): unreached.add(key) break return frozenset(unreached) def calls_after_a_definition(graph, dominance)-
The identities of the call sites a definition of the name they call certainly ran before: in the same body, or, for a call in a function body, before every statement outside any function body through which that body may first be entered. A helper defined at the top and called from functions the script calls later is one; a call from another function that runs before the definition is not. A body whose callers cannot all be listed (an unreadable graph, a function handed out as a value, a class method, a call nested in a block), or that a cycle of calls reaches, is entered from nowhere this can name. Strict dominance, for the reason
names_used_before_defined()gives.Expand source code Browse git
def calls_after_a_definition( graph: Ps1CallGraph, dominance: DominatorModel, ) -> frozenset[int]: """ The identities of the call sites a definition of the name they call certainly ran before: in the same body, or, for a call in a function body, before every statement outside any function body through which that body may first be entered. A helper defined at the top and called from functions the script calls later is one; a call from another function that runs before the definition is not. A body whose callers cannot all be listed (an unreadable graph, a function handed out as a value, a class method, a call nested in a block), or that a cycle of calls reaches, is entered from nowhere this can name. **Strict** dominance, for the reason `names_used_before_defined` gives. """ entries = _BodyEntries(graph) found: set[int] = set() for key in graph.defined_names: definitions = graph.definitions(key) for site in graph.call_sites(key): call = site.invocation if any(_runs_before(definition, call, dominance, entries) for definition in definitions): found.add(id(call)) return frozenset(found) def build_call_graph(root, world, options=None)-
Walk the whole tree once, recording every function definition, every statically named invocation with the function that holds it, and every reason the result is not the whole story.
The walk runs in source order so that a consumer removing what it finds removes from the front, and cannot short-circuit on the first unknown, because the definitions and call sites are still needed to decide what to keep.
The collision row is decided after the walk rather than during it, because a definition may be written below the call that resolves onto it and a script is not read top to bottom.
Ps1DeobfuscationOptions.trust_evalclears exactly the two rows that say code nobody can read reaches a name — the open world, through the verdict this is handed, and the opaque dispatch, here. The other four stand under it: an export and a collision are written in the script, and so are an assignment into thefunction:namespace and a function-level[Alias]attribute, each a definition under a spelling the scan does not read rather than a call from outside it.Expand source code Browse git
def build_call_graph( root: Ps1Script, world: Ps1TypeWorld, options: object | None = None, ) -> Ps1CallGraph: """ Walk the whole tree once, recording every function definition, every statically named invocation with the function that holds it, and every reason the result is not the whole story. The walk runs in source order so that a consumer removing what it finds removes from the front, and cannot short-circuit on the first unknown, because the definitions and call sites are still needed to decide what to keep. The collision row is decided after the walk rather than during it, because a definition may be written below the call that resolves onto it and a script is not read top to bottom. `refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` clears exactly the two rows that say code nobody can read reaches a name — the open world, through the verdict this is handed, and the opaque dispatch, here. The other four stand under it: an export and a collision are written in the script, and so are an assignment into the `function:` namespace and a function-level `[Alias]` attribute, each a definition under a spelling the scan does not read rather than a call from outside it. """ trusting = eval_is_trusted(options) definitions: dict[str, list[Ps1FunctionDefinition]] = {} keyword_definitions: dict[str, list[Ps1ScriptBlock]] = {} call_sites: dict[str, list[Ps1CallSite]] = {} unattributed_sites: list[Node] = [] qualified: list[str] = [] retried: dict[str, list[str]] = {} readable = world.command_table_closed exports = False handed_out: set[str] = set() hands_out_every = False for node in root.walk_in_order(): if isinstance(node, (Ps1CommandInvocation, Ps1InvokeMember)) and runs_code_it_cannot_read(node): unattributed_sites.append(node) handing = _functions_handed_out(node) if handing is None: hands_out_every = True else: handed_out.update(handing) if isinstance(node, Ps1FunctionDefinition): if not _is_class_method(node): definitions.setdefault(normalize_command_name(node.name), []).append(node) if _declares_a_command_alias(node): readable = False elif isinstance(node, Ps1CommandInvocation): binding = command_definition_keyword_binding(node) if binding is not None: keyword_name, keyword_body = binding keyword_definitions.setdefault(keyword_name, []).append(keyword_body) name = get_command_name(node) if name is None: if not trusting and is_opaque_dispatch(node): readable = False continue key = normalize_command_name(name) resolved = resolve_command_name(node) if resolved in _EXPORTING_COMMANDS: readable = False exports = True if resolved is not None and resolved != key: qualified.append(resolved) retry = None if resolved is None else implicit_get_retry(resolved) if retry is not None: retried.setdefault(key, []).append(retry) call_sites.setdefault(key, []).append(Ps1CallSite(node, _enclosing_function(node))) elif binds_command_identity(node): readable = False for key, names in retried.items(): if key not in definitions: qualified.extend(names) if _collides_with_a_definition(qualified, definitions): readable = False return Ps1CallGraph( definitions, keyword_definitions, call_sites, unattributed_sites, readable, exports, frozenset(handed_out), hands_out_every, )
Classes
class Ps1CallSite (invocation, caller)-
One invocation of a command name, paired with the key of the outermost function whose body holds it — see
_enclosing_function— which is the edge's source, where the invocation is its target.Expand source code Browse git
class Ps1CallSite(NamedTuple): """ One invocation of a command name, paired with the key of the outermost function whose body holds it — see `_enclosing_function` — which is the edge's source, where the invocation is its target. """ invocation: Ps1CommandInvocation caller: str | NoneAncestors
- builtins.tuple
Instance variables
var invocation-
Alias for field number 0
Expand source code Browse git
class Ps1CallSite(NamedTuple): """ One invocation of a command name, paired with the key of the outermost function whose body holds it — see `_enclosing_function` — which is the edge's source, where the invocation is its target. """ invocation: Ps1CommandInvocation caller: str | None var caller-
Alias for field number 1
Expand source code Browse git
class Ps1CallSite(NamedTuple): """ One invocation of a command name, paired with the key of the outermost function whose body holds it — see `_enclosing_function` — which is the edge's source, where the invocation is its target. """ invocation: Ps1CommandInvocation caller: str | None
class Ps1CallGraph (definitions, keyword_definitions, call_sites, unattributed_sites, readable, exports, handed_out=frozenset(), hands_out_every=False)-
The verdict of
build_call_graph(). Definitions and call sites are keyed throughnormalize_command_name(), sofunction global:Fand a bare call toFmeet on one key, and every definition sharing a key is kept: a redefinition shadows the earlier body only from the point it runs, which is order and scope information no walk over the tree carries.Expand source code Browse git
class Ps1CallGraph: """ The verdict of `build_call_graph`. Definitions and call sites are keyed through `refinery.lib.scripts.ps1.ast.normalize_command_name`, so `function global:F` and a bare call to `F` meet on one key, and every definition sharing a key is kept: a redefinition shadows the earlier body only from the point it runs, which is order and scope information no walk over the tree carries. """ def __init__( self, definitions: Mapping[str, Sequence[Ps1FunctionDefinition]], keyword_definitions: Mapping[str, Sequence[Ps1ScriptBlock]], call_sites: Mapping[str, Sequence[Ps1CallSite]], unattributed_sites: Sequence[Node], readable: bool, exports: bool, handed_out: frozenset[str] = frozenset(), hands_out_every: bool = False, ): self._definitions = dict(definitions) self._keyword_definitions = dict(keyword_definitions) self._call_sites = dict(call_sites) self._unattributed_sites = tuple(unattributed_sites) self._readable = readable self._exports = exports self._handed_out = handed_out self._hands_out_every = hands_out_every def hands_out(self, name: str) -> bool: """ Whether the script may reach the body of the function *name* other than by calling it by name: it reads `$function:K`, writes a path into the `function:` drive, asks `Get-Command` for it, looks a command up through `$ExecutionContext` or `$PSCmdlet`, reads `$MyInvocation` inside it, or does any of these where the name cannot be read. A body handed out this way may run wherever something runs a block, so no list of call sites is the list of places it runs. """ return self._hands_out_every or normalize_command_name(name) in self._handed_out def named_callers(self, body: Node) -> list[Node] | None: """ The calls naming the function whose body *body* is, or `None` where those are not all the calls that may run it beside `unattributed_sites`: *body* is no function's, this graph cannot be read, the function is a class method or has no name, or the script hands it out. """ parent = body.parent if ( not self._readable or not isinstance(parent, Ps1FunctionDefinition) or _is_class_method(parent) or not parent.name or self.hands_out(parent.name) ): return None return [site.invocation for site in self.call_sites(parent.name)] def sites_running(self, block: Ps1ScriptBlock, attributed_only: bool = False) -> list[Node] | None: """ The calls that may run *block*, or `None` where they cannot all be named. A function body is run by the calls naming the function (`named_callers`), and, unless *attributed_only*, by every call in `unattributed_sites`. A script block is run by the `&` or `.` that invokes it where it is written, and by the `ForEach-Object` or `Where-Object` it is handed to. """ parent = block.parent if isinstance(parent, Ps1FunctionDefinition): named = self.named_callers(block) if named is None or attributed_only: return named return [*named, *self.unattributed_sites] invocation = parent.parent if isinstance(parent, Ps1CommandArgument) else parent if not isinstance(invocation, Ps1CommandInvocation): return None if invocation.name is block: return [invocation] if invocation.invocation_operator in ('&', '.') else None if resolve_command_name(invocation) in ('foreach-object', 'where-object'): return [invocation] return None @property def is_readable(self) -> bool: """ Whether this tree is the whole story about what a command name denotes and who reaches it. Six independent things say it is not, and every one of them has to be asked, because none implies another: - the command table is open (`Ps1TypeWorld.command_table_closed` is false), so a dot-sourced file, an imported module, an `iex` or an aliasing cmdlet defines or imports a command the walk never read. A pure type-system mutation — a `class`, `Add-Type` — is *not* one of these: it opens the wider `closed_for_the_whole_run` but not the command table, since it binds no command name, so the graph stays readable beside it; - an invocation dispatches opaquely (`& $f`), so a name is reached without being written; - an assignment binds the `function:` or `alias:` namespace, which defines a command under a spelling the definition scan does not read — and leaves the world closed while doing it, since `${function:j} = { }` remaps nothing in the type system; - a function definition carries a function-level `[Alias]` attribute, the declarative form of the same binding: it names a second command for that function under a spelling the definition scan, keyed on the function's own name, does not read; see `_declares_a_command_alias`; - the script calls `Export-ModuleMember`, which hands a name to a caller outside the file; - a call written under a qualifier resolves onto a name this script defines; see `_collides_with_a_definition`. Every consumer reads this as fail-closed: an unreadable graph keeps more, never less. The first two rows are the ones `refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` switches off, being the two that say a name is reached by code nobody can read; see `build_call_graph`. The `Export-ModuleMember` row catches the explicit export and not the general case. A `.psm1` with no export statement exports every function by default, and nothing in a file says it is a module — the assumption the bare-output default rests on, stated where a caller can see it in `refinery.units.scripting.dps1`. """ return self._readable @property def exports_a_name(self) -> bool: """ Whether this script hands a command name to code outside the file, which is one of the six things `is_readable` reads and the only one that says a *definition* has a reader elsewhere. The distinction is worth the separate name because the two questions have different answers for the same script. An `Invoke-Expression` opens the type world and could in principle call anything, and every pass here has long accepted that risk in exchange for resolving the trampolines obfuscators are built out of. An export is not a risk taken for anything: the script says in as many words that a caller it cannot see will call this name, so a pass that deletes the definition deletes a reachable entry point. """ return self._exports def may_run_a_body(self, node: Node) -> bool: """ Whether evaluating *node* itself may run a body of this script, or code the script does not spell, which an error handler around it then guards: a script block, which runs wherever something runs it; a call that runs or makes one; or a command that dispatches to a target the source does not spell, runs code nobody can read, or names a function or keyword body this script defines. Where this graph is not the whole story, a name no cmdlet answers to may name a body code nobody can read defined; a name a cmdlet answers to is read as that cmdlet, the risk every pass here takes in exchange for reading through an `iex` at all. """ if isinstance(node, Ps1ScriptBlock): return True if isinstance(node, Ps1InvokeMember): return ( is_scriptblock_invoke(node) or may_create_a_scriptblock(node) or is_execution_context_invoke(node) ) if not isinstance(node, Ps1CommandInvocation): return False if is_opaque_dispatch(node) or runs_code_it_cannot_read(node): return True name = get_command_name(node) if name is None or self.definitions(name) or self.keyword_definitions(name): return True if self._readable: return False resolved = resolve_command_name(node) return resolved is None or resolved not in KNOWN_CMDLETS def definitions(self, name: str) -> Sequence[Ps1FunctionDefinition]: """ Every function definition a call to `name` could reach, in source order. """ return self._definitions.get(normalize_command_name(name), ()) def keyword_definitions(self, name: str) -> Sequence[Ps1ScriptBlock]: """ The scriptblock bodies a `workflow`/`configuration` statement binds to `name`, in source order. These define the same command name a `function` does but are not themselves a `refinery.lib.scripts.ps1.model.Ps1FunctionDefinition`, so a caller weighing whether a name is inert reads them beside `definitions`: the empty `function` a call names may be shadowed by a `workflow` whose body acts. """ return self._keyword_definitions.get(normalize_command_name(name), ()) def call_sites(self, name: str) -> Sequence[Ps1CallSite]: """ Every invocation of `name` standing in this tree, in source order, wherever it sits. This is what separates the graph from the reachability query below, which reads only the edges it can reach from the script itself. """ return self._call_sites.get(normalize_command_name(name), ()) @property def unattributed_sites(self) -> Sequence[Node]: """ Every node standing in this tree that runs code this analysis cannot read — a dispatch whose target the source does not spell (`& $f`), a string run as code (`iex $c`), a block made or run from one — in source order, under either reading of such code: it may call any function this script defines, and a later step may spell the very call it makes (`$f = 'f1'; & $f`, `iex 'f1'`), so a reader asking where a body may run reads these beside the sites that name it. """ return self._unattributed_sites @property def defined_names(self) -> Sequence[str]: """ Every key this script defines a function under, in source order of the first definition. """ return tuple(self._definitions) def reachable_names(self) -> frozenset[str]: """ Every command name some path from the script itself can reach: the names called outside any function, then the names those functions call, to a fixpoint. A function whose key is absent is called from nowhere in this tree. An unreadable graph answers with every name it saw, defined or called, since a name reached from outside is reached by a path this cannot trace. """ if not self._readable or self._hands_out_every: return frozenset(self._definitions) | frozenset(self._call_sites) calls_from: dict[str | None, set[str]] = {} calls_from[None] = set(self._handed_out) for name, sites in self._call_sites.items(): for site in sites: calls_from.setdefault(site.caller, set()).add(name) reachable = set(calls_from.get(None, ())) frontier = [name for name in reachable if name in self._definitions] while frontier: for name in calls_from.get(frontier.pop(), ()): if name in reachable: continue reachable.add(name) if name in self._definitions: frontier.append(name) return frozenset(reachable)Instance variables
var is_readable-
Whether this tree is the whole story about what a command name denotes and who reaches it. Six independent things say it is not, and every one of them has to be asked, because none implies another:
- the command table is open (
Ps1TypeWorld.command_table_closedis false), so a dot-sourced file, an imported module, aniexor an aliasing cmdlet defines or imports a command the walk never read. A pure type-system mutation — aclass,Add-Type— is not one of these: it opens the widerclosed_for_the_whole_runbut not the command table, since it binds no command name, so the graph stays readable beside it; - an invocation dispatches opaquely (
& $f), so a name is reached without being written; - an assignment binds the
function:oralias:namespace, which defines a command under a spelling the definition scan does not read — and leaves the world closed while doing it, since${function:j} = { }remaps nothing in the type system; - a function definition carries a function-level
[Alias]attribute, the declarative form of the same binding: it names a second command for that function under a spelling the definition scan, keyed on the function's own name, does not read; see_declares_a_command_alias; - the script calls
Export-ModuleMember, which hands a name to a caller outside the file; - a call written under a qualifier resolves onto a name this script defines; see
_collides_with_a_definition.
Every consumer reads this as fail-closed: an unreadable graph keeps more, never less.
The first two rows are the ones
Ps1DeobfuscationOptions.trust_evalswitches off, being the two that say a name is reached by code nobody can read; seebuild_call_graph().The
Export-ModuleMemberrow catches the explicit export and not the general case. A.psm1with no export statement exports every function by default, and nothing in a file says it is a module — the assumption the bare-output default rests on, stated where a caller can see it inrefinery.units.scripting.dps1.Expand source code Browse git
@property def is_readable(self) -> bool: """ Whether this tree is the whole story about what a command name denotes and who reaches it. Six independent things say it is not, and every one of them has to be asked, because none implies another: - the command table is open (`Ps1TypeWorld.command_table_closed` is false), so a dot-sourced file, an imported module, an `iex` or an aliasing cmdlet defines or imports a command the walk never read. A pure type-system mutation — a `class`, `Add-Type` — is *not* one of these: it opens the wider `closed_for_the_whole_run` but not the command table, since it binds no command name, so the graph stays readable beside it; - an invocation dispatches opaquely (`& $f`), so a name is reached without being written; - an assignment binds the `function:` or `alias:` namespace, which defines a command under a spelling the definition scan does not read — and leaves the world closed while doing it, since `${function:j} = { }` remaps nothing in the type system; - a function definition carries a function-level `[Alias]` attribute, the declarative form of the same binding: it names a second command for that function under a spelling the definition scan, keyed on the function's own name, does not read; see `_declares_a_command_alias`; - the script calls `Export-ModuleMember`, which hands a name to a caller outside the file; - a call written under a qualifier resolves onto a name this script defines; see `_collides_with_a_definition`. Every consumer reads this as fail-closed: an unreadable graph keeps more, never less. The first two rows are the ones `refinery.lib.scripts.ps1.options.Ps1DeobfuscationOptions.trust_eval` switches off, being the two that say a name is reached by code nobody can read; see `build_call_graph`. The `Export-ModuleMember` row catches the explicit export and not the general case. A `.psm1` with no export statement exports every function by default, and nothing in a file says it is a module — the assumption the bare-output default rests on, stated where a caller can see it in `refinery.units.scripting.dps1`. """ return self._readable - the command table is open (
var exports_a_name-
Whether this script hands a command name to code outside the file, which is one of the six things
is_readablereads and the only one that says a definition has a reader elsewhere.The distinction is worth the separate name because the two questions have different answers for the same script. An
Invoke-Expressionopens the type world and could in principle call anything, and every pass here has long accepted that risk in exchange for resolving the trampolines obfuscators are built out of. An export is not a risk taken for anything: the script says in as many words that a caller it cannot see will call this name, so a pass that deletes the definition deletes a reachable entry point.Expand source code Browse git
@property def exports_a_name(self) -> bool: """ Whether this script hands a command name to code outside the file, which is one of the six things `is_readable` reads and the only one that says a *definition* has a reader elsewhere. The distinction is worth the separate name because the two questions have different answers for the same script. An `Invoke-Expression` opens the type world and could in principle call anything, and every pass here has long accepted that risk in exchange for resolving the trampolines obfuscators are built out of. An export is not a risk taken for anything: the script says in as many words that a caller it cannot see will call this name, so a pass that deletes the definition deletes a reachable entry point. """ return self._exports var unattributed_sites-
Every node standing in this tree that runs code this analysis cannot read — a dispatch whose target the source does not spell (
& $f), a string run as code (iex $c), a block made or run from one — in source order, under either reading of such code: it may call any function this script defines, and a later step may spell the very call it makes ($f = 'f1'; & $f<code>, </code>iex 'f1'), so a reader asking where a body may run reads these beside the sites that name it.Expand source code Browse git
@property def unattributed_sites(self) -> Sequence[Node]: """ Every node standing in this tree that runs code this analysis cannot read — a dispatch whose target the source does not spell (`& $f`), a string run as code (`iex $c`), a block made or run from one — in source order, under either reading of such code: it may call any function this script defines, and a later step may spell the very call it makes (`$f = 'f1'; & $f`, `iex 'f1'`), so a reader asking where a body may run reads these beside the sites that name it. """ return self._unattributed_sites var defined_names-
Every key this script defines a function under, in source order of the first definition.
Expand source code Browse git
@property def defined_names(self) -> Sequence[str]: """ Every key this script defines a function under, in source order of the first definition. """ return tuple(self._definitions)
Methods
def hands_out(self, name)-
Whether the script may reach the body of the function name other than by calling it by name: it reads
$function:K, writes a path into thefunction:drive, asksGet-Commandfor it, looks a command up through$ExecutionContextor$PSCmdlet, reads$MyInvocationinside it, or does any of these where the name cannot be read. A body handed out this way may run wherever something runs a block, so no list of call sites is the list of places it runs.Expand source code Browse git
def hands_out(self, name: str) -> bool: """ Whether the script may reach the body of the function *name* other than by calling it by name: it reads `$function:K`, writes a path into the `function:` drive, asks `Get-Command` for it, looks a command up through `$ExecutionContext` or `$PSCmdlet`, reads `$MyInvocation` inside it, or does any of these where the name cannot be read. A body handed out this way may run wherever something runs a block, so no list of call sites is the list of places it runs. """ return self._hands_out_every or normalize_command_name(name) in self._handed_out def named_callers(self, body)-
The calls naming the function whose body body is, or
Nonewhere those are not all the calls that may run it besideunattributed_sites: body is no function's, this graph cannot be read, the function is a class method or has no name, or the script hands it out.Expand source code Browse git
def named_callers(self, body: Node) -> list[Node] | None: """ The calls naming the function whose body *body* is, or `None` where those are not all the calls that may run it beside `unattributed_sites`: *body* is no function's, this graph cannot be read, the function is a class method or has no name, or the script hands it out. """ parent = body.parent if ( not self._readable or not isinstance(parent, Ps1FunctionDefinition) or _is_class_method(parent) or not parent.name or self.hands_out(parent.name) ): return None return [site.invocation for site in self.call_sites(parent.name)] def sites_running(self, block, attributed_only=False)-
The calls that may run block, or
Nonewhere they cannot all be named. A function body is run by the calls naming the function (named_callers), and, unless attributed_only, by every call inunattributed_sites. A script block is run by the&or.that invokes it where it is written, and by theForEach-ObjectorWhere-Objectit is handed to.Expand source code Browse git
def sites_running(self, block: Ps1ScriptBlock, attributed_only: bool = False) -> list[Node] | None: """ The calls that may run *block*, or `None` where they cannot all be named. A function body is run by the calls naming the function (`named_callers`), and, unless *attributed_only*, by every call in `unattributed_sites`. A script block is run by the `&` or `.` that invokes it where it is written, and by the `ForEach-Object` or `Where-Object` it is handed to. """ parent = block.parent if isinstance(parent, Ps1FunctionDefinition): named = self.named_callers(block) if named is None or attributed_only: return named return [*named, *self.unattributed_sites] invocation = parent.parent if isinstance(parent, Ps1CommandArgument) else parent if not isinstance(invocation, Ps1CommandInvocation): return None if invocation.name is block: return [invocation] if invocation.invocation_operator in ('&', '.') else None if resolve_command_name(invocation) in ('foreach-object', 'where-object'): return [invocation] return None def may_run_a_body(self, node)-
Whether evaluating node itself may run a body of this script, or code the script does not spell, which an error handler around it then guards: a script block, which runs wherever something runs it; a call that runs or makes one; or a command that dispatches to a target the source does not spell, runs code nobody can read, or names a function or keyword body this script defines. Where this graph is not the whole story, a name no cmdlet answers to may name a body code nobody can read defined; a name a cmdlet answers to is read as that cmdlet, the risk every pass here takes in exchange for reading through an
iexat all.Expand source code Browse git
def may_run_a_body(self, node: Node) -> bool: """ Whether evaluating *node* itself may run a body of this script, or code the script does not spell, which an error handler around it then guards: a script block, which runs wherever something runs it; a call that runs or makes one; or a command that dispatches to a target the source does not spell, runs code nobody can read, or names a function or keyword body this script defines. Where this graph is not the whole story, a name no cmdlet answers to may name a body code nobody can read defined; a name a cmdlet answers to is read as that cmdlet, the risk every pass here takes in exchange for reading through an `iex` at all. """ if isinstance(node, Ps1ScriptBlock): return True if isinstance(node, Ps1InvokeMember): return ( is_scriptblock_invoke(node) or may_create_a_scriptblock(node) or is_execution_context_invoke(node) ) if not isinstance(node, Ps1CommandInvocation): return False if is_opaque_dispatch(node) or runs_code_it_cannot_read(node): return True name = get_command_name(node) if name is None or self.definitions(name) or self.keyword_definitions(name): return True if self._readable: return False resolved = resolve_command_name(node) return resolved is None or resolved not in KNOWN_CMDLETS def definitions(self, name)-
Every function definition a call to
namecould reach, in source order.Expand source code Browse git
def definitions(self, name: str) -> Sequence[Ps1FunctionDefinition]: """ Every function definition a call to `name` could reach, in source order. """ return self._definitions.get(normalize_command_name(name), ()) def keyword_definitions(self, name)-
The scriptblock bodies a
workflow/configurationstatement binds toname, in source order. These define the same command name afunctiondoes but are not themselves aPs1FunctionDefinition, so a caller weighing whether a name is inert reads them besidedefinitions: the emptyfunctiona call names may be shadowed by aworkflowwhose body acts.Expand source code Browse git
def keyword_definitions(self, name: str) -> Sequence[Ps1ScriptBlock]: """ The scriptblock bodies a `workflow`/`configuration` statement binds to `name`, in source order. These define the same command name a `function` does but are not themselves a `refinery.lib.scripts.ps1.model.Ps1FunctionDefinition`, so a caller weighing whether a name is inert reads them beside `definitions`: the empty `function` a call names may be shadowed by a `workflow` whose body acts. """ return self._keyword_definitions.get(normalize_command_name(name), ()) def call_sites(self, name)-
Every invocation of
namestanding in this tree, in source order, wherever it sits. This is what separates the graph from the reachability query below, which reads only the edges it can reach from the script itself.Expand source code Browse git
def call_sites(self, name: str) -> Sequence[Ps1CallSite]: """ Every invocation of `name` standing in this tree, in source order, wherever it sits. This is what separates the graph from the reachability query below, which reads only the edges it can reach from the script itself. """ return self._call_sites.get(normalize_command_name(name), ()) def reachable_names(self)-
Every command name some path from the script itself can reach: the names called outside any function, then the names those functions call, to a fixpoint. A function whose key is absent is called from nowhere in this tree.
An unreadable graph answers with every name it saw, defined or called, since a name reached from outside is reached by a path this cannot trace.
Expand source code Browse git
def reachable_names(self) -> frozenset[str]: """ Every command name some path from the script itself can reach: the names called outside any function, then the names those functions call, to a fixpoint. A function whose key is absent is called from nowhere in this tree. An unreadable graph answers with every name it saw, defined or called, since a name reached from outside is reached by a path this cannot trace. """ if not self._readable or self._hands_out_every: return frozenset(self._definitions) | frozenset(self._call_sites) calls_from: dict[str | None, set[str]] = {} calls_from[None] = set(self._handed_out) for name, sites in self._call_sites.items(): for site in sites: calls_from.setdefault(site.caller, set()).add(name) reachable = set(calls_from.get(None, ())) frontier = [name for name in reachable if name in self._definitions] while frontier: for name in calls_from.get(frontier.pop(), ()): if name in reachable: continue reachable.add(name) if name in self._definitions: frontier.append(name) return frozenset(reachable)
class Ps1BodyCallers (root, calls)-
The calls that may run each script block of the tree root, as
Ps1CallSitesasks for them: read off the call graph calls hands over, which is asked for only once a question needs it.Expand source code Browse git
class Ps1BodyCallers: """ The calls that may run each script block of the tree *root*, as `refinery.lib.scripts.ps1.analysis.dispatch.Ps1CallSites` asks for them: read off the call graph *calls* hands over, which is asked for only once a question needs it. """ def __init__(self, root: Node, calls: Callable[[], Ps1CallGraph]): self._root = root self._calls = calls def bodies(self) -> list[Node]: return [node for node in self._root.walk() if isinstance(node, Ps1ScriptBlock)] def sites_running(self, body: Node) -> list[Node] | None: if not isinstance(body, Ps1ScriptBlock): return None return self._calls().sites_running(body, attributed_only=True) def unattributed_sites(self) -> Sequence[Node]: return self._calls().unattributed_sitesMethods
def bodies(self)-
Expand source code Browse git
def bodies(self) -> list[Node]: return [node for node in self._root.walk() if isinstance(node, Ps1ScriptBlock)] def sites_running(self, body)-
Expand source code Browse git
def sites_running(self, body: Node) -> list[Node] | None: if not isinstance(body, Ps1ScriptBlock): return None return self._calls().sites_running(body, attributed_only=True) def unattributed_sites(self)-
Expand source code Browse git
def unattributed_sites(self) -> Sequence[Node]: return self._calls().unattributed_sites