bastion agent 0.1.16
ci / test (push) Successful in 1m1s
ci / personal-data (push) Successful in 3s
ci / reproducible (push) Successful in 4s

This commit is contained in:
Privatae
2026-10-25 23:16:50 +00:00
parent 53094a6940
commit e3074f50b0
34 changed files with 1345 additions and 156 deletions
+13 -4
View File
@@ -9,9 +9,18 @@ WORKDIR /app
# boot (recall.py was, once)
COPY __init__.py detect.py llmclient.py pipeline.py recall.py service.py vault.py /app/pii/
# Root inside the container is deliberate: the vault db and its directory
# are created 0700/0600 (service sets umask 077) on the mounted /data,
# and the single-purpose process is bind-scoped by PII_BIND. Revisit if
# the service ever grows a second concern.
# The service runs as its own unprivileged user. The vault and its directory
# are created 0700/0600 (the service sets umask 077), so a volume mounted on
# /data must be writable by uid 10001.
RUN groupadd --system --gid 10001 pii \
&& useradd --system --uid 10001 --gid 10001 --no-create-home \
--home-dir /nonexistent --shell /usr/sbin/nologin pii \
&& install -d -m 0700 -o pii -g pii /data
USER 10001:10001
# The service listens on 127.0.0.1 unless PII_BIND says otherwise, and inside
# a container that is reachable from nothing else. To publish it, choose the
# address and the host side explicitly, for example:
# docker run -e PII_BIND=0.0.0.0:8091 -p 127.0.0.1:8091:8091 ...
ENV PYTHONUNBUFFERED=1
CMD ["python", "-m", "pii.service"]
+6
View File
@@ -2,3 +2,9 @@ from . import detect, pipeline # noqa: F401
from .detect import PII_TYPES, Finding # noqa: F401
from .pipeline import TOKEN_RE, ground, rehydrate, replace_values, sterilize # noqa: F401
from .vault import Vault # noqa: F401
# The package's public names: the modules and what the agent calls in them.
__all__ = [
"detect", "pipeline", "PII_TYPES", "Finding", "TOKEN_RE", "ground", "rehydrate",
"replace_values", "sterilize", "Vault",
]
+79 -8
View File
@@ -54,23 +54,54 @@ IPV4_RE = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?!\d)(?!\.[\w-])")
IPV6_RE = re.compile(r"(?<![\w:.])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?![\w:])")
SSN_RE = re.compile(r"(?<![\d-])(\d{3})-(\d{2})-(\d{4})(?![\d-])")
# Spaced: not when the groups are part of a longer run of spaced digits.
SSN_SPACED_RE = re.compile(r"(?<![\d-])(?<!\d )(\d{3}) (\d{2}) (\d{4})(?![\d-])(?! \d)")
# Nine bare digits are an SSN only after a keyword: an invoice or order
# number has the same shape.
SSN_BARE_RE = re.compile(r"(?<![\w-])(\d{3})(\d{2})(\d{4})(?![\w-])")
SSN_CUE_RE = re.compile(r"(?i)\b(?:ssn|ss#|social\s+security|soc\.?\s*sec\b)")
# 13–19 digits with optional single space/dash separators; Luhn decides.
CARD_RE = re.compile(r"(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])")
# 13–19 digits with optional single space/dot/dash separators; Luhn decides.
# A dot right after a digit before it, or before a digit after it, makes the
# run part of a longer dotted number.
CARD_RE = re.compile(r"(?<![\d-])(?<!\d\.)\d(?:[ .-]?\d){12,18}(?![\d-])(?!\.\d)")
# Digit-count and shape checks happen in code; this only shapes candidates.
PHONE_RE = re.compile(
r"(?<![\w.])(?:\+\d{1,3}[\s.-]?)?(?:\(\d{1,4}\)[\s.-]?)?\d{2,4}(?:[\s.-]\d{2,4}){1,4}"
r"(?![\w-])(?!\.[\w-])" # a sentence-ending full stop is not part of the number
)
# International form: a leading + and country code, an optional (0) trunk
# prefix or (area) code, then digit groups of any length (+44 7700 900123,
# +49 (0)30 1234567, +442079460958). Groups after the first need a separator,
# so a long digit run cannot be split many ways.
INTL_PHONE_RE = re.compile(
r"(?<![\w+])\+\d{1,3}(?:[ .-]?\(\d{1,4}\))?[ .-]?\d{1,12}(?:[ .-]\d{1,8}){0,5}"
r"(?![\w-])(?!\.[\w-])"
)
# National form with a trunk 0 and a long subscriber group (07700 900123,
# 030 1234567, (030) 1234567). The separator after the area code is required.
NATIONAL_PHONE_RE = re.compile(
r"(?<![\w.+])(?:\(0\d{1,4}\) ?|0\d{1,4}[ .-])\d{3,8}(?:[ .-]\d{2,4}){0,2}"
r"(?![\w-])(?!\.[\w-])"
)
# A seven-digit local number (555-0142) is a phone only after a phone word;
# order and reference numbers have the same shape.
LOCAL_PHONE_RE = re.compile(r"(?<![\w.+-])\d{3}[ .-]\d{4}(?![\w-])(?!\.[\w-])")
PHONE_CUE_RE = re.compile(
r"(?i)\b(?:call|calling|ring|phone|tel|telephone|mobile|cell|fax|text|dial|reach)\b")
# A dotted number right after a version word is a version, not an address.
VERSION_CUE_RE = re.compile(r"(?i)(?:\bv|\bversion|\bver\.?|\brelease|\bbuild|\bfirmware)\s*:?\s*$")
# ── checksum-validated account identifiers (borrowed from Presidio's
# recognizer list; only the ones a checksum can anchor are here) ──────────
# IBAN: country code + 2 check digits + 11-30 alphanumerics, optional
# spaces every 4. mod-97 == 1 decides (ISO 13616).
IBAN_RE = re.compile(r"(?<![A-Za-z0-9])[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,4})?(?![A-Za-z0-9])")
# spaces every 4, in either case. mod-97 == 1 and the country's length
# decide (ISO 13616), so random text of the same shape is not claimed.
IBAN_RE = re.compile(r"(?<![A-Za-z0-9])[A-Za-z]{2}\d{2}(?:[ ]?[A-Za-z0-9]{4}){2,7}(?:[ ]?[A-Za-z0-9]{1,4})?(?![A-Za-z0-9])")
IBAN_LENGTHS = {
"AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28, "BA": 20, "BE": 16, "BG": 22,
"BH": 22, "BR": 29, "CH": 21, "CR": 22, "CY": 28, "CZ": 24, "DE": 22, "DK": 18,
@@ -147,8 +178,17 @@ def _find_secrets(text):
def _find_ibans(text):
for m in IBAN_RE.finditer(text):
if _iban_ok(m.group(0)):
yield Finding(m.group(0), "BANK_ACCOUNT", m.start(), m.end())
# a spaced IBAN followed by a four-letter word ("... 7034 then")
# matches with the word attached; drop trailing groups until it checks
cand = m.group(0)
while True:
if _iban_ok(cand):
yield Finding(cand, "BANK_ACCOUNT", m.start(), m.start() + len(cand))
break
cut = cand.rfind(" ")
if cut < 0:
break
cand = cand[:cut]
def _find_abas(text):
@@ -188,12 +228,18 @@ def _find_macs(text):
yield Finding(m.group(0), "MAC_ADDRESS", m.start(), m.end())
def _after_version_word(text, start):
return VERSION_CUE_RE.search(text[max(0, start - 12):start]) is not None
def _find_ips(text):
for m in IPV4_RE.finditer(text):
try:
ipaddress.IPv4Address(m.group(0))
except ValueError:
continue
if _after_version_word(text, m.start()):
continue
yield Finding(m.group(0), "IP_ADDRESS", m.start(), m.end())
for m in IPV6_RE.finditer(text):
cand = m.group(0)
@@ -204,10 +250,23 @@ def _find_ips(text):
yield Finding(cand, "IP_ADDRESS", m.start(), m.end())
def _ssn_ok(area, group, serial):
# never issued: area 000, 666 or 900-999, group 00, serial 0000
a = int(area)
return a != 0 and a != 666 and a < 900 and group != "00" and serial != "0000"
def _find_ssns(text):
for m in SSN_RE.finditer(text):
area = int(m.group(1))
if area == 0 or area == 666 or area >= 900:
if _ssn_ok(m.group(1), m.group(2), m.group(3)):
yield Finding(m.group(0), "SSN", m.start(), m.end())
for m in SSN_SPACED_RE.finditer(text):
if _ssn_ok(m.group(1), m.group(2), m.group(3)):
yield Finding(m.group(0), "SSN", m.start(), m.end())
for m in SSN_BARE_RE.finditer(text):
if not _ssn_ok(m.group(1), m.group(2), m.group(3)):
continue
if not SSN_CUE_RE.search(text[max(0, m.start() - 40):m.start()]):
continue
yield Finding(m.group(0), "SSN", m.start(), m.end())
@@ -225,6 +284,18 @@ def _find_phones(text):
if not 10 <= len(digits) <= 15:
continue
yield Finding(m.group(0), "PHONE", m.start(), m.end())
for m in INTL_PHONE_RE.finditer(text):
# E.164 allows at most 15 digits; a (0) trunk prefix is not one of them
digits = re.sub(r"\D", "", m.group(0).replace("(0)", "", 1))
if 8 <= len(digits) <= 15:
yield Finding(m.group(0), "PHONE", m.start(), m.end())
for m in NATIONAL_PHONE_RE.finditer(text):
digits = re.sub(r"\D", "", m.group(0))
if 10 <= len(digits) <= 11:
yield Finding(m.group(0), "PHONE", m.start(), m.end())
for m in LOCAL_PHONE_RE.finditer(text):
if PHONE_CUE_RE.search(text[max(0, m.start() - 30):m.start()]):
yield Finding(m.group(0), "PHONE", m.start(), m.end())
# Priority order: earlier detectors own their spans outright. Secrets go
+1 -1
View File
@@ -22,7 +22,7 @@ downgrade the sensor. Stdlib-only here; the gliner package is the one
optional third-party import in the module and it is loaded lazily.
"""
from typing import Callable, List, Tuple
from typing import List, Tuple
from . import llmclient
from .detect import PII_TYPES
+3 -2
View File
@@ -19,7 +19,8 @@ exists for the unit-test harness only. /rehydrate returns REAL values —
callers are trusted surfaces only; every call lands in the vault's audit
table.
Config (env): PII_BIND (host:port, default 0.0.0.0:8091), LLM_URL
Config (env): PII_BIND (host:port, default 127.0.0.1:8091; another address
only when set), LLM_URL
(default http://llm:8080), PII_DB (default /data/vault.db),
PII_LLM_TIMEOUT seconds (default 300).
"""
@@ -38,7 +39,7 @@ _MAX_BODY = 2 * 1024 * 1024
class Config:
def __init__(self, env):
self.bind = env.get("PII_BIND", "0.0.0.0:8091")
self.bind = env.get("PII_BIND", "127.0.0.1:8091")
self.llm_url = env.get("LLM_URL", "http://llm:8080")
self.db = env.get("PII_DB", "/data/vault.db")
self.api_key = env.get("PII_API_KEY", "")
+93 -4
View File
@@ -47,12 +47,12 @@ class DetectTests(unittest.TestCase):
self.assertEqual(self.find("ref 4111 1111 1111 1112"), set())
def test_ipv4_owns_dotted_span(self):
found = self.find("host at 192.168.68.54 responded")
self.assertIn(("192.168.68.54", "IP_ADDRESS"), found)
self.assertNotIn(("192.168.68.54", "PHONE"), found)
found = self.find("host at 198.51.100.54 responded")
self.assertIn(("198.51.100.54", "IP_ADDRESS"), found)
self.assertNotIn(("198.51.100.54", "PHONE"), found)
def test_ipv4_invalid_octet(self):
self.assertNotIn(("999.168.68.541", "IP_ADDRESS"), self.find("x 999.168.68.541"))
self.assertNotIn(("999.51.100.541", "IP_ADDRESS"), self.find("x 999.51.100.541"))
def test_ipv6_and_mac_disambiguation(self):
found = self.find("fe80::1ff:fe23:4567:890a from aa:bb:cc:dd:ee:ff")
@@ -127,6 +127,87 @@ class DetectTests(unittest.TestCase):
self.assertEqual(self.find("the skate park (AKA the bowl) opens at 9"), set())
class RulesOnlyFormatTests(unittest.TestCase):
"""The rules pass is all that flag mode runs, so each format here must be
found with no recall model at all, and each lookalike must be left alone."""
FOUND = [
("UK mobile, international", "Mobile: +44 7700 900123", "+44 7700 900123", "PHONE"),
("German, (0) trunk prefix", "Tel. +49 (0)30 1234567", "+49 (0)30 1234567", "PHONE"),
("international, no separators", "ring +442079460958 today", "+442079460958", "PHONE"),
("international, dots", "fax +33.6.39.98.12.34 now", "+33.6.39.98.12.34", "PHONE"),
("international, dashes", "cell +44-20-7946-0958.", "+44-20-7946-0958", "PHONE"),
("UK national, trunk 0", "call 07700 900123 later", "07700 900123", "PHONE"),
("German national, trunk 0", "Tel 030 1234567", "030 1234567", "PHONE"),
("German national, (0xx)", "Tel (030) 1234567", "(030) 1234567", "PHONE"),
("7-digit local after a phone word", "Ring 555-0142 for the gate code.", "555-0142", "PHONE"),
("7-digit local, call", "call me on 555 0199", "555 0199", "PHONE"),
("IBAN lowercase", "iban: gb82west12345698765432", "gb82west12345698765432", "BANK_ACCOUNT"),
("IBAN lowercase, spaced", "pay de89 3704 0044 0532 0130 00 now",
"de89 3704 0044 0532 0130 00", "BANK_ACCOUNT"),
("IBAN followed by a four-letter word", "to BE68 5390 0754 7034 then",
"BE68 5390 0754 7034", "BANK_ACCOUNT"),
("card with dots", "Card 4111.1111.1111.1111", "4111.1111.1111.1111", "CREDIT_CARD"),
("card with dashes", "card 5555-5555-5555-4444 ok", "5555-5555-5555-4444", "CREDIT_CARD"),
("card after no.", "card no.4111 1111 1111 1111", "4111 1111 1111 1111", "CREDIT_CARD"),
("card, 4-6-5 grouping", "amex 3782 822463 10005", "3782 822463 10005", "CREDIT_CARD"),
("SSN with spaces", "SSN 219 09 9999", "219 09 9999", "SSN"),
("SSN, no separators, keyword", "Social security number 219099999.", "219099999", "SSN"),
("SSN, no separators, SSN:", "ssn: 219099999", "219099999", "SSN"),
]
LEFT_ALONE = [
("order number", "Order 555-0142 shipped"),
("order number, digits only", "order #20431877 is packed"),
("9-digit invoice id", "invoice 219099999 is due"),
("16 digits failing Luhn, spaces", "ref 4111 1111 1111 1112"),
("16 digits failing Luhn, dots", "ref 4111.1111.1111.1112"),
("IBAN shape, bad checksum, lowercase", "iban gb82west12345698765433"),
("IBAN shape, bad checksum, uppercase", "iban GB82WEST12345698765433 ok"),
("version string", "version 1.2.3.4 shipped"),
("version string, v", "upgrade to v1.2.3.4 today"),
("ISO date", "released 2026-08-03 at noon"),
("US date", "Date of birth: 03/14/1987"),
("dotted date", "seen on 14.03.1987 in the hall"),
("SSN shape, group 00", "ssn 219-00-9999"),
("short number after +", "add +12 345 to it"),
("spaced digits inside a longer run", "lot 4 219 09 9999 7"),
]
def find(self, text):
return {(f.value, f.type) for f in detect.detect(text)}
def test_each_format_is_found(self):
for label, text, value, typ in self.FOUND:
with self.subTest(label):
self.assertIn((value, typ), self.find(text))
def test_lookalikes_are_left_alone(self):
for label, text in self.LEFT_ALONE:
with self.subTest(label):
self.assertEqual(self.find(text), set())
def test_rules_only_sterilize_leaves_no_value(self):
# what flag mode does: the pipeline with no recall model
with tempfile.TemporaryDirectory() as tmp:
vault = Vault(os.path.join(tmp, "v.db"))
for label, text, value, _ in self.FOUND:
with self.subTest(label):
out, _ = pipeline.sterilize(text, "s", vault, lambda t: [],
escape_held_literals=True)
self.assertNotIn(value, out)
self.assertEqual(pipeline.rehydrate(out, "s", vault)[0], text)
for label, text in self.LEFT_ALONE:
with self.subTest(label):
out, _ = pipeline.sterilize(text, "s", vault, lambda t: [],
escape_held_literals=True)
self.assertEqual(out, text)
def test_ip_address_is_still_found_after_other_words(self):
self.assertIn(("203.0.113.42", "IP_ADDRESS"), self.find("Server at 203.0.113.42 refused."))
self.assertIn(("203.0.113.42", "IP_ADDRESS"), self.find("server 203.0.113.42 version 2"))
class VaultTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -672,6 +753,14 @@ class ServiceTests(unittest.TestCase):
with self.assertRaises(SystemExit):
service.Config({"PII_DB": "x"})
def test_default_bind_is_loopback(self):
# /rehydrate returns real values over plain HTTP: only an explicit
# setting may open the service beyond this machine
self.assertEqual(service.Config({"PII_DB": "x", "PII_API_KEY": "k"}).bind,
"127.0.0.1:8091")
self.assertEqual(service.Config({"PII_DB": "x", "PII_API_KEY": "k",
"PII_BIND": "0.0.0.0:8091"}).bind, "0.0.0.0:8091")
def test_fail_closed_on_unstartable_recall_backend(self):
with self.assertRaises(SystemExit) as ctx:
service.Config({"PII_DB": "x", "PII_API_KEY": "k", "PII_RECALL": "nope"})