Add audit manifests and CI

This commit is contained in:
ashton
2026-06-04 04:19:41 -05:00
parent ef1cd60620
commit 432df66946
9 changed files with 463 additions and 51 deletions
+4 -3
View File
@@ -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']
+214
View 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
View File
@@ -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())