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
212 lines
8.8 KiB
Python
212 lines
8.8 KiB
Python
"""§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
|