315 lines
21 KiB
Markdown
315 lines
21 KiB
Markdown
# Модуль безопасности персональных данных
|
||
|
||
Прокси между системой-потребителем и 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` | предел объёма хранилища |
|