This commit is contained in:
dakocha3
2026-09-21 17:40:25 +03:00
commit 309188d191
52 changed files with 5124 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
# Модуль безопасности персональных данных
Прокси между системой-потребителем и 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`; прогрев там обязателен,
иначе первые секунды нагрузки идут по интерпретируемому коду.
Проверка:
```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 % |
Два узла с общим слоем в 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` | предел объёма хранилища |