mirror of
https://github.com/bikini/patchwork
synced 2026-06-27 08:08:41 +00:00
Add Abyss VM protection mode
This commit is contained in:
@@ -4,6 +4,11 @@ Python source obfuscator. Give it a `.py` file, get back a single self-contained
|
||||
|
||||
Each build is unique by default. Pass `--seed N` if you want a reproducible one.
|
||||
|
||||
Version 0.6 adds optional Abyss VM protection for selected functions. Protected
|
||||
function bodies are lowered into encrypted VM assets before CPython compilation,
|
||||
then executed by a generated per-build runtime instead of being restored as
|
||||
ordinary Python code objects.
|
||||
|
||||
Version 0.5 adds a shared encoded literal pool, randomized decode paths, more
|
||||
integer rewrite forms, and a reserved-import regression fix for reproducible
|
||||
stress-tested builds.
|
||||
@@ -17,6 +22,19 @@ keep-name files, dry runs, stats JSON, HTML reports, manifest verification,
|
||||
output budget gates, a version flag, and CI metadata that matches the supported
|
||||
Python versions.
|
||||
|
||||
## 0.6 Improvements
|
||||
|
||||
- `--abyss` and `--abyss-functions` add an opt-in virtualized protection tier
|
||||
for supported pure-ish functions.
|
||||
- Abyss-protected functions are replaced with wrappers before `compile(...)`;
|
||||
their real bodies are stored as encrypted data for a generated stack VM, not
|
||||
as marshaled CPython function code objects.
|
||||
- The generated VM uses per-build randomized opcode values, shuffled dispatch
|
||||
block order, encrypted constant/code assets, and preservation of referenced
|
||||
globals so targeted functions still run under identifier renaming.
|
||||
- Unsupported syntax is refused for explicitly named Abyss functions and skipped
|
||||
during broad `--abyss` auto-protection, keeping the default profile stable.
|
||||
|
||||
## 0.5 Improvements
|
||||
|
||||
- String and bytes literals are now stored in a shared encoded pool and decoded
|
||||
@@ -139,6 +157,8 @@ python -m patchwork INPUT [-o OUTPUT] [options]
|
||||
--no-junk turn off junk dead-branch injection
|
||||
--no-lazy turn off lazy per-function encryption
|
||||
--no-anti-debug turn off runtime anti-debug probes
|
||||
--abyss virtualize eligible functions into encrypted VM assets
|
||||
--abyss-functions NAMES virtualize only these functions, comma-separated or repeatable
|
||||
--audit-only analyze the input and exit without writing output
|
||||
--audit-json PATH write static audit metadata as JSON
|
||||
--manifest PATH write build manifest with hashes/options/audit data
|
||||
@@ -214,6 +234,8 @@ obf = Obfuscator(
|
||||
junk_branches=True,
|
||||
lazy_funcs=True,
|
||||
anti_debug=True,
|
||||
abyss=False,
|
||||
abyss_functions=[],
|
||||
layers=3,
|
||||
stage2_layers=3,
|
||||
keep={'public_api_name'},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .core import Obfuscator, obfuscate, obfuscate_file
|
||||
from .audit import analyze_source, build_manifest, build_stats, verify_manifest
|
||||
|
||||
__version__ = '0.5.0'
|
||||
__version__ = '0.6.0'
|
||||
|
||||
__all__ = [
|
||||
'Obfuscator',
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import json
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .util import gen_bytes
|
||||
|
||||
DISPATCH_NAME = "__pw_ab_dispatch__"
|
||||
ASSETS_NAME = "__pw_ab_assets__"
|
||||
|
||||
_OPS = (
|
||||
"CONST",
|
||||
"LOAD",
|
||||
"STORE",
|
||||
"POP",
|
||||
"DUP",
|
||||
"BIN",
|
||||
"UNARY",
|
||||
"COMPARE_CHAIN",
|
||||
"JUMP",
|
||||
"JUMP_IF_FALSE",
|
||||
"JUMP_IF_TRUE_KEEP",
|
||||
"JUMP_IF_FALSE_KEEP",
|
||||
"CALL",
|
||||
"GET_ATTR",
|
||||
"SUBSCR",
|
||||
"BUILD_SLICE",
|
||||
"BUILD_LIST",
|
||||
"BUILD_TUPLE",
|
||||
"BUILD_SET",
|
||||
"BUILD_DICT",
|
||||
"RETURN",
|
||||
"GET_ITER",
|
||||
"FOR_ITER",
|
||||
"UNPACK",
|
||||
"BUILD_STRING",
|
||||
"FORMAT_VALUE",
|
||||
)
|
||||
|
||||
_BIN_OPS: tuple[tuple[type[ast.operator], str], ...] = (
|
||||
(ast.Add, "add"),
|
||||
(ast.Sub, "sub"),
|
||||
(ast.Mult, "mul"),
|
||||
(ast.MatMult, "matmul"),
|
||||
(ast.Div, "truediv"),
|
||||
(ast.FloorDiv, "floordiv"),
|
||||
(ast.Mod, "mod"),
|
||||
(ast.Pow, "pow"),
|
||||
(ast.LShift, "lshift"),
|
||||
(ast.RShift, "rshift"),
|
||||
(ast.BitOr, "or"),
|
||||
(ast.BitXor, "xor"),
|
||||
(ast.BitAnd, "and"),
|
||||
)
|
||||
|
||||
_UNARY_OPS: tuple[tuple[type[ast.unaryop], str], ...] = (
|
||||
(ast.Invert, "invert"),
|
||||
(ast.Not, "not"),
|
||||
(ast.UAdd, "pos"),
|
||||
(ast.USub, "neg"),
|
||||
)
|
||||
|
||||
_COMPARE_OPS: tuple[tuple[type[ast.cmpop], str], ...] = (
|
||||
(ast.Eq, "eq"),
|
||||
(ast.NotEq, "ne"),
|
||||
(ast.Lt, "lt"),
|
||||
(ast.LtE, "le"),
|
||||
(ast.Gt, "gt"),
|
||||
(ast.GtE, "ge"),
|
||||
(ast.Is, "is"),
|
||||
(ast.IsNot, "is_not"),
|
||||
(ast.In, "in"),
|
||||
(ast.NotIn, "not_in"),
|
||||
)
|
||||
|
||||
|
||||
class UnsupportedAbyssNode(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncodedAbyssAssets:
|
||||
payload: str
|
||||
key: str
|
||||
opcodes: dict[str, int]
|
||||
|
||||
|
||||
class _Label:
|
||||
def __init__(self) -> None:
|
||||
self.index: int | None = None
|
||||
|
||||
|
||||
class _Emitter:
|
||||
def __init__(self) -> None:
|
||||
self.instructions: list[list[Any]] = []
|
||||
|
||||
def label(self) -> _Label:
|
||||
return _Label()
|
||||
|
||||
def mark(self, label: _Label) -> None:
|
||||
label.index = len(self.instructions)
|
||||
|
||||
def emit(self, op: str, *args: Any) -> None:
|
||||
self.instructions.append([op, *args])
|
||||
|
||||
def resolve(self) -> list[list[Any]]:
|
||||
resolved: list[list[Any]] = []
|
||||
for inst in self.instructions:
|
||||
op, *args = inst
|
||||
new_args: list[Any] = []
|
||||
for arg in args:
|
||||
if isinstance(arg, _Label):
|
||||
if arg.index is None:
|
||||
raise UnsupportedAbyssNode("internal unresolved VM label")
|
||||
new_args.append(arg.index)
|
||||
else:
|
||||
new_args.append(arg)
|
||||
resolved.append([op, *new_args])
|
||||
return resolved
|
||||
|
||||
|
||||
def _op_name(op: ast.AST, table: tuple[tuple[type[Any], str], ...]) -> str:
|
||||
for cls, name in table:
|
||||
if isinstance(op, cls):
|
||||
return name
|
||||
raise UnsupportedAbyssNode(f"unsupported operator {type(op).__name__}")
|
||||
|
||||
|
||||
def _strip_docstring(body: list[ast.stmt]) -> list[ast.stmt]:
|
||||
if (
|
||||
body
|
||||
and isinstance(body[0], ast.Expr)
|
||||
and isinstance(body[0].value, ast.Constant)
|
||||
and isinstance(body[0].value.value, str)
|
||||
):
|
||||
return body[1:]
|
||||
return body
|
||||
|
||||
|
||||
def _argument_names(args: ast.arguments) -> set[str]:
|
||||
names = {arg.arg for arg in args.posonlyargs + args.args + args.kwonlyargs}
|
||||
if args.vararg is not None:
|
||||
names.add(args.vararg.arg)
|
||||
if args.kwarg is not None:
|
||||
names.add(args.kwarg.arg)
|
||||
return names
|
||||
|
||||
|
||||
def _target_names(target: ast.AST) -> set[str]:
|
||||
if isinstance(target, ast.Name):
|
||||
return {target.id}
|
||||
if isinstance(target, (ast.Tuple, ast.List)):
|
||||
names: set[str] = set()
|
||||
for elt in target.elts:
|
||||
names.update(_target_names(elt))
|
||||
return names
|
||||
return set()
|
||||
|
||||
|
||||
class _ScopeCollector(ast.NodeVisitor):
|
||||
def __init__(self, args: ast.arguments) -> None:
|
||||
self.local_names = set(_argument_names(args))
|
||||
self.global_names: set[str] = set()
|
||||
|
||||
def visit_Global(self, node: ast.Global) -> None:
|
||||
self.global_names.update(node.names)
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
return
|
||||
|
||||
visit_AsyncFunctionDef = visit_FunctionDef
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
return
|
||||
|
||||
def visit_Lambda(self, node: ast.Lambda) -> None:
|
||||
return
|
||||
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
for target in node.targets:
|
||||
self.local_names.update(_target_names(target))
|
||||
self.visit(node.value)
|
||||
|
||||
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
||||
self.local_names.update(_target_names(node.target))
|
||||
if node.value is not None:
|
||||
self.visit(node.value)
|
||||
|
||||
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
||||
self.local_names.update(_target_names(node.target))
|
||||
self.visit(node.value)
|
||||
|
||||
def visit_For(self, node: ast.For) -> None:
|
||||
self.local_names.update(_target_names(node.target))
|
||||
self.visit(node.iter)
|
||||
for stmt in node.body + node.orelse:
|
||||
self.visit(stmt)
|
||||
|
||||
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
||||
self.local_names.update(_target_names(node.target))
|
||||
self.visit(node.value)
|
||||
|
||||
def finalize(self) -> tuple[set[str], set[str]]:
|
||||
return self.local_names - self.global_names, self.global_names
|
||||
|
||||
|
||||
class AbyssCompiler:
|
||||
def __init__(self) -> None:
|
||||
self.emitter = _Emitter()
|
||||
self.constants: list[Any] = []
|
||||
self.local_names: set[str] = set()
|
||||
self.global_names: set[str] = set()
|
||||
self.external_names: set[str] = set()
|
||||
self.loop_stack: list[tuple[_Label, _Label, int]] = []
|
||||
|
||||
def compile(self, node: ast.FunctionDef) -> dict[str, Any]:
|
||||
self._reject_function_shape(node)
|
||||
collector = _ScopeCollector(node.args)
|
||||
for stmt in node.body:
|
||||
collector.visit(stmt)
|
||||
self.local_names, self.global_names = collector.finalize()
|
||||
|
||||
body = _strip_docstring(node.body)
|
||||
if not body:
|
||||
self.emitter.emit("CONST", self._const(None))
|
||||
self.emitter.emit("RETURN")
|
||||
else:
|
||||
for stmt in body:
|
||||
self._stmt(stmt)
|
||||
self.emitter.emit("CONST", self._const(None))
|
||||
self.emitter.emit("RETURN")
|
||||
|
||||
return {
|
||||
"name": node.name,
|
||||
"code": self.emitter.resolve(),
|
||||
"consts": [_encode_const(value) for value in self.constants],
|
||||
"globals": sorted(self.global_names),
|
||||
"locals": sorted(self.local_names),
|
||||
"externals": sorted(self.external_names),
|
||||
}
|
||||
|
||||
def _reject_function_shape(self, node: ast.FunctionDef) -> None:
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, (ast.Yield, ast.YieldFrom, ast.Await)):
|
||||
raise UnsupportedAbyssNode("generators, coroutines, and await are not supported")
|
||||
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
||||
if child is not node:
|
||||
raise UnsupportedAbyssNode("nested functions/classes are not supported")
|
||||
|
||||
def _const(self, value: Any) -> int:
|
||||
idx = len(self.constants)
|
||||
self.constants.append(value)
|
||||
return idx
|
||||
|
||||
def _stmt(self, node: ast.stmt) -> None:
|
||||
if isinstance(node, ast.Return):
|
||||
if node.value is None:
|
||||
self.emitter.emit("CONST", self._const(None))
|
||||
else:
|
||||
self._expr(node.value)
|
||||
self.emitter.emit("RETURN")
|
||||
return
|
||||
if isinstance(node, ast.Assign):
|
||||
self._expr(node.value)
|
||||
for index, target in enumerate(node.targets):
|
||||
if index < len(node.targets) - 1:
|
||||
self.emitter.emit("DUP")
|
||||
self._store_target(target)
|
||||
return
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
if node.value is not None:
|
||||
self._expr(node.value)
|
||||
self._store_target(node.target)
|
||||
return
|
||||
if isinstance(node, ast.AugAssign):
|
||||
if not isinstance(node.target, ast.Name):
|
||||
raise UnsupportedAbyssNode("augmented assignment only supports local/global names")
|
||||
self._expr(ast.Name(id=node.target.id, ctx=ast.Load()))
|
||||
self._expr(node.value)
|
||||
self.emitter.emit("BIN", _op_name(node.op, _BIN_OPS))
|
||||
self.emitter.emit("STORE", node.target.id)
|
||||
return
|
||||
if isinstance(node, ast.Expr):
|
||||
self._expr(node.value)
|
||||
self.emitter.emit("POP")
|
||||
return
|
||||
if isinstance(node, ast.Pass):
|
||||
return
|
||||
if isinstance(node, ast.Global):
|
||||
return
|
||||
if isinstance(node, ast.If):
|
||||
else_label = self.emitter.label()
|
||||
end_label = self.emitter.label()
|
||||
self._expr(node.test)
|
||||
self.emitter.emit("JUMP_IF_FALSE", else_label)
|
||||
for stmt in node.body:
|
||||
self._stmt(stmt)
|
||||
self.emitter.emit("JUMP", end_label)
|
||||
self.emitter.mark(else_label)
|
||||
for stmt in node.orelse:
|
||||
self._stmt(stmt)
|
||||
self.emitter.mark(end_label)
|
||||
return
|
||||
if isinstance(node, ast.While):
|
||||
start_label = self.emitter.label()
|
||||
end_label = self.emitter.label()
|
||||
self.emitter.mark(start_label)
|
||||
self._expr(node.test)
|
||||
self.emitter.emit("JUMP_IF_FALSE", end_label)
|
||||
self.loop_stack.append((start_label, end_label, 0))
|
||||
for stmt in node.body:
|
||||
self._stmt(stmt)
|
||||
self.loop_stack.pop()
|
||||
self.emitter.emit("JUMP", start_label)
|
||||
self.emitter.mark(end_label)
|
||||
if node.orelse:
|
||||
raise UnsupportedAbyssNode("while/else is not supported")
|
||||
return
|
||||
if isinstance(node, ast.For):
|
||||
if node.orelse:
|
||||
raise UnsupportedAbyssNode("for/else is not supported")
|
||||
start_label = self.emitter.label()
|
||||
end_label = self.emitter.label()
|
||||
self._expr(node.iter)
|
||||
self.emitter.emit("GET_ITER")
|
||||
self.emitter.mark(start_label)
|
||||
self.emitter.emit("FOR_ITER", end_label)
|
||||
self._store_target(node.target)
|
||||
self.loop_stack.append((start_label, end_label, 1))
|
||||
for stmt in node.body:
|
||||
self._stmt(stmt)
|
||||
self.loop_stack.pop()
|
||||
self.emitter.emit("JUMP", start_label)
|
||||
self.emitter.mark(end_label)
|
||||
return
|
||||
if isinstance(node, ast.Break):
|
||||
if not self.loop_stack:
|
||||
raise UnsupportedAbyssNode("break outside loop")
|
||||
_, end_label, cleanup = self.loop_stack[-1]
|
||||
for _ in range(cleanup):
|
||||
self.emitter.emit("POP")
|
||||
self.emitter.emit("JUMP", end_label)
|
||||
return
|
||||
if isinstance(node, ast.Continue):
|
||||
if not self.loop_stack:
|
||||
raise UnsupportedAbyssNode("continue outside loop")
|
||||
start_label, _, _ = self.loop_stack[-1]
|
||||
self.emitter.emit("JUMP", start_label)
|
||||
return
|
||||
raise UnsupportedAbyssNode(f"unsupported statement {type(node).__name__}")
|
||||
|
||||
def _store_target(self, target: ast.AST) -> None:
|
||||
if isinstance(target, ast.Name):
|
||||
self.emitter.emit("STORE", target.id)
|
||||
return
|
||||
if isinstance(target, (ast.Tuple, ast.List)):
|
||||
self.emitter.emit("UNPACK", len(target.elts))
|
||||
for elt in target.elts:
|
||||
self._store_target(elt)
|
||||
return
|
||||
raise UnsupportedAbyssNode(f"unsupported assignment target {type(target).__name__}")
|
||||
|
||||
def _expr(self, node: ast.expr) -> None:
|
||||
if isinstance(node, ast.Constant):
|
||||
self.emitter.emit("CONST", self._const(node.value))
|
||||
return
|
||||
if isinstance(node, ast.Name):
|
||||
if isinstance(node.ctx, ast.Load):
|
||||
if node.id not in self.local_names or node.id in self.global_names:
|
||||
self.external_names.add(node.id)
|
||||
self.emitter.emit("LOAD", node.id)
|
||||
return
|
||||
raise UnsupportedAbyssNode("name expression is not load context")
|
||||
if isinstance(node, ast.BinOp):
|
||||
self._expr(node.left)
|
||||
self._expr(node.right)
|
||||
self.emitter.emit("BIN", _op_name(node.op, _BIN_OPS))
|
||||
return
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
self._expr(node.operand)
|
||||
self.emitter.emit("UNARY", _op_name(node.op, _UNARY_OPS))
|
||||
return
|
||||
if isinstance(node, ast.BoolOp):
|
||||
self._bool_op(node)
|
||||
return
|
||||
if isinstance(node, ast.Compare):
|
||||
self._expr(node.left)
|
||||
for comparator in node.comparators:
|
||||
self._expr(comparator)
|
||||
self.emitter.emit("COMPARE_CHAIN", [_op_name(op, _COMPARE_OPS) for op in node.ops])
|
||||
return
|
||||
if isinstance(node, ast.IfExp):
|
||||
else_label = self.emitter.label()
|
||||
end_label = self.emitter.label()
|
||||
self._expr(node.test)
|
||||
self.emitter.emit("JUMP_IF_FALSE", else_label)
|
||||
self._expr(node.body)
|
||||
self.emitter.emit("JUMP", end_label)
|
||||
self.emitter.mark(else_label)
|
||||
self._expr(node.orelse)
|
||||
self.emitter.mark(end_label)
|
||||
return
|
||||
if isinstance(node, ast.Call):
|
||||
if any(isinstance(arg, ast.Starred) for arg in node.args):
|
||||
raise UnsupportedAbyssNode("*args calls are not supported")
|
||||
if any(keyword.arg is None for keyword in node.keywords):
|
||||
raise UnsupportedAbyssNode("**kwargs calls are not supported")
|
||||
self._expr(node.func)
|
||||
for arg in node.args:
|
||||
self._expr(arg)
|
||||
keyword_names: list[str] = []
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg is None:
|
||||
raise UnsupportedAbyssNode("**kwargs calls are not supported")
|
||||
keyword_names.append(keyword.arg)
|
||||
self._expr(keyword.value)
|
||||
self.emitter.emit("CALL", len(node.args), keyword_names)
|
||||
return
|
||||
if isinstance(node, ast.Attribute):
|
||||
self._expr(node.value)
|
||||
self.emitter.emit("GET_ATTR", node.attr)
|
||||
return
|
||||
if isinstance(node, ast.Subscript):
|
||||
self._expr(node.value)
|
||||
self._expr(node.slice)
|
||||
self.emitter.emit("SUBSCR")
|
||||
return
|
||||
if isinstance(node, ast.Slice):
|
||||
for part in (node.lower, node.upper, node.step):
|
||||
if part is None:
|
||||
self.emitter.emit("CONST", self._const(None))
|
||||
else:
|
||||
self._expr(part)
|
||||
self.emitter.emit("BUILD_SLICE")
|
||||
return
|
||||
if isinstance(node, ast.List):
|
||||
self._sequence(node.elts, "BUILD_LIST")
|
||||
return
|
||||
if isinstance(node, ast.Tuple):
|
||||
self._sequence(node.elts, "BUILD_TUPLE")
|
||||
return
|
||||
if isinstance(node, ast.Set):
|
||||
self._sequence(node.elts, "BUILD_SET")
|
||||
return
|
||||
if isinstance(node, ast.Dict):
|
||||
for key, value in zip(node.keys, node.values):
|
||||
if key is None:
|
||||
raise UnsupportedAbyssNode("dictionary unpacking is not supported")
|
||||
self._expr(key)
|
||||
self._expr(value)
|
||||
self.emitter.emit("BUILD_DICT", len(node.keys))
|
||||
return
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
for value in node.values:
|
||||
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
||||
self.emitter.emit("CONST", self._const(value.value))
|
||||
elif isinstance(value, ast.FormattedValue):
|
||||
self._formatted_value(value)
|
||||
else:
|
||||
raise UnsupportedAbyssNode("unsupported f-string part")
|
||||
self.emitter.emit("BUILD_STRING", len(node.values))
|
||||
return
|
||||
if isinstance(node, ast.FormattedValue):
|
||||
self._formatted_value(node)
|
||||
return
|
||||
if isinstance(node, ast.NamedExpr):
|
||||
if not isinstance(node.target, ast.Name):
|
||||
raise UnsupportedAbyssNode("walrus target must be a name")
|
||||
self._expr(node.value)
|
||||
self.emitter.emit("DUP")
|
||||
self._store_target(node.target)
|
||||
return
|
||||
raise UnsupportedAbyssNode(f"unsupported expression {type(node).__name__}")
|
||||
|
||||
def _bool_op(self, node: ast.BoolOp) -> None:
|
||||
if not node.values:
|
||||
raise UnsupportedAbyssNode("empty boolean operation")
|
||||
end_label = self.emitter.label()
|
||||
jump_op = "JUMP_IF_TRUE_KEEP" if isinstance(node.op, ast.Or) else "JUMP_IF_FALSE_KEEP"
|
||||
for index, value in enumerate(node.values):
|
||||
self._expr(value)
|
||||
if index < len(node.values) - 1:
|
||||
self.emitter.emit(jump_op, end_label)
|
||||
self.emitter.mark(end_label)
|
||||
|
||||
def _sequence(self, elts: list[ast.expr], op: str) -> None:
|
||||
for elt in elts:
|
||||
if isinstance(elt, ast.Starred):
|
||||
raise UnsupportedAbyssNode("starred literals are not supported")
|
||||
self._expr(elt)
|
||||
self.emitter.emit(op, len(elts))
|
||||
|
||||
def _formatted_value(self, node: ast.FormattedValue) -> None:
|
||||
self._expr(node.value)
|
||||
if node.format_spec is not None:
|
||||
self._expr(node.format_spec)
|
||||
self.emitter.emit("FORMAT_VALUE", node.conversion, True)
|
||||
else:
|
||||
self.emitter.emit("FORMAT_VALUE", node.conversion, False)
|
||||
|
||||
|
||||
class AbyssTransformer(ast.NodeTransformer):
|
||||
def __init__(
|
||||
self,
|
||||
rng: random.Random,
|
||||
*,
|
||||
targets: set[str] | None = None,
|
||||
auto: bool = False,
|
||||
dispatch_name: str = DISPATCH_NAME,
|
||||
) -> None:
|
||||
self.rng = rng
|
||||
self.targets = set(targets or ())
|
||||
self.auto = auto
|
||||
self.dispatch_name = dispatch_name
|
||||
self.assets: list[dict[str, Any]] = []
|
||||
self.keep_names: set[str] = {dispatch_name}
|
||||
self.protected: list[str] = []
|
||||
self.skipped: list[tuple[str, str]] = []
|
||||
self._matched_targets: set[str] = set()
|
||||
self._class_stack: list[str] = []
|
||||
self._function_depth = 0
|
||||
|
||||
@property
|
||||
def explicit(self) -> bool:
|
||||
return bool(self.targets)
|
||||
|
||||
def protect(self, tree: ast.Module) -> ast.Module:
|
||||
new_tree = self.visit(tree)
|
||||
if not isinstance(new_tree, ast.Module):
|
||||
raise TypeError("expected module")
|
||||
missing = sorted(self.targets - self._matched_targets)
|
||||
if missing:
|
||||
raise ValueError(f"abyss target(s) not found: {', '.join(missing)}")
|
||||
return new_tree
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> ast.AST:
|
||||
self._class_stack.append(node.name)
|
||||
node.body = [self.visit(stmt) for stmt in node.body]
|
||||
self._class_stack.pop()
|
||||
return node
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
|
||||
qualname = ".".join([*self._class_stack, node.name]) if self._class_stack else node.name
|
||||
target_names = {node.name, qualname}
|
||||
targeted = bool(self.targets & target_names)
|
||||
desired = self._function_depth == 0 and (targeted or (self.auto and not self.explicit))
|
||||
if targeted:
|
||||
self._matched_targets.update(self.targets & target_names)
|
||||
if not desired:
|
||||
self._function_depth += 1
|
||||
node = self.generic_visit(node)
|
||||
self._function_depth -= 1
|
||||
return node
|
||||
|
||||
try:
|
||||
asset = AbyssCompiler().compile(node)
|
||||
except UnsupportedAbyssNode as exc:
|
||||
if targeted:
|
||||
raise ValueError(f"abyss target {qualname} is unsupported: {exc}") from exc
|
||||
self.skipped.append((qualname, str(exc)))
|
||||
self._function_depth += 1
|
||||
node = self.generic_visit(node)
|
||||
self._function_depth -= 1
|
||||
return node
|
||||
|
||||
fid = len(self.assets)
|
||||
self.assets.append(asset)
|
||||
self.keep_names.update(asset["externals"])
|
||||
self.protected.append(qualname)
|
||||
node.body = self._wrapper_body(fid, _function_docstring(node))
|
||||
return node
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
|
||||
qualname = ".".join([*self._class_stack, node.name]) if self._class_stack else node.name
|
||||
target_names = {node.name, qualname}
|
||||
targeted = bool(self.targets & target_names)
|
||||
if targeted:
|
||||
self._matched_targets.update(self.targets & target_names)
|
||||
raise ValueError(f"abyss target {qualname} is unsupported: async functions are not supported")
|
||||
return node
|
||||
|
||||
def _wrapper_body(self, fid: int, docstring: str | None) -> list[ast.stmt]:
|
||||
call = ast.Call(
|
||||
func=ast.Name(id=self.dispatch_name, ctx=ast.Load()),
|
||||
args=[
|
||||
ast.Constant(value=fid),
|
||||
ast.Call(func=ast.Name(id="locals", ctx=ast.Load()), args=[], keywords=[]),
|
||||
],
|
||||
keywords=[],
|
||||
)
|
||||
body: list[ast.stmt] = []
|
||||
if docstring is not None:
|
||||
body.append(ast.Expr(value=ast.Constant(value=docstring)))
|
||||
body.append(ast.Return(value=call))
|
||||
return body
|
||||
|
||||
|
||||
def _function_docstring(node: ast.FunctionDef) -> str | None:
|
||||
if (
|
||||
node.body
|
||||
and isinstance(node.body[0], ast.Expr)
|
||||
and isinstance(node.body[0].value, ast.Constant)
|
||||
and isinstance(node.body[0].value.value, str)
|
||||
):
|
||||
return node.body[0].value.value
|
||||
return None
|
||||
|
||||
|
||||
def _encode_const(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {"t": "none"}
|
||||
if value is Ellipsis:
|
||||
return {"t": "ellipsis"}
|
||||
if isinstance(value, bool):
|
||||
return {"t": "bool", "v": value}
|
||||
if isinstance(value, int):
|
||||
return {"t": "int", "v": str(value)}
|
||||
if isinstance(value, float):
|
||||
return {"t": "float", "v": repr(value)}
|
||||
if isinstance(value, str):
|
||||
return {"t": "str", "v": value}
|
||||
if isinstance(value, bytes):
|
||||
return {"t": "bytes", "v": base64.b85encode(value).decode("ascii")}
|
||||
raise UnsupportedAbyssNode(f"unsupported constant type {type(value).__name__}")
|
||||
|
||||
|
||||
def encode_assets(assets: list[dict[str, Any]], rng: random.Random) -> EncodedAbyssAssets:
|
||||
values = rng.sample(range(1, 251), len(_OPS))
|
||||
opcodes = dict(zip(_OPS, values))
|
||||
encoded_funcs: list[dict[str, Any]] = []
|
||||
for asset in assets:
|
||||
encoded_code = [[opcodes[inst[0]], *inst[1:]] for inst in asset["code"]]
|
||||
encoded_funcs.append(
|
||||
{
|
||||
"n": asset["name"],
|
||||
"c": asset["consts"],
|
||||
"g": asset["globals"],
|
||||
"l": asset["locals"],
|
||||
"b": encoded_code,
|
||||
}
|
||||
)
|
||||
document = json.dumps({"v": 1, "f": encoded_funcs}, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
key = gen_bytes(rng, rng.randint(24, 48))
|
||||
encrypted = bytes(byte ^ key[index % len(key)] for index, byte in enumerate(document))
|
||||
return EncodedAbyssAssets(
|
||||
payload=base64.b85encode(encrypted).decode("ascii"),
|
||||
key=base64.b85encode(key).decode("ascii"),
|
||||
opcodes=opcodes,
|
||||
)
|
||||
|
||||
|
||||
def build_runtime_stmts(encoded: EncodedAbyssAssets, rng: random.Random) -> list[ast.stmt]:
|
||||
blocks = _runtime_dispatch_blocks(encoded.opcodes)
|
||||
rng.shuffle(blocks)
|
||||
dispatch_blocks = "\n".join(blocks)
|
||||
source = f'''
|
||||
{ASSETS_NAME} = ({encoded.payload!r}, {encoded.key!r})
|
||||
__pw_ab_cache__ = None
|
||||
|
||||
def __pw_ab_const__(_x):
|
||||
_t = _x['t']
|
||||
if _t == 'none':
|
||||
return None
|
||||
if _t == 'ellipsis':
|
||||
return Ellipsis
|
||||
if _t == 'bool':
|
||||
return bool(_x['v'])
|
||||
if _t == 'int':
|
||||
return int(_x['v'])
|
||||
if _t == 'float':
|
||||
return float(_x['v'])
|
||||
if _t == 'str':
|
||||
return _x['v']
|
||||
if _t == 'bytes':
|
||||
return __import__('base64').b85decode(_x['v'].encode('ascii'))
|
||||
raise RuntimeError('invalid abyss constant')
|
||||
|
||||
def __pw_ab_load__():
|
||||
global __pw_ab_cache__
|
||||
if __pw_ab_cache__ is None:
|
||||
_base64 = __import__('base64')
|
||||
_json = __import__('json')
|
||||
_payload, _key = {ASSETS_NAME}
|
||||
_enc = _base64.b85decode(_payload.encode('ascii'))
|
||||
_raw_key = _base64.b85decode(_key.encode('ascii'))
|
||||
_raw = bytes(_b ^ _raw_key[_i % len(_raw_key)] for _i, _b in enumerate(_enc))
|
||||
_doc = _json.loads(_raw.decode('utf-8'))
|
||||
for _fn in _doc['f']:
|
||||
_fn['c'] = [__pw_ab_const__(_item) for _item in _fn['c']]
|
||||
__pw_ab_cache__ = _doc
|
||||
return __pw_ab_cache__
|
||||
|
||||
def __pw_ab_get__(_name, _locals, _globals, _builtins, _declared_globals, _declared_locals):
|
||||
if _name not in _declared_globals and _name in _locals:
|
||||
return _locals[_name]
|
||||
if _name not in _declared_globals and _name in _declared_locals:
|
||||
raise UnboundLocalError("cannot access local variable '" + _name + "' where it is not associated with a value")
|
||||
if _name in _globals:
|
||||
return _globals[_name]
|
||||
if _name in _builtins:
|
||||
return _builtins[_name]
|
||||
raise NameError(_name)
|
||||
|
||||
def __pw_ab_store__(_name, _value, _locals, _globals, _declared_globals):
|
||||
if _name in _declared_globals:
|
||||
_globals[_name] = _value
|
||||
else:
|
||||
_locals[_name] = _value
|
||||
|
||||
def __pw_ab_bin__(_op, _a, _b):
|
||||
if _op == 'add':
|
||||
return _a + _b
|
||||
if _op == 'sub':
|
||||
return _a - _b
|
||||
if _op == 'mul':
|
||||
return _a * _b
|
||||
if _op == 'matmul':
|
||||
return _a @ _b
|
||||
if _op == 'truediv':
|
||||
return _a / _b
|
||||
if _op == 'floordiv':
|
||||
return _a // _b
|
||||
if _op == 'mod':
|
||||
return _a % _b
|
||||
if _op == 'pow':
|
||||
return _a ** _b
|
||||
if _op == 'lshift':
|
||||
return _a << _b
|
||||
if _op == 'rshift':
|
||||
return _a >> _b
|
||||
if _op == 'or':
|
||||
return _a | _b
|
||||
if _op == 'xor':
|
||||
return _a ^ _b
|
||||
if _op == 'and':
|
||||
return _a & _b
|
||||
raise RuntimeError('invalid abyss binary op')
|
||||
|
||||
def __pw_ab_unary__(_op, _a):
|
||||
if _op == 'invert':
|
||||
return ~_a
|
||||
if _op == 'not':
|
||||
return not _a
|
||||
if _op == 'pos':
|
||||
return +_a
|
||||
if _op == 'neg':
|
||||
return -_a
|
||||
raise RuntimeError('invalid abyss unary op')
|
||||
|
||||
def __pw_ab_compare_one__(_op, _a, _b):
|
||||
if _op == 'eq':
|
||||
return _a == _b
|
||||
if _op == 'ne':
|
||||
return _a != _b
|
||||
if _op == 'lt':
|
||||
return _a < _b
|
||||
if _op == 'le':
|
||||
return _a <= _b
|
||||
if _op == 'gt':
|
||||
return _a > _b
|
||||
if _op == 'ge':
|
||||
return _a >= _b
|
||||
if _op == 'is':
|
||||
return _a is _b
|
||||
if _op == 'is_not':
|
||||
return _a is not _b
|
||||
if _op == 'in':
|
||||
return _a in _b
|
||||
if _op == 'not_in':
|
||||
return _a not in _b
|
||||
raise RuntimeError('invalid abyss compare op')
|
||||
|
||||
def __pw_ab_compare__(_ops, _values):
|
||||
for _idx, _op in enumerate(_ops):
|
||||
if not __pw_ab_compare_one__(_op, _values[_idx], _values[_idx + 1]):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __pw_ab_format__(_value, _conversion, _has_spec, _stack):
|
||||
_spec = _stack.pop() if _has_spec else ''
|
||||
if _conversion == 115:
|
||||
_value = str(_value)
|
||||
elif _conversion == 114:
|
||||
_value = repr(_value)
|
||||
elif _conversion == 97:
|
||||
_value = ascii(_value)
|
||||
return format(_value, _spec)
|
||||
|
||||
def __pw_ab_exec__(_fn, _initial_locals, _globals):
|
||||
_builtins = _globals.get('__builtins__', __builtins__)
|
||||
if not isinstance(_builtins, dict):
|
||||
_builtins = _builtins.__dict__
|
||||
_declared_globals = set(_fn.get('g', ()))
|
||||
_declared_locals = set(_fn.get('l', ()))
|
||||
_locals = dict(_initial_locals)
|
||||
_consts = _fn['c']
|
||||
_code = _fn['b']
|
||||
_stack = []
|
||||
_ip = 0
|
||||
while True:
|
||||
_inst = _code[_ip]
|
||||
_op = _inst[0]
|
||||
{dispatch_blocks}
|
||||
raise RuntimeError('invalid abyss opcode')
|
||||
|
||||
def {DISPATCH_NAME}(_fid, _env):
|
||||
_doc = __pw_ab_load__()
|
||||
return __pw_ab_exec__(_doc['f'][_fid], _env, globals())
|
||||
'''
|
||||
return ast.parse(source).body
|
||||
|
||||
|
||||
def _runtime_dispatch_blocks(opcodes: dict[str, int]) -> list[str]:
|
||||
return [
|
||||
f""" if _op == {opcodes['CONST']}:
|
||||
_stack.append(_consts[_inst[1]])
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['LOAD']}:
|
||||
_stack.append(__pw_ab_get__(_inst[1], _locals, _globals, _builtins, _declared_globals, _declared_locals))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['STORE']}:
|
||||
__pw_ab_store__(_inst[1], _stack.pop(), _locals, _globals, _declared_globals)
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['POP']}:
|
||||
_stack.pop()
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['DUP']}:
|
||||
_stack.append(_stack[-1])
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BIN']}:
|
||||
_b = _stack.pop()
|
||||
_a = _stack.pop()
|
||||
_stack.append(__pw_ab_bin__(_inst[1], _a, _b))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['UNARY']}:
|
||||
_stack.append(__pw_ab_unary__(_inst[1], _stack.pop()))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['COMPARE_CHAIN']}:
|
||||
_ops = _inst[1]
|
||||
_values = [_stack.pop() for _ in range(len(_ops) + 1)]
|
||||
_values.reverse()
|
||||
_stack.append(__pw_ab_compare__(_ops, _values))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['JUMP']}:
|
||||
_ip = _inst[1]
|
||||
continue""",
|
||||
f""" if _op == {opcodes['JUMP_IF_FALSE']}:
|
||||
_ip = _inst[1] if not _stack.pop() else _ip + 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['JUMP_IF_TRUE_KEEP']}:
|
||||
if _stack[-1]:
|
||||
_ip = _inst[1]
|
||||
else:
|
||||
_stack.pop()
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['JUMP_IF_FALSE_KEEP']}:
|
||||
if not _stack[-1]:
|
||||
_ip = _inst[1]
|
||||
else:
|
||||
_stack.pop()
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['CALL']}:
|
||||
_argc = _inst[1]
|
||||
_kw_names = _inst[2]
|
||||
_kw = {{}}
|
||||
for _name in reversed(_kw_names):
|
||||
_kw[_name] = _stack.pop()
|
||||
_args = [_stack.pop() for _ in range(_argc)]
|
||||
_args.reverse()
|
||||
_func = _stack.pop()
|
||||
_stack.append(_func(*_args, **_kw))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['GET_ATTR']}:
|
||||
_stack.append(getattr(_stack.pop(), _inst[1]))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['SUBSCR']}:
|
||||
_key = _stack.pop()
|
||||
_obj = _stack.pop()
|
||||
_stack.append(_obj[_key])
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BUILD_SLICE']}:
|
||||
_step = _stack.pop()
|
||||
_upper = _stack.pop()
|
||||
_lower = _stack.pop()
|
||||
_stack.append(slice(_lower, _upper, _step))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BUILD_LIST']}:
|
||||
_items = [_stack.pop() for _ in range(_inst[1])]
|
||||
_items.reverse()
|
||||
_stack.append(_items)
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BUILD_TUPLE']}:
|
||||
_items = [_stack.pop() for _ in range(_inst[1])]
|
||||
_items.reverse()
|
||||
_stack.append(tuple(_items))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BUILD_SET']}:
|
||||
_items = [_stack.pop() for _ in range(_inst[1])]
|
||||
_items.reverse()
|
||||
_stack.append(set(_items))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BUILD_DICT']}:
|
||||
_items = []
|
||||
for _ in range(_inst[1]):
|
||||
_value = _stack.pop()
|
||||
_key = _stack.pop()
|
||||
_items.append((_key, _value))
|
||||
_items.reverse()
|
||||
_stack.append(dict(_items))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['RETURN']}:
|
||||
return _stack.pop() if _stack else None""",
|
||||
f""" if _op == {opcodes['GET_ITER']}:
|
||||
_stack.append(iter(_stack.pop()))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['FOR_ITER']}:
|
||||
try:
|
||||
_stack.append(next(_stack[-1]))
|
||||
_ip += 1
|
||||
except StopIteration:
|
||||
_stack.pop()
|
||||
_ip = _inst[1]
|
||||
continue""",
|
||||
f""" if _op == {opcodes['UNPACK']}:
|
||||
_items = list(_stack.pop())
|
||||
if len(_items) != _inst[1]:
|
||||
raise ValueError('not enough values to unpack' if len(_items) < _inst[1] else 'too many values to unpack')
|
||||
for _item in reversed(_items):
|
||||
_stack.append(_item)
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['BUILD_STRING']}:
|
||||
_items = [_stack.pop() for _ in range(_inst[1])]
|
||||
_items.reverse()
|
||||
_stack.append(''.join(str(_item) for _item in _items))
|
||||
_ip += 1
|
||||
continue""",
|
||||
f""" if _op == {opcodes['FORMAT_VALUE']}:
|
||||
_stack.append(__pw_ab_format__(_stack.pop() if not _inst[2] else _stack.pop(-2), _inst[1], _inst[2], _stack))
|
||||
_ip += 1
|
||||
continue""",
|
||||
]
|
||||
+17
-2
@@ -33,6 +33,8 @@ def _parser() -> argparse.ArgumentParser:
|
||||
p.add_argument("--no-junk", action="store_false", dest="junk_branches", default=None)
|
||||
p.add_argument("--no-lazy", action="store_false", dest="lazy_funcs", default=None)
|
||||
p.add_argument("--no-anti-debug", action="store_false", dest="anti_debug", default=None)
|
||||
p.add_argument("--abyss", action="store_true", default=None, help="virtualize eligible functions into encrypted Abyss VM assets")
|
||||
p.add_argument("--abyss-functions", action="append", default=None, metavar="NAME[,NAME]", help="only virtualize the named function(s); repeatable or comma-separated")
|
||||
p.add_argument("--audit-only", action="store_true", help="analyze the input and exit without writing obfuscated output")
|
||||
p.add_argument("--audit-json", metavar="PATH", help="write static audit metadata as JSON")
|
||||
p.add_argument("--manifest", metavar="PATH", help="write build manifest with hashes, options, and audit metadata")
|
||||
@@ -47,6 +49,13 @@ def _parser() -> argparse.ArgumentParser:
|
||||
return p
|
||||
|
||||
|
||||
def _split_name_options(values: list[str] | None) -> list[str]:
|
||||
names: list[str] = []
|
||||
for value in values or []:
|
||||
names.extend(part.strip() for part in value.split(",") if part.strip())
|
||||
return names
|
||||
|
||||
|
||||
def _effective_config(args: argparse.Namespace) -> dict[str, object]:
|
||||
config = dict(DEFAULT_CONFIG)
|
||||
config.update(load_config(args.config))
|
||||
@@ -63,6 +72,7 @@ def _effective_config(args: argparse.Namespace) -> dict[str, object]:
|
||||
"junk_branches",
|
||||
"lazy_funcs",
|
||||
"anti_debug",
|
||||
"abyss",
|
||||
"max_output_bytes",
|
||||
"max_ratio",
|
||||
"max_review_indicators",
|
||||
@@ -75,6 +85,9 @@ def _effective_config(args: argparse.Namespace) -> dict[str, object]:
|
||||
keep.update(args.keep or [])
|
||||
keep.update(read_keep_file(args.keep_file))
|
||||
config["keep"] = sorted(keep)
|
||||
abyss_functions = set(config.get("abyss_functions") or [])
|
||||
abyss_functions.update(_split_name_options(args.abyss_functions))
|
||||
config["abyss_functions"] = sorted(abyss_functions)
|
||||
return normalize_config(config)
|
||||
|
||||
|
||||
@@ -88,6 +101,8 @@ def _obfuscator_options(config: dict[str, object]) -> dict[str, object]:
|
||||
"junk_branches": config["junk_branches"],
|
||||
"lazy_funcs": config["lazy_funcs"],
|
||||
"anti_debug": config["anti_debug"],
|
||||
"abyss": config["abyss"],
|
||||
"abyss_functions": config["abyss_functions"],
|
||||
"layers": config["layers"],
|
||||
"stage2_layers": config["stage2_layers"],
|
||||
"keep": sorted(config["keep"]),
|
||||
@@ -102,7 +117,7 @@ def _build_obfuscator(config: dict[str, object]) -> Obfuscator:
|
||||
|
||||
def _gate_errors(config: dict[str, object], audit: dict[str, object], stats: dict[str, object]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
review_count = int(audit.get("review", {}).get("indicator_count", 0)) # type: ignore[union-attr]
|
||||
review_count = int(audit.get("review", {}).get("indicator_count", 0))
|
||||
max_review = config.get("max_review_indicators")
|
||||
if max_review is not None and review_count > int(max_review):
|
||||
errors.append(f"review indicators {review_count} exceed max_review_indicators {max_review}")
|
||||
@@ -122,7 +137,7 @@ def _gate_errors(config: dict[str, object], audit: dict[str, object], stats: dic
|
||||
|
||||
def _print_audit_refusal(audit: dict[str, object]) -> None:
|
||||
print("patchwork: strict audit refused to obfuscate input with review indicators", file=sys.stderr)
|
||||
indicators = audit.get("review", {}).get("indicators", []) # type: ignore[union-attr]
|
||||
indicators = audit.get("review", {}).get("indicators", [])
|
||||
if isinstance(indicators, list):
|
||||
for indicator in indicators:
|
||||
if isinstance(indicator, dict):
|
||||
|
||||
+5
-2
@@ -17,6 +17,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"junk_branches": True,
|
||||
"lazy_funcs": True,
|
||||
"anti_debug": True,
|
||||
"abyss": False,
|
||||
"abyss_functions": [],
|
||||
"keep": [],
|
||||
"max_output_bytes": None,
|
||||
"max_ratio": None,
|
||||
@@ -32,15 +34,16 @@ BOOLEAN_KEYS = {
|
||||
"junk_branches",
|
||||
"lazy_funcs",
|
||||
"anti_debug",
|
||||
"abyss",
|
||||
}
|
||||
|
||||
INTEGER_KEYS = {"seed", "layers", "stage2_layers", "max_output_bytes", "max_review_indicators"}
|
||||
FLOAT_KEYS = {"max_ratio"}
|
||||
LIST_KEYS = {"keep"}
|
||||
LIST_KEYS = {"keep", "abyss_functions"}
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised for invalid Patchwork configuration files."""
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: str | Path | None) -> dict[str, Any]:
|
||||
|
||||
+20
-2
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from .abyss import ASSETS_NAME as _ABYSS_ASSETS_NAME, DISPATCH_NAME as _ABYSS_DISPATCH_NAME, AbyssTransformer, build_runtime_stmts as build_abyss_runtime, encode_assets as encode_abyss_assets
|
||||
from .lazy import encrypt_user_functions
|
||||
from .loader import build_loader
|
||||
from .packer import pack
|
||||
@@ -36,7 +37,7 @@ def _insert_runtime_stmts(tree: ast.Module, stmts: list[ast.stmt]) -> None:
|
||||
|
||||
class Obfuscator:
|
||||
|
||||
def __init__(self, *, seed: int | None=None, rename: bool=True, encrypt_strings: bool=True, obfuscate_numbers: bool=True, opaque_predicates: bool=True, mba: bool=True, junk_branches: bool=True, lazy_funcs: bool=True, anti_debug: bool=True, layers: int=3, stage2_layers: int=3, keep: set[str] | None=None):
|
||||
def __init__(self, *, seed: int | None=None, rename: bool=True, encrypt_strings: bool=True, obfuscate_numbers: bool=True, opaque_predicates: bool=True, mba: bool=True, junk_branches: bool=True, lazy_funcs: bool=True, anti_debug: bool=True, layers: int=3, stage2_layers: int=3, keep: set[str] | None=None, abyss: bool=False, abyss_functions: list[str] | set[str] | tuple[str, ...] | None=None):
|
||||
self.rng, self.seed = make_rng(seed)
|
||||
self.rename = rename
|
||||
self.encrypt_strings = encrypt_strings
|
||||
@@ -49,10 +50,25 @@ class Obfuscator:
|
||||
self.layers = max(1, int(layers))
|
||||
self.stage2_layers = max(1, int(stage2_layers))
|
||||
self.keep_extra = set(keep or ())
|
||||
self.abyss = bool(abyss)
|
||||
self.abyss_functions = set(abyss_functions or ())
|
||||
self.abyss_stats: dict[str, object] = {"protected": [], "skipped": []}
|
||||
|
||||
def obfuscate(self, source: str) -> str:
|
||||
tree = ast.parse(source)
|
||||
skip = collect_skip_names(tree)
|
||||
abyss_runtime_stmts: list[ast.stmt] = []
|
||||
abyss_keep: set[str] = set()
|
||||
if self.abyss or self.abyss_functions:
|
||||
abyss_transformer = AbyssTransformer(self.rng, targets=self.abyss_functions or None, auto=self.abyss)
|
||||
tree = abyss_transformer.protect(tree)
|
||||
abyss_keep = abyss_transformer.keep_names | {_ABYSS_DISPATCH_NAME, _ABYSS_ASSETS_NAME}
|
||||
self.abyss_stats = {
|
||||
"protected": list(abyss_transformer.protected),
|
||||
"skipped": list(abyss_transformer.skipped),
|
||||
}
|
||||
if abyss_transformer.assets:
|
||||
abyss_runtime_stmts = build_abyss_runtime(encode_abyss_assets(abyss_transformer.assets, self.rng), self.rng)
|
||||
seed_used = False
|
||||
if self.opaque_predicates:
|
||||
opi = OpaquePredicateInjector(self.rng, _SEED_NAME)
|
||||
@@ -78,8 +94,10 @@ class Obfuscator:
|
||||
runtime_stmts.extend(build_decrypt_helper(_DEC_S_NAME, _DEC_B_NAME))
|
||||
_insert_runtime_stmts(tree, runtime_stmts)
|
||||
if self.rename:
|
||||
renamer = IdentifierRenamer(self.rng, keep=skip | self.keep_extra | {_RESOLVE_NAME})
|
||||
renamer = IdentifierRenamer(self.rng, keep=skip | self.keep_extra | abyss_keep | {_RESOLVE_NAME})
|
||||
tree = renamer.visit(tree)
|
||||
if abyss_runtime_stmts:
|
||||
_insert_runtime_stmts(tree, abyss_runtime_stmts)
|
||||
ast.fix_missing_locations(tree)
|
||||
user_code = compile(tree, '<patchwork>', 'exec')
|
||||
if self.lazy_funcs:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "patchwork"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "Python source transformation tool with reproducible builds, static audit metadata, and manifest generation"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
import io
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from patchwork import Obfuscator
|
||||
from patchwork.abyss import AbyssTransformer, build_runtime_stmts, encode_assets
|
||||
|
||||
|
||||
def run_source(source: str) -> str:
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
exec(compile(source, "<test>", "exec"), {})
|
||||
return stdout.getvalue()
|
||||
|
||||
|
||||
def run_file(path: Path) -> str:
|
||||
result = subprocess.run([sys.executable, str(path)], capture_output=True, text=True, timeout=20)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"{path} exited {result.returncode}\nstderr:\n{result.stderr}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
class AbyssTests(unittest.TestCase):
|
||||
def test_transform_removes_protected_body_and_preserves_behavior(self) -> None:
|
||||
source = """
|
||||
def secret(limit):
|
||||
marker = "ABYSS_CLEAR_MARKER"
|
||||
total = 0
|
||||
for item in range(limit):
|
||||
if item % 2:
|
||||
total += item * 3
|
||||
else:
|
||||
total += item
|
||||
return f"{marker}:{total}"
|
||||
|
||||
print(secret(7))
|
||||
"""
|
||||
expected = run_source(source)
|
||||
rng = random.Random(2026)
|
||||
tree = ast.parse(source)
|
||||
transformer = AbyssTransformer(rng, targets={"secret"})
|
||||
tree = transformer.protect(tree)
|
||||
encoded = encode_assets(transformer.assets, rng)
|
||||
tree.body = build_runtime_stmts(encoded, rng) + tree.body
|
||||
ast.fix_missing_locations(tree)
|
||||
|
||||
transformed_source = ast.unparse(tree)
|
||||
self.assertNotIn("ABYSS_CLEAR_MARKER", transformed_source)
|
||||
self.assertIn("secret", transformer.protected)
|
||||
self.assertEqual(expected, run_source(transformed_source))
|
||||
|
||||
def test_obfuscator_abyss_function_can_call_preserved_global(self) -> None:
|
||||
source = """
|
||||
def helper(value):
|
||||
return value + 2
|
||||
|
||||
def secret(limit):
|
||||
total = 0
|
||||
for item in range(limit):
|
||||
total += helper(item)
|
||||
return total
|
||||
|
||||
def main():
|
||||
print("abyss", secret(6))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
obfuscator = Obfuscator(seed=99, abyss_functions={"secret"})
|
||||
obfuscated = obfuscator.obfuscate(source)
|
||||
self.assertEqual(["secret"], obfuscator.abyss_stats["protected"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
directory = Path(raw)
|
||||
src_path = directory / "sample.py"
|
||||
obf_path = directory / "sample_obf.py"
|
||||
src_path.write_text(source, encoding="utf-8")
|
||||
obf_path.write_text(obfuscated, encoding="utf-8")
|
||||
self.assertEqual(run_file(src_path), run_file(obf_path))
|
||||
|
||||
def test_cli_abyss_functions_output_runs(self) -> None:
|
||||
source = """
|
||||
def secret(left, right):
|
||||
value = (left << 2) ^ right
|
||||
return f"result={value}"
|
||||
|
||||
print(secret(9, 5))
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
directory = Path(raw)
|
||||
src_path = directory / "sample.py"
|
||||
out_path = directory / "sample_obf.py"
|
||||
src_path.write_text(source, encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"patchwork",
|
||||
str(src_path),
|
||||
"-o",
|
||||
str(out_path),
|
||||
"--seed",
|
||||
"17",
|
||||
"--abyss-functions",
|
||||
"secret",
|
||||
"--quiet",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual(run_file(src_path), run_file(out_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -68,7 +68,7 @@ class AuditTests(unittest.TestCase):
|
||||
result = self.run_cli("--version")
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertIn("patchwork 0.5.0", result.stdout)
|
||||
self.assertIn("patchwork 0.6.0", result.stdout)
|
||||
|
||||
def test_config_dump_and_keep_file_merge_options(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
|
||||
Reference in New Issue
Block a user