Init
This commit is contained in:
@@ -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` | предел объёма хранилища |
|
||||
Reference in New Issue
Block a user