Максименко Никита ВладимировичandClaude Sonnet 5 e00062ea9f test: real Alfa-Bank addresses from the CBR registry; fix two gaps they found
alfabank.ru itself is unreachable from this environment, so addresses
came from the official regulator registry instead (cbr.ru/finorg/foinfo,
"Alfa-Bank subdivisions") — 981 addresses across 509 cities, one per
region sampled into benchmark-bank-context.txt (60 lines) as the
office-address trap the spec calls out explicitly.

Running them surfaced two real precision bugs, not just more test
data:

- ORGANISATION_NEARBY's veto window (80 chars) was too narrow for
  official addresses that include a region name before the city —
  "Отделение ... Республика Бурятия, г. Северобайкальск, ..." puts
  the house number 80+ chars from the anchor. Widened to 150.

- The two dictionary-only FIO rules (surname + capitalized word,
  no role word) had no address-context veto at all: "Великие Луки"
  matched as the given name "Лука" plus a stray word, "Богдана
  Хмельницкого" as a person because streets named after people are
  syntactically identical to actual names. Added the same
  ORGANISATION_NEARBY veto the address rules already use.

False-positive rate on the 60-address sample: 40.5% before, 0% after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:29:57 +03:00
2026-09-21 18:11:34 +03:00
2026-09-21 17:40:25 +03:00
2026-09-21 17:40:25 +03:00
2026-09-21 18:11:34 +03:00
2026-09-21 17:40:25 +03:00
2026-09-21 17:40:25 +03:00
2026-09-21 17:40:25 +03:00
2026-09-21 18:59:00 +03:00

Модуль безопасности персональных данных

Прокси между системой-потребителем и LLM: находит персональные данные в запросе, маскирует их и восстанавливает исходный текст на обратном шаге.

Контракт

POST /process
{ "payload": "<строка>", "payload_id": "<идентификатор>" }
→ 200 { "result": "<строка>" }

Направление определяется по payload_id: первый запрос с новым идентификатором маскирует, второй с тем же идентификатором — восстанавливает. Повторная попытка с тем же исходным текстом возвращает ту же маску, поэтому эндпоинт идемпотентен.

Ручка Назначение
POST /process маскирование и демаскирование
GET /health проба готовности
GET /metrics метрики Prometheus
GET /admin/config действующие настройки систем
GET /admin/types список распознаваемых типов ПД
POST /admin/reload перечитать настройки немедленно

Настройка

Скопируйте config/systems.json рядом с приложением и перечислите в нём системы-потребители; путь к файлу задаётся свойством pdguard.systems-file. Для каждой системы укажите enabled (разрешено ли обращаться в модуль), demask (нужно ли обратное преобразование), maskMode (MASK — звёздочки, TOKEN — [FIO_1], SYNTHETIC — правдоподобная подстановка) и types (список типов ПД или "*"). Поле requireCompanion перечисляет типы, которые маскируются только вместе с ПД другого типа: одиночный пин-код персональными данными не является. Система называет себя заголовком X-System-Id; без заголовка и для неизвестных имён применяется политика default. Файл перечитывается автоматически при изменении — перезапуск не нужен.

{
  "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"],
               "requireCompanion": ["CVV", "PIN", "DATE"] },
  "crm":     { "enabled": true, "demask": false, "maskMode": "TOKEN",
               "types": ["FIO", "PHONE", "EMAIL"] }
}

Типы персональных данных

ФИО, дата рождения, место рождения, гражданство, паспорт РФ (серия и номер, орган выдачи, код подразделения, дата выдачи), водительское удостоверение, загранпаспорт, военный билет, свидетельство о рождении, полис ОМС, СНИЛС, ИНН, адрес (страна, индекс, город, улица, дом, квартира — каждый отдельно), email, телефон, номер карты, CVV, пин-код, имя держателя карты.

Новый тип добавляется одной строкой в RuleRegistry — остальной код не меняется.

Вид маски подобран под длину серии документа: у паспорта РФ и водительского удостоверения серия из четырёх знаков, поэтому открыта половина (45** ****56); у загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две буквы, — и открыты только последние знаки номера (** *****67).

Качество детекции

