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]
    }
LevelWhat it detectsSpeedWhen to use
RegexEmails, phones, DNI, IBAN, CC<2msSentiment output, simple APIs
Presidio+ Names, cities, organizations20-100msRAG, summaries of documents with people
LLM judgeEverything, including implicit PII+400msOnly 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

  1. Presidio (Microsoft) — The most complete library for PII detection/anonymization
  2. spaCy NER — Named Entity Recognition for names, places
  3. GDPR and PII — European data protection legal framework
  4. RGPD (Spain) — Spanish version of the GDPR
  5. Presidio Supported Entities — Complete list of entities Presidio detects
  6. Anonymization Techniques — Guide to anonymization techniques