PII Sanitization
PII sanitization is data minimization for your records: detect sensitive data and redact it before a decision is recorded or stored.
sanitize For: governance & audit
The principle is minimize before you store: a record you never wrote sensitive data into is one you never have to scrub later.
Install
pip install briefcase-ai[sanitize]Sanitize before capture
-
Detect — scan the incoming ticket text for known patterns (email, phone, and any custom patterns you register).
-
Redact — replace matches with a
[REDACTED_<TYPE>]marker so the meaning survives but the sensitive value doesn’t. -
Capture — record the decision on the sanitized text, so the stored record never held raw PII.
from briefcase.sanitize import Sanitizer
sanitizer = Sanitizer()result = sanitizer.sanitize("Email me at jane.doe@example.com please")
print(result.sanitized) # Email me at [REDACTED_EMAIL] pleaseprint(result.redaction_count) # 1print(result.has_redactions) # True
# feed result.sanitized into classify_ticket() so the captured record is cleansanitize() returns a SanitizationResult with .sanitized, .redactions, .redaction_count, and .has_redactions.
Redaction markers
Each match is replaced with a [REDACTED_<TYPE>] marker. The built-in PII types and their markers:
| PII type | Marker |
|---|---|
email | [REDACTED_EMAIL] |
phone | [REDACTED_PHONE] |
credit_card | [REDACTED_CREDIT_CARD] |
ssn | [REDACTED_SSN] |
ip_address | [REDACTED_IP] |
api_key | [REDACTED_API_KEY] |
What counts as a card number
Changed in 4.0.0Card detection spans 13 to 19 digits, contiguous or grouped by single spaces or hyphens, which covers 4-4-4-4, Amex 4-6-5, and Diners 4-6-4. Which of those matches is actually redacted follows two rules:
- A 16-digit run is redacted on shape alone.
- Any other length must pass the Luhn checksum and start with an issuer digit (3 to 6). Luhn alone accepts roughly one arbitrary digit run in ten, so gating on it by itself would redact epoch-millisecond timestamps and snowflake identifiers.
Card, phone, and SSN matches are also rejected when they continue into a longer
digit run, directly or across a hyphen. Without that, an all-numeric UUID came
out as [REDACTED_CREDIT_CARD]-[REDACTED_CREDIT_CARD], and part of an
identifier is worse than none of it.
Whitespace is deliberately not treated as a continuation, since it separates two values far more often than it groups one:
sanitizer.sanitize("cards 4111111111111111 5500000000000004").sanitized# 'cards [REDACTED_CREDIT_CARD] [REDACTED_CREDIT_CARD]' — two cards, both redacted
sanitizer.sanitize("trace 12345678-1234-5678-1234-567812345678").sanitized# unchanged — a UUID, not a card
sanitizer.sanitize("order 1699999999996").sanitized# unchanged — Luhn-valid, but no issuer prefixA run containing non-ASCII decimal digits (fullwidth, Arabic-Indic) defeats the checksum, so it is redacted outright. Over-redaction is the safe direction there.
Inspect redactions
Each entry in result.redactions is a Redaction with .pii_type, .start_position, .end_position, and .original_length (positions index into the original text).
from briefcase.sanitize import Sanitizer
sanitizer = Sanitizer()result = sanitizer.sanitize("Call 555-123-4567 or email jane.doe@example.com")
for redaction in result.redactions: print(redaction.pii_type, redaction.start_position, redaction.end_position)# phone 5 21# email 27 43Sanitize JSON
sanitize_json() walks a dict and redacts string values, returning a SanitizationJsonResult with .sanitized and .redaction_count. Useful for sanitizing a structured ticket payload before you record it.
from briefcase.sanitize import Sanitizer
sanitizer = Sanitizer()record = { "ticket_id": "TKT-4821", "contact_email": "jane.doe@example.com", "priority": 2,}
result = sanitizer.sanitize_json(record)print(result.sanitized)# {'contact_email': '[REDACTED_EMAIL]', 'priority': 2, 'ticket_id': 'TKT-4821'}print(result.redaction_count) # 1Reject sensitive data in a guardrail
Sometimes you don’t want to redact and continue — you want to stop. Use
contains_pii (a fast boolean) or analyze_pii (a summary that doesn’t modify
the text) to refuse a payload before it’s ever recorded.
from briefcase.sanitize import Sanitizer
sanitizer = Sanitizer()
def guard(text: str) -> None: if sanitizer.contains_pii(text): report = sanitizer.analyze_pii(text) # summary for logging the reason raise ValueError(f"refusing to store record: PII detected ({report})")
guard("Email jane.doe@example.com") # raises before classify_ticket is recordedreport = sanitizer.analyze_pii("Email jane.doe@example.com and call 555-123-4567")print(report)# {'has_pii': True, 'total_matches': 2, 'unique_types': 2,# 'detected_types': ['phone', 'email']}| Method | Returns | Use it to… |
|---|---|---|
sanitize(text) | SanitizationResult | Strip PII and keep going |
sanitize_json(data) | SanitizationJsonResult | Strip PII from a structured payload |
contains_pii(text) | bool | Cheaply gate a guardrail — proceed or reject |
analyze_pii(text) | summary dict | Get the details (types, counts) for logging or decisions |
This pairs naturally with Guardrails, where you can run
this check inside an evaluate() and return DENY when PII is still present.
Custom patterns
Register your own patterns for identifiers specific to your domain — a ticket
number scheme, an internal account ID format — with add_pattern(name, regex).
The marker uppercases the name, so ticket_id redacts to [REDACTED_TICKET_ID].
Registered patterns are picked up by sanitize, contains_pii, and
analyze_pii.
from briefcase.sanitize import Sanitizer
sanitizer = Sanitizer()sanitizer.add_pattern("ticket_id", r"\bTKT-\d{4}\b")
result = sanitizer.sanitize("Ticket TKT-4821 was escalated")print(result.sanitized) # Ticket [REDACTED_TICKET_ID] was escalated| Argument | Type | Description |
|---|---|---|
name | str | A label for the pattern; uppercased into the [REDACTED_<NAME>] marker and reported by analyze_pii |
pattern | str | The regex to match and redact |
remove_pattern(pattern_name) removes a registered pattern again.
Key classes
Sanitizer— detects and redacts PII;sanitize,sanitize_json,add_pattern,remove_pattern,contains_pii,analyze_pii.SanitizationResult—.sanitized,.redactions,.redaction_count,.has_redactions.Redaction—.pii_type,.start_position,.end_position,.original_length.SanitizationJsonResult—.sanitized,.redaction_count.
Limits
Detection is regex over six built-in types. That shapes everything below, and none of it is a bug report: it is the boundary you design around.
Identity that has no format is not detected. Names, postal addresses, dates of
birth, and free-text medical or financial detail pass through untouched.
"Jane Doe, born 1985-04-02, lives at 12 Oak St" sanitizes to itself. If your
records carry those, add custom patterns or keep the field out
of the record entirely.
Coverage is US-shaped. +14155552671 redacts as a phone number;
+44 20 7946 0958 does not. IPv4 redacts, IPv6 does not. IBANs, VAT numbers, and
national identifiers other than SSN have no pattern.
Some patterns are broad. Any nine-digit run reads as an SSN, so a passport or
reference number becomes [REDACTED_SSN]. Over-redaction is the safe direction; it
still means a redacted record can lose a value you needed.
An address with a non-ASCII local part redacts only from the ASCII onward.
jané.doe@example.com becomes jané[REDACTED_EMAIL], leaving a fragment behind.
Treat non-ASCII input as needing its own check.
sanitize_json redacts values, never keys. A payload that puts an address in
key position keeps it: {"jane@example.com": "..."} is returned unchanged. It does
walk nested dicts and lists, and leaves non-string leaves alone.
Redaction offsets index the original text, not .sanitized, because a marker is
a different length from what it replaced. Use them against the input you passed in.
API reference
briefcase.sanitize has the full Sanitizer surface.