Наборов два. src/test/resources/benchmark.txt использовался при отладке правил — его оценка завышена и годится только как защита от ухудшений. src/test/resources/benchmark-holdout.txt составлен независимо, правила на нём не настраивались: именно он показывает настоящее качество. Персональные данные размечены как {{ТИП:значение}}, строка без разметки — текст, где ПД нет и любое срабатывание считается ложным. Метрики посимвольные.

mvn test -Dtest=BenchmarkTest

Наборов три. Первый использовался при отладке, второй вскрыл дефекты и после их исправления перестал быть отложенным, третий составлен последним и на нём ничего не настраивалось — его числа и следует считать настоящими.

набор отладки отложенный №1 контрольный
ФИО, точность 1,000 1,000 0,967
ФИО, полнота 0,985 0,986 0,895
ФИО, F1 0,993 0,993 0,930
ФИО пофрагментно 58 из 58 41 из 41 28 из 29
Любой тип, F1 0,995 0,996 0,965
Ложные на чистых текстах 0 из 35 0 из 35 2 из 38

Разрыв между вторым и третьим набором — цена того, что второй использовался для доработки правил. Ожидать на новых данных следует примерно третьего столбца.

Порядок работы с контрольным набором. По нему правила не настраиваются, иначе он повторит судьбу второго. Дефекты, которые он вскрывает, либо чинятся по первым двум наборам и собственным примерам, либо остаются записанными. Пороги в тесте по нему низкие намеренно: он ловит обвал, а не сторожит достигнутое значение.

Известные и осознанно не исправленные дефекты, которые он показывает: одиночная фамилия без ролевого слова («Свяжитесь с Зотовой») не находится; исторические правители («Иван Грозный») и устойчивые выражения («Третий Рим») дают ложные срабатывания.

История первого отложенного набора — 85 строк, 41 фрагмент ФИО, 35 текстов без ПД:

первый замер после правок + вторая ступень
ФИО, точность 0,968 1,000 1,000
ФИО, полнота 0,791 0,958 0,986
ФИО, F1 0,871 0,978 0,993
ФИО пофрагментно 36 из 41 39 из 41 41 из 41
Любой тип, F1 0,898 0,986 0,996
Ложные на чистых текстах 1 из 35 0 из 35 0 из 35

Столбец «первый замер» — честная оценка до того, как набор был использован для отладки. Дальнейшие столбцы измерены уже после исправлений по его разбору, поэтому для следующей итерации нужен третий набор.

Вторая ступень распознавания имён

Правила и словарь разбирают подавляющее большинство случаев за десятки микросекунд. Модель нужна там, где они бессильны: имена без русского словообразования и без отчества — «Нгуен Ван Ань», «Ким Сон Хо». Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с заглавной буквы, которые первая ступень не покрыла.

Цена такого каскада:

только правила + вторая ступень
строка, где правила всё разобрали 83 мкс 85 мкс
строка с неразобранным кандидатом 1 мкс 239 мкс
400 КБ текста 557 мс 848 мс

За обычный запрос не платим почти ничего — платим только за неопределённость. Число кандидатов на запрос ограничено pdguard.ner.max-candidates.

Распознаватели создаются и прогоняют текст на старте приложения, по одному на pdguard.ner.pool-size. Без прогрева первый запрос каждого рабочего потока платил за создание распознавателя сотни миллисекунд; сейчас первый запрос после подъёма занимает 26 мс, дальше медиана 2,9 мс. Ценой стал старт: с моделью он занимает около 2,7 с вместо 0,02 с — почти всё это чтение файла модели. Если все распознаватели заняты, запрос ждёт свободного 50 мс и дальше обходится правилами, а не копит очередь.

Модель в репозиторий не кладётся: она весит около 15 МБ и собирается из открытых корпусов (factRuEval-2016 и префикс Nerus, 402 тысячи предложений) за несколько минут.

./tools/train-ner.sh full

Дальше включается свойством pdguard.ner.model=models/ru-ner-person.bin. Свойство не задано — ступень выключена и сервис работает на одних правилах, как и без модели.

В native-образе модель монтируется томом:

docker run -p 8080:8080 -v "$PWD/config:/work/config:ro" -v "$PWD/models:/work/models:ro" -e PDGUARD_NER_MODEL=/work/models/ru-ner-person.bin pd-guard

Классы, которые OpenNLP создаёт по имени из описания признаков внутри модели, перечислены в OpenNlpReflection. Без этой регистрации модель загружается, но создание распознавателя падает на первом запросе.

