"""§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:]``. Это НЕ основная гарантия — основная это (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"(? str: """Вырезать RU PII из свободного текста → ``[REDACTED:]``. Вторичная защита (см. модульный 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"]