Some checks failed
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m42s
CI / backend-tests (pull_request) Failing after 8m59s
_INN_RE now requires an explicit ИНН/inn keyword anchor (case-insensitive) within ~20 chars before a 10/12-digit block. Bare digit sequences without the keyword are no longer candidates — eliminates false-positives on large monetary amounts such as 1 200 000 000 (which coincidentally passes the ФНС checksum). Checksum validation is kept as a second gate to avoid redacting e.g. «ИНН 1234567890» with bad digits. _inn_repl updated to use match.group(1) (digit-only capture group) instead of the full match that now also includes the keyword prefix. 7 new regression tests in test_redaction.py: bare large numbers not redacted, keyword-cued real INNs (10/12 digit) still redacted, bad-checksum + keyword left intact, latin «inn:» accepted.
239 lines
16 KiB
Python
239 lines
16 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 collections.abc import Callable
|
||
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)")
|
||
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
|
||
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
|
||
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
|
||
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
|
||
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
|
||
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
|
||
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
|
||
# Email.
|
||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
||
# ИНН: требуется контекстный якорь — маркер «ИНН» / «inn» (регистронезависимо) не
|
||
# далее ~20 символов перед 10/12-значным блоком цифр (#1640). Голые цифры без якоря
|
||
# НЕ редактируются — это исключает легитимные суммы (1 200 000 000 = 10 цифр) и
|
||
# прочие числа, которые случайно совпадают с длиной ИНН.
|
||
# «Прокладка» (?:[^\d\n]{0,20}) позволяет «ИНН физлица 500100732259» (слово между
|
||
# маркером и числом), но не перешагивает через строки или другие цифровые блоки.
|
||
# Группа 1 захватывает только цифры — передаётся в _inn_checksum_valid.
|
||
_INN_RE = re.compile(r"(?i:инн|inn)\s*[:№]?\s*(?:[^\d\n]{0,20})(\d{12}|\d{10})(?!\d)")
|
||
|
||
|
||
def _inn_checksum_valid(token: str) -> bool:
|
||
"""Проверить контрольные цифры ИНН по алгоритму ФНС.
|
||
|
||
10-значный (юрлицо): одна контрольная цифра (последняя).
|
||
12-значный (физлицо/ИП): две контрольные цифры (11-я и 12-я).
|
||
Веса — стандартные коэффициенты ФНС.
|
||
|
||
Вызывается из _inn_repl с group(1) — только цифровой блок без keyword-префикса.
|
||
Дополнительный gate поверх контекстного якоря (_INN_RE): отфильтровывает
|
||
синтетические 10/12-значные числа, у которых первые цифры случайно дают верную
|
||
контрольную сумму.
|
||
"""
|
||
digits = [int(c) for c in token]
|
||
if len(digits) == 10:
|
||
weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||
control = sum(w * d for w, d in zip(weights, digits[:9], strict=True)) % 11 % 10
|
||
return control == digits[9]
|
||
if len(digits) == 12:
|
||
w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||
w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
|
||
c1 = sum(w * d for w, d in zip(w1, digits[:10], strict=True)) % 11 % 10
|
||
c2 = sum(w * d for w, d in zip(w2, digits[:11], strict=True)) % 11 % 10
|
||
return c1 == digits[10] and c2 == digits[11]
|
||
return False
|
||
|
||
|
||
def _inn_repl(match: re.Match[str]) -> str:
|
||
"""re.sub callback: редактировать ИНН только при валидной контрольной сумме.
|
||
|
||
_INN_RE захватывает цифры в group(1) — keyword-префикс в group(0). Для замены
|
||
нужна только часть с цифрами (group 1). Если checksum не проходит — возвращаем
|
||
исходную строку целиком (keyword + цифры), не удаляем ничего.
|
||
"""
|
||
digits = match.group(1)
|
||
return "[REDACTED:inn]" if _inn_checksum_valid(digits) else match.group(0)
|
||
|
||
|
||
# СНИЛС «голый»: ровно 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, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
|
||
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
|
||
# Применяются последовательно в этом порядке.
|
||
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
||
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
||
_Repl = str | Callable[[re.Match[str]], str]
|
||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
|
||
(_SNILS_RE, "snils", "[REDACTED:snils]"),
|
||
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
|
||
(_PHONE_RE, "phone", "[REDACTED:phone]"),
|
||
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
|
||
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
|
||
(_EMAIL_RE, "email", "[REDACTED:email]"),
|
||
(_INN_RE, "inn", _inn_repl),
|
||
(_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
|
||
(_FULLNAME_RE, "name", "[REDACTED:name]"),
|
||
)
|
||
|
||
|
||
def scrub_text(value: str) -> str:
|
||
"""Вырезать RU PII из свободного текста → ``[REDACTED:<kind>]``.
|
||
|
||
Вторичная защита (см. модульный docstring). НИКОГДА не логирует ни исходный, ни
|
||
результирующий текст — только факт срабатывания и kind (без значения).
|
||
"""
|
||
if not value:
|
||
return value
|
||
redacted = value
|
||
placeholder = "[REDACTED:%s]"
|
||
for pattern, kind, repl in _PII_PATTERNS:
|
||
# n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
|
||
# которые callback вернул без изменений (не прошли checksum). Поэтому реальное
|
||
# число замен берём по приросту числа плейсхолдеров — корректно и для str, и
|
||
# для callback, без утечки самого PII-значения в лог.
|
||
before_count = redacted.count(placeholder % kind)
|
||
redacted = pattern.sub(repl, redacted)
|
||
n = redacted.count(placeholder % kind) - before_count
|
||
if n > 0:
|
||
# Логируем ТОЛЬКО 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"]
|