gendesign/backend/app/services/llm/redaction.py
Light1YT f12c03588d
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-worker (push) Successful in 2m54s
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m25s
Deploy / build-backend (push) Successful in 1m46s
Deploy / deploy (push) Successful in 1m10s
fix(llm): close §19 redaction regex gaps for bare phone / CAPS name / bare SNILS (#1207)
`_PHONE_RE` требовал префикс +7/8 + хотя бы один разделитель → copy-paste форма
«79221234567»/«89221234567» (самая частая) уходила в OpenAI как есть. `_FULLNAME_RE`
требовал строчные после первой буквы → стандарт ЕГРН/паспортных выписок «ИВАНОВ
ИВАН ИВАНОВИЧ» не матчил. СНИЛС без разделителей не покрывался вообще (`_SNILS_RE`
ждёт формат «NNN-NNN-NNN NN»). Для chat-пути scrub — единственный барьер перед
OpenAI (SafePayload.text без allowlist'а), при llm_enabled=True PII покидает РФ
(нарушение §19/152-ФЗ).

Patch:
- _PHONE_BARE_RE: (?<!\d)[78]\d{10}(?!\d) — 11-значные с префиксом 7/8.
- _SNILS_BARE_RE: (?<!\d)\d{11}(?!\d) — любые 11-значные без разделителей.
- _FULLNAME_RE: альтернатива в каждом слове — Titlecase | CAPS-2+.
- Конфликт phone-bare vs SNILS-bare разрешён порядком в _PII_PATTERNS: phone
  раньше SNILS (7/8-префикс семантически точнее).
- INN (ровно 10/12) с SNILS-bare не пересекается по длине.

8 новых юнит-тестов покрывают все три дыры + конфликт-резолюцию. 24/24 redaction
тестов + 55/55 LLM-suite зелёные.

Closes #1207
2026-06-13 09:56:58 +05:00

175 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§19 data-residency redaction layer (#960) — THE critical safety boundary.
Провайдер LLM ВНЕШНИЙ (OpenAI) → данные покидают РФ. Чувствительные данные не
должны попасть наружу НИКОГДА. Защита эшелонирована (defense-in-depth):
1. Allowlist / safe-payload (ПЕРВИЧНОЕ): публичный API заставляет вызывающего
явно собрать ``SafePayload`` из не-чувствительных полей/агрегатов/публичного
текста — а НЕ передавать сырые строки БД. См. ``SafePayload`` ниже и контракт
в ``app/services/llm/__init__.py``.
2. Hard-block (ENFORCED): ``SafePayload(is_confidential=True)`` → ``RedactionRefusedError``.
Контент, помеченный конфиденциальным (напр. insight.is_confidential), НИКОГДА
не уходит во внешнего провайдера — клиент поднимает типизированную ошибку, а не
«тихо» отправляет.
3. Regex-scrub (ВТОРИЧНОЕ, belt-and-suspenders): из свободного текста вырезаются
RU PII-паттерны (телефон / email / СНИЛС / ИНН / паспорт / ФИО-подобное) и
заменяются на ``[REDACTED:<kind>]``. Это НЕ основная гарантия — основная это (1).
``scrub_safe_payload`` — единственная точка, через которую ``client.complete``
прогоняет данные перед внешним вызовом (mandatory на external-пути; будущий
RU-hosted провайдер сможет это ослабить — см. шов ``LLMProvider.is_external``).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
class RedactionRefusedError(Exception):
"""Полезная нагрузка помечена конфиденциальной → отправка наружу запрещена.
Поднимается из ``scrub_safe_payload`` для external-провайдера, когда
``SafePayload.is_confidential`` is True. Клиент ловит это и превращает в
детерминированный fallback (система не падает из-за того что данные нельзя слать).
"""
@dataclass(frozen=True, slots=True)
class SafePayload:
"""Явно собранная вызывающим НЕ-чувствительная нагрузка для внешней LLM.
Контракт (allowlist-first): консьюмеры (#956 граддок-extraction, #957 chat)
ОБЯЗАНЫ конструировать это вручную из проверенных полей — НЕ передавать сырые
DB-строки/insight'ы. Структура — единственное, что физически доходит до клиента.
Attributes:
text: Свободный текст для модели (публичный градрегламент, агрегаты, вопрос
пользователя). Прогоняется через regex-scrub перед отправкой.
fields: Структурированные не-чувствительные пары ключ→значение (числа/коды/
короткие строки). Значения-строки тоже скрабятся.
is_confidential: Если True — вся нагрузка под жёстким блоком: ``scrub_safe_payload``
для external-провайдера поднимет ``RedactionRefusedError``. Прокидывать сюда
ЛЮБОЙ confidential-флаг источника (напр. insight.is_confidential).
"""
text: str = ""
fields: dict[str, Any] = field(default_factory=dict)
is_confidential: bool = False
# ── RU PII regex (вторичная защита) ──────────────────────────────────────────
# Порядок применения важен: более специфичные/длинные паттерны раньше, чтобы они не
# «съедались» более общими (СНИЛС/ИНН/паспорт раньше «голых» групп цифр).
# СНИЛС: NNN-NNN-NNN NN (последняя пара — через пробел/дефис).
_SNILS_RE = re.compile(r"\b\d{3}-\d{3}-\d{3}[\s-]\d{2}\b")
# Паспорт РФ: NNNN NNNNNN (серия 4 + номер 6, разделитель пробел/дефис).
_PASSPORT_RE = re.compile(r"\b\d{4}[\s-]\d{6}\b")
# Телефон РФ: +7/8, опц. скобки/пробелы/дефисы, 10 значащих цифр.
_PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\d{2}\b")
# Телефон РФ «голый»: 11 цифр без разделителей, начинаются с 7 или 8 (#1207).
# Покрывает copy-paste форму «79221234567» / «89221234567», которую _PHONE_RE
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
# него — phone-bare съест 7/8-префиксы, СНИЛС-bare добьёт остаток.
_SNILS_BARE_RE = re.compile(r"(?<!\d)\d{11}(?!\d)")
# ФИО-подобное: три слова с заглавной кириллической буквы подряд (Фамилия Имя Отчество).
# Каждое слово — либо «Иванов» (Titlecase), либо «ИВАНОВ» (CAPS, мин 2 буквы) — последнее
# покрывает стандарт ЕГРН/паспортных выписок «ИВАНОВ ИВАН ИВАНОВИЧ» (#1207).
# Грубая эвристика — может задеть «Министерство Строительства России» и подобное; это
# приемлемо для вторичной защиты (лучше лишний REDACTED, чем утечка ФИО).
_FULLNAME_RE = re.compile(
r"\b(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})"
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})"
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
)
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(_SNILS_RE, "snils"),
(_PASSPORT_RE, "passport"),
(_PHONE_RE, "phone"),
(_PHONE_BARE_RE, "phone"),
(_EMAIL_RE, "email"),
(_INN_RE, "inn"),
(_SNILS_BARE_RE, "snils"),
(_FULLNAME_RE, "name"),
)
def scrub_text(value: str) -> str:
"""Вырезать RU PII из свободного текста → ``[REDACTED:<kind>]``.
Вторичная защита (см. модульный docstring). НИКОГДА не логирует ни исходный, ни
результирующий текст — только факт срабатывания и kind (без значения).
"""
if not value:
return value
redacted = value
for pattern, kind in _PII_PATTERNS:
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
if n:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted
def _scrub_value(value: Any) -> Any:
"""Рекурсивно проскрабить строковые значения в структуре fields."""
if isinstance(value, str):
return scrub_text(value)
if isinstance(value, dict):
return {k: _scrub_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
scrubbed = [_scrub_value(v) for v in value]
return type(value)(scrubbed)
return value
def scrub_safe_payload(payload: SafePayload, *, is_external: bool) -> SafePayload:
"""Mandatory-шаг перед внешним вызовом: hard-block + regex-scrub.
Args:
payload: явно собранная вызывающим нагрузка.
is_external: True для провайдера, выгружающего данные за пределы РФ
(OpenAI). Для будущего RU-hosted провайдера можно передать False, чтобы
ослабить scrub — это и есть задокументированный шов (data остаётся в РФ).
Returns:
Новый ``SafePayload`` с проскрабленными ``text``/``fields``.
Raises:
RedactionRefusedError: если ``is_external`` и ``payload.is_confidential``.
"""
if is_external and payload.is_confidential:
# НЕ логируем содержимое — только факт отказа.
logger.warning("redaction: refusing confidential payload for external LLM provider")
raise RedactionRefusedError(
"payload marked is_confidential=True cannot be sent to an external LLM provider"
)
if not is_external:
# RU-hosted провайдер: данные не покидают РФ — scrub не обязателен.
return payload
return SafePayload(
text=scrub_text(payload.text),
fields=_scrub_value(payload.fields),
is_confidential=payload.is_confidential,
)
__all__ = ["RedactionRefusedError", "SafePayload", "scrub_safe_payload", "scrub_text"]