#!/usr/bin/env python3 """Gateway acceptance test: runs the reference leak fixtures THROUGH the live /scrub + /rehydrate ASGI endpoints (ner=rules_only, deterministic/offline) plus the gateway-specific security contract: - parity: every must_vanish identifier absent from /scrub responses; substance survives - map-leak: no real value (incl. Tier-1) appears in any response body OR the server map's Claude-bound surface; Tier-1 values are absent from the stored map entirely - round-trip: /rehydrate via the server-held map reproduces raw (Tier-1 -> [redacted]) - handle reuse: a 2nd /scrub with the same map_handle keeps tokens stable - 409 tripwire: strict /rehydrate with an unmapped token - 410: rehydrate against an unknown/expired handle - 422 fail-closed: tier1_action=reject on Tier-1 input emits nothing Run: cd image && python3 -m app.redaction.test_gateway (no Spark/Qwen/network needed) """ import asyncio import os import re import sys import tempfile import httpx from fastapi import FastAPI sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import scrub as R # noqa: E402 (vendored engine) import test_scrub_leak as REF # noqa: E402 (reference fixtures) # Build the gateway app against a throwaway map store. os.environ.setdefault("SPARK1_HOST", "") os.environ.setdefault("SPARK2_HOST", "") from app.config import Settings # noqa: E402 from app.redaction_gateway import build_router, MapStore # noqa: E402 FAILS = [] def check(cond, msg): print((" PASS " if cond else " FAIL ") + msg) if not cond: FAILS.append(msg) def tier1_redacted(raw): s = raw for _, pat in R.TIER1_PATTERNS: s = pat.sub("[redacted]", s) return s async def main(): db = os.path.join(tempfile.mkdtemp(), "maps.db") store = MapStore(db, ttl_seconds=3600) app = FastAPI() app.include_router(build_router(Settings.from_env(), store)) transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://gw") as c: for case in REF.CASES: print(f"\n[{case['name']}]") r = await c.post("/scrub", json={ "task_id": "t-" + case["name"][:8], "actor": "analyst", "items": [{"id": "ctx_1", "text": case["raw"]}], "known_entities": case["known"], "ner": "rules_only", }) check(r.status_code == 200, f"/scrub 200 (got {r.status_code} {r.text[:120]})") if r.status_code != 200: continue d = r.json() scrubbed = d["items"][0]["scrubbed_text"] handle = d["map_handle"] body_blob = r.text for v in case["must_vanish"]: check(v not in scrubbed, f"identifier {v!r} absent from scrubbed_text") check(v not in body_blob, f"identifier {v!r} absent from entire /scrub response body") for s in case["substance"]: check(s in scrubbed, f"substance survives: {s!r}") # map-leak: Tier-1 values must not be in the server-held map at all stored = store.get(handle) for v in case["tier1_excluded"]: check(all(v not in val for val in stored.values()), f"Tier-1 {v!r} not in server map (excluded, not tokenized)") # round-trip via the server-held map rr = await c.post("/rehydrate", json={ "task_id": "t", "map_handle": handle, "items": [{"id": "out_1", "text": scrubbed}], "strict": True, }) check(rr.status_code == 200, f"/rehydrate 200 (got {rr.status_code})") if rr.status_code == 200: rehy = rr.json()["items"][0]["rehydrated_text"] check(rehy == tier1_redacted(case["raw"]), "rehydrate via server map == raw with Tier-1 redacted") # ── handle reuse keeps tokens stable across calls ── print("\n[map_handle reuse — stable tokens]") r1 = await c.post("/scrub", json={"task_id": "reuse", "items": [{"id": "a", "text": "Dana Whitfield called."}], "known_entities": {"persons": ["Dana Whitfield", "Dana", "Whitfield"]}, "ner": "rules_only"}) h = r1.json()["map_handle"] tok1 = r1.json()["items"][0]["scrubbed_text"] r2 = await c.post("/scrub", json={"task_id": "reuse", "map_handle": h, "items": [{"id": "b", "text": "Dana Whitfield emailed again."}], "known_entities": {"persons": ["Dana Whitfield", "Dana", "Whitfield"]}, "ner": "rules_only"}) tok2 = r2.json()["items"][0]["scrubbed_text"] same_token = re.findall(r"\[PERSON_\d+\]", tok1) == re.findall(r"\[PERSON_\d+\]", tok2) check("Dana Whitfield" not in tok1 and "Dana Whitfield" not in tok2, "name tokenized both calls") check(same_token and bool(re.search(r"\[PERSON_1\]", tok2)), "same entity -> same token across calls (reuse)") # ── 409 strict tripwire on unmapped token ── print("\n[strict rehydrate tripwire]") r409 = await c.post("/rehydrate", json={"task_id": "reuse", "map_handle": h, "items": [{"id": "x", "text": "see [PERSON_99] smuggled"}], "strict": True}) check(r409.status_code == 409, f"unmapped token -> 409 (got {r409.status_code})") # ── 410 unknown/expired handle ── print("\n[unknown handle -> 410]") r410 = await c.post("/rehydrate", json={"task_id": "z", "map_handle": "deadbeef" * 4, "items": [{"id": "x", "text": "[PERSON_1]"}], "strict": True}) check(r410.status_code == 410, f"unknown handle -> 410 (got {r410.status_code})") # ── 422 fail-closed: tier1_action=reject emits nothing ── print("\n[fail-closed tier1 reject]") r422 = await c.post("/scrub", json={"task_id": "fc", "tier1_action": "reject", "items": [{"id": "x", "text": "Wire to acct 000123456789 today."}], "known_entities": {}, "ner": "rules_only"}) check(r422.status_code == 422, f"Tier-1 + reject -> 422 (got {r422.status_code})") check("000123456789" not in r422.text, "rejected call does NOT echo the Tier-1 value") # ── error bodies expose top-level documented keys (NOT wrapped under "detail") ── print("\n[error body shape]") check(r409.json().get("error") == "unknown_tokens" and "tokens" in r409.json(), "409 body top-level {error:unknown_tokens, tokens:[...]}") check(r410.json().get("error") == "map_expired", "410 body top-level {error:map_expired}") check(r422.json().get("error") == "tier1_detected", "422 body top-level {error:tier1_detected}") # ── tokens_used is BARE (PERSON_1, not [PERSON_1]) per the handover contract ── print("\n[tokens_used bare]") rb = await c.post("/scrub", json={"task_id": "bare", "items": [{"id": "a", "text": "Dana Whitfield called."}], "known_entities": {"persons": ["Dana Whitfield"]}, "ner": "rules_only"}) tu = rb.json()["items"][0]["tokens_used"] check(tu and all("[" not in t and "]" not in t for t in tu), f"tokens_used bare: {tu}") # ── P0 fix unit tests: descriptive token-substitution match + fail-closed ── print("\n[descriptive redaction — P0 fail-open fix]") from app.redaction_gateway import _redact_descriptive, _apply_tokenmap_to_span, _Contract tmap = {"[ORG_1]": "Acme Mining"} # The NER stashed the span with the plaintext name; the final text has it tokenized. final_text = "He is part of [redacted-was-here] the family that sold [ORG_1] in Texas last year, big deal." span = "the family that sold Acme Mining in Texas last year" sub = _apply_tokenmap_to_span(span, tmap) check(sub == "the family that sold [ORG_1] in Texas last year", "token-substituted span matches scrubbed form") out, flags = _redact_descriptive(final_text, [span], tmap, "i") check("[redacted]" in out and "the family that sold" not in out, "descriptive span removed via token-substituted match (no fail-open leak)") # substantial span that can't be located anywhere -> fail closed (422) try: _redact_descriptive("totally unrelated text", ["the founder who sold his company in Wyoming last year"], {}, "i") check(False, "unremovable substantial span should fail closed") except _Contract as e: check(e.status == 422 and e.body.get("error") == "descriptive_unredactable", "unremovable substantial descriptive span -> 422 fail-closed") # ── P0 fix: map store db file is NOT world-readable ── print("\n[map store file perms — P0]") import stat as _stat mode = _stat.S_IMODE(os.stat(db).st_mode) check(mode & 0o077 == 0, f"map db is 0600-ish (mode={oct(mode)}, no group/other access)") print() if FAILS: print(f"FAILED ({len(FAILS)}):") for f in FAILS: print(" - " + f) sys.exit(1) print("ALL PASS (gateway acceptance — parity + map-leak + round-trip + tripwires)") if __name__ == "__main__": asyncio.run(main())