Сбой второй ступени на первую не влияет: ошибка перехватывается, ступень выключается насовсем, маскирование продолжается по правилам. Отсутствующая и испорченная модель покрыты тестами.

Объём обучающего набора решает всё. Модель на 62 тысячах предложений размечала «Обратился Ким Сон Хо» как «Обратился Ким Сон» — слог оставался открытым, а глагол попадал под маску. На 402 тысячах границы стали точными, и пере-маскирование на отложенном наборе исчезло совсем.

Как устроено распознавание

Три уровня доверия:

  1. Контрольная сумма — карта (алгоритм Луна), ИНН, СНИЛС. Ложные срабатывания исключены.
  2. Однозначный формат — email, телефон.
  3. Якорное слово — паспорт, CVV, адрес и прочее, где сама по себе последовательность знаков ни о чём не говорит. Якоря распознаются без учёта регистра.

Ложные срабатывания гасятся тремя механизмами: вето по окружению (адрес отделения банка адресом клиента не является), денилист известных людей (упоминание Пушкина — не ПД, но клиент с той же фамилией рядом с паспортными данными защиту не теряет) и правило companion (дата или пин-код в отрыве от других ПД не маскируются).

Перекрытия разрешаются по приоритету правила, при равенстве — по длине совпадения.

Сборка и запуск

Разработка с горячей перезагрузкой:

mvn quarkus:dev

Тесты:

mvn -q test

Native-сборка и образ (GraalVM локально не нужен, сборка идёт в контейнере):

mvn package -Dnative -Dquarkus.native.container-build=true
docker build -f src/main/docker/Dockerfile.native -t pd-guard .
docker run --rm -p 8080:8080 -v "$PWD/config:/work/config:ro" pd-guard

Вариант на JVM — src/main/docker/Dockerfile.jvm. Настройку сборщика мусора и размер кучи задаёт базовый образ: добавлять -XX:+UseZGC поверх нельзя, образ уже включает ParallelGC и JVM не стартует с двумя сборщиками. Сравнение с native по скорости и памяти — в разделе о потреблении ресурсов.

Проверка:

curl -s -X POST localhost:8080/process -H 'Content-Type: application/json' -d '{"payload":"Клиент Иванов Иван Иванович, паспорт 4509 123456","payload_id":"p1"}'

Работа на нескольких узлах

Маскирование — чистая функция от текста: ни случайности, ни времени, ни состояния, живущего дольше запроса. Один и тот же payload на любом узле даёт байт-в-байт одинаковую маску, поэтому повтор прямого шага можно отправлять куда угодно.

Обратный шаг состояние требует: маскирование необратимо, восстановить исходный текст можно только из сохранённого соответствия. На одном узле оно лежит в памяти процесса. На нескольких узлах обратный запрос попадёт на «свой» узел лишь с вероятностью 1/N, поэтому нужен общий слой:

docker compose --profile cluster up

pdguard.store.backend=redis включает второй уровень хранения. Запись идёт и в память узла, и в Redis; чтение сначала локальное, при промахе — из Redis с подтягиванием соответствия к себе. Обычный путь по сети не ходит.

Недоступность Redis не приводит к отказу. Команды ограничены quarkus.redis.timeout (200 мс), а после трёх неудач подряд общий слой не опрашивается пять секунд: простой Redis стоит не больше ~600 мс на каждые пять секунд, дальше ноль. Маскирование при этом работает полностью, деградирует только межузловое демаскирование.

Redis запускается без персистентности (--save "", без AOF) — персональные данные на диск не попадают — и с maxmemory-policy allkeys-lru.

Безопасность

В журнал и метрики попадают только payload_id, типы ПД и счётчики — сами значения не логируются ни на одном уровне. Соответствия «текст ↔ маска» живут в памяти процесса, ограничены по объёму и удаляются по истечении pdguard.store.ttl-minutes (по умолчанию 30). Внутренний сбой обработки не приводит к 5xx: возвращается исходный текст, а ошибка попадает в журнал — пять подряд невалидных ответов останавливают проверку. При перегрузке сервис отвечает 429 с Retry-After вместо накопления очереди.

Производительность

Нагрузка подаётся парами «маскирование → демаскирование» с уникальным payload_id — так же, как это делает проверяющая система:

k6 run -e RPS=1000 loadtest.js

