Перейти к содержимому

PII в промпте или ответе

Пользователь вставил в чат номер карты, модель процитировала чью-то почту в ответе, в лог утёк телефон — это персональные данные там, где их быть не должно, и в проде с реальными пользователями это прямой compliance-инцидент. Детектор должен ловить PII на входе и выходе, слать push с высоким приоритетом и — важно — не тащить сами данные в текст алёрта.

Дешёвый первый слой поверх любого текста — почта, телефон, карта. В самом алёрте показываем только маску, никогда не полное значение:

import re, os, requests
PATTERNS = {
"email": r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
"phone": r"(?:\+?\d[\d\s\-()]{9,}\d)",
"card": r"\b(?:\d[ -]*?){13,16}\b",
}
RX = {k: re.compile(v) for k, v in PATTERNS.items()}
def mask(value: str) -> str:
v = re.sub(r"\s|-", "", value)
return v[:2] + "" + v[-2:] if len(v) > 4 else ""
def scan_pii(text: str, where: str):
found = {}
for kind, rx in RX.items():
if kind == "card":
hits = [m.group(0) for m in rx.finditer(text or "") if luhn(m.group(0))]
else:
hits = [m.group(0) for m in rx.finditer(text or "")]
if hits:
found[kind] = hits
if found:
summary = "; ".join(f"{k}×{len(v)} ({mask(v[0])})" for k, v in found.items())
notify(f"🔒 PII в {where}",
f"Найдено: {summary}\n\n"
"Замаскируйте перед записью в лог и проверьте источник.",
priority=10) # громкий — это compliance
return found
def luhn(number: str) -> bool:
digits = [int(d) for d in re.sub(r"\D", "", number)]
if not 13 <= len(digits) <= 16:
return False
chk = 0
for i, d in enumerate(reversed(digits)):
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
chk += d
return chk % 10 == 0
def notify(title, message, priority):
requests.post(f"{os.environ['NOTIFLY_URL']}/message",
params={"token": os.environ["NOTIFLY_TOKEN"]},
json={"title": title, "message": message, "priority": priority},
timeout=5)

Проверка luhn() отсекает ложные срабатывания на длинных числах, которые не являются картами.

Regex ловит форматы, но не имена и адреса. Для серьёзной модерации возьмите Presidio: он даёт тип сущности и confidence, а результат можно так же замаскировать в алёрте:

from presidio_analyzer import AnalyzerEngine
analyzer = AnalyzerEngine()
def scan_pii_presidio(text: str, where: str, min_score=0.6):
results = analyzer.analyze(text=text, language="en")
strong = [r for r in results if r.score >= min_score]
if strong:
by_type = {}
for r in strong:
by_type.setdefault(r.entity_type, 0)
by_type[r.entity_type] += 1
summary = ", ".join(f"{t}×{c}" for t, c in by_type.items())
notify(f"🔒 PII в {where}",
f"Presidio нашёл: {summary}. Данные в push не вкладываются.",
priority=10)
return strong
  • На входе — до отправки в LLM: если нашли PII, где его быть не должно, отклоняйте запрос или редактируйте перед отправкой.
  • На выходе — до записи в лог и до показа пользователю: маскируйте найденное и алёртите.
  • Помеченные ответы отправляйте в очередь модерации на ручной разбор.
  • типы и количество найденных сущностей, но не значения;
  • маску (52…19), а не полный номер;
  • где нашли: prompt, ответ, лог;
  • id запроса, чтобы найти оригинал в защищённом хранилище.