Module refinery.lib.scripts.pipeline
Dependency-tree-based deobfuscation scheduler.
Transformers are organized into groups of co-dependent transforms that iterate internally until stable. Groups form a DAG: a group only runs once all of its declared dependencies are stable. When any group makes changes, all other groups are marked unstable.
Expand source code Browse git
"""
Dependency-tree-based deobfuscation scheduler.
Transformers are organized into groups of co-dependent transforms that iterate internally until
stable. Groups form a DAG: a group only runs once all of its declared dependencies are stable. When
any group makes changes, all other groups are marked unstable.
"""
from __future__ import annotations
from refinery.lib.scripts import AnalysisCache, Node, Transformer
class DeobfuscationTimeout(Exception):
"""
Raised when the pipeline exceeds the maximum number of transformation steps. The exception
names the group and the transformer whose change exhausted the budget; with an oscillating
fixpoint that transformer is the one that failed to settle.
"""
def __init__(self, group: str, transformer: str):
super().__init__(F'transformer {transformer} in group {group} exceeded the step budget')
self.group = group
self.transformer = transformer
class PipelineObserver:
"""
A hook the scheduler calls around every transformer invocation, so that a property of the tree can
be read before and after and the pass that changed it can be named. The scheduler knows nothing
about the property: an observer for a particular language reads whatever that language's meaning
depends on, and the hook only says when to look and on whose behalf.
An observer sees a pass that ran and what it left behind. It cannot see a pass that examined
something and declined, which reports no change and is indistinguishable here from one that found
nothing to do — a refusal costs recall silently, and naming it needs a channel out of the pass
itself rather than a hook around it.
Every `before` is answered exactly once, by `after` when the transformer returns and by `failed`
when it raises. `after` is not called on the failure path, because a transformer that raises
leaves the tree half-edited and a reading taken from it would be reported as the pass's result;
`failed` exists so an observer holding the reading taken in `before`, and through it the tree,
can drop both rather than keep them for as long as it lives and compare the next pass against
them. An `Exception` out of `failed` is suppressed, because it runs while the transformer's own
exception unwinds and would otherwise replace the exception the caller needs. A `BaseException`
is not, since `KeyboardInterrupt` is the caller asking for the run to end and no exception in
flight outranks that. Nothing out of `after` is suppressed: the transformer returned, so there
is no exception to mask, and an observer that cannot read a tree a pass finished with is a
defect of the observer that the caller has to be shown.
"""
def before(self, group: str, transformer: type[Transformer], ast: Node) -> None:
"""
Called with the tree as it stands before *transformer* runs.
"""
def after(
self, group: str, transformer: type[Transformer], ast: Node, changed: bool,
) -> None:
"""
Called with the tree as *transformer* left it, and whether it reported a change.
"""
def failed(self, group: str, transformer: type[Transformer]) -> None:
"""
Called instead of `after` when *transformer* raised. The tree is not passed, because the
state it was left in is not the pass's result and must not be read as one.
"""
class TransformerGroup:
"""
A named set of co-dependent transformers that iterate until stable.
"""
def __init__(self, name: str, *transformers: type[Transformer]):
self.name = name
self.transformers = transformers
def run(
self,
ast: Node,
steps: int = 0,
max_steps: int = 0,
models: AnalysisCache | None = None,
options: object | None = None,
observer: PipelineObserver | None = None,
) -> tuple[bool, int]:
"""
Run all transformers in a loop until none report changes. Returns (changed, steps) where
changed indicates whether any transformation was applied and steps is the updated step
counter. Each transformer instance shares the *models* cache so it reuses the run's analysis
models instead of rebuilding them, and invalidates that cache when it changes the tree. The
*options* value is attached to every transformer so language-specific transforms can read
caller-supplied settings, and *observer* is called around each one.
"""
changed = False
active = set(range(len(self.transformers)))
while True:
round_changed = False
for i, cls in enumerate(self.transformers):
if i not in active:
continue
t = cls()
t.models = models
t.options = options
if observer is None:
t.visit(ast)
else:
observer.before(self.name, cls, ast)
try:
t.visit(ast)
except BaseException:
try:
observer.failed(self.name, cls)
except Exception:
pass
raise
observer.after(self.name, cls, ast, t.changed)
if t.changed:
steps += 1
round_changed = True
active = set(range(len(self.transformers)))
if cls.self_converging:
active.discard(i)
if max_steps and steps > max_steps:
raise DeobfuscationTimeout(self.name, cls.__name__)
else:
active.discard(i)
if not round_changed:
break
changed = True
return changed, steps
class DeobfuscationPipeline:
"""
Scheduler that runs transformer groups respecting a dependency DAG.
Groups are run in declaration order, skipping any whose dependencies are not yet stable. When a
group makes changes, all other groups are invalidated unless a selective invalidation set is
configured for that group. The pipeline terminates when every group is stable.
"""
def __init__(
self,
groups: list[TransformerGroup],
dependencies: dict[str, set[str]] | None = None,
invalidators: dict[str, set[str]] | None = None,
):
self._groups = {g.name: g for g in groups}
self._pipeline = [g.name for g in groups]
self._dependencies = dependencies or {}
self._invalidators = invalidators or {}
all_names = set(self._pipeline)
for name, deps in self._dependencies.items():
if name not in all_names:
raise ValueError(F'unknown group in dependencies: {name!r}')
if unknown := deps - all_names:
raise ValueError(F'group {name!r} depends on unknown groups: {unknown}')
for name, targets in self._invalidators.items():
if name not in all_names:
raise ValueError(F'unknown group in invalidators: {name!r}')
if unknown := targets - all_names:
raise ValueError(F'group {name!r} invalidates unknown groups: {unknown}')
# TODO: refuse an invalidation set that omits a group depending on this one. Absent from
# `invalidators` a group invalidates everything, so an entry is an optimization that has to
# keep listing every group transitively downstream of it by hand, and dropping one is
# silent: that group stays stable and never sees the tree the groups it depends on went on
# to change. The `js` pipeline has such a gap today — `cleanup` depends on `fold` and
# appears in no invalidation set, so nothing re-opens it once it has run. Adding the check
# here means fixing that in the same commit, which is why it waits for the `js` branch.
def run(
self,
ast: Node,
max_steps: int = 0,
initial_steps: int = 0,
models: AnalysisCache | None = None,
options: object | None = None,
observer: PipelineObserver | None = None,
) -> int:
"""
Execute the pipeline. Returns the total number of transformer invocations that resulted in a
change, including `initial_steps` carried over from an earlier phase so that a shared
`max_steps` budget is enforced across phases. A return value equal to `initial_steps` means
the pipeline was already stable. When *models* is given, every transformer in the run shares
that analysis cache. The *options* value is passed through to every transformer, and
*observer* is called around each one.
"""
stable: set[str] = set()
steps = initial_steps
while True:
progress = False
for name in self._pipeline:
if name in stable:
continue
if (d := self._dependencies.get(name)) and not d <= stable:
continue
group = self._groups[name]
changed, steps = group.run(ast, steps, max_steps, models, options, observer)
stable.add(name)
if changed:
targets = self._invalidators.get(name)
if targets is None:
stable = {name}
else:
stable -= targets
progress = True
break
progress = True
if not progress:
break
return steps
Classes
class DeobfuscationTimeout (group, transformer)-
Raised when the pipeline exceeds the maximum number of transformation steps. The exception names the group and the transformer whose change exhausted the budget; with an oscillating fixpoint that transformer is the one that failed to settle.
Expand source code Browse git
class DeobfuscationTimeout(Exception): """ Raised when the pipeline exceeds the maximum number of transformation steps. The exception names the group and the transformer whose change exhausted the budget; with an oscillating fixpoint that transformer is the one that failed to settle. """ def __init__(self, group: str, transformer: str): super().__init__(F'transformer {transformer} in group {group} exceeded the step budget') self.group = group self.transformer = transformerAncestors
- builtins.Exception
- builtins.BaseException
class PipelineObserver-
A hook the scheduler calls around every transformer invocation, so that a property of the tree can be read before and after and the pass that changed it can be named. The scheduler knows nothing about the property: an observer for a particular language reads whatever that language's meaning depends on, and the hook only says when to look and on whose behalf.
An observer sees a pass that ran and what it left behind. It cannot see a pass that examined something and declined, which reports no change and is indistinguishable here from one that found nothing to do — a refusal costs recall silently, and naming it needs a channel out of the pass itself rather than a hook around it.
Every
beforeis answered exactly once, byafterwhen the transformer returns and byfailedwhen it raises.afteris not called on the failure path, because a transformer that raises leaves the tree half-edited and a reading taken from it would be reported as the pass's result;failedexists so an observer holding the reading taken inbefore, and through it the tree, can drop both rather than keep them for as long as it lives and compare the next pass against them. AnExceptionout offailedis suppressed, because it runs while the transformer's own exception unwinds and would otherwise replace the exception the caller needs. ABaseExceptionis not, sinceKeyboardInterruptis the caller asking for the run to end and no exception in flight outranks that. Nothing out ofafteris suppressed: the transformer returned, so there is no exception to mask, and an observer that cannot read a tree a pass finished with is a defect of the observer that the caller has to be shown.Expand source code Browse git
class PipelineObserver: """ A hook the scheduler calls around every transformer invocation, so that a property of the tree can be read before and after and the pass that changed it can be named. The scheduler knows nothing about the property: an observer for a particular language reads whatever that language's meaning depends on, and the hook only says when to look and on whose behalf. An observer sees a pass that ran and what it left behind. It cannot see a pass that examined something and declined, which reports no change and is indistinguishable here from one that found nothing to do — a refusal costs recall silently, and naming it needs a channel out of the pass itself rather than a hook around it. Every `before` is answered exactly once, by `after` when the transformer returns and by `failed` when it raises. `after` is not called on the failure path, because a transformer that raises leaves the tree half-edited and a reading taken from it would be reported as the pass's result; `failed` exists so an observer holding the reading taken in `before`, and through it the tree, can drop both rather than keep them for as long as it lives and compare the next pass against them. An `Exception` out of `failed` is suppressed, because it runs while the transformer's own exception unwinds and would otherwise replace the exception the caller needs. A `BaseException` is not, since `KeyboardInterrupt` is the caller asking for the run to end and no exception in flight outranks that. Nothing out of `after` is suppressed: the transformer returned, so there is no exception to mask, and an observer that cannot read a tree a pass finished with is a defect of the observer that the caller has to be shown. """ def before(self, group: str, transformer: type[Transformer], ast: Node) -> None: """ Called with the tree as it stands before *transformer* runs. """ def after( self, group: str, transformer: type[Transformer], ast: Node, changed: bool, ) -> None: """ Called with the tree as *transformer* left it, and whether it reported a change. """ def failed(self, group: str, transformer: type[Transformer]) -> None: """ Called instead of `after` when *transformer* raised. The tree is not passed, because the state it was left in is not the pass's result and must not be read as one. """Subclasses
Methods
def before(self, group, transformer, ast)-
Called with the tree as it stands before transformer runs.
Expand source code Browse git
def before(self, group: str, transformer: type[Transformer], ast: Node) -> None: """ Called with the tree as it stands before *transformer* runs. """ def after(self, group, transformer, ast, changed)-
Called with the tree as transformer left it, and whether it reported a change.
Expand source code Browse git
def after( self, group: str, transformer: type[Transformer], ast: Node, changed: bool, ) -> None: """ Called with the tree as *transformer* left it, and whether it reported a change. """ def failed(self, group, transformer)-
Called instead of
afterwhen transformer raised. The tree is not passed, because the state it was left in is not the pass's result and must not be read as one.Expand source code Browse git
def failed(self, group: str, transformer: type[Transformer]) -> None: """ Called instead of `after` when *transformer* raised. The tree is not passed, because the state it was left in is not the pass's result and must not be read as one. """
class TransformerGroup (name, *transformers)-
A named set of co-dependent transformers that iterate until stable.
Expand source code Browse git
class TransformerGroup: """ A named set of co-dependent transformers that iterate until stable. """ def __init__(self, name: str, *transformers: type[Transformer]): self.name = name self.transformers = transformers def run( self, ast: Node, steps: int = 0, max_steps: int = 0, models: AnalysisCache | None = None, options: object | None = None, observer: PipelineObserver | None = None, ) -> tuple[bool, int]: """ Run all transformers in a loop until none report changes. Returns (changed, steps) where changed indicates whether any transformation was applied and steps is the updated step counter. Each transformer instance shares the *models* cache so it reuses the run's analysis models instead of rebuilding them, and invalidates that cache when it changes the tree. The *options* value is attached to every transformer so language-specific transforms can read caller-supplied settings, and *observer* is called around each one. """ changed = False active = set(range(len(self.transformers))) while True: round_changed = False for i, cls in enumerate(self.transformers): if i not in active: continue t = cls() t.models = models t.options = options if observer is None: t.visit(ast) else: observer.before(self.name, cls, ast) try: t.visit(ast) except BaseException: try: observer.failed(self.name, cls) except Exception: pass raise observer.after(self.name, cls, ast, t.changed) if t.changed: steps += 1 round_changed = True active = set(range(len(self.transformers))) if cls.self_converging: active.discard(i) if max_steps and steps > max_steps: raise DeobfuscationTimeout(self.name, cls.__name__) else: active.discard(i) if not round_changed: break changed = True return changed, stepsMethods
def run(self, ast, steps=0, max_steps=0, models=None, options=None, observer=None)-
Run all transformers in a loop until none report changes. Returns (changed, steps) where changed indicates whether any transformation was applied and steps is the updated step counter. Each transformer instance shares the models cache so it reuses the run's analysis models instead of rebuilding them, and invalidates that cache when it changes the tree. The options value is attached to every transformer so language-specific transforms can read caller-supplied settings, and observer is called around each one.
Expand source code Browse git
def run( self, ast: Node, steps: int = 0, max_steps: int = 0, models: AnalysisCache | None = None, options: object | None = None, observer: PipelineObserver | None = None, ) -> tuple[bool, int]: """ Run all transformers in a loop until none report changes. Returns (changed, steps) where changed indicates whether any transformation was applied and steps is the updated step counter. Each transformer instance shares the *models* cache so it reuses the run's analysis models instead of rebuilding them, and invalidates that cache when it changes the tree. The *options* value is attached to every transformer so language-specific transforms can read caller-supplied settings, and *observer* is called around each one. """ changed = False active = set(range(len(self.transformers))) while True: round_changed = False for i, cls in enumerate(self.transformers): if i not in active: continue t = cls() t.models = models t.options = options if observer is None: t.visit(ast) else: observer.before(self.name, cls, ast) try: t.visit(ast) except BaseException: try: observer.failed(self.name, cls) except Exception: pass raise observer.after(self.name, cls, ast, t.changed) if t.changed: steps += 1 round_changed = True active = set(range(len(self.transformers))) if cls.self_converging: active.discard(i) if max_steps and steps > max_steps: raise DeobfuscationTimeout(self.name, cls.__name__) else: active.discard(i) if not round_changed: break changed = True return changed, steps
class DeobfuscationPipeline (groups, dependencies=None, invalidators=None)-
Scheduler that runs transformer groups respecting a dependency DAG.
Groups are run in declaration order, skipping any whose dependencies are not yet stable. When a group makes changes, all other groups are invalidated unless a selective invalidation set is configured for that group. The pipeline terminates when every group is stable.
Expand source code Browse git
class DeobfuscationPipeline: """ Scheduler that runs transformer groups respecting a dependency DAG. Groups are run in declaration order, skipping any whose dependencies are not yet stable. When a group makes changes, all other groups are invalidated unless a selective invalidation set is configured for that group. The pipeline terminates when every group is stable. """ def __init__( self, groups: list[TransformerGroup], dependencies: dict[str, set[str]] | None = None, invalidators: dict[str, set[str]] | None = None, ): self._groups = {g.name: g for g in groups} self._pipeline = [g.name for g in groups] self._dependencies = dependencies or {} self._invalidators = invalidators or {} all_names = set(self._pipeline) for name, deps in self._dependencies.items(): if name not in all_names: raise ValueError(F'unknown group in dependencies: {name!r}') if unknown := deps - all_names: raise ValueError(F'group {name!r} depends on unknown groups: {unknown}') for name, targets in self._invalidators.items(): if name not in all_names: raise ValueError(F'unknown group in invalidators: {name!r}') if unknown := targets - all_names: raise ValueError(F'group {name!r} invalidates unknown groups: {unknown}') # TODO: refuse an invalidation set that omits a group depending on this one. Absent from # `invalidators` a group invalidates everything, so an entry is an optimization that has to # keep listing every group transitively downstream of it by hand, and dropping one is # silent: that group stays stable and never sees the tree the groups it depends on went on # to change. The `js` pipeline has such a gap today — `cleanup` depends on `fold` and # appears in no invalidation set, so nothing re-opens it once it has run. Adding the check # here means fixing that in the same commit, which is why it waits for the `js` branch. def run( self, ast: Node, max_steps: int = 0, initial_steps: int = 0, models: AnalysisCache | None = None, options: object | None = None, observer: PipelineObserver | None = None, ) -> int: """ Execute the pipeline. Returns the total number of transformer invocations that resulted in a change, including `initial_steps` carried over from an earlier phase so that a shared `max_steps` budget is enforced across phases. A return value equal to `initial_steps` means the pipeline was already stable. When *models* is given, every transformer in the run shares that analysis cache. The *options* value is passed through to every transformer, and *observer* is called around each one. """ stable: set[str] = set() steps = initial_steps while True: progress = False for name in self._pipeline: if name in stable: continue if (d := self._dependencies.get(name)) and not d <= stable: continue group = self._groups[name] changed, steps = group.run(ast, steps, max_steps, models, options, observer) stable.add(name) if changed: targets = self._invalidators.get(name) if targets is None: stable = {name} else: stable -= targets progress = True break progress = True if not progress: break return stepsMethods
def run(self, ast, max_steps=0, initial_steps=0, models=None, options=None, observer=None)-
Execute the pipeline. Returns the total number of transformer invocations that resulted in a change, including
initial_stepscarried over from an earlier phase so that a sharedmax_stepsbudget is enforced across phases. A return value equal toinitial_stepsmeans the pipeline was already stable. When models is given, every transformer in the run shares that analysis cache. The options value is passed through to every transformer, and observer is called around each one.Expand source code Browse git
def run( self, ast: Node, max_steps: int = 0, initial_steps: int = 0, models: AnalysisCache | None = None, options: object | None = None, observer: PipelineObserver | None = None, ) -> int: """ Execute the pipeline. Returns the total number of transformer invocations that resulted in a change, including `initial_steps` carried over from an earlier phase so that a shared `max_steps` budget is enforced across phases. A return value equal to `initial_steps` means the pipeline was already stable. When *models* is given, every transformer in the run shares that analysis cache. The *options* value is passed through to every transformer, and *observer* is called around each one. """ stable: set[str] = set() steps = initial_steps while True: progress = False for name in self._pipeline: if name in stable: continue if (d := self._dependencies.get(name)) and not d <= stable: continue group = self._groups[name] changed, steps = group.run(ast, steps, max_steps, models, options, observer) stable.add(name) if changed: targets = self._invalidators.get(name) if targets is None: stable = {name} else: stable -= targets progress = True break progress = True if not progress: break return steps