diff --git a/README.md b/README.md index a668cb0..d0343ef 100644 --- a/README.md +++ b/README.md @@ -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'}, diff --git a/patchwork/__init__.py b/patchwork/__init__.py index 12c501a..6ab1599 100644 --- a/patchwork/__init__.py +++ b/patchwork/__init__.py @@ -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', diff --git a/patchwork/abyss.py b/patchwork/abyss.py new file mode 100644 index 0000000..9448505 --- /dev/null +++ b/patchwork/abyss.py @@ -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""", + ] diff --git a/patchwork/cli.py b/patchwork/cli.py index 317052a..2cf01b8 100644 --- a/patchwork/cli.py +++ b/patchwork/cli.py @@ -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): diff --git a/patchwork/config.py b/patchwork/config.py index 1d0ef6b..1401c14 100644 --- a/patchwork/config.py +++ b/patchwork/config.py @@ -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]: diff --git a/patchwork/core.py b/patchwork/core.py index 0405d65..1e2da93 100644 --- a/patchwork/core.py +++ b/patchwork/core.py @@ -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, '', 'exec') if self.lazy_funcs: diff --git a/pyproject.toml b/pyproject.toml index 6c4973e..ef9e4d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/tests/test_abyss.py b/tests/test_abyss.py new file mode 100644 index 0000000..9c46b1a --- /dev/null +++ b/tests/test_abyss.py @@ -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, "", "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() diff --git a/tests/test_audit.py b/tests/test_audit.py index d48163f..622822c 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -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: