bastion agent 0.1.16
This commit is contained in:
+13
-4
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user