Módulo 4: Guardrails — Input & Output Validation

6. PII Detection y Redaction

Descripción

PII (Personally Identifiable Information) son datos que identifican a una persona: nombre, email, teléfono, DNI, dirección, número de tarjeta de crédito. En apps LLM, el PII puede aparecer en el input del usuario (texto que contiene datos personales), o en el output del LLM (cuando procesa documentos con PII y los reproduce en el resultado). Esta cápsula cubre tres niveles de detección: regex para patrones estándar, Presidio (biblioteca de Microsoft) para NER, y los trade-offs de cada enfoque.


Los dos vectores de PII en apps LLM

# Vector 1: PII en el INPUT del usuario
user_input = "Analiza este email de mi cliente: Mi nombre es Juan García, 
              puedes contactarme en juan.garcia@empresa.com o al 612 345 678."

# El PII del input puede:
# - Aparecer en los logs del sistema
# - Ser incluido en el output ("El sentimiento de Juan García es positivo")
# - Ser enviado a APIs de terceros (el LLM provider recibe este texto)

# Vector 2: PII en el OUTPUT del LLM
document = """Informe Q3
Responsable: María López (m.lopez@empresa.com, DNI 12345678A)
Ventas: $1.2M en el trimestre..."""

llm_output = summarize(document)
# El LLM puede incluir: "María López logró ventas de $1.2M..."
# → PII de María expuesta en el output

Nivel 1: Regex para patrones estándar

# 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

# Patrones por tipo de PII
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
    ),
    
    # Teléfono España: 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'
    ),
    
    # DNI/NIE España
    "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
    ),
    
    # Tarjeta de crédito: 16 dígitos en grupos
    "credit_card": re.compile(
        r'\b(?:\d{4}[\s\-]?){3}\d{4}\b'
    ),
    
    # IBAN España
    "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 (puede ser PII en algunos contextos)
    "ip_address": re.compile(
        r'\b(?:\d{1,3}\.){3}\d{1,3}\b'
    ),
}

def detect_pii_regex(text: str, patterns: dict = None) -> PIIDetectionResult:
    """
    Detecta PII usando patrones regex.
    
    Rápido (<2ms) pero solo captura patrones conocidos con formato estándar.
    No detecta: nombres en texto libre, direcciones, fechas de nacimiento.
    
    Args:
        text: Texto a analizar
        patterns: Dict de patrones (por defecto usa PII_PATTERNS)
    
    Returns:
        PIIDetectionResult con los matches encontrados
    """
    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
    )

Estrategias de redacción

