Files
privatae-bastion/scripts/test_no_personal_data.py
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

762 lines
35 KiB
Python

#!/usr/bin/env python3
"""Gate: no person's data in this repository — invented data only.
python3 scripts/test_no_personal_data.py [--root DIR] [path-prefix ...]
Reads files, runs nothing. --root is the tree to scan (default: the directory
above this file). Optional path prefixes narrow the scan (for working on one
area); the gate always scans the whole tree.
A git repository holds code. Every value in a test, fixture, comment, doc,
config or prompt is invented: fictional names, example.com / .example domains,
entity ids of 9000 and above. A real incident is described by its mechanism,
never by its content: no database row, chat message, transcript line or
person's own words is pasted in, even to reproduce a bug. Source is copied
everywhere (clones, mirrors, the code index, CI logs, images), so data that
lands here is published far past where it came from.
This fails on:
term a word or phrase from TERMS, a list of details about real people
that must not appear in this repository. They are held as keyed
digests, so this file does not publish what it keeps out, and a
guess cannot be tested against the table without the key: every
1-3 token n-gram of a line is normalised (lowercase letters and
digits, joined by one space), HMAC-SHA256'd with the maintainers'
key and looked up. A backslash escape (\\n, \\t, \\x41, \\u00e9...)
separates tokens as whitespace would, so a term written right
after one is still found. The key lives outside the repository
(NO_PERSONAL_DATA_KEY_FILE, default
/etc/privatae/keys/no-personal-data.hmac), as do the plain list
and the script that hashes it. Where the key is absent (a CI job,
which holds no secrets) this class is skipped and the run says
so; the other classes still run. A key other than the one TERMS
was made with fails the run (KEY_CHECK), since it would match
nothing.
entity-id a reserved entity id, one that labels a person's Host, in
entity-N, entity N, entity_id=N, "entity_id": N, entity id N,
Host N, Host #N or host-N form; examples and tests use 9000 and
up. The ids live outside the repository, one per line
(RESERVED_ENTITY_IDS_FILE, default
/etc/privatae/reserved-entity-ids). Where that file is absent (a
CI job) this class is skipped and the run says so; a file that
holds no id, or a line that is not one, fails the run, since it
would match nothing.
provenance a line that says its data is real: "real rows" followed by
"that" or "from", an all-caps "REAL" row, "verbatim" from a
transcript or a run, a row "read off" an entity...
email a mailbox at a personal mail provider (gmail, yahoo, proton...)
phone a North American number outside the 555 fiction range
A run of 24 or more base64 or base64url characters that decodes to mostly
printable UTF-8 is decoded and its text checked for every class as well, so a
value is not let through by being encoded.
Every tracked text file is scanned, file names included, and every text member
of each zip-format bundle (an app bundle is one), since a bundle is what
installs. Lockfiles and other binary files are not.
A legitimate hit goes in the allow file beside this script
(test_no_personal_data.allow: path regex, class, text that must appear on the
line, why; tab-separated). An allow entry that matches nothing fails too, so
the list cannot outlive the line it was written for. The allow file quotes the
entity ids it allows, so that one class is not checked in it; the others are.
The same file is copied into each repository unchanged (it began as the Host
source's own gate of the same name). A copy rather than a
shared package: these repositories share no package index, and each CI job
fetches only its own repository with a token that can read nothing else.
What differs per repository is only its allow file.
Exit 0 = clean; exit 1 + FAIL lines otherwise.
"""
from __future__ import annotations
import base64
import binascii
import hmac
import os
import re
import subprocess
import sys
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
ALLOW_FILE = os.path.join(HERE, "test_no_personal_data.allow")
# ── what is refused ──────────────────────────────────────────────────────────
# HMAC-SHA256(key, n-gram)[:32 hex] of each listed term (no labels: a label
# would say what kind of thing to guess).
TERMS: frozenset[str] = frozenset({
"000bc5fad3f02d146be0dc6e4f26715d",
"04e463f569b219d8f11026f0959826cb",
"06f4cd7cf91b60d004f6412c9dfb7b06",
"0c13f4c827dd9d1f534a8e789217289e",
"1060330bf845a73f1ea3ff9afb6c1e6b",
"116f56fc49ff92182ecf551aa998bc55",
"12ae7bf559abc1e9b8006c7bdd0a8853",
"19045b4e8ab55b83d7326a7cd729969d",
"2415c59bff2a7b3b8a6d5d33c5181c02",
"29ea8199c8b46d4bcfcfa1de1786e39c",
"485c89ce20424f63cf61bcf0e99e8628",
"4b1490e2c82c17e0549459f3db90d345",
"4f6395fbbb21bed61fd7f09f130741bf",
"5a1a3e985be55d572508f5ffe14efd31",
"5c4cbfc9dcea9220b3e704d195227c9e",
"5fc3674ca953220355c326e90b4f3550",
"6b8cb443b5065174f6e43fb173366dae",
"6fe596c2149f0b18f8f7c90d7f2c7624",
"7f10fd17ee0f18bc8a1a5eb2df807633",
"817e1b4a08dd711ac7acf8ab1174abd7",
"8c322d5c2dd63340e29c692e333377cd",
"99924c62fd06d98054ab779a56c3a32a",
"a2e3a188d9d11a5cfb2d955df979d68c",
"aed4a071febc17392ac3a6caf3addce6",
"b5370efcf25e30a7f1dc99c97b595a69",
"b5c8646d8b020e1badc0acf1c02a5b3c",
"bb91be7ac9ffaaf209f03d398678692a",
"cd007c979d9a8878075178046aa0d4fc",
"cd0f03e03fd7cdde5a71b5dba23db423",
"cf489ac86c8ea473b7e9ad7d0cd0621f",
"d23b9d85227c17501b30ba87b4458139",
"d7f731ffa3b06c56d17ad46b22a9f26a",
"dbfb3de5d93b52e8f40698032af5569c",
"e28851bfa17cafb8ac4b64c5a74819b0",
"ef07986d74c66d20d72735adb76e419f",
"f7e7951627d22c5021e56d11c59680db",
"fe58dd5d2fd55c306be80b45c333adee",
})
_NGRAM_MAX = 3
# The reserved entity ids are read from a file outside the repository, one id
# per line; blank lines and lines starting with # are ignored.
RESERVED_ENTITY_IDS_FILE = os.environ.get("RESERVED_ENTITY_IDS_FILE", "/etc/privatae/reserved-entity-ids")
def load_reserved_ids(path: str = RESERVED_ENTITY_IDS_FILE):
"""(ids, error). A missing file is (None, None): the class is skipped. A
file that cannot be read, holds a line that is not an id, or holds no id
at all is (None, error): a list that matches nothing must not pass."""
try:
with open(path, encoding="utf-8") as f:
lines = f.read().splitlines()
except FileNotFoundError:
return None, None
except (OSError, UnicodeDecodeError) as e:
return None, "the reserved entity ids at %s cannot be read: %s" % (path, e)
ids = []
for n, raw in enumerate(lines, 1):
s = raw.strip()
if not s or s.startswith("#"):
continue
if not s.isdigit():
return None, "%s:%d is not an entity id (one id per line)" % (path, n)
ids.append(int(s))
if not ids:
return None, "%s holds no entity id, so the entity-id check would match nothing" % path
return tuple(ids), None
def entity_id_pattern(ids):
"""The entity-id class for these ids, or None (the class is off)."""
if not ids:
return None
alts = "|".join(str(i) for i in sorted(set(ids), key=lambda i: -len(str(i))))
return re.compile(
r"\b(?:entit(?:y|ies)(?:[-_ ]|_id\s*==?\s*|_id[\"']?\s*:\s*|\s+id\s+|\s+#)"
r"|Host\s+#?|host-)(?:%s)\b" % alts, re.I)
PROVENANCE = re.compile(
r"\breal rows? (?:that|from|read|copied|taken|off)\b"
r"|(?-i:\bREAL rows?\b)"
r"|\bverbatim from (?:the |a |an )?(?:raw )?(?:transcript|capture|entity|host|run|turn|thread|chat|pod)\b"
r"|\bread off entity\b"
r"|\bcopied (?:in )?from (?:a|the) (?:live|real) (?:host|row|db|database|pod)\b"
r"|\breal (?:host )?repl(?:y|ies) (?:from|on|of) (?:entity|host)\b"
r"|\bthe incident's real\b", re.I)
EMAIL = re.compile(
r"[A-Za-z0-9._%+-]+@(?:gmail|googlemail|yahoo|hotmail|outlook|live|icloud|me|aol|proton|protonmail|pm|gmx)"
r"\.(?:com|me|net|org|co\.uk|de)\b", re.I)
PHONE = re.compile(r"(?<![\w.+])(?<!\d[ .-])(?:\+1[ .-]?)?\(?([2-9]\d{2})\)?[ .-]([2-9]\d{2})[ .-](\d{4})(?!\w)(?![ .-]\d)"
r"|(?<![\w.+])\+1([2-9]\d{2})([2-9]\d{2})(\d{4})(?!\w)")
CLASSES = ("term", "entity-id", "provenance", "email", "phone")
SKIP_FILES = re.compile(r"(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$")
BINARY_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2",
".ttf", ".otf", ".eot", ".pdf", ".gz", ".tgz", ".wasm",
".mp3", ".mp4", ".webm", ".ogg", ".wav", ".bin", ".der", ".raw", ".pem", ".crt"}
# A bundle is recognised by its zip signature, not its name: any tracked file
# that opens as a zip and is not one of the binary types above. A .zip is not
# one of them: a zip a repository serves (a browser extension) installs too.
ZIP_MAGIC = b"PK\x03\x04"
_TOKEN = re.compile(r"[A-Za-z0-9]+")
KEY_FILE = os.environ.get("NO_PERSONAL_DATA_KEY_FILE", "/etc/privatae/keys/no-personal-data.hmac")
def load_key(path: str = KEY_FILE) -> bytes | None:
try:
with open(path) as f:
return bytes.fromhex(f.read().strip())
except (OSError, ValueError):
return None
def digest(gram: str, key: bytes) -> str:
return hmac.new(key, gram.encode(), "sha256").hexdigest()[:32]
# digest(KEY_CHECK_TEXT, key) for the key TERMS was made with. Any other key (a
# rotated, truncated or empty key file) would match none of TERMS and pass every
# tree, so the run fails on it instead. A MAC of a fixed public text says
# nothing about the key or the terms.
KEY_CHECK_TEXT = "no-personal-data key check"
KEY_CHECK = "7294506d83fc44365cf8647a7e400e89"
def key_matches(key: bytes) -> bool:
return hmac.compare_digest(digest(KEY_CHECK_TEXT, key), KEY_CHECK)
# A backslash escape as source and data files write it: a named control
# character, a hex or unicode escape, or an octal one. "a\nb" is two words, but
# the tokenizer alone reads "a" and "nb". The line is read once as written and
# once with each escape turned into a space; a hit in either counts. Reading it
# as written too keeps a term that follows a backslash which is not an escape
# (a Windows path: C:\name) whole.
_ESCAPE = re.compile(r"\\(?:x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|[0-7]{1,3}|[abefnrtv])")
class TermScanner:
"""Finds TERMS in a line by hashing its 1-3 token n-grams. Digests are
cached, so a repeated n-gram is hashed once. It also carries the
entity-id pattern for the reserved ids (None: that class is off)."""
def __init__(self, terms, key, entity_ids=None):
self.terms = terms
self.key = key
self.entity_ids = entity_ids
self.entity_re = entity_id_pattern(entity_ids)
self._cache = {}
def _hit(self, gram: str) -> bool:
if gram not in self._cache:
self._cache[gram] = digest(gram, self.key) in self.terms
return self._cache[gram]
def _grams(self, text: str) -> dict:
toks = [t.lower() for t in _TOKEN.findall(text)]
hits = {}
for n in range(1, _NGRAM_MAX + 1):
for i in range(len(toks) - n + 1):
gram = " ".join(toks[i:i + n])
if self._hit(gram):
hits[gram] = hits.get(gram, 0) + 1
return hits
def scan(self, line: str) -> int:
if self.key is None:
return 0
hits = self._grams(line)
if "\\" in line:
for gram, n in self._grams(_ESCAPE.sub(" ", line)).items():
hits[gram] = max(hits.get(gram, 0), n)
return sum(hits.values())
# ── values inside base64 ─────────────────────────────────────────────────────
# A run of base64 or base64url characters, with its padding. The run is decoded
# at each of the four offsets, since it may be glued to the text before it or
# cut by a window edge; an offset whose bytes are mostly printable UTF-8 is the
# text. Random bytes almost never are, so identifiers, paths and digests that
# happen to be base64-shaped decode to nothing worth reading.
_B64_RUN = re.compile(r"[A-Za-z0-9+/_-]{24,}={0,2}")
_B64_MIN = 24
_B64_PRINTABLE = 0.85
# A decoded text may itself hold base64; it is followed this many levels down.
_B64_DEPTH = 2
def _printable_share(text: str) -> float:
if not text:
return 0.0
ok = sum(1 for ch in text if ch in "\t\n\r" or (ch.isprintable() and ch != "\ufffd"))
return ok / len(text)
def b64_texts(line: str):
"""The text of every base64 run in line that decodes to mostly printable
UTF-8, one per run."""
for m in _B64_RUN.finditer(line):
run = m.group(0).rstrip("=").replace("-", "+").replace("_", "/")
for off in range(4):
body = run[off:]
body = body[:len(body) - len(body) % 4] if len(body) % 4 == 1 else body
if len(body) < _B64_MIN:
break
try:
raw = base64.b64decode(body + "=" * (-len(body) % 4), validate=True)
except (ValueError, binascii.Error):
continue
text = raw.decode("utf-8", "replace")
if _printable_share(text) >= _B64_PRINTABLE:
yield text
break
# A minified asset or a bundle member can hold one line of megabytes. It is read
# in windows rather than cut short, so a term past the first window is still
# found; the overlap is wider than any n-gram the table holds.
_WINDOW = 20000
_OVERLAP = 512
def windows(line: str):
if len(line) <= _WINDOW:
yield line
return
step = _WINDOW - _OVERLAP
for i in range(0, len(line), step):
yield line[i:i + _WINDOW]
if i + _WINDOW >= len(line):
return
def line_hits(line: str, scanner: TermScanner):
"""(window, class, what) for one line. A hit that two overlapping windows
both hold is reported once."""
seen = set()
chunked = len(line) > _WINDOW
for part in windows(line):
for cls, what in line_findings(part, scanner):
if chunked:
if (cls, what) in seen:
continue
seen.add((cls, what))
yield part, cls, what
# Classes whose line would carry the personal value itself: their FAIL line
# names the file, the line and the class, never the text, so a CI log does not
# publish what the gate keeps out. A line that holds one shows no text for any
# finding, since another class's excerpt of the line would carry the value too.
_QUIET = {"term", "email", "phone"}
_PATH_SEP = re.compile(r"([/!])")
def shown_path(label: str, scanner: TermScanner) -> str:
"""label as a FAIL line shows it. A path component (a directory, a file
name, a bundle member's name) that holds a quiet value is replaced by a
placeholder giving its depth, so the line says where without the text. A
value that runs across components withholds them all."""
def quiet(text):
return any(cls in _QUIET for cls, _ in line_findings(text, scanner))
if not quiet(label):
return label
parts = _PATH_SEP.split(label)
def masked(every):
out, depth = [], 0
for p in parts:
if p in ("/", "!"):
out.append(p)
continue
depth += 1
out.append("<withheld %d>" % depth if every or quiet(p) else p)
return "".join(out)
shown = masked(False)
return masked(True) if quiet(shown) else shown
def line_report(label: str, n: int, line: str, scanner: TermScanner, allow=(), used=None,
skip_entity_ids: bool = False):
"""(class, FAIL text) for each finding on line n of label that no allow
entry covers."""
used = set() if used is None else used
hits = list(line_hits(line, scanner))
quiet = any(cls in _QUIET for _part, cls, _what in hits)
out = []
where = None
for part, cls, what in hits:
if (cls == "entity-id" and skip_entity_ids) or allowed(allow, label, cls, part, used):
continue
if where is None:
where = shown_path(label, scanner)
shown = "" if quiet else part.strip()[:140]
out.append((cls, ("%s:%d [%s: %s] %s" % (where, n, cls, what, shown)).rstrip()))
return out
def line_findings(line: str, scanner: TermScanner, _depth: int = 0):
out = []
for _ in range(scanner.scan(line)):
out.append(("term", "a listed term"))
if scanner.entity_re is not None:
for m in scanner.entity_re.finditer(line):
out.append(("entity-id", m.group(0)))
for m in PROVENANCE.finditer(line):
out.append(("provenance", m.group(0)))
for m in EMAIL.finditer(line):
out.append(("email", "a personal mail provider"))
for m in PHONE.finditer(line):
if (m.group(2) or m.group(5)) != "555":
out.append(("phone", "a phone number"))
if _depth < _B64_DEPTH:
for text in b64_texts(line):
for cls, what in line_findings(text, scanner, _depth + 1):
out.append((cls, what if what.endswith("(in base64)") else what + " (in base64)"))
return out
# ── the allow file ───────────────────────────────────────────────────────────
def load_allow(path: str = ALLOW_FILE):
"""[(path regex, class, text, why)] from the allow file; a missing file is
an empty list. A malformed line is a failure, not a skipped entry."""
entries, errors = [], []
try:
with open(path, encoding="utf-8") as f:
lines = f.read().splitlines()
except OSError:
return entries, errors
for n, raw in enumerate(lines, 1):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
parts = raw.split("\t")
if len(parts) != 4 or not all(p.strip() for p in parts):
errors.append("%s:%d: an allow entry is 4 tab-separated fields: path regex, class, text, why"
% (os.path.basename(path), n))
continue
path_re, cls, text, why = parts
if cls not in CLASSES:
errors.append("%s:%d: class %r is not one of %s" % (os.path.basename(path), n, cls, ", ".join(CLASSES)))
continue
try:
re.compile(path_re)
except re.error as e:
errors.append("%s:%d: path regex does not compile: %s" % (os.path.basename(path), n, e))
continue
entries.append((path_re, cls, text, why))
return entries, errors
def allowed(allow, label: str, cls: str, line: str, used: set) -> bool:
for i, (path_re, a_cls, text, _why) in enumerate(allow):
if a_cls == cls and text in line and re.search(path_re, label):
used.add(i)
return True
return False
# ── reading the tree ─────────────────────────────────────────────────────────
def tracked_files(root: str):
try:
out = subprocess.run(["git", "-C", root, "ls-files", "-z"], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True, timeout=60).stdout
files = [p for p in out.decode("utf-8", "replace").split("\0") if p]
if files:
return files
except (OSError, subprocess.SubprocessError):
pass
files = []
for d, dirs, names in os.walk(root):
dirs[:] = [x for x in dirs if x not in (".git", "node_modules")]
for n in names:
files.append(os.path.relpath(os.path.join(d, n), root))
return files
def is_text(data: bytes) -> bool:
return b"\0" not in data[:8192]
def texts(root: str, rel: str):
"""(label, text) for one tracked path: the file, or each bundle member.
A file with a zip signature that does not open as a zip gives (label,
None): its members cannot be read, so the run fails on it rather than
passing what it could not see."""
path = os.path.join(root, rel)
if not os.path.isfile(path) or SKIP_FILES.search(rel):
return
if os.path.splitext(rel)[1].lower() in BINARY_EXT:
return
with open(path, "rb") as f:
data = f.read()
if data.startswith(ZIP_MAGIC):
try:
with zipfile.ZipFile(path) as z:
for name in z.namelist():
if os.path.splitext(name)[1].lower() in BINARY_EXT or SKIP_FILES.search(name):
continue
member = z.read(name)
if is_text(member):
yield "%s!%s" % (rel, name), member.decode("utf-8", "replace")
except zipfile.BadZipFile:
yield rel, None
return
if is_text(data):
yield rel, data.decode("utf-8", "replace")
# ── self-test: the detector is not vacuous ───────────────────────────────────
def self_test():
"""Each class fires on a planted line and stays quiet on its look-alikes.
Planted lines carry a "|" that is removed before they are read, so this
file does not match its own patterns when it is scanned."""
def live(s: str) -> str:
return s.replace("|", "")
fails = []
probe_key = os.urandom(32)
# Invented ids stand in for the reserved ones, which this file never holds.
probe_ids = (9101, 9202, 9303)
sc = TermScanner(frozenset(digest(g, probe_key) for g in ("zorblaxquent", "quilmar vesk", "tam orvel prax")),
probe_key, probe_ids)
def classes(line):
return {c for c, _ in line_findings(line, sc)}
def b64(s: str, url: bool = False) -> str:
enc = base64.urlsafe_b64encode if url else base64.b64encode
return enc(live(s).encode("utf-8")).decode("ascii").rstrip("=" if url else "")
positives = [(c, live(s)) for c, s in [
("term", "pays into the Zorblaxquent ledger"), ("term", "QUILMAR-vesk sent it"),
("term", "a tam_orvel.prax file"),
# A term right after a backslash escape, as a string literal or a JSON
# value writes it, and after a backslash that is not one.
("term", r'"line one\nZorblax|quent"'), ("term", r"col\tzorblax|quent\tcol"),
("term", r"end\r\nquilmar| vesk"), ("term", r"a\x41zorblax|quent"), ("term", r"x\u00e9zorblax|quent"),
("term", r"C:\Users\zorblax|quent\notes"),
("entity-id", "entity_id=|9101,"), ("entity-id", '"entity_id": |9202'), ("entity-id", "Entity |9303's app"),
("entity-id", "kubectl -n entity-|9101 exec"), ("entity-id", "pod entity-|9101-0 restarted"),
("entity-id", "measured on Host #|9303"), ("entity-id", "gitea_user = 'host-|9101'"),
("entity-id", "entity id |9202 knocked"),
("provenance", "the real| rows that fired on the Host"), ("provenance", "the REAL| row 9"),
("provenance", "verbatim| from the transcript"), ("provenance", "read| off entity 9001"),
("email", "mail ana.b@gm|ail.com today"), ("email", "x@proto|nmail.com"),
("phone", "call +1 (2|02) 555-|0100 or +1 30|1 444 1234"), ("phone", "+1|3014441234"),
]]
# Every class is read inside base64 and base64url, padded or not, whole or
# glued to the text before it.
positives += [
("term", 'data: "%s"' % b64("a note that names Zorblax|quent outright")),
("term", "token=%s" % b64("pays QUILMAR| vesk ~~ été ok??", url=True)),
("term", "xyz" + b64("a note that names Zorblax|quent outright")),
("term", b64(b64("twice over: zorblax|quent, still found"))),
("entity-id", "blob %s end" % b64("the pod entity-|9101-0 restarted twice")),
("email", "%s" % b64("write to ana.b@gm|ail.com about it")),
]
negatives = [live(s) for s in [
"zorblax quent", "quilmar", "a vesk quilmar", "orvel prax tam", r"zorblax\nquent",
"entity_id=91011", "entity_id=910", "entity 92021", "entity-9101x", "host-91012", "Host 9104",
"entity-9310-0.svc", "the v0.9.9101 tag", "entity_id = <id>",
"real rows in a throwaway container", "the real row replaces the echo",
"returns the text verbatim", "ana@example.com", "a•••@gmail.com shows the first letter",
"+1 415 555 0199", "+1|5555550100", "ids 2026091812345", "port 8800, 4096 MiB",
"MemoryHigh=15032385536 MemoryMax=17179869184", "2026-10-07 12:00",
"widths 278 584 556 1015 667 722",
# Base64-shaped text that is not base64 of text: identifiers, paths,
# digests and random bytes are not decoded into findings.
"TestPublishedSourceCarriesNoOldInternalNameAnywhere",
"/usr/local/lib/python3/site-packages/zorbtool/__init__",
"sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59",
base64.b64encode(bytes(range(200, 256))).decode("ascii"),
b64("a plain note with nothing listed in it at all"),
]]
for cls, line in positives:
if cls not in classes(line):
fails.append("self-test: %s not found in %r" % (cls, line))
for line in negatives:
if classes(line):
fails.append("self-test: false hit %s in %r" % (classes(line), line))
# A long line is read to its end: a term far past the first window, and one
# straddling a window edge, are each found once.
pad = "x " * 40000
far = pad + live("the zorblax|quent ledger")
edge = "y" * (_WINDOW - 10) + live(" zorblax|quent ") + "z" * 30000
for name, line in (("far past the first window", far), ("on a window edge", edge)):
got = [c for _p, c, _w in line_hits(line, sc) if c == "term"]
if got != ["term"]:
fails.append("self-test: a term %s of a long line is found once, got %s" % (name, got))
# The real table is wired: every entry is a digest of the right shape.
if not TERMS or not all(re.fullmatch(r"[0-9a-f]{32}", k) for k in TERMS):
fails.append("self-test: TERMS is empty or malformed")
# A line holding a quiet value shows no text for any finding on it: here a
# provenance phrase beside a mailbox, and one base64 run holding both an
# entity id and a term.
blob = b64("the pod entity-|9101-0 belongs to zorblax|quent")
for line, want, secret in ((live("the real| rows that came from ana.b@gm|ail.com"),
{"provenance", "email"}, live("ana.b@gm|ail")),
("note %s end" % blob, {"entity-id", "term"}, blob[:24])):
got = line_report("x.md", 1, line, sc)
if {c for c, _ in got} != want or any(secret in t for _c, t in got):
fails.append("self-test: a line with a quiet value is reported without its text, got %s" % got)
# A path that holds a quiet value is shown with that component withheld,
# by depth; one split across components withholds them all.
for label, want in (("docs/zorblaxquent-notes.md", "docs/<withheld 2>"),
("app.bundle!notes/" + live("ana.b@gm|ail.com") + ".txt", "app.bundle!notes/<withheld 3>"),
("quilmar/vesk.md", "<withheld 1>/<withheld 2>"),
("docs/readme.md", "docs/readme.md")):
if shown_path(label, sc) != want:
fails.append("self-test: path %r shown as %r, want %r" % (label, shown_path(label, sc), want))
got = line_report("docs/" + live("ana.b@gm|ail.com") + ".md", 1, live("the real| rows that"), sc)
if [t for _c, t in got] != [live("docs/<withheld 2>:1 [provenance: real| rows that] the real| rows that")]:
fails.append("self-test: a FAIL line's path is shown withheld, got %s" % got)
# A term is counted once per place it stands, however many ways it is read.
got = [c for c, _ in line_findings(live(r"a\nzorblax|quent b"), sc)]
if got != ["term"]:
fails.append("self-test: a term after an escape is one hit, got %s" % got)
# Without a key the term class is off and nothing else changes.
planted = live("the zorblax|quent ledger, entity_id=|9101, x@proto|nmail.com")
keyless = TermScanner(sc.terms, None, probe_ids)
got = {c for c, _ in line_findings(planted, keyless)}
if got != {"entity-id", "email"}:
fails.append("self-test: without a key only the term class is off, got %s" % sorted(got))
# Without the ids the entity-id class is off and nothing else changes.
got = {c for c, _ in line_findings(planted, TermScanner(sc.terms, probe_key))}
if got != {"term", "email"}:
fails.append("self-test: without the ids only the entity-id class is off, got %s" % sorted(got))
# A key other than the one TERMS was made with is told apart from it.
if key_matches(probe_key) or key_matches(b""):
fails.append("self-test: a key TERMS was not made with passes the key check")
# An allow entry needs all four fields and a known class.
import tempfile
with tempfile.TemporaryDirectory() as td:
p = os.path.join(td, "a.allow")
with open(p, "w", encoding="utf-8") as f:
f.write("# a comment\n^docs/x\\.md$\tentity-id\tentity\tok\n^y$\tnot-a-class\tz\twhy\n^z$\tphone\n")
entries, errors = load_allow(p)
if len(entries) != 1 or len(errors) != 2:
fails.append("self-test: the allow file reader kept %d entries and %d errors, want 1 and 2"
% (len(entries), len(errors)))
# The reserved ids are read one per line; a missing file turns the
# class off, and a file that would match nothing is an error.
idf = os.path.join(td, "ids")
for body, want in (("# reserved\n9101\n\n 9202 \n", ((9101, 9202), False)),
("9101\nentity-9202\n", (None, True)), ("# none\n\n", (None, True)), ("", (None, True))):
with open(idf, "w", encoding="utf-8") as f:
f.write(body)
ids, err = load_reserved_ids(idf)
if (ids, err is not None) != want:
fails.append("self-test: reserved ids %r read as %r, %r" % (body, ids, err))
if load_reserved_ids(os.path.join(td, "absent")) != (None, None):
fails.append("self-test: a missing reserved-ids file is not (None, None)")
# A zip-format bundle is opened by its signature, whatever it is named
# (an app bundle, or a .zip such as a browser extension a site serves),
# and its text members are read.
for name in ("app.bundle", "ext.zip"):
with zipfile.ZipFile(os.path.join(td, name), "w") as z:
z.writestr("notes.txt", live("write to x@proto|nmail.com\n"))
got = [(label, {c for ln in text.splitlines() for c, _ in line_findings(ln, keyless)})
for label, text in texts(td, name)]
if got != [(name + "!notes.txt", {"email"})]:
fails.append("self-test: a bundle's text member is read, got %s" % got)
# A file with the zip signature that does not open is reported as
# unreadable, not passed as an empty text.
with open(os.path.join(td, "broken.bundle"), "wb") as f:
f.write(ZIP_MAGIC + b"\x14\x00 not a zip")
got = list(texts(td, "broken.bundle"))
if got != [("broken.bundle", None)]:
fails.append("self-test: a bundle that does not open is reported, got %s" % got)
return fails
# ── main ─────────────────────────────────────────────────────────────────────
def main(argv) -> int:
root = os.path.dirname(HERE)
args = list(argv)
if "--root" in args:
i = args.index("--root")
if i + 1 >= len(args):
print("usage: test_no_personal_data.py [--root DIR] [path-prefix ...]")
return 2
root = os.path.abspath(args[i + 1])
del args[i:i + 2]
only = [a.rstrip("/") for a in args if not a.startswith("-")]
fails = self_test()
for f in fails:
print(" FAIL %s" % f)
allow, allow_errors = load_allow()
for e in allow_errors:
fails.append(e)
print(" FAIL %s" % e)
key = load_key()
if key is None:
print(" NOTE term check skipped: no key at %s (set NO_PERSONAL_DATA_KEY_FILE); "
"the other checks still run" % KEY_FILE)
elif not key_matches(key):
fails.append("the key at %s is not the one TERMS was made with, so it would match "
"nothing: point NO_PERSONAL_DATA_KEY_FILE at that key, or remake TERMS and KEY_CHECK" % KEY_FILE)
print(" FAIL %s" % fails[-1])
key = None
ids, ids_error = load_reserved_ids()
if ids_error:
fails.append(ids_error)
print(" FAIL %s" % ids_error)
elif ids is None:
print(" NOTE entity-id check skipped: no reserved ids at %s (set RESERVED_ENTITY_IDS_FILE); "
"the other checks still run" % RESERVED_ENTITY_IDS_FILE)
scanner = TermScanner(TERMS, key, ids)
used = set()
findings = []
counts = {}
n_texts = 0
# The allow file quotes the entity ids it allows; every other class is
# still checked in it.
allow_rel = os.path.relpath(ALLOW_FILE, root).replace(os.sep, "/")
for rel in sorted(tracked_files(root)):
if only and not any(rel == p or rel.startswith(p + "/") for p in only):
continue
for cls, what in line_findings(rel, scanner):
if not allowed(allow, rel, cls, rel, used):
findings.append("%s [%s: %s] (file name)" % (shown_path(rel, scanner), cls, what))
counts[cls] = counts.get(cls, 0) + 1
for label, text in texts(root, rel):
n_texts += 1
if text is None:
fails.append("%s has a zip signature but does not open as a zip, so its "
"members cannot be read" % shown_path(label, scanner))
print(" FAIL %s" % fails[-1])
continue
# A bundle member's name installs with it, like a file name.
member = label[len(rel) + 1:]
for cls, what in (line_findings(member, scanner) if member else ()):
if not allowed(allow, label, cls, member, used):
findings.append("%s [%s: %s] (bundle member name)" % (shown_path(label, scanner), cls, what))
counts[cls] = counts.get(cls, 0) + 1
for i, line in enumerate(text.splitlines(), 1):
for cls, finding in line_report(label, i, line, scanner, allow, used, label == allow_rel):
findings.append(finding)
counts[cls] = counts.get(cls, 0) + 1
for f in findings:
print(" FAIL %s" % f)
if not only:
for i, entry in enumerate(allow):
if i not in used:
fails.append("allow entry matches nothing, remove it: %s" % (entry[:3],))
print(" FAIL %s" % fails[-1])
if findings or fails:
summary = ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items()))
print("FAIL %d finding(s) in %d texts (%s); %d other failure(s)"
% (len(findings), n_texts, summary, len(fails)))
return 1
print("ok %d texts clean" % n_texts)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))