Adds current public figures (Nabiullina, Gref, Putin, ...) so news-
style mentions near CB/banking topics aren't treated as client PII.
More importantly, isWellKnown() only worked for consonant-ending
surnames: "Пушкина" starts with "Пушкин" so it matched, but "Толстого"
doesn't start with "Толстой" (adjectival declension replaces the
ending, doesn't extend it), and neither does any oblique case of a
feminine -a surname ("Набиуллиной" vs "Набиуллина"). Strips the
trailing vowel at load time, same trick already used for given names.
Also lets the list grow without a rebuild: an optional external file
(pdguard.well-known-file, default config/well-known.txt) is merged on
top of the bundled list and re-read on change, mirroring how
SystemsConfig watches systems.json.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Модуль безопасности персональных данных
Прокси между системой-потребителем и 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 тысячах границы стали точными, и пере-маскирование на отложенном наборе исчезло совсем.
Как устроено распознавание
Три уровня доверия:
- Контрольная сумма — карта (алгоритм Луна), ИНН, СНИЛС. Ложные срабатывания исключены.
- Однозначный формат — email, телефон.
- Якорное слово — паспорт, 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 |
предел объёма хранилища |