fix(llm): close §19 redaction regex gaps for bare phone / CAPS name / bare SNILS (#1207) #1243

Merged
bot-backend merged 1 commit from fix/llm-redaction-pii-gaps into main 2026-06-13 05:04:47 +00:00
2 changed files with 79 additions and 1 deletions

View file

@ -72,22 +72,42 @@ _SNILS_RE = re.compile(r"\b\d{3}-\d{3}-\d{3}[\s-]\d{2}\b")
_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)")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
# СНИЛС «голый»: ровно 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[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\b")
_FULLNAME_RE = re.compile(
r"\b(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})"
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})"
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
)
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(_SNILS_RE, "snils"),
(_PASSPORT_RE, "passport"),
(_PHONE_RE, "phone"),
(_PHONE_BARE_RE, "phone"),
(_EMAIL_RE, "email"),
(_INN_RE, "inn"),
(_SNILS_BARE_RE, "snils"),
(_FULLNAME_RE, "name"),
)

View file

@ -66,6 +66,64 @@ def test_scrub_fullname() -> None:
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