mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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
156 lines
5.5 KiB
Python
Executable File
156 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Where the forbidden words come from -- all of it outside this repository.
|
|
|
|
Split out of check_personal_data.py at the 250-line cap, and the seam is the
|
|
honest one: this half knows the private data, that half knows nothing but how
|
|
to search. A list of real names kept *in* the repository to check against
|
|
would be the leak the check exists to prevent, so nothing here is ever
|
|
written down -- it is read at run time from the photo pack, the message
|
|
database and the local .env, and on a machine with none of those it simply
|
|
comes back empty.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
REPO: Final = Path(__file__).resolve().parent.parent
|
|
|
|
# Three, because real first names are three letters long and one of this
|
|
# group's is. Two would match inside half the words in the repository; three
|
|
# needs a handful of allowlist entries for the given names that are also
|
|
# popes, which is a cost worth paying to catch a real one.
|
|
MIN_NAME: Final = 3
|
|
|
|
|
|
def _pack_names(directory: Path) -> set[str]:
|
|
"""Every trigger word in a photo pack manifest -- i.e. every name."""
|
|
manifest = directory / "people.json"
|
|
if not manifest.is_file():
|
|
return set()
|
|
try:
|
|
document = json.loads(manifest.read_text(encoding="utf-8"))
|
|
except OSError, json.JSONDecodeError:
|
|
return set()
|
|
entries = [*document.get("people", []), *document.get("combos", [])]
|
|
return {
|
|
str(trigger).lstrip("!").strip()
|
|
for entry in entries
|
|
for trigger in entry.get("triggers", [])
|
|
}
|
|
|
|
|
|
def _database_names(path: Path) -> set[str]:
|
|
"""Display names of everybody the bot has ever seen speak."""
|
|
if not path.is_file():
|
|
return set()
|
|
try:
|
|
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
except sqlite3.Error:
|
|
return set()
|
|
# Literal statements, not a table name interpolated into one: a
|
|
# formatted query is a SQL-injection shape even when every input is a
|
|
# constant, and this repository takes no lint suppressions.
|
|
queries = (
|
|
"SELECT DISTINCT display_name FROM message_counts",
|
|
"SELECT DISTINCT display_name FROM group_people",
|
|
"SELECT DISTINCT display_name FROM message_events",
|
|
)
|
|
found: set[str] = set()
|
|
with connection:
|
|
for query in queries:
|
|
try:
|
|
rows = connection.execute(query)
|
|
except sqlite3.Error:
|
|
continue
|
|
found.update(str(row[0]) for row in rows if row[0])
|
|
connection.close()
|
|
return found
|
|
|
|
|
|
def _database_groups(path: Path) -> set[str]:
|
|
"""Every real group id the bot has seen, in both spellings.
|
|
|
|
Both, because they are the same group written two ways: /v1/groups
|
|
reports "group.<base64>" and an inbound envelope carries the id that
|
|
base64 decodes to. A check that knew only the first would have missed the
|
|
one that was sitting in tests/test_config.py.
|
|
"""
|
|
if not path.is_file():
|
|
return set()
|
|
try:
|
|
connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
except sqlite3.Error:
|
|
return set()
|
|
queries = (
|
|
"SELECT DISTINCT group_id FROM group_meta",
|
|
"SELECT DISTINCT group_id FROM message_events",
|
|
)
|
|
found: set[str] = set()
|
|
with connection:
|
|
for query in queries:
|
|
try:
|
|
rows = connection.execute(query)
|
|
except sqlite3.Error:
|
|
continue
|
|
found.update(str(row[0]) for row in rows if row[0])
|
|
connection.close()
|
|
inner: set[str] = set()
|
|
for group in found:
|
|
payload = group.removeprefix("group.")
|
|
try:
|
|
inner.add(base64.b64decode(payload, validate=True).decode("utf-8"))
|
|
except ValueError, UnicodeDecodeError:
|
|
continue
|
|
return found | inner
|
|
|
|
|
|
def _env_values(path: Path) -> set[str]:
|
|
"""Phone numbers, group ids and inviters from the deployment's .env."""
|
|
if not path.is_file():
|
|
return set()
|
|
interesting = {"PHONE_NUMBER", "GROUP_IDS", "GROUP_ID", "TRUSTED_INVITERS"}
|
|
found: set[str] = set()
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
key, _, value = line.partition("=")
|
|
if key.strip() in interesting:
|
|
found.update(part.strip() for part in value.split(",") if part.strip())
|
|
return found
|
|
|
|
|
|
def forbidden(permitted: set[str]) -> tuple[set[str], list[str]]:
|
|
"""Every literal that must not be committed, and where they came from.
|
|
|
|
The sources are all outside the repository on purpose. A list of real
|
|
names kept *in* the repository to check against would be the leak it is
|
|
meant to prevent.
|
|
"""
|
|
pack = os.environ.get("PHOTO_PACK_DIR", "").strip()
|
|
directory = (
|
|
Path(pack)
|
|
if pack
|
|
else Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
/ "signal-bot"
|
|
/ "pack"
|
|
)
|
|
sources: list[str] = []
|
|
words: set[str] = set()
|
|
for label, found in (
|
|
(f"pack manifest ({directory})", _pack_names(directory)),
|
|
("message database", _database_names(REPO / "signal_bot.sqlite3")),
|
|
("group ids", _database_groups(REPO / "signal_bot.sqlite3")),
|
|
(".env", _env_values(REPO / ".env")),
|
|
):
|
|
if found:
|
|
sources.append(label)
|
|
words |= found
|
|
return {
|
|
word.lower() for word in words if len(word) >= MIN_NAME and word.lower() not in permitted
|
|
}, sources
|