signal-bot/scripts/check_personal_data.py
Krzysztof kuhy Rudnicki 37a27c6dff feat: refuse to commit personal data, learned from outside the repository
The leak this repository had was not carelessness with a secret. It was
somebody reaching for a real name because it was the example in front of
them -- a fixture built from a live database, an envelope pasted out of a
running container, a help text written while looking at the actual group.
Three different people-shaped mistakes, none of which a rule about "be
careful" would have stopped.

So it is a gate. The hard part is that the thing being checked must not
contain the list of what to refuse, or the check *is* the leak. It reads the
names at run time from outside: the photo pack's manifest, the message
database, the local .env. It never prints the word it matched, because this
output reaches CI logs. On a machine with none of that private data it says
"shapes only" out loud rather than passing quietly, and CI enforces the half
that needs nothing -- phone numbers, uuids and Signal group ids, compared
against an allowlist of values invented for the fixtures.

It runs on staged files, on the commit message -- where two of the leaks
actually travelled -- and over the whole tree in CI.

It paid for itself twice before it was wired up. The live test group's id was
in tests/test_config.py in two spellings, and truncated in three more files,
after four rounds of grepping by hand had pronounced the tree clean -- nobody
thinks to grep for base64. Then it refused the commit that introduced it,
because DOCS-privacy.md had used a real name as the example of a name.

An allowlist line may be scoped to one file, which is how the given names
that are also popes stay legal in popes.json and refused in every fixture
that has no business carrying them. The threshold is three characters,
because one of this group's given names is three characters long.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 23:28:29 +02:00

159 lines
6.3 KiB
Python
Executable File

#!/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"(?<![\w]){re.escape(word)}(?![\w])", lowered):
# The word itself is never printed: this output can end up in
# a CI log, and naming the name there would be the same leak
# by a longer route.
hits.append((number, "a name from your private data"))
break
hits.extend(
(number, f"{label} {match!r} is not in .personal-data-allowlist")
for label, pattern in PATTERNS.items()
for match in pattern.findall(line)
if match.lower() not in permitted
)
return hits
def readable(path: Path) -> 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())