All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m13s
CI / changes (push) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Optional external-OpenAI layer over the deterministic forecasting engine. Gated by llm_enabled (default False) so prod makes no network calls until deliberately enabled. Allowlist-first SafePayload contract + is_confidential hard-block + RU-PII regex scrub (mandatory on the external path). Abstract LLMProvider seam (is_external) for a future RU-hosted provider. Sync httpx core (Celery-friendly); tool/function-calling pass-through; timeout + bounded 429/5xx retry + per-request call cap, all degrading to fallback. Raw httpx (no openai SDK -> no pyproject/lock drift). 47 tests, ruff + mypy clean. Refs #960
155 lines
9 KiB
Python
155 lines
9 KiB
Python
"""§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")
|
||
# 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)")
|
||
# ФИО-подобное: три слова с заглавной кириллической буквы подряд (Фамилия Имя Отчество).
|
||
# Грубая эвристика — может задеть «Министерство Строительства России» и подобное; это
|
||
# приемлемо для вторичной защиты (лучше лишний REDACTED, чем утечка ФИО).
|
||
_FULLNAME_RE = re.compile(r"\b[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\b")
|
||
|
||
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
|
||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||
(_SNILS_RE, "snils"),
|
||
(_PASSPORT_RE, "passport"),
|
||
(_PHONE_RE, "phone"),
|
||
(_EMAIL_RE, "email"),
|
||
(_INN_RE, "inn"),
|
||
(_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"]
|