gendesign/backend/tests/services/llm/test_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

212 lines
8.8 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 redaction tests (#960) — самый важный слой безопасности.
Покрываем: каждый тип RU PII, hard-block is_confidential, allowlist-поведение
(SafePayload как единственный вход), и шов RU-hosted провайдера (is_external=False).
"""
from __future__ import annotations
import pytest
from app.services.llm.redaction import (
RedactionRefusedError,
SafePayload,
scrub_safe_payload,
scrub_text,
)
# ── Per-PII-type scrub ────────────────────────────────────────────────────────
def test_scrub_phone_ru() -> None:
out = scrub_text("Звоните +7 (912) 345-67-89 срочно")
assert "[REDACTED:phone]" in out
assert "912" not in out
def test_scrub_phone_8_prefix() -> None:
out = scrub_text("тел 8 912 345 67 89")
assert "[REDACTED:phone]" in out
assert "345" not in out
def test_scrub_email() -> None:
out = scrub_text("пишите ivan.petrov@example.ru пожалуйста")
assert "[REDACTED:email]" in out
assert "example.ru" not in out
def test_scrub_snils() -> None:
out = scrub_text("СНИЛС 112-233-445 95 в деле")
assert "[REDACTED:snils]" in out
assert "112-233-445" not in out
def test_scrub_inn_12() -> None:
out = scrub_text("ИНН физлица 500100732259 проверен")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_scrub_inn_10() -> None:
out = scrub_text("ИНН юрлица 7707083893 в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_scrub_passport() -> None:
out = scrub_text("паспорт 6512 123456 выдан")
assert "[REDACTED:passport]" in out
assert "123456" not in out
def test_scrub_fullname() -> None:
out = scrub_text("ответственный Иванов Иван Иванович подписал")
assert "[REDACTED:name]" in out
assert "Иванович" not in out
# ── #1207: regex-дыры в первичном scrub'е (P2-security) ─────────────────────────
def test_scrub_phone_bare_11_digits_starting_with_7() -> None:
"""Copy-paste форма «79221234567» (11 цифр, без + и без 8) — самая частая дыра.
_PHONE_RE требует «+7»/«\\b8» + разделитель → этот вариант пропускался и уходил
в OpenAI как есть. _PHONE_BARE_RE закрывает дыру.
"""
out = scrub_text("позвоните 79221234567 договоримся")
assert "[REDACTED:phone]" in out
assert "79221234567" not in out
def test_scrub_phone_bare_11_digits_starting_with_8() -> None:
"""Аналогично — «89221234567» без разделителей."""
out = scrub_text("моб 89221234567 в любое время")
assert "[REDACTED:phone]" in out
assert "89221234567" not in out
def test_scrub_fullname_caps_format() -> None:
"""Стандарт ЕГРН/паспортных выписок «ИВАНОВ ИВАН ИВАНОВИЧ» — старый regex требовал
строчные после первой буквы → каполная форма уходила как есть."""
out = scrub_text("собственник ИВАНОВ ИВАН ИВАНОВИЧ зарегистрирован")
assert "[REDACTED:name]" in out
assert "ИВАНОВИЧ" not in out
def test_scrub_fullname_mixed_caps_and_titlecase() -> None:
"""Смешанная форма «ИВАНОВ Иван Иванович» — тоже должна срабатывать."""
out = scrub_text("по доверенности ИВАНОВ Иван Иванович подписал")
assert "[REDACTED:name]" in out
assert "ИВАНОВ" not in out
def test_scrub_snils_bare_11_digits() -> None:
"""СНИЛС без разделителей «12345678901» — не покрывался ни phone (нет 7/8 префикса),
ни ИНН (длина не 10/12)."""
out = scrub_text("СНИЛС 12345678901 в личном деле")
assert "[REDACTED:snils]" in out
assert "12345678901" not in out
def test_phone_bare_wins_over_snils_bare_for_7_prefix() -> None:
"""Конфликт _PHONE_BARE_RE vs _SNILS_BARE_RE: 11 цифр, начинаются с 7 → phone."""
out = scrub_text("79221234567")
assert "[REDACTED:phone]" in out
assert "[REDACTED:snils]" not in out
def test_inn_10_digits_not_caught_as_snils_bare() -> None:
"""10-значный ИНН не должен попадать в _SNILS_BARE_RE (тот требует ровно 11)."""
out = scrub_text("ИНН 7707083893 действующий")
assert "[REDACTED:inn]" in out
assert "[REDACTED:snils]" not in out
def test_scrub_multiple_pii_in_one_text() -> None:
out = scrub_text("Иванов Иван Иванович, +7 912 345 67 89, mail@x.ru")
assert "[REDACTED:name]" in out
assert "[REDACTED:phone]" in out
assert "[REDACTED:email]" in out
def test_scrub_preserves_safe_text() -> None:
"""Безопасный текст (без PII) не трогается."""
safe = "Зона Ц-1, максимальная этажность 16, процент застройки 60."
assert scrub_text(safe) == safe
def test_scrub_empty_string_noop() -> None:
assert scrub_text("") == ""
# ── Hard-block: is_confidential ───────────────────────────────────────────────
def test_confidential_payload_refused_for_external() -> None:
"""is_confidential=True + external провайдер → RedactionRefusedError (НЕ отправляем)."""
payload = SafePayload(text="секретная аналитика", is_confidential=True)
with pytest.raises(RedactionRefusedError):
scrub_safe_payload(payload, is_external=True)
def test_non_confidential_payload_passes_external() -> None:
"""is_confidential=False → проходит (и скрабится)."""
payload = SafePayload(text="публичный текст про зону Ц-1", is_confidential=False)
out = scrub_safe_payload(payload, is_external=True)
assert out.text == "публичный текст про зону Ц-1"
assert out.is_confidential is False
# ── Scrub применяется к text И fields на external-пути ─────────────────────────
def test_scrub_safe_payload_scrubs_text_and_fields() -> None:
payload = SafePayload(
text="контакт +7 912 345 67 89",
fields={"note": "почта a@b.ru", "floors": 16, "code": "Ц-1"},
)
out = scrub_safe_payload(payload, is_external=True)
assert "[REDACTED:phone]" in out.text
assert "[REDACTED:email]" in out.fields["note"]
# Не-строковые и безопасные значения сохранены.
assert out.fields["floors"] == 16
assert out.fields["code"] == "Ц-1"
def test_scrub_nested_fields() -> None:
"""Рекурсивный scrub вложенных list/dict в fields."""
payload = SafePayload(fields={"items": [{"phone": "+7 912 345 67 89"}, "a@b.ru"]})
out = scrub_safe_payload(payload, is_external=True)
assert "[REDACTED:phone]" in out.fields["items"][0]["phone"]
assert "[REDACTED:email]" in out.fields["items"][1]
# ── RU-hosted seam: is_external=False ─────────────────────────────────────────
def test_ru_hosted_provider_skips_scrub_and_allows_confidential() -> None:
"""is_external=False (будущий RU-hosted): scrub не обязателен, confidential проходит.
Данные не покидают РФ → §19 не нарушается. Это задокументированный шов.
"""
payload = SafePayload(
text="контакт +7 912 345 67 89",
is_confidential=True,
)
out = scrub_safe_payload(payload, is_external=False)
# Не бросает, текст НЕ скраблен (RU-провайдер может получить как есть).
assert out is payload
assert "+7 912 345 67 89" in out.text
# ── Allowlist contract: SafePayload — единственный вход ────────────────────────
def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
"""По умолчанию SafePayload пуст и не-конфиденциален — безопасный нейтральный старт."""
p = SafePayload()
assert p.text == ""
assert p.fields == {}
assert p.is_confidential is False