Module 4: Guardrails — Input & Output Validation
6. PII Detection and Redaction
Description
PII (Personally Identifiable Information) is data that identifies a person: name, email, phone, national ID, address, credit card number. In LLM apps, PII can appear in the user's input (text that contains personal data), or in the LLM's output (when it processes documents with PII and reproduces them in the result). This capsule covers three levels of detection: regex for standard patterns, Presidio (Microsoft's library) for NER, and the trade-offs of each approach.
The two PII vectors in LLM apps
# Vector 1: PII in the user's INPUT
user_input = "Analyze this email from my client: My name is Juan García,
you can reach me at juan.garcia@empresa.com or at 612 345 678."
# The input's PII can:
# - Appear in the system logs
# - Be included in the output ("Juan García's sentiment is positive")
# - Be sent to third-party APIs (the LLM provider receives this text)
# Vector 2: PII in the LLM's OUTPUT
document = """Q3 Report
Owner: María López (m.lopez@empresa.com, DNI 12345678A)
Sales: $1.2M in the quarter..."""
llm_output = summarize(document)
# The LLM might include: "María López achieved sales of $1.2M..."
# → María's PII exposed in the output
Level 1: Regex for standard patterns
# src/guardrails/pii_detector.py
import re
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class PIIMatch:
pii_type: str
value: str
start: int
end: int
@dataclass
class PIIDetectionResult:
has_pii: bool
matches: list[PIIMatch] = field(default_factory=list)
redacted_text: Optional[str] = None
# Patterns by PII type
PII_PATTERNS = {
# Email: user@domain.tld
"email": re.compile(
r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,10}\b',
re.IGNORECASE
),
# Spain phone: 6XX XXX XXX, +34 6XX XXX XXX, 9XX XXX XXX
"phone_es": re.compile(
r'(?:(?:\+34|0034)\s?)?(?:6|7|8|9)\d{2}[\s.\-]?\d{3}[\s.\-]?\d{3}\b'
),
# Spain DNI/NIE
"dni_es": re.compile(
r'\b\d{8}[A-HJ-NP-TV-Z]\b|\b[XYZ]\d{7}[A-HJ-NP-TV-Z]\b',
re.IGNORECASE
),
# Credit card: 16 digits in groups
"credit_card": re.compile(
r'\b(?:\d{4}[\s\-]?){3}\d{4}\b'
),
# Spain IBAN
"iban_es": re.compile(
r'\bES\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}\b',
re.IGNORECASE
),
# IP Address (can be PII in some contexts)
"ip_address": re.compile(
r'\b(?:\d{1,3}\.){3}\d{1,3}\b'
),
}
def detect_pii_regex(text: str, patterns: dict = None) -> PIIDetectionResult:
"""
Detects PII using regex patterns.
Fast (<2ms) but only captures known patterns with a standard format.
Doesn't detect: names in free text, addresses, birth dates.
Args:
text: Text to analyze
patterns: Dict of patterns (uses PII_PATTERNS by default)
Returns:
PIIDetectionResult with the matches found
"""
if patterns is None:
patterns = PII_PATTERNS
matches = []
for pii_type, pattern in patterns.items():
for match in pattern.finditer(text):
matches.append(PIIMatch(
pii_type=pii_type,
value=match.group(),
start=match.start(),
end=match.end()
))
return PIIDetectionResult(
has_pii=len(matches) > 0,
matches=matches
)
Redaction strategies
def redact_pii(
text: str,
strategy: str = "replace",
replacement_char: str = "*",
patterns: dict = None
) -> tuple[str, PIIDetectionResult]:
"""
Detects and redacts PII from the text.
Strategies:
- "replace": Replaces with a placeholder [EMAIL], [PHONE], etc.
- "mask": Partially masks: juan@empresa.com → j***@empresa.com
- "remove": Removes completely: "Juan at j@e.com" → "Juan at "
Returns:
(redacted_text, PIIDetectionResult)
"""
detection = detect_pii_regex(text, patterns)
if not detection.has_pii:
return text, detection
redacted = text
if strategy == "replace":
redacted = _replace_pii(text, detection.matches)
elif strategy == "mask":
redacted = _mask_pii(text, detection.matches)
elif strategy == "remove":
redacted = _remove_pii(text, detection.matches)
detection.redacted_text = redacted
return redacted, detection
def _replace_pii(text: str, matches: list[PIIMatch]) -> str:
"""Replaces PII with readable placeholders."""
replacements = {
"email": "[EMAIL]",
"phone_es": "[PHONE]",
"dni_es": "[DNI]",
"credit_card": "[CARD]",
"iban_es": "[IBAN]",
"ip_address": "[IP]",
}
# Sort matches by position (back to front so indices don't break)
sorted_matches = sorted(matches, key=lambda m: m.start, reverse=True)
result = text
for match in sorted_matches:
placeholder = replacements.get(match.pii_type, "[REDACTED]")
result = result[:match.start] + placeholder + result[match.end:]
return result
def _mask_pii(text: str, matches: list[PIIMatch]) -> str:
"""Partially masks PII, preserving readability."""
sorted_matches = sorted(matches, key=lambda m: m.start, reverse=True)
result = text
for match in sorted_matches:
masked = _mask_value(match.value, match.pii_type)
result = result[:match.start] + masked + result[match.end:]
return result
def _mask_value(value: str, pii_type: str) -> str:
"""Masks a specific value according to its type."""
if pii_type == "email":
local, domain = value.rsplit("@", 1)
if len(local) > 1:
return local[0] + "*" * (len(local) - 1) + "@" + domain
return "*@" + domain
elif pii_type == "phone_es":
digits = re.sub(r'\D', '', value)
if len(digits) >= 6:
return digits[:3] + "***" + digits[-3:]
return "***"
elif pii_type == "dni_es":
return value[:2] + "****" + value[-2:]
elif pii_type == "credit_card":
digits = re.sub(r'\D', '', value)
return "**** **** **** " + digits[-4:]
else:
n = len(value)
visible = max(1, n // 4)
return value[:visible] + "*" * (n - visible * 2) + value[-visible:]
def _remove_pii(text: str, matches: list[PIIMatch]) -> str:
"""Removes PII completely from the text."""
sorted_matches = sorted(matches, key=lambda m: m.start, reverse=True)
result = text
for match in sorted_matches:
result = result[:match.start] + result[match.end:]
return result.strip()
Level 2: Presidio (NER for more complex PII)
Microsoft Presidio uses Named Entity Recognition to detect PII that's harder to capture with regex:
pip install presidio-analyzer presidio-anonymizer
python -m spacy download en_core_web_md # Model for English
python -m spacy download en_core_web_lg # Larger model (better coverage)
from functools import lru_cache
@lru_cache(maxsize=1)
def get_presidio_engines():
"""Loads the Presidio engines once and caches them."""
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
# Configure for English
provider = NlpEngineProvider(nlp_configuration={
"nlp_engine_name": "spacy",
"models": [{"lang_code": "en", "model_name": "en_core_web_md"}]
})
analyzer = AnalyzerEngine(nlp_engine=provider.create_engine())
anonymizer = AnonymizerEngine()
return analyzer, anonymizer
def detect_pii_presidio(
text: str,
language: str = "en",
entities: list[str] = None
) -> PIIDetectionResult:
"""
Detects PII using Presidio with NER.
Detects what regex can't:
- PERSON (people's names)
- LOCATION (cities, addresses)
- ORGANIZATION
- DATE_TIME (can be PII, like a birth date)
Latency: ~20-100ms (slower than regex but more accurate for complex PII)
"""
try:
analyzer, _ = get_presidio_engines()
# Entities to detect (by default: the most common ones)
if entities is None:
entities = ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "LOCATION",
"CREDIT_CARD", "IBAN_CODE", "NRP"] # NRP = National Registration Number
results = analyzer.analyze(
text=text,
language=language,
entities=entities
)
matches = [
PIIMatch(
pii_type=r.entity_type.lower(),
value=text[r.start:r.end],
start=r.start,
end=r.end
)
for r in results
]
return PIIDetectionResult(has_pii=len(matches) > 0, matches=matches)
except Exception as e:
# If Presidio fails, fall back to regex
import logging
logging.getLogger("guardrails").warning(f"Presidio failed: {e}")
return detect_pii_regex(text)
def redact_with_presidio(text: str, language: str = "en") -> str:
"""Redacts PII using Presidio (more accurate, slower)."""
try:
from presidio_anonymizer.entities import OperatorConfig
analyzer, anonymizer = get_presidio_engines()
results = analyzer.analyze(text=text, language=language)
anonymized = anonymizer.anonymize(
text=text,
analyzer_results=results,
operators={
"DEFAULT": OperatorConfig("replace", {"new_value": "[REDACTED]"}),
"PERSON": OperatorConfig("replace", {"new_value": "[NAME]"}),
"EMAIL_ADDRESS": OperatorConfig("replace", {"new_value": "[EMAIL]"}),
"PHONE_NUMBER": OperatorConfig("replace", {"new_value": "[PHONE]"}),
}
)
return anonymized.text
except Exception:
# Fall back to regex if Presidio fails
redacted, _ = redact_pii(text)
return redacted
Comparison: when to use each level
# Decision function to choose the appropriate level:
def redact_pii_smart(
text: str,
use_presidio: bool = False,
language: str = "en"
) -> tuple[str, dict]:
"""
Redacts PII by choosing the appropriate level.
Level 1 (regex): for sentiment outputs (emails, phones are rare but possible)
Level 2 (Presidio): for RAG outputs with documents (may have names)
"""
if use_presidio:
try:
redacted = redact_with_presidio(text, language)
return redacted, {"method": "presidio"}
except ImportError:
pass # Presidio not installed → use regex
redacted, detection = redact_pii(text)
return redacted, {
"method": "regex",
"pii_types_found": [m.pii_type for m in detection.matches]
}
| Level | What it detects | Speed | When to use |
|---|---|---|---|
| Regex | Emails, phones, DNI, IBAN, CC | <2ms | Sentiment output, simple APIs |
| Presidio | + Names, cities, organizations | 20-100ms | RAG, summaries of documents with people |
| LLM judge | Everything, including implicit PII | +400ms | Only for very sensitive data (medical, legal) |
PII detector tests
# tests/unit/guardrails/test_pii_detector.py
import pytest
from src.guardrails.pii_detector import detect_pii_regex, redact_pii
class TestEmailDetection:
def test_detects_standard_email(self):
result = detect_pii_regex("Contact at juan@empresa.com please")
assert result.has_pii
assert any(m.pii_type == "email" for m in result.matches)
def test_detects_complex_email(self):
result = detect_pii_regex("Email: juan.garcia+filtro@subdomain.empresa.es")
assert result.has_pii
def test_no_false_positive_for_non_email(self):
result = detect_pii_regex("Version 3.0 is better than 2.5")
email_matches = [m for m in result.matches if m.pii_type == "email"]
assert len(email_matches) == 0
class TestPhoneDetection:
def test_detects_mobile_spain(self):
result = detect_pii_regex("Call me at 612 345 678")
phone_matches = [m for m in result.matches if m.pii_type == "phone_es"]
assert len(phone_matches) > 0
def test_detects_phone_with_country_code(self):
result = detect_pii_regex("Phone: +34 612345678")
assert result.has_pii
class TestRedaction:
def test_email_redacted_with_replace(self):
redacted, _ = redact_pii("Email: test@example.com", strategy="replace")
assert "test@example.com" not in redacted
assert "[EMAIL]" in redacted
def test_email_redacted_with_mask(self):
redacted, _ = redact_pii("Email: test@example.com", strategy="mask")
assert "test@example.com" not in redacted
assert "@example.com" in redacted # The domain is preserved
def test_multiple_pii_all_redacted(self):
text = "Juan: juan@email.com, phone 612345678"
redacted, detection = redact_pii(text)
assert "juan@email.com" not in redacted
assert "612345678" not in redacted
def test_text_without_pii_unchanged(self):
text = "The sentiment is positive with high confidence."
redacted, detection = redact_pii(text)
assert redacted == text
assert not detection.has_pii
@pytest.mark.parametrize("text,pii_type", [
("Send to juan@empresa.com", "email"),
("Call 612345678", "phone_es"),
("DNI: 12345678A", "dni_es"),
("Card: 4111 1111 1111 1111", "credit_card"),
])
def test_pii_detection_parametrized(text, pii_type):
result = detect_pii_regex(text)
assert result.has_pii
assert any(m.pii_type == pii_type for m in result.matches)
Integration in the pipeline
# In the pipeline's output guardrail:
def process_output_with_pii_redaction(output: dict) -> dict:
"""
Redacts PII from all of the output's text fields.
For the sentiment output, the fields to redact are:
- explanation (may mention the analyzed text with PII)
- keywords (could contain names or data from the text)
"""
if "explanation" in output and output["explanation"]:
redacted, detection = redact_pii(output["explanation"])
if detection.has_pii:
import logging
logging.getLogger("guardrails.pii").info(
"pii_redacted_from_output",
extra={
"field": "explanation",
"pii_types": list(set(m.pii_type for m in detection.matches))
}
)
output["explanation"] = redacted
if "keywords" in output:
cleaned_keywords = []
for kw in output["keywords"]:
redacted_kw, detection = redact_pii(kw)
if not detection.has_pii:
cleaned_keywords.append(kw)
# If the keyword is PII, simply remove it
output["keywords"] = cleaned_keywords
return output
Exercises
Exercise 1: Regex for IBAN
Write and test a regex for a Spanish IBAN (format: ES XX XXXX XXXX XX XXXXXXXXXX):
See solution
import re
IBAN_ES_PATTERN = re.compile(
r'\bES\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{2}[\s]?\d{10}\b',
re.IGNORECASE
)
# Tests:
test_cases = [
("IBAN: ES91 2100 0418 4502 0005 1332", True),
("ES9121000418450200051332", True),
("No IBAN here", False),
("ES1234 not valid", False),
]
for text, expected in test_cases:
found = bool(IBAN_ES_PATTERN.search(text))
status = "✅" if found == expected else "❌"
print(f"{status} '{text[:40]}': {'detected' if found else 'not detected'}")
Exercise 2: Mask for a credit card
Implement a function that masks a credit card number showing only the last 4 digits:
See solution
import re
def mask_credit_card(card_number: str) -> str:
"""
Masks a card number: 4111 1111 1111 1111 → **** **** **** 1111
"""
# Extract only digits
digits = re.sub(r'\D', '', card_number)
if len(digits) < 13:
return "*" * len(card_number)
# Show only the last 4 digits
last_four = digits[-4:]
masked_digits = "**** **** **** " + last_four
return masked_digits
# Tests:
assert mask_credit_card("4111 1111 1111 1111") == "**** **** **** 1111"
assert mask_credit_card("4111111111111111") == "**** **** **** 1111"
Exercise 3: Complete redaction test
Write a test that verifies that a text with an email AND a phone gets completely redacted:
See solution
def test_complete_redaction():
text = "Data: María García, contact maria@empresa.com, phone +34 612 345 678"
redacted, detection = redact_pii(text)
# Verify that PII was detected
assert detection.has_pii
assert len(detection.matches) >= 2
# Verify that PII was removed from the output
assert "maria@empresa.com" not in redacted
assert "612 345 678" not in redacted
assert "612345678" not in redacted
# The name "María García" may remain (regex doesn't detect names)
# → This is a documented trade-off: regex doesn't detect names in free text
Summary
- PII in LLM apps: two vectors — in the user's input and in the LLM's output
- Level 1 (regex): emails, phones, DNI, IBAN, cards — fast (<2ms), no cost
- Level 2 (Presidio): + names, cities, organizations — NER, 20-100ms
- Three redaction strategies: replace (placeholders), mask (partial), remove (delete)
- Never log PII — always redact before sending to logs or analytics
- Documented trade-off: regex doesn't detect names in free text — that requires Presidio or an LLM
Additional resources
- Presidio (Microsoft) — The most complete library for PII detection/anonymization
- spaCy NER — Named Entity Recognition for names, places
- GDPR and PII — European data protection legal framework
- RGPD (Spain) — Spanish version of the GDPR
- Presidio Supported Entities — Complete list of entities Presidio detects
- Anonymization Techniques — Guide to anonymization techniques