gendesign/backend/tests/services/llm/test_redaction.py
bot-backend 5c5be890ab
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
fix(llm): INN redaction requires context/checksum, stop eating large numbers (#1640)
_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.
2026-06-17 20:41:39 +03:00

270 lines
12 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
# ── #1640: INN context-guard — large bare numbers must NOT be redacted ────────
def test_large_sum_10_digits_not_redacted_without_inn_keyword() -> None:
"""Слитная 10-значная сумма (1 200 000 000 руб) без якоря «ИНН» — НЕ редактируется.
Регрессия #1640: старый _INN_RE ловил любые 10/12 цифр → «1200000000» уходило в
[REDACTED:inn]. Новый паттерн требует маркер ИНН/inn перед числом.
"""
text = "стоимость участка составила 1200000000 рублей по договору"
out = scrub_text(text)
assert out == text, f"bare large sum was incorrectly redacted: {out!r}"
def test_large_sum_12_digits_not_redacted_without_inn_keyword() -> None:
"""12-значная слитная сумма без якоря — НЕ редактируется."""
text = "капитализация компании 123456789012 рублей"
out = scrub_text(text)
assert out == text
def test_round_billion_not_redacted() -> None:
"""Круглые суммы без контекста — НЕ редактируются."""
for amount in ("5000000000", "1000000000", "9876543210"):
out = scrub_text(f"сумма {amount} руб")
assert "[REDACTED:inn]" not in out, f"amount {amount!r} was falsely redacted"
def test_inn_with_keyword_is_redacted() -> None:
"""Валидный ИНН с явным маркером «ИНН» — редактируется."""
out = scrub_text("ИНН 7707083893 зарегистрирован в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_12_with_keyword_is_redacted() -> None:
"""Валидный 12-значный ИНН физлица с маркером — редактируется."""
out = scrub_text("ИНН физлица 500100732259 подтверждён")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_inn_keyword_latin_case_insensitive() -> None:
"""Маркер 'inn' (латиница, любой регистр) тоже активирует редакцию."""
out = scrub_text("inn: 7707083893")
assert "[REDACTED:inn]" in out
def test_inn_bad_checksum_with_keyword_not_redacted() -> None:
"""Число с маркером «ИНН», но не прошедшее checksum, НЕ редактируется.
Предотвращает ложные срабатывания на «ИНН 1234567890» (неверная контрольная цифра).
"""
out = scrub_text("ИНН 1234567890 (некорректный)")
assert "[REDACTED:inn]" not in out
assert "1234567890" in out