def redact_pii(
    text: str,
    strategy: str = "replace",
    replacement_char: str = "*",
    patterns: dict = None
) -> tuple[str, PIIDetectionResult]:
    """
    Detecta y redacta PII del texto.
    
    Strategies:
    - "replace": Reemplaza con placeholder [EMAIL], [PHONE], etc.
    - "mask": Enmascara parcialmente: juan@empresa.com → j***@empresa.com
    - "remove": Elimina completamente: "Juan en j@e.com" → "Juan en "
    
    Returns:
        (texto_redactado, 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:
    """Reemplaza PII con placeholders legibles."""
    replacements = {
        "email": "[EMAIL]",
        "phone_es": "[TELÉFONO]",
        "dni_es": "[DNI]",
        "credit_card": "[TARJETA]",
        "iban_es": "[IBAN]",
        "ip_address": "[IP]",
    }
    
    # Ordenar matches por posición (de atrás hacia adelante para no romper índices)
    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:
    """Enmascara PII parcialmente, preservando legibilidad."""
    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:
    """Enmascara un valor específico según su tipo."""
    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:
    """Elimina PII completamente del texto."""
    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()

Nivel 2: Presidio (NER para PII más complejo)

Microsoft Presidio usa Named Entity Recognition para detectar PII más difícil de capturar con regex:

pip install presidio-analyzer presidio-anonymizer
python -m spacy download es_core_news_md   # Modelo para español
python -m spacy download en_core_web_lg    # Modelo para inglés (mejor cobertura)
from functools import lru_cache

@lru_cache(maxsize=1)
def get_presidio_engines():
    """Carga los engines de Presidio una vez y los cachea."""
    from presidio_analyzer import AnalyzerEngine
    from presidio_anonymizer import AnonymizerEngine
    from presidio_analyzer.nlp_engine import NlpEngineProvider
    
    # Configurar para español
    provider = NlpEngineProvider(nlp_configuration={
        "nlp_engine_name": "spacy",
        "models": [{"lang_code": "es", "model_name": "es_core_news_md"}]
    })
    
    analyzer = AnalyzerEngine(nlp_engine=provider.create_engine())
    anonymizer = AnonymizerEngine()
    
    return analyzer, anonymizer

def detect_pii_presidio(
    text: str,
    language: str = "es",
    entities: list[str] = None
) -> PIIDetectionResult:
    """
    Detecta PII usando Presidio con NER.
    
    Detecta lo que regex no puede:
    - PERSON (nombres de personas)
    - LOCATION (ciudades, direcciones)
    - ORGANIZATION
    - DATE_TIME (puede ser PII como fecha de nacimiento)
    
    Latencia: ~20-100ms (más lento que regex pero más preciso para PII complejo)
    """
    try:
        analyzer, _ = get_presidio_engines()
        
        # Entidades a detectar (por defecto: las más comunes)
        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:
        # Si Presidio falla, hacer fallback a regex
        import logging
        logging.getLogger("guardrails").warning(f"Presidio failed: {e}")
        return detect_pii_regex(text)

def redact_with_presidio(text: str, language: str = "es") -> str:
    """Redacta PII usando Presidio (más preciso, más lento)."""
    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": "[NOMBRE]"}),
                "EMAIL_ADDRESS": OperatorConfig("replace", {"new_value": "[EMAIL]"}),
                "PHONE_NUMBER": OperatorConfig("replace", {"new_value": "[TELÉFONO]"}),
            }
        )
        
        return anonymized.text
    
    except Exception:
        # Fallback a regex si Presidio falla
        redacted, _ = redact_pii(text)
        return redacted

Comparación: cuándo usar cada nivel

# Función de decisión para elegir el nivel apropiado:

def redact_pii_smart(
    text: str,
    use_presidio: bool = False,
    language: str = "es"
) -> tuple[str, dict]:
    """
    Redacta PII eligiendo el nivel apropiado.
    
    Nivel 1 (regex): para outputs de sentimiento (emails, teléfonos son raro pero posible)
    Nivel 2 (Presidio): para outputs de RAG con documentos (pueden tener nombres)
    """
    if use_presidio:
        try:
            redacted = redact_with_presidio(text, language)
            return redacted, {"method": "presidio"}
        except ImportError:
            pass  # Presidio no instalado → usar regex
    
    redacted, detection = redact_pii(text)
    return redacted, {
        "method": "regex",
        "pii_types_found": [m.pii_type for m in detection.matches]
    }
NivelQué detectaVelocidadCuándo usar
RegexEmails, teléfonos, DNI, IBAN, CC<2msOutput de sentimiento, APIs simples
Presidio+ Nombres, ciudades, organizaciones20-100msRAG, resúmenes de documentos con personas
LLM judgeTodo, incluyendo PII implícito+400msSolo para datos muy sensibles (médico, legal)

Tests del PII detector

# 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("Contacta en juan@empresa.com por favor")
        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("La versión 3.0 es mejor que 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("Llámame al 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("Teléfono: +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  # El dominio se preserva
    
    def test_multiple_pii_all_redacted(self):
        text = "Juan: juan@email.com, teléfono 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 = "El sentimiento es positivo con alta confianza."
        redacted, detection = redact_pii(text)
        assert redacted == text
        assert not detection.has_pii

@pytest.mark.parametrize("text,pii_type", [
    ("Envía a juan@empresa.com", "email"),
    ("Llama al 612345678", "phone_es"),
    ("DNI: 12345678A", "dni_es"),
    ("Tarjeta: 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)

Integración en el pipeline

# En el output guardrail del pipeline:

def process_output_with_pii_redaction(output: dict) -> dict:
    """
    Redacta PII de todos los campos de texto del output.
    
    Para el output de sentimiento, los campos a redactar son:
    - explanation (puede mencionar el texto analizado con PII)
    - keywords (podrían contener nombres o datos del texto)
    """
    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)
            # Si la keyword es PII, simplemente eliminarla
        output["keywords"] = cleaned_keywords
    
    return output

Ejercicios

Ejercicio 1: Regex para IBAN

Escribe y prueba un regex para IBAN español (formato: ES XX XXXX XXXX XX XXXXXXXXXX):

Ver solución
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 hay IBAN aquí", False),
    ("ES1234 no válido", 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'}")

Ejercicio 2: Mask para tarjeta de crédito

Implementa una función que enmascare un número de tarjeta de crédito mostrando solo los últimos 4 dígitos:

Ver solución
import re

def mask_credit_card(card_number: str) -> str:
    """
    Enmascara número de tarjeta: 4111 1111 1111 1111 → **** **** **** 1111
    """
    # Extraer solo dígitos
    digits = re.sub(r'\D', '', card_number)
    
    if len(digits) < 13:
        return "*" * len(card_number)
    
    # Mostrar solo últimos 4 dígitos
    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"

Ejercicio 3: Test de redacción completa

Escribe un test que verifica que un texto con email Y teléfono queda completamente redactado:

Ver solución
def test_complete_redaction():
    text = "Datos: María García, contacto maria@empresa.com, teléfono +34 612 345 678"
    
    redacted, detection = redact_pii(text)
    
    # Verificar que PII fue detectado
    assert detection.has_pii
    assert len(detection.matches) >= 2
    
    # Verificar que PII fue eliminado del output
    assert "maria@empresa.com" not in redacted
    assert "612 345 678" not in redacted
    assert "612345678" not in redacted
    
    # El nombre "María García" puede quedar (regex no detecta nombres)
    # → Esto es un trade-off documentado: regex no detecta nombres en texto libre

Resumen

  • PII en apps LLM: dos vectores — en el input del usuario y en el output del LLM
  • Nivel 1 (regex): emails, teléfonos, DNI, IBAN, tarjetas — rápido (<2ms), sin costo
  • Nivel 2 (Presidio): + nombres, ciudades, organizaciones — NER, 20-100ms
  • Tres estrategias de redacción: replace (placeholders), mask (parcial), remove (eliminar)
  • Nunca loguear PII — siempre redactar antes de enviar a logs o analytics
  • Trade-off documentado: regex no detecta nombres en texto libre — eso requiere Presidio o LLM

Recursos adicionales

  1. Presidio (Microsoft) — La biblioteca más completa para PII detection/anonymization
  2. spaCy NER — Named Entity Recognition para nombres, lugares
  3. GDPR y PII — Marco legal europeo de protección de datos
  4. RGPD (España) — Versión española del GDPR
  5. Presidio Supported Entities — Lista completa de entidades que Presidio detecta
  6. Anonymization Techniques — Guía de técnicas de anonimización