mirror of
https://github.com/bikini/patchwork
synced 2026-06-27 08:08:41 +00:00
Add audit manifests and CI
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
from .core import Obfuscator, obfuscate, obfuscate_file
|
||||
__version__ = '0.1.0'
|
||||
__all__ = ['Obfuscator', 'obfuscate', 'obfuscate_file']
|
||||
from .core import Obfuscator, obfuscate, obfuscate_file
|
||||
from .audit import analyze_source, build_manifest
|
||||
__version__ = '0.2.0'
|
||||
__all__ = ['Obfuscator', 'analyze_source', 'build_manifest', 'obfuscate', 'obfuscate_file']
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import json
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SENSITIVE_IMPORTS = {
|
||||
"ctypes",
|
||||
"marshal",
|
||||
"mmap",
|
||||
"pickle",
|
||||
"socket",
|
||||
"subprocess",
|
||||
"winreg",
|
||||
}
|
||||
|
||||
SENSITIVE_CALLS = {
|
||||
"__import__",
|
||||
"compile",
|
||||
"eval",
|
||||
"exec",
|
||||
"marshal.loads",
|
||||
"os.execl",
|
||||
"os.execle",
|
||||
"os.execlp",
|
||||
"os.execlpe",
|
||||
"os.execv",
|
||||
"os.execve",
|
||||
"os.execvp",
|
||||
"os.execvpe",
|
||||
"os.popen",
|
||||
"os.spawnl",
|
||||
"os.spawnle",
|
||||
"os.spawnlp",
|
||||
"os.spawnlpe",
|
||||
"os.spawnv",
|
||||
"os.spawnve",
|
||||
"os.spawnvp",
|
||||
"os.spawnvpe",
|
||||
"os.system",
|
||||
"pickle.loads",
|
||||
"subprocess.call",
|
||||
"subprocess.check_call",
|
||||
"subprocess.check_output",
|
||||
"subprocess.Popen",
|
||||
"subprocess.run",
|
||||
}
|
||||
|
||||
|
||||
def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _tool_version() -> str:
|
||||
try:
|
||||
return version("patchwork")
|
||||
except PackageNotFoundError:
|
||||
return "0.2.0"
|
||||
|
||||
|
||||
def _call_name(node: ast.AST) -> str | None:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
parent = _call_name(node.value)
|
||||
if parent:
|
||||
return f"{parent}.{node.attr}"
|
||||
return node.attr
|
||||
return None
|
||||
|
||||
|
||||
class AuditVisitor(ast.NodeVisitor):
|
||||
def __init__(self) -> None:
|
||||
self.imports: set[str] = set()
|
||||
self.functions: list[str] = []
|
||||
self.classes: list[str] = []
|
||||
self.review_indicators: list[dict[str, object]] = []
|
||||
self.node_count = 0
|
||||
|
||||
def generic_visit(self, node: ast.AST) -> None:
|
||||
self.node_count += 1
|
||||
super().generic_visit(node)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
top_level = alias.name.split(".", 1)[0]
|
||||
self.imports.add(alias.name)
|
||||
if top_level in SENSITIVE_IMPORTS:
|
||||
self.review_indicators.append(
|
||||
{
|
||||
"kind": "import",
|
||||
"name": alias.name,
|
||||
"line": node.lineno,
|
||||
"reason": "sensitive standard-library capability",
|
||||
}
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
module = node.module or ""
|
||||
if module:
|
||||
self.imports.add(module)
|
||||
top_level = module.split(".", 1)[0]
|
||||
if top_level in SENSITIVE_IMPORTS:
|
||||
self.review_indicators.append(
|
||||
{
|
||||
"kind": "import",
|
||||
"name": module,
|
||||
"line": node.lineno,
|
||||
"reason": "sensitive standard-library capability",
|
||||
}
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
||||
self.functions.append(node.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
||||
self.functions.append(node.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||
self.classes.append(node.name)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
name = _call_name(node.func)
|
||||
if name in SENSITIVE_CALLS:
|
||||
self.review_indicators.append(
|
||||
{
|
||||
"kind": "call",
|
||||
"name": name,
|
||||
"line": node.lineno,
|
||||
"reason": "dynamic execution or process capability",
|
||||
}
|
||||
)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def analyze_source(source: str, *, path: str | Path | None = None) -> dict[str, Any]:
|
||||
tree = ast.parse(source, filename=str(path or "<memory>"))
|
||||
visitor = AuditVisitor()
|
||||
visitor.visit(tree)
|
||||
review_indicators = sorted(visitor.review_indicators, key=lambda item: (int(item["line"]), str(item["name"])))
|
||||
return {
|
||||
"path": str(path) if path else None,
|
||||
"sha256": sha256_text(source),
|
||||
"lines": source.count("\n") + (1 if source else 0),
|
||||
"bytes_utf8": len(source.encode("utf-8")),
|
||||
"ast_nodes": visitor.node_count,
|
||||
"imports": sorted(visitor.imports),
|
||||
"functions": sorted(visitor.functions),
|
||||
"classes": sorted(visitor.classes),
|
||||
"review": {
|
||||
"indicator_count": len(review_indicators),
|
||||
"requires_human_review": bool(review_indicators),
|
||||
"indicators": review_indicators,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_manifest(
|
||||
*,
|
||||
input_path: str | Path,
|
||||
output_path: str | Path,
|
||||
input_source: str,
|
||||
output_source: str,
|
||||
seed: int,
|
||||
options: dict[str, object],
|
||||
audit: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "patchwork.manifest.v1",
|
||||
"generated_at": _utc_now(),
|
||||
"tool": {
|
||||
"name": "patchwork",
|
||||
"version": _tool_version(),
|
||||
"python": platform.python_version(),
|
||||
"platform": platform.platform(),
|
||||
},
|
||||
"input": {
|
||||
"path": str(input_path),
|
||||
"sha256": sha256_text(input_source),
|
||||
"bytes_utf8": len(input_source.encode("utf-8")),
|
||||
},
|
||||
"output": {
|
||||
"path": str(output_path),
|
||||
"sha256": sha256_text(output_source),
|
||||
"bytes_utf8": len(output_source.encode("utf-8")),
|
||||
},
|
||||
"build": {
|
||||
"seed": seed,
|
||||
"options": options,
|
||||
},
|
||||
"audit": audit,
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: str | Path, data: dict[str, Any]) -> Path:
|
||||
output_path = Path(path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return output_path
|
||||
+93
-44
@@ -1,44 +1,93 @@
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .core import Obfuscator
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog='patchwork')
|
||||
p.add_argument('input')
|
||||
p.add_argument('-o', '--output')
|
||||
p.add_argument('--seed', type=int, default=None)
|
||||
p.add_argument('--layers', type=int, default=3)
|
||||
p.add_argument('--stage2-layers', type=int, default=3, dest='stage2_layers')
|
||||
p.add_argument('--keep', action='append', default=[], metavar='NAME')
|
||||
p.add_argument('--no-rename', action='store_true')
|
||||
p.add_argument('--no-encrypt-strings', action='store_true')
|
||||
p.add_argument('--no-obfuscate-numbers', action='store_true')
|
||||
p.add_argument('--no-opaque', action='store_true')
|
||||
p.add_argument('--no-mba', action='store_true')
|
||||
p.add_argument('--no-junk', action='store_true')
|
||||
p.add_argument('--no-lazy', action='store_true')
|
||||
p.add_argument('--no-anti-debug', action='store_true')
|
||||
p.add_argument('--quiet', '-q', action='store_true')
|
||||
return p
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
inp = Path(args.input)
|
||||
if not inp.is_file():
|
||||
print(f'patchwork: input file not found: {inp}', file=sys.stderr)
|
||||
return 2
|
||||
src = inp.read_text(encoding='utf-8')
|
||||
obf = Obfuscator(seed=args.seed, rename=not args.no_rename, encrypt_strings=not args.no_encrypt_strings, obfuscate_numbers=not args.no_obfuscate_numbers, opaque_predicates=not args.no_opaque, mba=not args.no_mba, junk_branches=not args.no_junk, lazy_funcs=not args.no_lazy, anti_debug=not args.no_anti_debug, layers=args.layers, stage2_layers=args.stage2_layers, keep=set(args.keep))
|
||||
out_src = obf.obfuscate(src)
|
||||
out_path = Path(args.output) if args.output else inp.with_name(inp.stem + '_obf.py')
|
||||
out_path.write_text(out_src, encoding='utf-8')
|
||||
if not args.quiet:
|
||||
in_size = len(src)
|
||||
out_size = len(out_src)
|
||||
ratio = out_size / max(in_size, 1)
|
||||
print(f'patchwork: {inp} -> {out_path}\n input: {in_size:,} bytes\n output: {out_size:,} bytes ({ratio:.1f}x)\n seed: {obf.seed}\n layers: {obf.layers}', file=sys.stderr)
|
||||
return 0
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .audit import analyze_source, build_manifest, write_json
|
||||
from .core import Obfuscator
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog='patchwork')
|
||||
p.add_argument('input')
|
||||
p.add_argument('-o', '--output')
|
||||
p.add_argument('--seed', type=int, default=None)
|
||||
p.add_argument('--layers', type=int, default=3)
|
||||
p.add_argument('--stage2-layers', type=int, default=3, dest='stage2_layers')
|
||||
p.add_argument('--keep', action='append', default=[], metavar='NAME')
|
||||
p.add_argument('--no-rename', action='store_true')
|
||||
p.add_argument('--no-encrypt-strings', action='store_true')
|
||||
p.add_argument('--no-obfuscate-numbers', action='store_true')
|
||||
p.add_argument('--no-opaque', action='store_true')
|
||||
p.add_argument('--no-mba', action='store_true')
|
||||
p.add_argument('--no-junk', action='store_true')
|
||||
p.add_argument('--no-lazy', action='store_true')
|
||||
p.add_argument('--no-anti-debug', action='store_true')
|
||||
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')
|
||||
p.add_argument('--strict-audit', action='store_true', help='refuse to obfuscate when sensitive API indicators are present')
|
||||
p.add_argument('--quiet', '-q', action='store_true')
|
||||
return p
|
||||
|
||||
def _options_dict(args: argparse.Namespace) -> dict[str, object]:
|
||||
return {
|
||||
'rename': not args.no_rename,
|
||||
'encrypt_strings': not args.no_encrypt_strings,
|
||||
'obfuscate_numbers': not args.no_obfuscate_numbers,
|
||||
'opaque_predicates': not args.no_opaque,
|
||||
'mba': not args.no_mba,
|
||||
'junk_branches': not args.no_junk,
|
||||
'lazy_funcs': not args.no_lazy,
|
||||
'anti_debug': not args.no_anti_debug,
|
||||
'layers': args.layers,
|
||||
'stage2_layers': args.stage2_layers,
|
||||
'keep': sorted(set(args.keep)),
|
||||
}
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
inp = Path(args.input)
|
||||
if not inp.is_file():
|
||||
print(f'patchwork: input file not found: {inp}', file=sys.stderr)
|
||||
return 2
|
||||
src = inp.read_text(encoding='utf-8')
|
||||
audit = analyze_source(src, path=inp)
|
||||
if args.audit_json:
|
||||
write_json(args.audit_json, audit)
|
||||
if args.audit_only:
|
||||
if not args.quiet:
|
||||
print(json.dumps(audit, indent=2, sort_keys=True))
|
||||
return 1 if args.strict_audit and audit['review']['requires_human_review'] else 0
|
||||
if args.strict_audit and audit['review']['requires_human_review']:
|
||||
print('patchwork: strict audit refused to obfuscate input with review indicators', file=sys.stderr)
|
||||
for indicator in audit['review']['indicators']:
|
||||
print(f" line {indicator['line']}: {indicator['kind']} {indicator['name']} - {indicator['reason']}", file=sys.stderr)
|
||||
return 3
|
||||
options = _options_dict(args)
|
||||
obf = Obfuscator(seed=args.seed, keep=set(args.keep), **{key: value for key, value in options.items() if key != 'keep'})
|
||||
out_src = obf.obfuscate(src)
|
||||
out_path = Path(args.output) if args.output else inp.with_name(inp.stem + '_obf.py')
|
||||
out_path.write_text(out_src, encoding='utf-8')
|
||||
if args.manifest:
|
||||
manifest = build_manifest(
|
||||
input_path=inp,
|
||||
output_path=out_path,
|
||||
input_source=src,
|
||||
output_source=out_src,
|
||||
seed=obf.seed,
|
||||
options=options,
|
||||
audit=audit,
|
||||
)
|
||||
write_json(args.manifest, manifest)
|
||||
if not args.quiet:
|
||||
in_size = len(src)
|
||||
out_size = len(out_src)
|
||||
ratio = out_size / max(in_size, 1)
|
||||
print(f'patchwork: {inp} -> {out_path}\n input: {in_size:,} bytes\n output: {out_size:,} bytes ({ratio:.1f}x)\n seed: {obf.seed}\n layers: {obf.layers}', file=sys.stderr)
|
||||
if args.manifest:
|
||||
print(f' manifest: {args.manifest}', file=sys.stderr)
|
||||
if audit['review']['requires_human_review']:
|
||||
print(f" audit: {audit['review']['indicator_count']} review indicator(s)", file=sys.stderr)
|
||||
return 0
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user