gendesign/backend/tests/services/llm/test_redaction.py
bot-backend 4309a12c67
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
fix(llm): word-boundary INN match, tighten edge cases (#1640 follow-up)
Replace (?<!\d)/(?!\d) lookarounds on the digit block in _INN_RE with
(?<!\w)/(?!\w). The old (?<!\d) did not block alpha-prefixed tokens
(e.g. «ИНН ref7707083893»), and (?!\d) did not block alpha-suffixed
tokens (e.g. «ИНН 7707083893more»). \b is unsuitable here because
Python \w covers both letters and digits, so there is no \b boundary
between an alpha char and a digit char. The (?<!\w)/(?!\w) pair
correctly anchors the 10/12-digit INN block to non-word boundaries on
both sides. Context anchor and checksum gate from #1640 are unchanged.

Adds 6 regression tests covering: alpha-prefix, alpha-suffix,
embedded mid-token, 12-digit alpha-prefix, punctuation separator
(should match), and end-of-string (should match).
2026-06-17 21:24:59 +03:00

334 lines
15 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
# ── #1640 follow-up: word-boundary guards ((?<!\w)/(?!\w)) ───────────────────
def test_inn_digits_alpha_prefix_not_redacted() -> None:
"""Цифры ИНН как суффикс буквенно-цифрового токена НЕ редактируются.
Регрессия: (?<!\\d) не защищал от «ИНН ref7707083893» — 'f' не цифра, старый
lookaround пропускал. Новый (?<!\\w) блокирует любой word-char перед цифровым блоком.
"""
out = scrub_text("ИНН ref7707083893")
assert "[REDACTED:inn]" not in out
assert "7707083893" in out
def test_inn_digits_alpha_suffix_not_redacted() -> None:
"""Цифры ИНН как префикс буквенно-цифрового токена НЕ редактируются.
Регрессия: старый (?!\\d) не блокировал буквенный суффикс — «7707083893more»
совпадало, т.к. 'm' не цифра. Новый (?!\\w) закрывает alpha-суффикс.
"""
out = scrub_text("ИНН 7707083893more")
assert "[REDACTED:inn]" not in out
assert "7707083893" in out
def test_inn_digits_embedded_in_alphanumeric_token_not_redacted() -> None:
"""ИНН-подобный блок цифр внутри алфавитно-цифрового токена НЕ редактируется.
«ИНН ref7707083893xyz» — цифры с обеих сторон окружены word-char'ами.
"""
out = scrub_text("ИНН ref7707083893xyz")
assert "[REDACTED:inn]" not in out
def test_inn_alpha_prefix_on_12digit_block_not_redacted() -> None:
"""12-значный валидный ИНН с буквенным префиксом (без пробела) НЕ редактируется.
«ИНН ABC500100732259» — 'C' является word-char, (?<!\\w) блокирует совпадение.
"""
out = scrub_text("ИНН ABC500100732259")
assert "[REDACTED:inn]" not in out
assert "500100732259" in out
def test_inn_standalone_with_punctuation_is_redacted() -> None:
"""Валидный ИНН, отделённый знаком препинания (не word-char), редактируется.
Двоеточие, запятая, скобка — не \\w → (?<!\\w) пропускает. Это корректное поведение.
"""
out = scrub_text("ИНН:7707083893 в реестре")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_inn_standalone_at_end_of_string_is_redacted() -> None:
"""Валидный ИНН в конце строки без символов после редактируется.
Конец строки — не \\w → (?!\\w) пропускает. Базовый smoke.
"""
out = scrub_text("ИНН 7707083893")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out