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
`_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
175 lines
11 KiB
Python
175 lines
11 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")
|
||
# Телефон РФ «голый»: 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"]
|