Skip to content

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

Terminal window
pip install briefcase-ai[sanitize]

Sanitize before capture

  1. Detect — scan the incoming ticket text for known patterns (email, phone, and any custom patterns you register).

  2. Redact — replace matches with a [REDACTED_<TYPE>] marker so the meaning survives but the sensitive value doesn’t.

  3. 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] please
print(result.redaction_count) # 1
print(result.has_redactions) # True
# feed result.sanitized into classify_ticket() so the captured record is clean

sanitize() 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 typeMarker
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.0

Card 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 prefix

A 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 43

Sanitize 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) # 1

Reject 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 recorded
report = 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']}
MethodReturnsUse it to…
sanitize(text)SanitizationResultStrip PII and keep going
sanitize_json(data)SanitizationJsonResultStrip PII from a structured payload
contains_pii(text)boolCheaply gate a guardrail — proceed or reject
analyze_pii(text)summary dictGet 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
ArgumentTypeDescription
namestrA label for the pattern; uppercased into the [REDACTED_<NAME>] marker and reported by analyze_pii
patternstrThe 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.

Where this fits