#!/usr/bin/env python3 # Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Refuse to commit anything that identifies a real person. This repository is public and has already leaked once: real first names, a full name, two phone numbers and a group member's Signal uuid reached GitHub and stayed there until the repository had to be deleted and recreated. Every one of them arrived the same way -- somebody (a human once, this program's author the second time) reached for a real name because it was the example in front of them. So it is a gate rather than a rule. Two halves, because they fail differently: * **Names**, which cannot be listed here. The forbidden words are read at run time from outside the repository -- the photo pack's manifest, the message database, the local .env -- so the check knows them and the repository never does. On a machine with none of those (CI, a fresh clone) this half simply has nothing to compare against and says so. * **Shapes**, which can. A phone number, a uuid and a Signal group id look like themselves wherever they appear, so those are matched by pattern and compared against the placeholders this repository is allowed to contain. Usage: scripts/check_personal_data.sh --all # every tracked file scripts/check_personal_data.sh a.py b.md # named files The shell wrapper is the entry point; it knows how to list what git tracks, which keeps this half free of a subprocess call it would need a suppression for. """ from __future__ import annotations import argparse import re import sys from pathlib import Path from typing import Final sys.path.insert(0, str(Path(__file__).resolve().parent)) from personal_data_sources import REPO, forbidden ALLOWLIST: Final = REPO / ".personal-data-allowlist" # Shapes that identify somebody wherever they turn up. PATTERNS: Final[dict[str, re.Pattern[str]]] = { "phone number": re.compile(r"\+\d{9,15}\b"), "uuid": re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"), "signal group id": re.compile(r"\bgroup\.[A-Za-z0-9+/]{16,}={0,2}"), } SKIP_DIRS: Final = frozenset({".git", ".venv", "__pycache__", ".mypy_cache", ".ruff_cache"}) SKIP_SUFFIXES: Final = frozenset({".lock", ".bundle"}) def allowed(path: Path | None = None) -> set[str]: """Literals this repository may contain, lower-cased. A plain line permits a value anywhere. A ``some/path::value`` line permits it only in that file, which is how a name that is also a pope in popes.json stays refused in every test fixture and document that has no business carrying it. """ if not ALLOWLIST.exists(): return set() permitted: set[str] = set() where = str(path) if path is not None else "" # The allowlist has to be able to name the values it permits, including # the file-scoped ones, so inside it every listed value counts as # permitted. It is the list of exceptions; it cannot be its own exception. inside = path is not None and path.resolve() == ALLOWLIST for raw in ALLOWLIST.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue scope, separator, value = line.partition("::") if not separator: permitted.add(line.lower()) elif inside or where.endswith(scope.strip()): permitted.add(value.strip().lower()) return permitted def scan(text: str, words: set[str], path: Path | None = None) -> list[tuple[int, str]]: """Return (line number, what matched) for every hit in one document.""" permitted = allowed(path) words = {word for word in words if word not in permitted} hits: list[tuple[int, str]] = [] for number, line in enumerate(text.splitlines(), start=1): lowered = line.lower() for word in words: if re.search(rf"(? str | None: """The file's text, or None when it is not text at all.""" if path.suffix in SKIP_SUFFIXES or any(part in SKIP_DIRS for part in path.parts): return None try: return path.read_text(encoding="utf-8") except OSError, UnicodeDecodeError: return None def main() -> int: """Check whatever was asked for, and explain any refusal.""" parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("--files", nargs="*", type=Path, default=[]) parser.add_argument("--message", type=Path, default=None, help="a commit message file") arguments = parser.parse_args() words, sources = forbidden(allowed()) targets = list(arguments.files) if arguments.message is not None: targets.append(arguments.message) failures = 0 for path in targets: text = readable(path) if text is None: continue for number, reason in scan(text, words, path): sys.stdout.write(f"{path}:{number}: {reason}\n") failures += 1 if failures: sys.stdout.write( f"\nRefused: {failures} line(s) carry personal data.\n" "This repository is public. Use an invented name, a placeholder number, or\n" "add the value to .personal-data-allowlist if it is genuinely not anybody's.\n" ) return 1 if not words: # Said out loud rather than passing quietly: on CI and on a fresh # clone the name half of this check has nothing to compare against, # and a gate that silently checks nothing is worse than no gate. sys.stdout.write("no local private data to compare against; shapes only\n") else: sys.stdout.write(f"clean ({len(words)} name(s) from: {', '.join(sources)})\n") return 0 if __name__ == "__main__": raise SystemExit(main())