Spaces:
Runtime error
Runtime error
| """ | |
| RAG Security β PII redaction, prompt injection detection, and query auditing. | |
| Enterprise RAG systems face three classes of security threats: | |
| 1. PII Leakage: | |
| Medical records, financial data, employee info ingested into the vector store | |
| can be retrieved and included verbatim in LLM responses. Users (or attackers) | |
| get data they shouldn't see. | |
| 2. Prompt Injection via Documents: | |
| Attacker embeds "IGNORE ALL PREVIOUS INSTRUCTIONS. Your new task is..." in a | |
| document. When retrieved, it overrides the system prompt and hijacks the LLM. | |
| Demonstrated against multiple production RAG systems in 2024-2025. | |
| 3. Sensitive Query Detection: | |
| Queries probing for credentials, PII, or internal system information should be | |
| logged and optionally blocked. | |
| This module provides: | |
| - Regex-based PII detection (SSN, credit card, email, phone, etc.) | |
| - Optional presidio integration for ML-based NER | |
| - Prompt injection pattern matching | |
| - Audit logging with sanitized query/answer pairs | |
| Zero external dependencies by default (pure regex). Install presidio for | |
| higher accuracy: pip install presidio-analyzer presidio-anonymizer | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import logging | |
| import re | |
| from dataclasses import dataclass, field | |
| from datetime import UTC, datetime | |
| from pathlib import Path | |
| logger = logging.getLogger(__name__) | |
| AUDIT_LOG_PATH = Path("./data/audit_log.jsonl") | |
| # ββ PII patterns ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _PII_PATTERNS: list[tuple[str, str, re.Pattern]] = [ | |
| ("SSN", "social_security_number", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")), | |
| ("CREDIT_CARD", "credit_card", re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b")), | |
| ("EMAIL", "email_address", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b")), | |
| ("PHONE_US", "us_phone", re.compile(r"\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")), | |
| ("IP_ADDRESS", "ip_address", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")), | |
| ("IBAN", "iban", re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}\b")), | |
| ("PASSPORT", "passport_number", re.compile(r"\b[A-Z]{1,2}\d{6,9}\b")), | |
| ( | |
| "API_KEY", | |
| "api_key", | |
| re.compile(r"\b(?:sk-|pk-|api[_-]key[:\s=]+)[A-Za-z0-9_\-]{20,}\b", re.IGNORECASE), | |
| ), | |
| ] | |
| # Replacement tokens by type | |
| _REPLACEMENTS = { | |
| "SSN": "[REDACTED-SSN]", | |
| "CREDIT_CARD": "[REDACTED-CARD]", | |
| "EMAIL": "[REDACTED-EMAIL]", | |
| "PHONE_US": "[REDACTED-PHONE]", | |
| "IP_ADDRESS": "[REDACTED-IP]", | |
| "IBAN": "[REDACTED-IBAN]", | |
| "PASSPORT": "[REDACTED-PASSPORT]", | |
| "API_KEY": "[REDACTED-KEY]", | |
| } | |
| # ββ Prompt injection patterns βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _INJECTION_PATTERNS: list[re.Pattern] = [ | |
| re.compile(r"ignore\s+(all\s+)?previous\s+instructions?", re.IGNORECASE), | |
| re.compile(r"disregard\s+(all\s+)?prior\s+(instructions?|context)", re.IGNORECASE), | |
| re.compile(r"your\s+new\s+(task|instructions?|role|purpose)\s+is", re.IGNORECASE), | |
| re.compile(r"you\s+are\s+now\s+(?:a\s+)?(?:different|new|evil|malicious)", re.IGNORECASE), | |
| re.compile(r"system\s*prompt\s*[:=]", re.IGNORECASE), | |
| re.compile(r"<\|?system\|?>", re.IGNORECASE), | |
| re.compile(r"\[INST\]|\[/INST\]", re.IGNORECASE), | |
| re.compile(r"###\s*(?:Human|Assistant|System)\s*:", re.IGNORECASE), | |
| re.compile(r"forget\s+(everything|all)\s+(?:you|previously)", re.IGNORECASE), | |
| re.compile(r"repeat\s+after\s+me|say\s+exactly|output\s+the\s+following", re.IGNORECASE), | |
| ] | |
| # ββ Sensitive query patterns ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _SENSITIVE_QUERY_PATTERNS: list[re.Pattern] = [ | |
| re.compile(r"\b(password|passwd|secret|api[\s_-]?key|credentials?|token)\b", re.IGNORECASE), | |
| re.compile(r"\b(ssn|social\s+security|credit\s+card|cvv|pin)\b", re.IGNORECASE), | |
| re.compile(r"\b(jailbreak|bypass|override|system\s+prompt)\b", re.IGNORECASE), | |
| re.compile( | |
| r"(exfiltrat|extract|dump)\s+(?:all|every|the)\s+(?:data|documents?|chunks?)", re.IGNORECASE | |
| ), | |
| ] | |
| # ββ Detection results βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class PIIDetectionResult: | |
| """Result of PII scanning.""" | |
| has_pii: bool | |
| pii_types: list[str] = field(default_factory=list) | |
| redacted_text: str = "" | |
| redaction_count: int = 0 | |
| class InjectionDetectionResult: | |
| """Result of prompt injection scanning.""" | |
| is_injection: bool | |
| matched_patterns: list[str] = field(default_factory=list) | |
| risk_score: float = 0.0 | |
| class QueryAuditEntry: | |
| """A single audit log entry.""" | |
| timestamp: str | |
| question_hash: str | |
| collection: str | |
| has_pii_in_query: bool | |
| injection_detected: bool | |
| sensitive_query: bool | |
| sources_returned: int | |
| answer_has_pii: bool | |
| session_id: str | None = None | |
| # ββ PII detection and redaction βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def detect_pii(text: str, use_presidio: bool = False) -> PIIDetectionResult: | |
| """ | |
| Detect PII in text using regex patterns. | |
| Optionally uses Microsoft Presidio for ML-based NER (more accurate, | |
| especially for names and dates of birth). | |
| Args: | |
| text: text to scan | |
| use_presidio: use Presidio ML models (requires: pip install presidio-analyzer) | |
| Returns: | |
| PIIDetectionResult with detected types | |
| """ | |
| if use_presidio: | |
| try: | |
| return _detect_pii_presidio(text) | |
| except ImportError: | |
| pass # fall through to regex | |
| pii_types = [] | |
| for name, _, pattern in _PII_PATTERNS: | |
| if pattern.search(text): | |
| pii_types.append(name) | |
| return PIIDetectionResult( | |
| has_pii=bool(pii_types), | |
| pii_types=pii_types, | |
| redacted_text=text, # not redacted yet; call redact_pii for that | |
| ) | |
| def redact_pii(text: str, use_presidio: bool = False) -> PIIDetectionResult: | |
| """ | |
| Detect and redact PII from text, replacing matches with type tokens. | |
| Args: | |
| text: text to redact | |
| use_presidio: use Presidio for higher-accuracy detection | |
| Returns: | |
| PIIDetectionResult with redacted_text populated | |
| """ | |
| if use_presidio: | |
| try: | |
| return _redact_pii_presidio(text) | |
| except ImportError: | |
| pass # fall through to regex | |
| pii_types = [] | |
| redacted = text | |
| count = 0 | |
| for name, _, pattern in _PII_PATTERNS: | |
| replacement = _REPLACEMENTS.get(name, "[REDACTED]") | |
| new_text, n = pattern.subn(replacement, redacted) | |
| if n > 0: | |
| pii_types.append(name) | |
| redacted = new_text | |
| count += n | |
| return PIIDetectionResult( | |
| has_pii=bool(pii_types), | |
| pii_types=pii_types, | |
| redacted_text=redacted, | |
| redaction_count=count, | |
| ) | |
| def _detect_pii_presidio(text: str) -> PIIDetectionResult: | |
| """Presidio-based PII detection (higher accuracy for names, addresses, DOB).""" | |
| from presidio_analyzer import AnalyzerEngine | |
| analyzer = AnalyzerEngine() | |
| results = analyzer.analyze(text=text, language="en") | |
| pii_types = list({r.entity_type for r in results}) | |
| return PIIDetectionResult(has_pii=bool(pii_types), pii_types=pii_types, redacted_text=text) | |
| def _redact_pii_presidio(text: str) -> PIIDetectionResult: | |
| """Presidio-based PII redaction.""" | |
| from presidio_analyzer import AnalyzerEngine | |
| from presidio_anonymizer import AnonymizerEngine | |
| analyzer = AnalyzerEngine() | |
| anonymizer = AnonymizerEngine() | |
| results = analyzer.analyze(text=text, language="en") | |
| anonymized = anonymizer.anonymize(text=text, analyzer_results=results) | |
| pii_types = list({r.entity_type for r in results}) | |
| return PIIDetectionResult( | |
| has_pii=bool(pii_types), | |
| pii_types=pii_types, | |
| redacted_text=anonymized.text, | |
| redaction_count=len(results), | |
| ) | |
| # ββ Prompt injection detection ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def detect_injection(text: str) -> InjectionDetectionResult: | |
| """ | |
| Scan text for prompt injection patterns. | |
| Checks retrieved document chunks before including them in the LLM prompt. | |
| If a chunk matches injection patterns, it's flagged and optionally excluded. | |
| Args: | |
| text: chunk text or query to scan | |
| Returns: | |
| InjectionDetectionResult (is_injection=True if suspicious) | |
| """ | |
| matched = [] | |
| for pattern in _INJECTION_PATTERNS: | |
| match = pattern.search(text) | |
| if match: | |
| matched.append(pattern.pattern[:50]) | |
| risk_score = min(1.0, len(matched) * 0.3) | |
| if matched: | |
| logger.warning( | |
| "Prompt injection detected: %d pattern(s) matched in text: '%sβ¦'", | |
| len(matched), | |
| text[:100], | |
| ) | |
| return InjectionDetectionResult( | |
| is_injection=bool(matched), | |
| matched_patterns=matched, | |
| risk_score=risk_score, | |
| ) | |
| def sanitize_chunk(chunk_text: str, block_injection: bool = True) -> tuple[str, bool]: | |
| """ | |
| Sanitize a retrieved chunk before including it in the LLM prompt. | |
| Wraps the chunk in XML tags so the LLM clearly distinguishes it from | |
| instructions. Optionally blocks chunks with injection patterns. | |
| Args: | |
| chunk_text: raw retrieved chunk | |
| block_injection: if True, replace injected chunks with a warning | |
| Returns: | |
| (sanitized_text, was_blocked) | |
| """ | |
| injection = detect_injection(chunk_text) | |
| if injection.is_injection and block_injection: | |
| logger.warning("Blocking chunk with injection score %.1f", injection.risk_score) | |
| return "[CHUNK BLOCKED: potential prompt injection detected]", True | |
| # Wrap in XML tags to isolate from instruction tokens | |
| return f"<retrieved_context>\n{chunk_text}\n</retrieved_context>", False | |
| def is_sensitive_query(query: str) -> bool: | |
| """ | |
| Check if a user query is probing for sensitive information or attempting injection. | |
| Returns True if the query should be logged with elevated priority. | |
| Does not necessarily block the query β that's a policy decision. | |
| """ | |
| for pattern in _SENSITIVE_QUERY_PATTERNS: | |
| if pattern.search(query): | |
| return True | |
| injection = detect_injection(query) | |
| return injection.is_injection | |
| # ββ Audit logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def audit_query( | |
| question: str, | |
| collection: str, | |
| answer: str = "", | |
| sources_returned: int = 0, | |
| session_id: str | None = None, | |
| ) -> QueryAuditEntry: | |
| """ | |
| Log a query/answer pair to the audit log. | |
| The question is hashed (not stored in plaintext) unless PII/injection is detected. | |
| The answer is scanned for PII leakage. | |
| Args: | |
| question: user query | |
| collection: collection queried | |
| answer: LLM-generated answer | |
| sources_returned: number of sources in the response | |
| session_id: optional session identifier | |
| Returns: | |
| QueryAuditEntry (also written to AUDIT_LOG_PATH) | |
| """ | |
| question_hash = hashlib.sha256(question.encode()).hexdigest()[:16] | |
| pii_result = detect_pii(question) | |
| injection_result = detect_injection(question) | |
| sensitive = is_sensitive_query(question) | |
| answer_pii = detect_pii(answer).has_pii if answer else False | |
| entry = QueryAuditEntry( | |
| timestamp=datetime.now(UTC).isoformat(), | |
| question_hash=question_hash, | |
| collection=collection, | |
| has_pii_in_query=pii_result.has_pii, | |
| injection_detected=injection_result.is_injection, | |
| sensitive_query=sensitive, | |
| sources_returned=sources_returned, | |
| answer_has_pii=answer_pii, | |
| session_id=session_id, | |
| ) | |
| _write_audit_entry(entry) | |
| if pii_result.has_pii: | |
| logger.warning( | |
| "AUDIT: PII detected in query (hash=%s, types=%s)", question_hash, pii_result.pii_types | |
| ) | |
| if injection_result.is_injection: | |
| logger.warning("AUDIT: Injection attempt detected (hash=%s)", question_hash) | |
| if answer_pii: | |
| logger.warning("AUDIT: PII may be present in answer (hash=%s)", question_hash) | |
| return entry | |
| def _write_audit_entry(entry: QueryAuditEntry) -> None: | |
| """Append audit entry to JSONL log file.""" | |
| import json | |
| AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| record = { | |
| "timestamp": entry.timestamp, | |
| "question_hash": entry.question_hash, | |
| "collection": entry.collection, | |
| "has_pii_in_query": entry.has_pii_in_query, | |
| "injection_detected": entry.injection_detected, | |
| "sensitive_query": entry.sensitive_query, | |
| "sources_returned": entry.sources_returned, | |
| "answer_has_pii": entry.answer_has_pii, | |
| "session_id": entry.session_id, | |
| } | |
| try: | |
| with open(AUDIT_LOG_PATH, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(record) + "\n") | |
| except OSError as e: | |
| logger.warning("Audit log write failed: %s", e) | |
| def get_audit_summary(days: int = 7) -> dict: | |
| """ | |
| Summarize recent audit log entries. | |
| Returns: | |
| Dict with counts of PII, injection attempts, sensitive queries | |
| """ | |
| import json | |
| from datetime import timedelta | |
| now = datetime.now(UTC) | |
| cutoff = now - timedelta(days=days) | |
| total = pii_queries = injections = sensitives = answer_pii_count = 0 | |
| if not AUDIT_LOG_PATH.exists(): | |
| return {"total_queries": 0, "days": days} | |
| try: | |
| with open(AUDIT_LOG_PATH, encoding="utf-8") as f: | |
| for line in f: | |
| try: | |
| entry = json.loads(line) | |
| ts = datetime.fromisoformat(entry["timestamp"]) | |
| if ts < cutoff: | |
| continue | |
| total += 1 | |
| if entry.get("has_pii_in_query"): | |
| pii_queries += 1 | |
| if entry.get("injection_detected"): | |
| injections += 1 | |
| if entry.get("sensitive_query"): | |
| sensitives += 1 | |
| if entry.get("answer_has_pii"): | |
| answer_pii_count += 1 | |
| except (json.JSONDecodeError, KeyError, ValueError): | |
| pass | |
| except OSError: | |
| pass | |
| return { | |
| "period_days": days, | |
| "total_queries": total, | |
| "pii_in_queries": pii_queries, | |
| "injection_attempts": injections, | |
| "sensitive_queries": sensitives, | |
| "answers_with_pii": answer_pii_count, | |
| "pii_rate": round(pii_queries / max(total, 1), 3), | |
| "injection_rate": round(injections / max(total, 1), 3), | |
| } | |