fix(llm): close §19 redaction regex gaps for bare phone / CAPS name / bare SNILS (#1207)
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
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
This commit is contained in:
parent
c8000d1089
commit
f12c03588d
2 changed files with 79 additions and 1 deletions
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue