mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 22:27:15 +01:00
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
159 lines
6.3 KiB
Python
Executable File
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())
|