Files
bastion-agent/bastion_agent/test_sanitize.py
T
Privatae 3ae3a2841c
ci / personal-data (push) Successful in 3s
ci / test (push) Successful in 45s
ci / reproducible (push) Successful in 3s
bastion agent 0.1.17
2026-10-29 21:42:06 +00:00

400 lines
18 KiB
Python

"""Unit tests for the sanitize stage. Run: python3 -m unittest discover .
The real pii pipeline and vault run in-process; the recall backend
is a fake (no model), the service path is a stub HTTP server speaking the
real /sterilize wire format. Nothing here needs a model or the network.
"""
import json
import os
import shutil
import stat
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest import mock
from . import sanitize as sz
from .extract import extract
from .manifest import validate_document
from .walk import walk
IS_WINDOWS = os.name == "nt"
def manifest(root, mode="flag", verify=False, scope=None):
san = {"mode": mode, "verify": verify}
if scope:
san["scope"] = scope
return validate_document({
"freeagent": 1, "id": "t", "owner": {"host": "h"},
"scope": {"roots": [root]},
"mask": ["read"], "stages": ["walk", "extract", "sanitize"], "sweep": "manual",
"sanitize": san,
"sink": {"catalog": "local", "vectors": "local", "plaintext": "never"},
})
def put(root, rel, text):
path = os.path.join(root, *rel.split("/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
class FakeBackend:
name = "fake"
calls = 0
def __init__(self, findings=(), fail=False):
self.findings = list(findings)
self.fail = fail
def describe(self):
return "fake"
def extract(self, text):
FakeBackend.calls += 1
if self.fail:
_, recall, _ = sz.pii_modules()
raise recall.RecallError("model unreachable")
return list(self.findings) # proposes everything; grounding must drop the ghosts
def verify(self, text):
return []
class Case(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp(prefix="fa-sz-")
self.out = tempfile.mkdtemp(prefix="fa-szout-")
put(self.root, "note.txt", "Marisol's email is m@example.com, card 4111 1111 1111 1111.\n")
put(self.root, "twin.txt", "Marisol's email is m@example.com, card 4111 1111 1111 1111.\n")
put(self.root, "clean.txt", "nothing personal here\n")
put(self.root, "blob.bin", "\x00\x01")
FakeBackend.calls = 0
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
shutil.rmtree(self.out, ignore_errors=True)
def prep(self, m):
walk(m, self.out)
extract(m, self.out, use_external=False)
def run_stage(self, m, env=None, backend=None):
self.prep(m)
if backend is not None:
with mock.patch.object(sz, "BUILD_BACKEND", lambda e: backend):
s = sz.sanitize(m, self.out, env=env or {})
else:
s = sz.sanitize(m, self.out, env=env or {})
with open(os.path.join(self.out, "sanitize.jsonl"), encoding="utf-8") as fh:
idx = {json.loads(l)["path"]: json.loads(l) for l in fh}
return s, idx
def text_of(self, rec):
with open(sz.store_path(self.out, rec["sha256"]), encoding="utf-8") as fh:
return fh.read()
class FlagModeTests(Case):
def test_rules_only_redaction(self):
s, idx = self.run_stage(manifest(self.root, "flag"))
out = self.text_of(idx["note.txt"])
self.assertNotIn("m@example.com", out)
for card in ("4111 1111 1111 1111", "4111111111111111"):
self.assertNotIn(card, out)
self.assertIn("Marisol", out) # contextual PII is NOT caught by flag mode
self.assertIn("[PII_EMAIL_1]", out)
self.assertIn("[PII_CREDIT_CARD_1]", out)
self.assertEqual(idx["note.txt"]["types"], {"EMAIL": 1, "CREDIT_CARD": 1})
self.assertEqual(s["sterilizer"], "flag:rules-only")
self.assertEqual(s["types"], {"EMAIL": 1, "CREDIT_CARD": 1})
# twin shares content -> deduped, counted once as ok
self.assertEqual(idx["twin.txt"]["sha256"], idx["note.txt"]["sha256"])
self.assertEqual(s["counts"]["ok"], 3)
self.assertEqual(idx["blob.bin"]["status"], "skipped")
self.assertEqual(s["counts"]["skipped"], 1)
def test_index_and_summary_carry_no_values(self):
s, idx = self.run_stage(manifest(self.root, "flag"))
blob = json.dumps(idx) + json.dumps(s)
self.assertNotIn("m@example.com", blob)
# the whole card: four digits alone also turn up inside hashes and times
for card in ("4111 1111 1111 1111", "4111111111111111"):
self.assertNotIn(card, blob)
@unittest.skipIf(IS_WINDOWS, "POSIX permission bits")
def test_vault_is_private(self):
self.run_stage(manifest(self.root, "flag"))
mode = stat.S_IMODE(os.stat(os.path.join(self.out, "sanitize", "vault.db")).st_mode)
self.assertEqual(mode, 0o600)
def test_reuse_and_invalidate(self):
s1, _ = self.run_stage(manifest(self.root, "flag"))
s2, idx = self.run_stage(manifest(self.root, "flag"))
self.assertEqual(s2["counts"]["reused"], s1["counts"]["ok"])
self.assertTrue(idx["note.txt"]["reused"])
# a different scope is a different cache key -> redone
s3, _ = self.run_stage(manifest(self.root, "flag", scope="other"))
self.assertEqual(s3["counts"]["reused"], 0)
# a changed file is redone
put(self.root, "clean.txt", "now with a@b.co\n")
s4, idx4 = self.run_stage(manifest(self.root, "flag"))
self.assertIn("[PII_EMAIL_", self.text_of(idx4["clean.txt"]))
def test_result_from_an_older_pipeline_version_is_not_reused(self):
# the version is the only reuse-key part that changes with the
# pipeline: output written by older code must be redone on upgrade
with mock.patch.object(sz, "SANITIZE_VERSION", 1):
s1, _ = self.run_stage(manifest(self.root, "flag"))
self.assertGreaterEqual(sz.SANITIZE_VERSION, 2)
s2, idx = self.run_stage(manifest(self.root, "flag"))
self.assertEqual(s2["counts"]["reused"], 0)
self.assertEqual(s2["counts"]["ok"], s1["counts"]["ok"])
self.assertFalse(idx["note.txt"].get("reused", False))
def test_text_kept_by_the_older_rules_is_redone(self):
# version 2 kept an international number and a lowercase IBAN in the
# clear; a store it wrote must not be reused by the wider rules
put(self.root, "intl.txt", "Mobile +44 7700 900123, iban gb82west12345698765432\n")
with mock.patch.object(sz, "SANITIZE_VERSION", 2):
self.run_stage(manifest(self.root, "flag"))
s, idx = self.run_stage(manifest(self.root, "flag"))
self.assertEqual(s["counts"]["reused"], 0)
out = self.text_of(idx["intl.txt"])
self.assertNotIn("7700 900123", out)
self.assertNotIn("gb82west", out)
self.assertEqual(idx["intl.txt"]["types"], {"PHONE": 1, "BANK_ACCOUNT": 1})
def test_tokens_stable_across_files_in_scope(self):
put(self.root, "again.txt", "ping m@example.com again\n")
_, idx = self.run_stage(manifest(self.root, "flag"))
self.assertIn("[PII_EMAIL_1]", self.text_of(idx["again.txt"]))
self.assertIn("[PII_EMAIL_1]", self.text_of(idx["note.txt"]))
def test_vault_dir_env_keeps_vault_out_of_out(self):
vdir = tempfile.mkdtemp(prefix="fa-vault-")
try:
s, idx = self.run_stage(manifest(self.root, "flag"), env={"FREEAGENT_VAULT_DIR": vdir})
self.assertTrue(os.path.exists(os.path.join(vdir, "vault.db")))
self.assertFalse(os.path.exists(os.path.join(self.out, "sanitize", "vault.db")))
self.assertIn("[PII_EMAIL_1]", self.text_of(idx["note.txt"]))
finally:
shutil.rmtree(vdir, ignore_errors=True)
def test_requires_extract_first(self):
with self.assertRaises(FileNotFoundError):
sz.sanitize(manifest(self.root, "flag"), self.out, env={})
class SterilizeModeTests(Case):
def test_recall_backend_findings_are_tokenized(self):
fake = FakeBackend([("Marisol", "PERSON"), ("Ghost", "PERSON")])
s, idx = self.run_stage(manifest(self.root, "sterilize"), backend=fake)
out = self.text_of(idx["note.txt"])
self.assertNotIn("Marisol", out)
self.assertIn("[PII_PERSON_1]", out)
self.assertEqual(idx["note.txt"]["types"]["PERSON"], 1)
self.assertEqual(idx["note.txt"]["stats"]["llm_ungrounded"], 1)
self.assertEqual(s["sterilizer"], "sterilize:fake")
self.assertEqual(FakeBackend.calls, 2) # note (twin deduped) + clean
def test_fail_closed_stops_the_stage(self):
s, idx = self.run_stage(manifest(self.root, "sterilize"), backend=FakeBackend(fail=True))
self.assertEqual(s["counts"]["ok"], 0)
self.assertEqual(s["counts"]["not_run"], 3)
self.assertIn("model unreachable", s["stopped"])
self.assertEqual(idx["note.txt"]["status"], "not_run")
self.assertFalse(os.path.exists(sz.store_path(self.out, idx["note.txt"]["sha256"])))
self.assertEqual(FakeBackend.calls, 1) # stopped after the first failure
def test_backend_that_cannot_start_raises(self):
self.prep(manifest(self.root, "sterilize"))
with self.assertRaises(sz.SanitizeError):
sz.sanitize(manifest(self.root, "sterilize"), self.out, env={"PII_RECALL": "nope"})
def test_chunking_round_trip(self):
big = ("para %d with m@example.com\n\n" % i for i in range(2000))
text = "".join(big)
put(self.root, "big.txt", text)
chunks = sz.split_chunks(text, limit=5000)
self.assertGreater(len(chunks), 5)
self.assertEqual("".join(chunks), text)
self.assertTrue(all(len(c) <= 5000 for c in chunks))
sizes = []
real = sz.InProcess.sterilize
def spy(front, chunk, scope):
if chunk.startswith("para "): # big.txt's chunks, not the other files
sizes.append(len(chunk))
return real(front, chunk, scope)
with mock.patch.object(sz, "SANITIZE_CHUNK_CHARS", 5000), \
mock.patch.object(sz.InProcess, "sterilize", spy):
_, idx = self.run_stage(manifest(self.root, "flag"))
# the stage itself must have split at the patched limit, not the default
self.assertLessEqual(max(sizes), 5000)
self.assertEqual(len(sizes), len(chunks))
out = self.text_of(idx["big.txt"])
self.assertNotIn("m@example.com", out)
self.assertEqual(out.count("[PII_EMAIL_1]"), 2000) # one token, every chunk
def test_hard_cut_never_splits_a_placeholder(self):
_, _, Vault = sz.pii_modules()
from pii import pipeline
self.assertEqual(sz._PLACEHOLDER_RE.pattern, pipeline.TOKEN_RE.pattern)
vault = Vault(os.path.join(self.out, "v.db"))
vault.token_for("s", "EMAIL", "ana@x.org") # [PII_EMAIL_1] is live
# the corpus stage escapes every placeholder in a document, but only
# one that reaches the sterilizer whole: split in half, neither half
# is recognised and the literal is stored raw, to rehydrate later as
# the vault's value
text = "x" * 15 + "[PII_EMAIL_1]" + "y" * 10 # one line, no newline
chunks = sz.split_chunks(text, limit=20)
self.assertEqual("".join(chunks), text)
self.assertTrue(any("[PII_EMAIL_1]" in c for c in chunks), chunks)
out = "".join(pipeline.sterilize(c, "s", vault, lambda t: [], escape_held_literals=True)[0]
for c in chunks)
self.assertNotIn("[PII_EMAIL_1]", out)
self.assertEqual(pipeline.rehydrate(out, "s", vault)[0], text)
# a placeholder at the very start of an over-long piece stays whole
self.assertEqual(sz.split_chunks("[PII_PERSON_1]" + "z" * 30, limit=10)[0], "[PII_PERSON_1]")
def test_hard_cut_clears_a_placeholder_of_any_length(self):
long_ph = "[PII_" + "OTHER_" * 20 + "123456789]" # well over 64 characters
for before in (30, 90, 150):
with self.subTest(before=before):
text = "x" * before + long_ph + "y" * 40
chunks = sz.split_chunks(text, limit=100)
self.assertEqual("".join(chunks), text)
self.assertTrue(any(long_ph in c for c in chunks), [len(c) for c in chunks])
def test_corpus_document_quoting_a_live_placeholder_round_trips(self):
# one manifest-wide scope: a.txt mints [PII_EMAIL_1]; b.txt quotes that
# placeholder literally and must come back as written, not as a's email
for name in ("note.txt", "twin.txt", "clean.txt", "blob.bin"):
os.remove(os.path.join(self.root, name))
put(self.root, "a.txt", "mail ana@x.org\n")
quote = "the export shows [PII_EMAIL_1] where the address was\n"
put(self.root, "b.txt", quote)
_, idx = self.run_stage(manifest(self.root, "flag"))
_, _, Vault = sz.pii_modules()
from pii import pipeline
vault = Vault(os.path.join(self.out, "sanitize", "vault.db"))
self.assertEqual(vault.value_for("t", "[PII_EMAIL_1]"), "ana@x.org")
stored = self.text_of(idx["b.txt"])
self.assertNotIn("[PII_EMAIL_1]", stored)
self.assertEqual(pipeline.rehydrate(stored, "t", vault)[0], quote)
def test_split_chunks_hard_cuts_a_giant_line(self):
text = "x" * 12000 + "\n" + "y" * 100
chunks = sz.split_chunks(text, limit=5000)
self.assertEqual("".join(chunks), text)
self.assertTrue(all(len(c) <= 5000 for c in chunks))
self.assertEqual(sz.split_chunks("", 10), [])
self.assertEqual(sz.split_chunks("short", 10), ["short"])
class _StubPii(BaseHTTPRequestHandler):
seen = []
status = 200
echo = "honour" # "honour", "strip" (a service that predates the field) or "false"
def do_POST(self):
n = int(self.headers.get("Content-Length", "0"))
req = json.loads(self.rfile.read(n))
_StubPii.seen.append((self.path, self.headers.get("Authorization"), req))
if _StubPii.status != 200:
self.send_response(_StubPii.status)
self.send_header("Content-Length", "0")
self.end_headers()
return
body = json.dumps({"text": req["text"].replace("Marisol", "[PII_PERSON_1]"),
"findings": [{"token": "[PII_PERSON_1]", "type": "PERSON"}],
"stats": dict({"rules": 0, "llm": 1, "recall": "llm"}, **(
{} if _StubPii.echo == "strip" else
{"escape_held_literals": _StubPii.echo == "honour"
and req.get("escape_held_literals") is True}))}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
class ServiceModeTests(Case):
@classmethod
def setUpClass(cls):
cls.srv = ThreadingHTTPServer(("127.0.0.1", 0), _StubPii)
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
cls.addr = "127.0.0.1:%d" % cls.srv.server_address[1]
@classmethod
def tearDownClass(cls):
cls.srv.shutdown()
cls.srv.server_close()
def setUp(self):
super().setUp()
_StubPii.seen, _StubPii.status, _StubPii.echo = [], 200, "honour"
def test_service_path(self):
env = {"PII_ADDR": self.addr, "PII_API_KEY": "k1"}
s, idx = self.run_stage(manifest(self.root, "sterilize", scope="conv9"), env=env)
self.assertEqual(s["sterilizer"], "service:http://" + self.addr)
self.assertNotIn("Marisol", self.text_of(idx["note.txt"]))
path, auth, req = _StubPii.seen[0]
self.assertEqual((path, auth, req["scope"], req["verify"]), ("/sterilize", "Bearer k1", "conv9", False))
self.assertIs(req["escape_held_literals"], True) # documents: escape every literal
# the echo is a handshake, not a count: it never reaches the index
self.assertNotIn("escape_held_literals", idx["note.txt"]["stats"])
with open(os.path.join(self.out, "sanitize.jsonl"), encoding="utf-8") as fh:
self.assertNotIn("escape_held_literals", fh.read())
self.assertNotIn("escape_held_literals", json.dumps(s))
self.assertEqual(idx["note.txt"]["stats"]["recall"], "llm")
# no local vault in service mode — the service owns it
self.assertFalse(os.path.exists(os.path.join(self.out, "sanitize", "vault.db")))
def test_service_502_fails_closed(self):
_StubPii.status = 502
env = {"PII_ADDR": self.addr, "PII_API_KEY": "k1"}
s, idx = self.run_stage(manifest(self.root, "sterilize"), env=env)
self.assertEqual(s["counts"]["not_run"], 3)
self.assertIn("502", s["stopped"])
def test_service_that_does_not_echo_the_literal_rule_stops_the_stage(self):
# an older service ignores escape_held_literals and answers 200 under
# the narrower rule; the stage must not store what it cannot trust
env = {"PII_ADDR": self.addr, "PII_API_KEY": "k1"}
for echo in ("strip", "false"):
with self.subTest(echo):
_StubPii.echo = echo
shutil.rmtree(self.out, ignore_errors=True)
os.makedirs(self.out)
s, idx = self.run_stage(manifest(self.root, "sterilize"), env=env)
self.assertIn("did not honour escape_held_literals", s["stopped"])
self.assertEqual(s["counts"]["ok"], 0)
self.assertEqual(s["counts"]["not_run"], 3)
self.assertFalse(os.path.exists(sz.store_path(self.out, idx["note.txt"]["sha256"])))
def test_service_without_key_refused(self):
self.prep(manifest(self.root, "sterilize"))
with self.assertRaises(sz.SanitizeError):
sz.sanitize(manifest(self.root, "sterilize"), self.out, env={"PII_ADDR": self.addr})
def test_flag_mode_never_uses_the_service(self):
env = {"PII_ADDR": self.addr, "PII_API_KEY": "k1"}
s, _ = self.run_stage(manifest(self.root, "flag"), env=env)
self.assertEqual(_StubPii.seen, [])
self.assertEqual(s["sterilizer"], "flag:rules-only")
if __name__ == "__main__":
unittest.main()