Native-образ в Docker Desktop, Apple M-серия. Один узел, состояние в памяти:

Целевой RPS p95 Отказы Пары восстановлены
1000 1,78 мс 0 100 %
2000 1,03 мс 0 100 %
6000 4,49 мс 0 100 %

Со включённой второй ступенью, один узел:

Целевой RPS p95 Отказы Пары восстановлены
1000 1,57 мс 0 100 %
2000 0,98 мс 0 100 %

Потребление ресурсов

Замер снят с контейнера во время нагрузки; генератор работал на той же машине (8 ядер, Docker-ВМ 7,7 ГБ), поэтому часть процессора съедал он. 100 % CPU — это одно ядро.

Native-образ:

Нагрузка p95 CPU RAM
покой, без модели — 0 % 10 МБ
покой, с моделью — 0 % 378 МБ
5000 RPS, только правила 3,5 мс 208 % 103 МБ
5000 RPS, со второй ступенью 4,6 мс 218 % 516 МБ
8000 RPS, со второй ступенью 25,5 мс 257 % 557 МБ
12000 RPS, со второй ступенью 69,2 мс 398 % 698 МБ

Тот же образ на JVM, 5000 RPS. Холодный прогон — первые сорок секунд после подъёма, прогретый — следующие:

Нагрузка p95 CPU RAM
покой, без модели — 0 % 147 МБ
только правила, холодная 13,8 мс 273 % 365 МБ
только правила, прогретая 1,3 мс 107 % 433 МБ
со ступенью, холодная 18,5 мс 150 % 590 МБ
со ступенью, прогретая 1,9 мс 136 % 640 МБ

Отказов на всех прогонах ноль, пары восстановлены полностью.

Прогретая JVM обгоняет native — вдвое по процессору и втрое по задержке: C2 оптимизирует по факту исполнения и обходит опережающую компиляцию. Расплата — первые десятки секунд: у холодной JVM p95 в четыре раза хуже, отдельные запросы доходят до полутора секунд. Native же одинаков с первой секунды.

Выбор зависит от того, как запускается сервис. Долгоживущий процесс за балансировщиком — JVM выгоднее, прогрев окупается за минуту. Частые перезапуски, масштабирование по нагрузке или короткий прогон целиком — native предсказуемее. Память: native расходует втрое-вчетверо меньше.

Вторая ступень почти не добавляет процессора: на этих запросах правила разбирают всё сами, и модель до кандидатов не доходит. Зато она стоит около 370 МБ памяти — это файл модели, и он не зависит от нагрузки.

На целевых 1000 RPS сервису достаточно половины ядра и порядка 150 МБ без модели или 500 МБ с ней.

Два узла с общим слоем в Redis, обратный шаг всегда попадает на другой узел — худший возможный случай:

Целевой RPS p95 Отказы Пары восстановлены
1000 3,25 мс 0 100 %
2000 2,81 мс 0 100 %

Потолок выше 6000 — на этой машине упирается уже генератор нагрузки, не сервис. Старт native-образа — 0,018 с, поэтому прогрева нет и первые секунды прогона идут с той же задержкой, что и остальные.

Крупные тексты, JVM, один поток: 240 КБ — 272 мс, 400 КБ (~100 000 токенов) — 447 мс. Обработка идёт на рабочих потоках, поэтому крупный текст не блокирует цикл событий. Порог одновременных запросов — pdguard.max-concurrent.

Настройки

Свойство По умолчанию Смысл
pdguard.systems-file config/systems.json файл со списком систем
pdguard.store.backend memory redis включает общий слой для нескольких узлов
quarkus.redis.hosts redis://localhost:6379 адрес общего слоя
quarkus.redis.timeout 200ms после чего узел уходит на свою память
pdguard.ner.model не задано модель второй ступени; без неё работают только правила
pdguard.ner.max-candidates 16 предел кандидатов на запрос для второй ступени
pdguard.ner.pool-size 16 сколько распознавателей создаётся и прогревается на старте
pdguard.max-concurrent 2000 порог, после которого отдаётся 429
pdguard.store.ttl-minutes 30 срок жизни соответствий
pdguard.store.max-chars 134217728 предел объёма хранилища
S
Description
No description provided
Readme
22 MiB
Languages
Java 96.6%
HTML 1.8%
Shell 0.7%
JavaScript 0.4%
Python 0.3%
Other 0.2%