# Модуль безопасности персональных данных Прокси между системой-потребителем и 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`. Файл перечитывается автоматически при изменении — перезапуск не нужен. ```json { "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` составлен независимо, правила на нём не настраивались: именно он показывает настоящее качество. Персональные данные размечены как `{{ТИП:значение}}`, строка без разметки — текст, где ПД нет и любое срабатывание считается ложным. Метрики посимвольные. ```bash 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 тысячи предложений) за несколько минут. ```bash ./tools/train-ner.sh full ``` Дальше включается свойством `pdguard.ner.model=models/ru-ner-person.bin`. Свойство не задано — ступень выключена и сервис работает на одних правилах, как и без модели. В native-образе модель монтируется томом: ```bash 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 (дата или пин-код в отрыве от других ПД не маскируются). Перекрытия разрешаются по приоритету правила, при равенстве — по длине совпадения. ## Сборка и запуск Разработка с горячей перезагрузкой: ```bash mvn quarkus:dev ``` Тесты: ```bash mvn -q test ``` Native-сборка и образ (GraalVM локально не нужен, сборка идёт в контейнере): ```bash mvn package -Dnative -Dquarkus.native.container-build=true ``` ```bash docker build -f src/main/docker/Dockerfile.native -t pd-guard . ``` ```bash 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 по скорости и памяти — в разделе о потреблении ресурсов. Проверка: ```bash curl -s -X POST localhost:8080/process -H 'Content-Type: application/json' -d '{"payload":"Клиент Иванов Иван Иванович, паспорт 4509 123456","payload_id":"p1"}' ``` ## Работа на нескольких узлах Маскирование — чистая функция от текста: ни случайности, ни времени, ни состояния, живущего дольше запроса. Один и тот же payload на любом узле даёт байт-в-байт одинаковую маску, поэтому повтор прямого шага можно отправлять куда угодно. Обратный шаг состояние требует: маскирование необратимо, восстановить исходный текст можно только из сохранённого соответствия. На одном узле оно лежит в памяти процесса. На нескольких узлах обратный запрос попадёт на «свой» узел лишь с вероятностью 1/N, поэтому нужен общий слой: ```bash 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` — так же, как это делает проверяющая система: ```bash 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` | предел объёма хранилища |