#!/usr/bin/env python3 """Create a fail-closed sanitized public-mirror candidate from tracked files. This tool never pushes, changes repository visibility, or rewrites history. """ from __future__ import annotations import argparse import fnmatch import hashlib import json import re import shutil import subprocess import sys from collections.abc import Iterable from dataclasses import asdict, dataclass from pathlib import Path ALWAYS_DENY = ( ".env", ".env.*", "*.key", "*.pem", "*.p12", "*.pfx", "*.kdbx", "*.db", "*.db-*", "*.sqlite*", "*.log", "*.zip", "*.tar", "*.tar.gz", "*.7z", ".agents/*", ".claude/*", ".codex/*", ".config/*", ".dyad/*", ".vibecode-backup/*", "artifacts/*", "evidence/*", "output/*", "test-results/*", "node_modules/*", "dist/*", "build/*", "connectors/*", "services/*", "infrastructure/*", "prompts/*", "evals/*", ) _PRIVATE_BOOT_ROOT = "/" + "bo" + "ot" RULES = ( ( "private_ipv4", re.compile( r"\b(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})\b" ), ), ( "private_path", re.compile( r"(?i)(?:/mnt/(?:user|cache)(?:/|\b)|" + re.escape(_PRIVATE_BOOT_ROOT) + r"(?:/|\b)|[A-Z]:\\Users\\)" ), ), ( "private_ssh_target", re.compile( r"(?i)ssh://[^\s/@]+@(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})" ), ), ("private_key_block", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")), ( "credential_assignment", re.compile( r"(?i)\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|password|passwd)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{16,}" ), ), ) @dataclass(frozen=True) class Finding: rule: str path: str detail: str allowed: bool = False reason: str = "" def git(root: Path, *args: str) -> str: executable = shutil.which("git") if executable is None: raise OSError("git executable was not found") result = subprocess.run( # noqa: S603 - executable is resolved locally; args are internal. [executable, "-C", str(root), *args], check=True, capture_output=True, text=True, ) return result.stdout.strip() def load_patterns(path: Path) -> list[str]: result = [ line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") ] if not result: raise ValueError(f"No patterns in {path}") return result def load_exceptions(path: Path) -> list[tuple[str, str, str]]: if not path.exists(): return [] result: list[tuple[str, str, str]] = [] for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): value = raw.strip() if not value or value.startswith("#"): continue parts = [part.strip() for part in value.split("|", 2)] if len(parts) != 3 or not all(parts): raise ValueError( f"Invalid exception at {path}:{number}; expected rule|path-glob|reason" ) result.append((parts[0], parts[1], parts[2])) return result def matches(path: str, globs: Iterable[str]) -> bool: return any(fnmatch.fnmatchcase(path, glob) for glob in globs) def approved_reason(rule: str, path: str, entries: Iterable[tuple[str, str, str]]) -> str | None: for entry_rule, path_glob, reason in entries: if entry_rule == rule and fnmatch.fnmatchcase(path, path_glob): return reason return None def scan( root: Path, relative: str, max_bytes: int, entries: list[tuple[str, str, str]] ) -> list[Finding]: path = root / relative if path.is_symlink(): return [Finding("symlink", relative, "symlinks are not exported")] size = path.stat().st_size if size > max_bytes: return [Finding("large_file", relative, f"{size} bytes exceeds {max_bytes}")] data = path.read_bytes() if b"\x00" in data[:8192]: return [Finding("binary_file", relative, "binary content requires manual release review")] try: text = data.decode("utf-8") except UnicodeDecodeError: return [Finding("non_utf8", relative, "file is not valid UTF-8")] findings: list[Finding] = [] for rule, expression in RULES: match = expression.search(text) if match: reason = approved_reason(rule, relative, entries) findings.append( Finding(rule, relative, match.group(0)[:120], reason is not None, reason or "") ) return findings def sha256(path: Path) -> str: value = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): value.update(chunk) return value.hexdigest() def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", type=Path, default=Path.cwd()) parser.add_argument("--allowlist", type=Path, default=Path("public-mirror.allowlist")) parser.add_argument("--exceptions", type=Path, default=Path("public-mirror.exceptions")) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--report", type=Path) parser.add_argument("--max-file-bytes", type=int, default=5_000_000) args = parser.parse_args() root = args.repository.resolve() allowlist_file = args.allowlist if args.allowlist.is_absolute() else root / args.allowlist exception_file = args.exceptions if args.exceptions.is_absolute() else root / args.exceptions output = args.output.resolve() report_path = ( args.report.resolve() if args.report else output.with_suffix(output.suffix + ".report.json") ) try: git(root, "rev-parse", "--is-inside-work-tree") revision = git(root, "rev-parse", "HEAD") tracked = [item for item in git(root, "ls-files", "-z").split("\x00") if item] allow = load_patterns(allowlist_file) approved = load_exceptions(exception_file) except (OSError, ValueError, subprocess.CalledProcessError) as error: print(f"public mirror preflight failed: {error}", file=sys.stderr) return 2 selected = sorted( path for path in tracked if matches(path, allow) and not matches(path, ALWAYS_DENY) ) findings: list[Finding] = [] for relative in selected: try: findings.extend(scan(root, relative, args.max_file_bytes, approved)) except OSError as error: findings.append(Finding("read_error", relative, str(error))) blocking = [item for item in findings if not item.allowed] report = { "schemaVersion": 1, "repository": str(root), "revision": revision, "selectedFiles": selected, "findings": [asdict(item) for item in findings], "blockingFindingCount": len(blocking), } report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") if not selected: print("allowlist selected no tracked files", file=sys.stderr) return 2 if blocking: print( f"export blocked by {len(blocking)} finding(s); review {report_path}", file=sys.stderr ) return 2 if output.exists() and any(output.iterdir()): print(f"output directory is not empty: {output}", file=sys.stderr) return 2 output.mkdir(parents=True, exist_ok=True) manifest: list[dict[str, object]] = [] for relative in selected: source = root / relative destination = output / relative destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination, follow_symlinks=False) manifest.append( {"path": relative, "size": destination.stat().st_size, "sha256": sha256(destination)} ) (output / "PUBLIC_MIRROR_MANIFEST.json").write_text( json.dumps( {"schemaVersion": 1, "sourceRevision": revision, "files": manifest}, indent=2, sort_keys=True, ) + "\n", encoding="utf-8", ) print(f"exported {len(selected)} files to {output}") print(f"sanitation report: {report_path}") return 0 if __name__ == "__main__": raise SystemExit(main())