Files
bastion-agent/pii/detect.py
T
Privatae e3074f50b0
ci / test (push) Successful in 1m1s
ci / personal-data (push) Successful in 3s
ci / reproducible (push) Successful in 4s
bastion agent 0.1.16
2026-10-25 23:16:50 +00:00

321 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deterministic PII detectors — the high-precision anchor pass.
Two jobs: (1) anchor the sterilizer with detections that never depend on
model behavior, and (2) re-scan sterilized output as the residual check
(pipeline.py loops until this pass finds nothing). Recall for contextual
PII — names, addresses, employers, "my daughter's school" — is the LLM
pass's job (llmclient.py); here precision is deliberately favored over
recall, because a miss still has two more passes to be caught while a
false positive burns a vault row forever.
Overlapping candidates are resolved by type priority then span length:
an IPv4 address must not be re-claimed as a dotted phone number, a MAC
must not be claimed as truncated IPv6.
"""
import hashlib
import ipaddress
import re
from dataclasses import dataclass
from typing import List
# Full type vocabulary — shared with the LLM extraction schema. Rules
# only ever emit the subset in _DETECTORS; the rest are LLM-only types.
PII_TYPES = [
"PERSON", "EMAIL", "PHONE", "ADDRESS", "SSN", "DOB",
"CREDIT_CARD", "BANK_ACCOUNT", "IP_ADDRESS", "MAC_ADDRESS",
"USERNAME", "CREDENTIAL", "MEDICAL", "ID_NUMBER", "ORG",
"LOCATION", "OTHER",
]
@dataclass(frozen=True)
class Finding:
value: str
type: str
start: int
end: int
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}")
MAC_RE = re.compile(
r"(?<![0-9A-Fa-f:.-])(?:[0-9A-Fa-f]{2}([:-]))(?:[0-9A-Fa-f]{2}\1){4}[0-9A-Fa-f]{2}"
r"(?![0-9A-Fa-f:-])(?!\.[\w-])"
)
# A full stop after the address ends a sentence; one followed by a letter or
# digit makes it part of something longer (a dotted number, a file name).
# The same rule applies to phone numbers and MAC addresses.
IPV4_RE = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?!\d)(?!\.[\w-])")
# Loose candidate; ipaddress.ip_address() is the validator (also rejects
# MAC-shaped hex, which has 6 groups and no '::').
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/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, 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,
"DO": 28, "EE": 20, "EG": 29, "ES": 24, "FI": 18, "FO": 18, "FR": 27, "GB": 22,
"GE": 22, "GI": 23, "GL": 18, "GR": 27, "GT": 28, "HR": 21, "HU": 28, "IE": 22,
"IL": 23, "IS": 26, "IT": 27, "JO": 30, "KW": 30, "KZ": 20, "LB": 28, "LI": 21,
"LT": 20, "LU": 20, "LV": 21, "MC": 27, "MD": 24, "ME": 22, "MK": 19, "MR": 27,
"MT": 31, "MU": 30, "NL": 18, "NO": 15, "PK": 24, "PL": 28, "PS": 29, "PT": 25,
"QA": 29, "RO": 24, "RS": 22, "SA": 24, "SE": 24, "SI": 19, "SK": 24, "SM": 27,
"TN": 24, "TR": 26, "UA": 29, "VG": 24, "XK": 20,
}
# US ABA routing number: 9 digits with a weighted checksum. One in ten
# random 9-digit strings passes, so a routing/ABA/RTN cue within 40 chars
# before the number is required — same precision stance as Presidio.
ABA_RE = re.compile(r"(?<!\d)\d{9}(?!\d)")
ABA_CUE_RE = re.compile(r"(?i)\b(?:routing|aba|rtn|transit)\b")
# Bitcoin: legacy/P2SH base58check (1.../3...). Bech32 (bc1...) has its own
# checksum; kept out of v1.2 — its decode is more code than its recall buys.
BTC_RE = re.compile(r"(?<![A-Za-z0-9])[13][a-km-zA-HJ-NP-Z1-9]{25,34}(?![A-Za-z0-9])")
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
# Secrets: vendor-prefixed tokens whose prefix alone is near-unambiguous,
# plus the two structural forms (PEM blocks, JWTs). Typed CREDENTIAL.
SECRET_RES = [
re.compile(r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])"), # AWS access key id
re.compile(r"(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{36,255}(?![A-Za-z0-9])"), # GitHub tokens
re.compile(r"(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{22,255}(?![A-Za-z0-9])"), # GitHub fine-grained
re.compile(r"(?<![A-Za-z0-9_])xox[abposr]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9])"), # Slack
re.compile(r"(?<![A-Za-z0-9_])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9_-])"), # Google API key
re.compile(r"(?<![A-Za-z0-9_])(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}(?![A-Za-z0-9])"), # Stripe
re.compile(r"(?<![A-Za-z0-9_])glpat-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])"), # GitLab PAT
re.compile(r"(?<![A-Za-z0-9_])sk-(?:proj-)?[A-Za-z0-9_-]{32,}(?![A-Za-z0-9_-])"), # OpenAI-style
re.compile(r"(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])"), # JWT
re.compile(r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----"), # PEM
]
def _iban_ok(cand):
s = cand.replace(" ", "").upper()
if IBAN_LENGTHS.get(s[:2]) != len(s):
return False
rearranged = s[4:] + s[:4]
num = "".join(str(ord(c) - 55) if c.isalpha() else c for c in rearranged)
return int(num) % 97 == 1
def _aba_ok(digits):
w = (3, 7, 1, 3, 7, 1, 3, 7, 1)
return sum(int(d) * k for d, k in zip(digits, w)) % 10 == 0
def _b58check_ok(addr):
n = 0
for ch in addr:
idx = _B58.find(ch)
if idx < 0:
return False
n = n * 58 + idx
raw = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
raw = b"\x00" * (len(addr) - len(addr.lstrip("1"))) + raw # leading '1' = 0x00
if len(raw) != 25:
return False
body, check = raw[:-4], raw[-4:]
return hashlib.sha256(hashlib.sha256(body).digest()).digest()[:4] == check
def _find_secrets(text):
for rx in SECRET_RES:
for m in rx.finditer(text):
yield Finding(m.group(0), "CREDENTIAL", m.start(), m.end())
def _find_ibans(text):
for m in IBAN_RE.finditer(text):
# 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):
for m in ABA_RE.finditer(text):
if not _aba_ok(m.group(0)):
continue
if not ABA_CUE_RE.search(text[max(0, m.start() - 40):m.start()]):
continue
yield Finding(m.group(0), "BANK_ACCOUNT", m.start(), m.end())
def _find_btc(text):
for m in BTC_RE.finditer(text):
if _b58check_ok(m.group(0)):
yield Finding(m.group(0), "BANK_ACCOUNT", m.start(), m.end())
def _luhn_ok(digits: str) -> bool:
total = 0
for i, ch in enumerate(reversed(digits)):
d = int(ch)
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def _find_emails(text):
for m in EMAIL_RE.finditer(text):
yield Finding(m.group(0), "EMAIL", m.start(), m.end())
def _find_macs(text):
for m in MAC_RE.finditer(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)
try:
ipaddress.IPv6Address(cand)
except ValueError:
continue
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):
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())
def _find_cards(text):
for m in CARD_RE.finditer(text):
digits = re.sub(r"\D", "", m.group(0))
if 13 <= len(digits) <= 19 and _luhn_ok(digits):
yield Finding(m.group(0), "CREDIT_CARD", m.start(), m.end())
def _find_phones(text):
for m in PHONE_RE.finditer(text):
digits = re.sub(r"\D", "", m.group(0))
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
# first (a PEM block or JWT contains digit runs that would otherwise be
# claimed piecemeal); IBAN before cards (an IBAN's digits can pass Luhn).
_DETECTORS = [
_find_secrets, _find_emails, _find_macs, _find_ips, _find_ibans, _find_btc,
_find_ssns, _find_cards, _find_abas, _find_phones,
]
def detect(text: str) -> List[Finding]:
"""All rule findings, non-overlapping, priority- then position-ordered."""
candidates = []
for prio, fn in enumerate(_DETECTORS):
for f in fn(text):
candidates.append((prio, -(f.end - f.start), f.start, f))
accepted = [] # type: List[Finding]
for _, _, _, f in sorted(candidates, key=lambda c: (c[0], c[1], c[2])):
if all(f.end <= a.start or f.start >= a.end for a in accepted):
accepted.append(f)
return sorted(accepted, key=lambda f: f.start)