Sensitive Data Discovery in Python: Build a PII Scanner That Validates, Not Just Matches
Before you can encrypt, mask, or delete personal data, you need to know where it lives. That sounds obvious until someone asks “which of our 400 tables hold card numbers?” and the honest answer is a shrug. GDPR Article 3
Before you can encrypt, mask, or delete personal data, you need to know where it lives. That sounds obvious until someone asks “which of our 400 tables hold card numbers?” and the honest answer is a shrug. GDPR Article 30 expects a controller to keep a record of processing that describes “the categories of personal data”, and you cannot write that record from memory.
That is the job of sensitive data discovery: scan the stores you own, find columns that hold PII, and label them. The usual first attempt is a folder of regexes. It finds everything, including order numbers, timestamps, ZIP+4 codes and SKUs, and after a week nobody reads its report.
In this post we build a small scanner in Python that does the boring parts properly:
Walks a SQLite database and a folder of CSV exports.
Samples each column with reservoir sampling (bounded memory, reproducible).
Finds candidates with regexes, then validates them: Luhn and network prefixes for cards, ISO 7064 mod-97 for IBANs, SSA rules for SSNs, libphonenumber for phones.
Uses the column name as a second signal and computes a confidence score per column.
Writes a data inventory in JSON and Markdown with classification labels and masked examples.
Compares the result with Microsoft Presidio on the same samples.
Versions: Python 3.13, Faker 40.44.0, phonenumbers 9.0.42, pytest 9.2.0, and for the comparison presidio-analyzer 2.2.366 with spaCy 3.8.18 and en_core_web_sm 3.8.0. All test data is synthetic (seeded Faker), and every output block below is copied from a real run.
What a column-level scanner needs
Discovery for analytics, as the PixelPlex guide describes it, is about finding patterns and insights in data. Discovery for privacy asks a narrower question about each column: “does this hold a data type that law, contracts or policy say we must protect?” Answering it per column, not per value, is the key design decision. A single cell can look like anything. Two hundred cells from the same column rarely lie.
| Stage | What it does | Why it matters |
|---|---|---|
| Walk | List tables/files and columns | Coverage: you only classify what you enumerate |
| Sample | Up to 200 non-empty values per column | Bounded cost per column, no full-table scans |
| Detect | Cheap regex finds candidates | Fast, high recall |
| Validate | Checksums and numbering rules | Removes look-alikes |
| Score | Hit ratio + column-name signal | A column decision, not a cell decision |
| Report | Labels, confidence, masked examples | Something a human can review and act on |
Step 1. Synthetic data with traps
make_data.py builds shop.db with three tables and two CSV exports. Real PII is mixed with values that a lazy regex will misread:
| Column | Real content | Looks like |
|---|---|---|
| orders.order_number | 16 random digits | a card number |
| app_events.device_serial | 16 random digits | a card number |
| orders.sku | US48XKQ93821PZ7A style codes | an IBAN |
| payments.batch_ref | 000-, 666-, 9xx- codes | an SSN |
| customers.created_at, zip, app_events.app_version | dates, ZIP+4, versions | phone numbers |
| orders.card_masked | **** **** **** 1234 | a card column by name only |
| customers.notes | free text, some with an email or phone | mostly harmless text |
| leads.contact | half emails, half phones | two entity types in one column |
"""Generate a synthetic SQLite database and CSV exports for the scanner demo.
Every value comes from Faker (seeded) or random.Random (seeded). No real PII.
"""
import csv
import random
import sqlite3
from datetime import datetime
from pathlib import Path
from faker import Faker
SEED = 42
# Fixed ranges: relative ones like "-30d" would change the data every day.
START, END = datetime(2024, 1, 1), datetime(2026, 1, 1)
DATA = Path(__file__).parent / "data"
def digits(rng: random.Random, n: int) -> str:
return "".join(rng.choice("0123456789") for _ in range(n))
def main() -> None:
fake = Faker("en_US")
Faker.seed(SEED)
rng = random.Random(SEED)
DATA.mkdir(exist_ok=True)
(DATA / "exports").mkdir(exist_ok=True)
db_path = DATA / "shop.db"
db_path.unlink(missing_ok=True)
con = sqlite3.connect(db_path)
con.executescript(
"""
CREATE TABLE customers (id INTEGER PRIMARY KEY, full_name TEXT, email TEXT,
phone TEXT, ssn TEXT, zip TEXT, created_at TEXT, notes TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, order_number TEXT, customer_id INTEGER,
amount REAL, sku TEXT, card_masked TEXT, placed_at TEXT);
CREATE TABLE payments (id INTEGER PRIMARY KEY, customer_id INTEGER, card_number TEXT,
iban TEXT, batch_ref TEXT, company_code TEXT);
"""
)
customers = []
for i in range(1, 401):
note = fake.sentence(nb_words=10)
roll = rng.random()
if roll < 0.08:
note += f" Prefers email: {fake.free_email()}"
elif roll < 0.16:
note += f" Call back on {fake.phone_number()}"
customers.append((i, fake.name(), fake.email(), fake.phone_number(), fake.ssn(),
fake.zipcode_plus4(), fake.date_between(START, END).isoformat(), note))
con.executemany("INSERT INTO customers VALUES (?,?,?,?,?,?,?,?)", customers)
orders = []
for i in range(1, 1201):
sku = (rng.choice(["US", "DE", "FR", "GB"]) + digits(rng, 2)
+ "".join(rng.choice("ABCDEFGHJKLMNPQRSTUVWXYZ0123456789") for _ in range(12)))
orders.append((i, digits(rng, 16), rng.randint(1, 400), round(rng.uniform(5, 900), 2),
sku, "**** **** **** " + digits(rng, 4),
fake.date_time_between(START, END).isoformat(timespec="seconds")))
con.executemany("INSERT INTO orders VALUES (?,?,?,?,?,?,?)", orders)
payments = []
for i in range(1, 401):
batch_ref = f"{rng.choice(['000', '666', str(rng.randint(900, 999))])}-{digits(rng, 2)}-{digits(rng, 4)}"
payments.append((i, rng.randint(1, 400), fake.credit_card_number(), fake.iban(),
batch_ref, "CP-" + digits(rng, 6)))
con.executemany("INSERT INTO payments VALUES (?,?,?,?,?,?)", payments)
con.commit()
con.close()
with open(DATA / "exports" / "leads.csv", "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["lead_id", "name", "contact", "company", "bank_acct"])
for i in range(1, 301):
contact = fake.company_email() if rng.random() < 0.5 else fake.phone_number()
w.writerow([i, fake.name(), contact, fake.company(), fake.iban()])
with open(DATA / "exports" / "app_events.csv", "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["event_id", "device_serial", "app_version", "event", "ts"])
for i in range(1, 601):
w.writerow([fake.uuid4(), digits(rng, 16),
f"{rng.randint(1, 12)}.{rng.randint(0, 9)}.{rng.randint(0, 99)}.{rng.randint(1000, 9999)}",
rng.choice(["open", "tap", "purchase", "close"]),
fake.date_time_between(datetime(2025, 12, 1), END).isoformat(timespec="seconds")])
print(f"wrote {db_path.relative_to(DATA.parent)}: customers=400 orders=1200 payments=400")
print("wrote data/exports/leads.csv (300 rows), data/exports/app_events.csv (600 rows)")
if __name__ == "__main__":
main()
# Output:
# wrote data/shop.db: customers=400 orders=1200 payments=400
# wrote data/exports/leads.csv (300 rows), data/exports/app_events.csv (600 rows)
Faker is seeded, but its docs warn that results are not guaranteed to be stable across patch versions, so the version is pinned to the patch level in requirements.txt.
Step 2. The “before”: regex only
The scanner has a --naive flag that keeps the regexes and turns off validators and the name signal. This is what many homegrown PII detection scripts look like:
python -m pii_scan --db data/shop.db --csv data/exports --naive
[naive] columns=31 findings=31 confirmed=23 review=8
app_events.event_id CREDIT_CARD review conf=0.01 valid=1% regex=1%
app_events.device_serial CREDIT_CARD confirmed conf=1.00 valid=100% regex=100%
app_events.device_serial PHONE confirmed conf=1.00 valid=100% regex=100%
app_events.app_version PHONE confirmed conf=1.00 valid=100% regex=100%
app_events.ts PHONE confirmed conf=1.00 valid=100% regex=100%
leads.contact CREDIT_CARD review conf=0.01 valid=0% regex=0%
leads.contact EMAIL confirmed conf=0.53 valid=53% regex=53%
leads.contact PHONE review conf=0.47 valid=47% regex=47%
leads.bank_acct IBAN confirmed conf=1.00 valid=100% regex=100%
leads.bank_acct PHONE confirmed conf=1.00 valid=100% regex=100%
customers.email EMAIL confirmed conf=1.00 valid=100% regex=100%
customers.phone CREDIT_CARD review conf=0.05 valid=5% regex=5%
customers.phone PHONE confirmed conf=1.00 valid=100% regex=100%
customers.ssn US_SSN confirmed conf=1.00 valid=100% regex=100%
customers.ssn PHONE confirmed conf=1.00 valid=100% regex=100%
customers.zip PHONE confirmed conf=1.00 valid=100% regex=100%
customers.created_at PHONE confirmed conf=1.00 valid=100% regex=100%
customers.notes CREDIT_CARD review conf=0.01 valid=0% regex=0%
customers.notes EMAIL review conf=0.09 valid=9% regex=9%
customers.notes PHONE review conf=0.07 valid=8% regex=8%
orders.order_number CREDIT_CARD confirmed conf=1.00 valid=100% regex=100%
orders.order_number PHONE confirmed conf=1.00 valid=100% regex=100%
orders.sku IBAN confirmed conf=1.00 valid=100% regex=100%
orders.placed_at PHONE confirmed conf=1.00 valid=100% regex=100%
payments.card_number CREDIT_CARD confirmed conf=1.00 valid=100% regex=100%
payments.card_number PHONE confirmed conf=1.00 valid=100% regex=100%
payments.iban IBAN confirmed conf=1.00 valid=100% regex=100%
payments.iban PHONE confirmed conf=1.00 valid=100% regex=100%
payments.batch_ref US_SSN confirmed conf=1.00 valid=100% regex=100%
payments.batch_ref PHONE confirmed conf=1.00 valid=100% regex=100%
23 columns “confirmed”, and only 7 of them really hold PII. The phone regex fires on timestamps, ZIP+4 codes, app versions, SSNs, IBANs and card numbers. The card regex is certain that order numbers and device serials are payment cards. orders.sku is “100% IBAN”. A report like this gets ignored, and then the one real finding in it gets ignored too.
Step 3. Validators
Each detector is a pair: a regex that finds candidates and a function that rejects look-alikes. Here is the whole module:
"""PII detectors: a candidate regex plus a validator that rejects look-alikes."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable
import phonenumbers
# ---------- validators ----------
def luhn_ok(number: str) -> bool:
"""Luhn mod-10 check over a string of digits."""
total = 0
for i, ch in enumerate(reversed(number)):
d = int(ch)
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def card_ok(raw: str) -> bool:
num = re.sub(r"[ -]", "", raw)
if not num.isdigit() or not 12 <= len(num) <= 19: # Maestro goes down to 12 digits
return False
if len(set(num)) == 1: # 0000000000000000 passes Luhn, it is not a card
return False
# Major networks start with 2-6; 1800/2131 are 15-digit JCB prefixes that Faker generates.
if num[0] not in "23456" and not num.startswith(("1800", "2131")):
return False
return luhn_ok(num)
def iban_ok(raw: str) -> bool:
s = raw.replace(" ", "").upper()
if not re.fullmatch(r"[A-Z]{2}\d{2}[A-Z0-9]{11,30}", s):
return False
rearranged = s[4:] + s[:4]
as_int = "".join(str(int(c, 36)) for c in rearranged) # A=10 ... Z=35
return int(as_int) % 97 == 1
SSN_BLOCKLIST = {"078-05-1120", "219-09-9999"} # numbers the SSA lists as misused/advertising
def ssn_ok(raw: str) -> bool:
m = re.fullmatch(r"(\d{3})-(\d{2})-(\d{4})", raw)
if not m:
return False
area, group, serial = m.groups()
if area in ("000", "666") or area.startswith("9"):
return False
if group == "00" or serial == "0000":
return False
return raw not in SSN_BLOCKLIST
def email_ok(raw: str) -> bool:
local, _, domain = raw.rpartition("@")
if not local or local.startswith(".") or local.endswith(".") or ".." in local:
return False
labels = domain.split(".")
if len(labels) < 2 or not labels[-1].isalpha():
return False
return all(lbl and not lbl.startswith("-") and not lbl.endswith("-") for lbl in labels)
def phone_matches(text: str, region: str = "US",
leniency=phonenumbers.Leniency.VALID) -> list[str]:
"""libphonenumber's matcher instead of a regex. VALID = real ranges, POSSIBLE = right length."""
return [m.raw_string for m in phonenumbers.PhoneNumberMatcher(text, region, leniency=leniency)]
def phone_weight(text: str) -> float:
if phone_matches(text):
return 1.0
if phone_matches(text, leniency=phonenumbers.Leniency.POSSIBLE):
return 0.5 # right length, but not in an assigned range: half credit
return 0.0
# ---------- detector table ----------
@dataclass(frozen=True)
class Detector:
entity: str
label: str # classification label for the inventory
pattern: re.Pattern # cheap candidate finder (the "naive" scanner uses only this)
validate: Callable[[str], bool]
name_hints: frozenset[str] # column-name tokens that count as a signal
DETECTORS = [
Detector("CREDIT_CARD", "Restricted",
re.compile(r"\b(?:\d[ -]?){11,18}\d\b"), card_ok,
frozenset({"card", "cc", "pan", "ccnum", "credit"})),
Detector("US_SSN", "Restricted",
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), ssn_ok,
frozenset({"ssn", "social", "tin", "taxid"})),
Detector("IBAN", "Restricted",
re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"), iban_ok,
frozenset({"iban", "bank", "acct", "account"})),
Detector("EMAIL", "Confidential",
re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), email_ok,
frozenset({"email", "mail", "contact"})),
Detector("PHONE", "Confidential",
re.compile(r"\+?\d[\d\s().-]{7,}\d"), lambda s: phone_weight(s) > 0,
frozenset({"phone", "mobile", "tel", "cell", "msisdn", "contact"})),
]
def name_tokens(column: str) -> set[str]:
"""'customerEmail' -> {'customer', 'email'}; 'cc_num' -> {'cc', 'num'}."""
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", column)
return {t for t in re.split(r"[^A-Za-z0-9]+", spaced.lower()) if t}
def value_hit(det: Detector, value: str, validate: bool) -> tuple[bool, float]:
"""Return (regex_hit, validated weight 0..1) for one cell value."""
candidates = det.pattern.findall(value)
if not candidates:
return False, 0.0
if not validate:
return True, 1.0
if det.entity == "PHONE":
return True, phone_weight(value)
return True, float(any(det.validate(c) for c in candidates))
What each validator buys you:
-
Cards. The Luhn check digit (ISO/IEC 7812-1) catches all single-digit typos, but a random number passes it one time in ten. That is what the test
test_luhn_accepts_one_in_ten_random_numberspins down. A network prefix check (first digit 2 to 6) halves the survivors again. Note the bounds: payment card numbers can be 8 to 19 digits, Maestro goes down to 12, and the Mastercard 2221–2720 range has been live since October 2016. - IBAN. Move the first four characters to the end, turn letters into numbers (A=10 … Z=35), and the result mod 97 must be 1. A random string passes about once in 97.
-
SSN. The SSA has not issued area numbers 000, 666 or 900–999, and no group of digits can be all zeros. Two famous sample numbers,
078-05-1120from a 1938 wallet insert and219-09-9999from a 1940 pamphlet, go on a blocklist. -
Phone. Instead of a regex,
phonenumbers.PhoneNumberMatcher(a port of Google’s libphonenumber) scans the text. Its default leniencyVALIDaccepts only numbers in assigned ranges.POSSIBLEonly checks that the length is right, so it gets half credit.
Column names are split into tokens (customerEmail becomes customer, email) and compared as whole words.
explain_fp.py shows what each layer removes on the trap columns:
CREDIT_CARD: regex -> Luhn -> Luhn + network prefix
orders.order_number regex 200/200 (100%) luhn 22/200 ( 11%) +prefix 13/200 ( 6%)
app_events.device_serial regex 200/200 (100%) luhn 19/200 ( 10%) +prefix 10/200 ( 5%)
payments.card_number regex 200/200 (100%) luhn 200/200 (100%) +prefix 200/200 (100%)
IBAN: regex -> ISO 7064 mod-97
orders.sku regex 200/200 (100%) mod-97 0/200 ( 0%)
payments.iban regex 200/200 (100%) mod-97 200/200 (100%)
US_SSN: regex -> area/group/serial rules
payments.batch_ref regex 200/200 (100%) rules 0/200 ( 0%)
customers.ssn regex 200/200 (100%) rules 200/200 (100%)
PHONE: regex -> libphonenumber VALID
customers.created_at regex 200/200 (100%) valid 0/200 ( 0%)
customers.zip regex 200/200 (100%) valid 0/200 ( 0%)
app_events.app_version regex 200/200 (100%) valid 0/200 ( 0%)
orders.order_number regex 200/200 (100%) valid 0/200 ( 0%)
customers.phone regex 200/200 (100%) valid 86/200 ( 43%)
Look at orders.order_number. Even after Luhn and the prefix check, 13 of 200 order numbers are “valid card numbers”. That is not a bug in the validator, it is arithmetic: 10% pass Luhn by chance, and about half of those start with 2 to 6. No per-value rule can fix this. The fix is to decide at the column level.
Step 4. Walk and sample SQLite and CSV
"""Walk data sources and yield a bounded, reproducible sample per column."""
from __future__ import annotations
import csv
import random
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Iterator
@dataclass
class ColumnSample:
source: str # "sqlite:shop.db" or "csv:exports/leads.csv"
table: str
column: str
values: list[str] # non-null, stringified
rows_seen: int
def reservoir(values: Iterable, k: int, rng: random.Random) -> tuple[list[str], int]:
"""Algorithm R: uniform sample of k non-empty values in one pass, O(k) memory."""
sample: list[str] = []
n = 0
for v in values:
if v is None or str(v).strip() == "":
continue
n += 1
if len(sample) < k:
sample.append(str(v))
else:
j = rng.randrange(n)
if j < k:
sample[j] = str(v)
return sample, n
def scan_sqlite(path: Path, k: int, seed: int) -> Iterator[ColumnSample]:
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) # read-only: a scanner never writes
try:
tables = [
r[0]
for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
]
for table in tables:
cols = [r[1] for r in con.execute(f'PRAGMA table_info("{table}")')]
for col in cols:
rng = random.Random(f"{seed}:{table}:{col}")
cur = con.execute(f'SELECT "{col}" FROM "{table}"')
values, n = reservoir((r[0] for r in cur), k, rng)
yield ColumnSample(f"sqlite:{path.name}", table, col, values, n)
finally:
con.close()
def scan_csv_dir(root: Path, k: int, seed: int) -> Iterator[ColumnSample]:
for path in sorted(root.rglob("*.csv")):
with open(path, newline="", encoding="utf-8", errors="replace") as fh:
reader = csv.DictReader(fh)
columns = reader.fieldnames or []
rows = list(reader) # fine for exports; stream per column for multi-GB files
rel = path.relative_to(root.parent)
for col in columns:
rng = random.Random(f"{seed}:{rel}:{col}")
values, n = reservoir((r.get(col) for r in rows), k, rng)
yield ColumnSample(f"csv:{rel}", path.stem, col, values, n)
Three decisions here:
-
Read-only connection.
file:...?mode=rowithuri=Truemakes SQLite open the database read-only. A scanner that runs against production copies should not be able to write, even by accident. -
Reservoir sampling (Algorithm R). One pass, $k$ values in memory, every value has the same chance of being picked, and you do not need to know the row count first.
LIMIT 200would give you the oldest 200 rows, which on a table where a column was repurposed last year tells you nothing about today. -
Per-column seeds.
random.Random(f"{seed}:{table}:{col}")makes each run reproducible, so two scans of unchanged data produce identical inventories and diffs show only real changes.
Step 5. Score columns, not cells
"""Score every (column, entity) pair and decide what goes into the inventory."""
from __future__ import annotations
import statistics
from dataclasses import asdict, dataclass
from .detectors import DETECTORS, Detector, name_tokens, value_hit
from .sources import ColumnSample
CONFIRM_AT = 0.6 # confidence needed for status "confirmed"
REVIEW_AT = 0.2 # below this a finding is dropped (unless the column is free text)
MIN_TEXT_HITS = 3 # free-text columns: this many validated hits -> "review"
@dataclass
class Finding:
source: str
table: str
column: str
entity: str
label: str
status: str # confirmed | review
confidence: float
hit_ratio: float # validated hits / sampled values
regex_ratio: float # raw regex hits / sampled values (what a naive scanner sees)
sampled: int
rows_seen: int
name_signal: bool
free_text: bool
examples: list[str] # masked, never raw values
def is_free_text(values: list[str]) -> bool:
if not values:
return False
return statistics.median(len(v) for v in values) > 30 and \
sum(" " in v for v in values) / len(values) > 0.5
def mask(value: str) -> str:
if "@" in value:
local, _, domain = value.partition("@")
return f"{local[:1]}***@{domain}"
keep = 4 if len(value) > 8 else 2
return "*" * (len(value) - keep) + value[-keep:]
def score(det: Detector, col: ColumnSample, validate: bool) -> Finding | None:
n = len(col.values)
if n == 0:
return None
regex_hits, valid_hits = 0, 0.0
examples: list[str] = []
for v in col.values:
r, ok = value_hit(det, v, validate)
regex_hits += r
valid_hits += ok
if ok and len(examples) < 2:
examples.append(mask(v) if len(v) < 40 else mask(v[-24:]))
name_signal = validate and bool(name_tokens(col.column) & det.name_hints)
hit_ratio = valid_hits / n
# 80% of the weight on what the data says, 20% on what the column is called.
confidence = round(min(1.0, 0.8 * hit_ratio + 0.2 * name_signal), 2) if validate else round(hit_ratio, 2)
free_text = is_free_text(col.values)
if not validate:
status = "confirmed" if hit_ratio >= 0.5 else ("review" if regex_hits else None)
elif confidence >= CONFIRM_AT:
status = "confirmed"
elif confidence >= REVIEW_AT or (free_text and valid_hits >= MIN_TEXT_HITS):
status = "review"
else:
status = None
if status is None:
return None
return Finding(col.source, col.table, col.column, det.entity, det.label, status,
confidence, round(hit_ratio, 3), round(regex_hits / n, 3), n, col.rows_seen,
name_signal, free_text, examples)
def scan_columns(columns, validate: bool = True) -> list[Finding]:
findings = []
for col in columns:
for det in DETECTORS:
f = score(det, col, validate)
if f:
findings.append(f)
return findings
def to_dicts(findings: list[Finding]) -> list[dict]:
return [asdict(f) for f in findings]
The confidence formula is deliberately simple:
confidence = 0.8 * validated_hit_ratio + 0.2 * column_name_signal
- 0.6 or more is confirmed, 0.2 to 0.6 is review, below that the finding is dropped.
-
A name alone gives 0.2, which lands in review. That is how
orders.card_maskedshows up: the name says card, the values are masked, and a person should confirm the masking is real. -
A column where half the values are valid cards stays in review if it is called
ref, and becomes confirmed if it is calledcc_num. The testtest_column_name_breaks_the_tiechecks exactly this. - Free text is different. If the median value is longer than 30 characters and most values contain spaces, three or more validated hits are enough for review, because PII in notes is sparse by nature. Without the “free text” condition, the 13 Luhn-valid order numbers would have triggered this rule too.
Step 6. The inventory report
"""CLI: python -m pii_scan --db data/shop.db --csv data/exports [--naive]"""
from __future__ import annotations
import argparse
import itertools
from pathlib import Path
from .report import build_inventory, to_json, to_markdown
from .scanner import scan_columns, to_dicts
from .sources import scan_csv_dir, scan_sqlite
def main() -> None:
ap = argparse.ArgumentParser(description="Sensitive data discovery for SQLite + CSV")
ap.add_argument("--db", type=Path, action="append", default=[])
ap.add_argument("--csv", type=Path, action="append", default=[])
ap.add_argument("--sample", type=int, default=200, help="values sampled per column")
ap.add_argument("--seed", type=int, default=7)
ap.add_argument("--naive", action="store_true", help="regex only: no validators, no name signal")
ap.add_argument("--out", type=Path, default=Path("out"))
args = ap.parse_args()
columns = list(itertools.chain(
*(scan_sqlite(p, args.sample, args.seed) for p in args.db),
*(scan_csv_dir(p, args.sample, args.seed) for p in args.csv)))
findings = to_dicts(scan_columns(columns, validate=not args.naive))
mode = "naive" if args.naive else "validated"
inv = build_inventory(findings, len(columns), mode)
args.out.mkdir(exist_ok=True)
(args.out / f"inventory_{mode}.json").write_text(to_json(inv))
(args.out / f"inventory_{mode}.md").write_text(to_markdown(inv))
print(f"[{mode}] columns={len(columns)} findings={inv['findings']} "
f"confirmed={inv['confirmed']} review={inv['review']}")
for t in inv["tables"]:
for c in t["columns"]:
print(f" {t['table'] + '.' + c['column']:<26} {c['entity']:<12} {c['status']:<9} "
f"conf={c['confidence']:.2f} valid={c['hit_ratio']:.0%} regex={c['regex_ratio']:.0%}")
if __name__ == "__main__":
main()
"""Turn findings into a data inventory: JSON for machines, Markdown for humans."""
from __future__ import annotations
import json
from collections import defaultdict
LABEL_RANK = {"Public": 0, "Internal": 1, "Confidential": 2, "Restricted": 3}
def build_inventory(findings: list[dict], columns_scanned: int, mode: str) -> dict:
tables: dict[tuple[str, str], dict] = defaultdict(lambda: {"label": "Internal", "columns": []})
for f in findings:
t = tables[(f["source"], f["table"])]
t["columns"].append(f)
# A table inherits the most sensitive label among its confirmed columns.
if f["status"] == "confirmed" and LABEL_RANK[f["label"]] > LABEL_RANK[t["label"]]:
t["label"] = f["label"]
return {
"mode": mode,
"columns_scanned": columns_scanned,
"findings": len(findings),
"confirmed": sum(f["status"] == "confirmed" for f in findings),
"review": sum(f["status"] == "review" for f in findings),
"tables": [{"source": s, "table": t, **v} for (s, t), v in sorted(tables.items())],
}
def to_json(inv: dict) -> str:
return json.dumps(inv, indent=2)
def to_markdown(inv: dict) -> str:
out = [
f"# Sensitive data inventory ({inv['mode']})",
"",
f"Columns scanned: {inv['columns_scanned']} | findings: {inv['findings']} "
f"(confirmed {inv['confirmed']}, review {inv['review']})",
"",
]
for t in inv["tables"]:
out += [
f"## {t['source']} / {t['table']} - {t['label']}",
"",
"| column | entity | status | confidence | valid hits | regex hits | name signal | example |",
"|---|---|---|---|---|---|---|---|",
]
for c in sorted(t["columns"], key=lambda c: -c["confidence"]):
out.append(
f"| {c['column']} | {c['entity']} | {c['status']} | {c['confidence']:.2f} | "
f"{c['hit_ratio']:.0%} | {c['regex_ratio']:.0%} | {'yes' if c['name_signal'] else 'no'} | "
f"{(c['examples'] or [''])[0]} |"
)
out.append("")
return "\n".join(out)
The “after” run:
python -m pii_scan --db data/shop.db --csv data/exports
[validated] columns=31 findings=11 confirmed=7 review=4
leads.contact EMAIL confirmed conf=0.62 valid=53% regex=53%
leads.contact PHONE review conf=0.46 valid=32% regex=47%
leads.bank_acct IBAN confirmed conf=1.00 valid=100% regex=100%
customers.email EMAIL confirmed conf=1.00 valid=100% regex=100%
customers.phone PHONE confirmed conf=0.69 valid=62% regex=100%
customers.ssn US_SSN confirmed conf=1.00 valid=100% regex=100%
customers.notes EMAIL review conf=0.07 valid=9% regex=9%
customers.notes PHONE review conf=0.04 valid=4% regex=8%
orders.card_masked CREDIT_CARD review conf=0.20 valid=0% regex=0%
payments.card_number CREDIT_CARD confirmed conf=1.00 valid=100% regex=100%
payments.iban IBAN confirmed conf=1.00 valid=100% regex=100%
From 23 confirmed columns to 7, and the 7 are exactly the columns with real PII. All 16 false confirmations are gone, and nothing real was lost: the leads.contact phone finding moved to review because only a third of that mixed column are valid phone numbers, while the same column is confirmed as EMAIL.
Each table gets the highest label among its confirmed columns (Restricted for cards, SSNs and IBANs, Confidential for emails and phones, Internal otherwise). A slice of the JSON inventory:
{
"source": "sqlite:shop.db",
"table": "payments",
"label": "Restricted",
"columns": [
{
"source": "sqlite:shop.db",
"table": "payments",
"column": "card_number",
"entity": "CREDIT_CARD",
"label": "Restricted",
"status": "confirmed",
"confidence": 1.0,
"hit_ratio": 1.0,
"regex_ratio": 1.0,
"sampled": 200,
"rows_seen": 400,
"name_signal": true,
"free_text": false,
"examples": [
"************0134",
"************1528"
]
}
]
}
The examples field is masked on purpose. A discovery report that contains raw card numbers is itself a place where card numbers live, and it will get pasted into tickets and chat. The test test_report_never_contains_raw_values regenerates the data, runs the CLI, and asserts that no SSN from the database appears in the JSON. Emails keep their domain (h***@example.net), which is a trade-off: useful for spotting corporate versus personal addresses, but still a small leak. Drop it if your policy says so.
Step 7. Tests
Listing: tests/test_scanner.py (full)import json
import random
import sqlite3
import subprocess
import sys
from pathlib import Path
from pii_scan.detectors import luhn_ok
from pii_scan.scanner import scan_columns
from pii_scan.sources import ColumnSample
ROOT = Path(__file__).resolve().parents[1]
def col(name, values, table="t"):
return ColumnSample("test", table, name, values, len(values))
def luhn_valid_order_numbers(n, seed=1):
"""Order numbers that would pass a card check on their own: Luhn-valid, starting with 4."""
rng, out = random.Random(seed), []
while len(out) < n:
body = "4" + "".join(rng.choice("0123456789") for _ in range(14))
out.append(next(body + d for d in "0123456789" if luhn_ok(body + d)))
return out
def test_order_numbers_are_not_cards_after_validation():
rng = random.Random(0)
values = ["".join(rng.choice("0123456789") for _ in range(16)) for _ in range(200)]
naive = scan_columns([col("order_number", values)], validate=False)
smart = scan_columns([col("order_number", values)], validate=True)
assert any(f.entity == "CREDIT_CARD" and f.status == "confirmed" for f in naive)
assert not any(f.entity == "CREDIT_CARD" for f in smart)
def test_column_name_breaks_the_tie():
# Half the values are card-valid: "review" alone, "confirmed" with a card-ish column name
values = luhn_valid_order_numbers(100) + ["n/a"] * 100
anon = scan_columns([col("ref", values)])[0]
named = scan_columns([col("cc_num", values)])[0]
assert (anon.status, named.status) == ("review", "confirmed")
def test_free_text_with_sparse_emails_goes_to_review():
values = [f"Customer asked about delivery window number {i}" for i in range(195)]
values += [f"Please reply to buyer{i}@example.com about the refund" for i in range(5)]
f = scan_columns([col("notes", values)])[0]
assert (f.entity, f.status, f.free_text) == ("EMAIL", "review", True)
def test_report_never_contains_raw_values(tmp_path):
subprocess.run([sys.executable, "make_data.py"], cwd=ROOT, check=True, capture_output=True)
subprocess.run(
[sys.executable, "-m", "pii_scan", "--db", "data/shop.db", "--csv", "data/exports", "--out", str(tmp_path)],
cwd=ROOT,
check=True,
capture_output=True,
)
report = (tmp_path / "inventory_validated.json").read_text()
inv = json.loads(report)
ssns = [r[0] for r in sqlite3.connect(ROOT / "data/shop.db").execute("SELECT ssn FROM customers")]
assert not any(s in report for s in ssns)
labels = {(t["table"], t["label"]) for t in inv["tables"]}
assert ("payments", "Restricted") in labels and ("orders", "Internal") in labels
python -m pytest -q
27 passed in 1.88s
tests/test_detectors.py (in the repo archive) covers the validators: a known Visa test number, a one-digit-off variant, the GB82 WEST 1234 5698 7654 32 example IBAN with good and bad check digits, every excluded SSN rule, and a phone in an assigned versus an unassigned range.
Step 8. Compare with Microsoft Presidio PII detection
Presidio is the obvious open-source alternative for PII detection. Its analyzer combines regexes, checksums, context words and spaCy NER, and its docs list the default model as en_core_web_lg (382 MB for version 3.8.0). For card, SSN, IBAN, email and phone the documented detection methods are pattern matching, checksums and context, so the small 12 MB en_core_web_sm model is enough, and Presidio installs fine without the large one.
Presidio is built for free text. To make it look at columns, the script below analyzes every sampled value twice: once alone, once with the column name tokens passed through analyze(context=...), which is what the Presidio context tutorial suggests for metadata such as column names.
"""Run Microsoft Presidio's analyzer over the same column samples and compare per-column hit ratios."""
import itertools
import logging
import time
from collections import Counter
from pathlib import Path
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
from pii_scan.detectors import name_tokens
from pii_scan.sources import scan_csv_dir, scan_sqlite
# Presidio's email recognizer uses tldextract, which tries to download the Public Suffix List
# on first use. Offline it logs a traceback and falls back to its bundled snapshot.
logging.getLogger("tldextract").setLevel(logging.CRITICAL)
ENTITIES = ["CREDIT_CARD", "US_SSN", "IBAN_CODE", "EMAIL_ADDRESS", "PHONE_NUMBER"]
THRESHOLD = 0.5
# en_core_web_sm (12 MB) instead of the default en_core_web_lg; we only need pattern recognizers.
nlp = NlpEngineProvider(nlp_configuration={
"nlp_engine_name": "spacy",
"models": [{"lang_code": "en", "model_name": "en_core_web_sm"}]
}).create_engine()
analyzer = AnalyzerEngine(nlp_engine=nlp, supported_languages=["en"])
cols = list(itertools.chain(scan_sqlite(Path("data/shop.db"), 200, 7),
scan_csv_dir(Path("data/exports"), 200, 7)))
start = time.perf_counter()
values = 0
print(f"{'column':<26} {'no context (>=0.5)':<28} {'column name as context (>=0.5)'}")
for col in cols:
plain, ctx = Counter(), Counter()
for v in col.values:
values += 1
artifacts = nlp.process_text(v, "en") # run spaCy once, reuse for both passes
for counter, context in ((plain, None), (ctx, sorted(name_tokens(col.column)))):
res = analyzer.analyze(text=v, language="en", entities=ENTITIES, context=context,
nlp_artifacts=artifacts)
for ent in {r.entity_type for r in res if r.score >= THRESHOLD}:
counter[ent] += 1
if plain or ctx:
share = lambda n: f"{n / len(col.values):.0%}" if n / len(col.values) >= 0.01 else "<1%"
fmt = lambda c: ", ".join(f"{e} {share(n)}" for e, n in c.most_common(2)) or "-"
print(f"{col.table + '.' + col.column:<26} {fmt(plain):<28} {fmt(ctx)}")
elapsed = time.perf_counter() - start
print(f"\nanalyzed {values} values x 2 passes in {elapsed:.1f}s")
column no context (>=0.5) column name as context (>=0.5)
customers.email EMAIL_ADDRESS 100% EMAIL_ADDRESS 100%
customers.phone - PHONE_NUMBER 82%
customers.ssn US_SSN 100% US_SSN 100%
customers.notes EMAIL_ADDRESS 9%, PHONE_NUMBER <1% EMAIL_ADDRESS 9%, PHONE_NUMBER <1%
orders.order_number CREDIT_CARD 6% PHONE_NUMBER 8%, CREDIT_CARD 6%
payments.card_number CREDIT_CARD 75% CREDIT_CARD 75%, PHONE_NUMBER 3%
payments.iban IBAN_CODE 100% IBAN_CODE 100%
payments.batch_ref US_SSN 29% US_SSN 29%
app_events.device_serial CREDIT_CARD 5% CREDIT_CARD 5%
leads.contact EMAIL_ADDRESS 53% EMAIL_ADDRESS 53%
leads.bank_acct IBAN_CODE 100% IBAN_CODE 100%
analyzed 6200 values x 2 passes in 45.5s
The run time above is from a shared 2-vCPU sandbox and varied between runs, so treat it as a rough number. Our scanner processes the same 31 columns in under two seconds, including interpreter start-up, because it never calls an NLP pipeline. What the numbers say:
-
Context matters a lot for phones. Presidio’s phone recognizer scores matches at 0.4, below the 0.5 cut-off, so
customers.phoneshows nothing. With the column name as context, the score is boosted (the default boost factor is 0.35) and 82% of values pass. That 82% is higher than our 62% because Presidio scores possible-but-unassigned numbers too, the same thing our half credit does. -
Context can also hurt.
numberis one of the phone recognizer’s context words, soorder_numberpicks up an 8%PHONE_NUMBERsignal it did not have without context. Context words are blunt. -
Card coverage is narrower. Presidio’s card regex in this version does not match Mastercard numbers starting with 2, 19-digit Visa, 12-digit Maestro, or the 15-digit 2131 numbers Faker emits as JCB. Faker produces all of these, which is why
payments.card_numberis 75% instead of 100%. -
SSN rules differ. Presidio rejects 000 and 666 areas but accepts 900–999, so
batch_refshows 29%US_SSN. Numbers in 900–999 are the ITIN range, which is sensitive too, so if you have ITINs, detect them as a separate entity instead of calling them SSNs. -
Per-value hits only. Like our naive scan, Presidio still finds 6% “credit cards” in order numbers. Turning that into “this column is not a card column” is your job, or the job of the separate
presidio-structuredpackage, whose default strategy picks the most common entity per column.
The two approaches combine well: use validators and column scoring for structured columns, and Presidio (with a context list built from column names) for long free-text fields where NER for names and addresses pays off.
Gotchas I hit while building this
- My first card validator was wrong. It required 13 to 19 digits and a first digit of 2 to 6. Across all 400 synthetic cards, the regex missed 23 (12-digit Maestro numbers) and the validator rejected 9 more (15-digit numbers starting with 1800, which Faker generates as JCB). Bounds copied from memory are a bug; check them against a reference and test with a generator that covers every network. One Faker Maestro prefix, 0604, is still rejected because I could not confirm it in a reference, so it stays out on purpose.
-
Faker phone numbers are mostly not valid numbers. Only 86 of 200 sampled
customers.phonevalues pass libphonenumber’sVALIDcheck, because Faker picks area codes at random. Strict validation on synthetic data looks like poor recall. Real data behaves better, but legacy fields with typos exist, which is whyPOSSIBLEmatches get half credit instead of zero. -
Substring matching on column names.
"pan" in "company_code"isTrue. Tokenize names first. -
Mixed columns.
leads.contactholds two entity types. Score every detector against every column instead of stopping at the first match. -
Air-gapped scans. Presidio’s email recognizer uses
tldextract, which fetches the Public Suffix List on first use. In my sandbox without internet access that produced a long traceback in the log, after which detection carried on with the snapshot bundled in the package. For production,tldextract.TLDExtract(suffix_list_urls=())disables the fetch.
Taking it to production
-
Sampling budget. 200 values per column is enough to separate “100% cards” from “6% cards”. For huge tables, sample in the database (Postgres
TABLESAMPLE, BigQueryTABLESAMPLE SYSTEM) instead of streaming every row to Python. -
Run it on a schedule and diff. Seeded sampling gives stable output, so a nightly job can compare today’s inventory with yesterday’s and alert on new
Restrictedcolumns. - Push labels to the catalog. The JSON inventory maps directly to tags in a data catalog or to column comments, so access policies and masking rules can key off them.
- Add entities that matter to you: national IDs for your markets, IP addresses, dates of birth next to names. Each new detector should come with a validator and a test that includes look-alikes.
-
Keep humans in the loop.
reviewexists so that a person decides the borderline cases, and that decision should be stored so the next scan does not ask again.
Checklist:
- [ ] Every data store is listed as a source
- [ ] Each detector has a validator, not just a regex
- [ ] Decisions are per column, with a hit ratio and a name signal
- [ ] Free text has its own rule
- [ ] Reports never contain raw values
- [ ] Tests include look-alikes, not only true positives
- [ ] Library versions are pinned (Faker, phonenumbers metadata, Presidio)
Wrapping up
Automated data discovery and classification does not need a platform to get started. A few hundred lines of Python, honest validators and a column-level score turned 23 noisy “confirmed” columns into the 7 that really hold PII, with a report that is safe to share. Presidio is a good companion, especially for free text, as long as you give it column context and check its recognizers against the formats you actually store.
What does your team use for PII discovery in structured data: a commercial scanner, Presidio, or something homegrown? And which false positive hurt you the most?
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.


