From 309188d191bf58ccc957d8c13fff6a3350cf7985 Mon Sep 17 00:00:00 2001 From: dakocha3 Date: Mon, 21 Sep 2026 17:40:25 +0300 Subject: [PATCH] Init --- .dockerignore | 7 + .gitignore | 4 + README.md | 314 +++++++++++++ compose.yaml | 61 +++ config/systems.json | 27 ++ loadtest.js | 58 +++ pom.xml | 109 +++++ src/main/docker/Dockerfile.jvm | 17 + src/main/docker/Dockerfile.native | 15 + .../java/ru/pdguard/api/AdminResource.java | 48 ++ .../java/ru/pdguard/api/HealthResource.java | 17 + .../java/ru/pdguard/api/ProcessResource.java | 107 +++++ .../java/ru/pdguard/config/SystemPolicy.java | 44 ++ .../java/ru/pdguard/config/SystemsConfig.java | 137 ++++++ .../java/ru/pdguard/core/PayloadStore.java | 174 ++++++++ src/main/java/ru/pdguard/core/Pipeline.java | 233 ++++++++++ .../java/ru/pdguard/core/SharedIndex.java | 167 +++++++ src/main/java/ru/pdguard/core/Span.java | 26 ++ .../java/ru/pdguard/detect/NameCascade.java | 205 +++++++++ .../ru/pdguard/detect/NameDictionary.java | 124 ++++++ .../ru/pdguard/detect/OpenNlpReflection.java | 45 ++ src/main/java/ru/pdguard/detect/Rule.java | 90 ++++ .../java/ru/pdguard/detect/RuleRegistry.java | 421 ++++++++++++++++++ .../java/ru/pdguard/detect/Validators.java | 125 ++++++ .../java/ru/pdguard/mask/MaskContext.java | 33 ++ src/main/java/ru/pdguard/mask/MaskMode.java | 14 + src/main/java/ru/pdguard/mask/Masker.java | 70 +++ src/main/java/ru/pdguard/mask/Strategies.java | 115 +++++ src/main/java/ru/pdguard/mask/Synthetic.java | 95 ++++ src/main/resources/application.properties | 50 +++ src/main/resources/names/given-names.txt | 167 +++++++ src/main/resources/names/well-known.txt | 45 ++ src/main/resources/systems.json | 27 ++ src/test/java/ru/pdguard/BenchmarkTest.java | 350 +++++++++++++++ .../java/ru/pdguard/ContextDetectionTest.java | 137 ++++++ .../java/ru/pdguard/DateAndAddressTest.java | 143 ++++++ src/test/java/ru/pdguard/FioTest.java | 131 ++++++ .../java/ru/pdguard/IdentityDocumentTest.java | 70 +++ src/test/java/ru/pdguard/MaskModeTest.java | 85 ++++ src/test/java/ru/pdguard/NameCascadeTest.java | 56 +++ .../java/ru/pdguard/PayloadStoreTest.java | 53 +++ src/test/java/ru/pdguard/PipelineTest.java | 138 ++++++ .../java/ru/pdguard/ProcessResourceTest.java | 118 +++++ .../java/ru/pdguard/SystemsConfigTest.java | 90 ++++ .../java/ru/pdguard/tools/NerTrainer.java | 66 +++ src/test/resources/benchmark-holdout.txt | 106 +++++ src/test/resources/benchmark-holdout2.txt | 106 +++++ src/test/resources/benchmark.txt | 132 ++++++ src/test/resources/systems-test.json | 6 + tools/conllu-to-opennlp.py | 38 ++ tools/factrueval-to-opennlp.py | 69 +++ tools/train-ner.sh | 39 ++ 52 files changed, 5124 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 config/systems.json create mode 100644 loadtest.js create mode 100644 pom.xml create mode 100644 src/main/docker/Dockerfile.jvm create mode 100644 src/main/docker/Dockerfile.native create mode 100644 src/main/java/ru/pdguard/api/AdminResource.java create mode 100644 src/main/java/ru/pdguard/api/HealthResource.java create mode 100644 src/main/java/ru/pdguard/api/ProcessResource.java create mode 100644 src/main/java/ru/pdguard/config/SystemPolicy.java create mode 100644 src/main/java/ru/pdguard/config/SystemsConfig.java create mode 100644 src/main/java/ru/pdguard/core/PayloadStore.java create mode 100644 src/main/java/ru/pdguard/core/Pipeline.java create mode 100644 src/main/java/ru/pdguard/core/SharedIndex.java create mode 100644 src/main/java/ru/pdguard/core/Span.java create mode 100644 src/main/java/ru/pdguard/detect/NameCascade.java create mode 100644 src/main/java/ru/pdguard/detect/NameDictionary.java create mode 100644 src/main/java/ru/pdguard/detect/OpenNlpReflection.java create mode 100644 src/main/java/ru/pdguard/detect/Rule.java create mode 100644 src/main/java/ru/pdguard/detect/RuleRegistry.java create mode 100644 src/main/java/ru/pdguard/detect/Validators.java create mode 100644 src/main/java/ru/pdguard/mask/MaskContext.java create mode 100644 src/main/java/ru/pdguard/mask/MaskMode.java create mode 100644 src/main/java/ru/pdguard/mask/Masker.java create mode 100644 src/main/java/ru/pdguard/mask/Strategies.java create mode 100644 src/main/java/ru/pdguard/mask/Synthetic.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/names/given-names.txt create mode 100644 src/main/resources/names/well-known.txt create mode 100644 src/main/resources/systems.json create mode 100644 src/test/java/ru/pdguard/BenchmarkTest.java create mode 100644 src/test/java/ru/pdguard/ContextDetectionTest.java create mode 100644 src/test/java/ru/pdguard/DateAndAddressTest.java create mode 100644 src/test/java/ru/pdguard/FioTest.java create mode 100644 src/test/java/ru/pdguard/IdentityDocumentTest.java create mode 100644 src/test/java/ru/pdguard/MaskModeTest.java create mode 100644 src/test/java/ru/pdguard/NameCascadeTest.java create mode 100644 src/test/java/ru/pdguard/PayloadStoreTest.java create mode 100644 src/test/java/ru/pdguard/PipelineTest.java create mode 100644 src/test/java/ru/pdguard/ProcessResourceTest.java create mode 100644 src/test/java/ru/pdguard/SystemsConfigTest.java create mode 100644 src/test/java/ru/pdguard/tools/NerTrainer.java create mode 100644 src/test/resources/benchmark-holdout.txt create mode 100644 src/test/resources/benchmark-holdout2.txt create mode 100644 src/test/resources/benchmark.txt create mode 100644 src/test/resources/systems-test.json create mode 100644 tools/conllu-to-opennlp.py create mode 100644 tools/factrueval-to-opennlp.py create mode 100755 tools/train-ner.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..333f7fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/* +!target/*-runner +!target/quarkus-app +.git +.idea +*.iml +models/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40c575c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +.idea/ +*.iml +models/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e5bf20 --- /dev/null +++ b/README.md @@ -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` | предел объёма хранилища | diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..76732db --- /dev/null +++ b/compose.yaml @@ -0,0 +1,61 @@ +# Один узел: памяти процесса достаточно, общий слой не нужен. +# docker compose up pd-guard +# +# Несколько узлов: маскирование детерминировано и работает на любом узле, а вот +# обратный шаг требует общего состояния — иначе запрос попадёт не на тот узел. +# docker compose --profile cluster up +services: + + pd-guard: + build: + context: . + dockerfile: src/main/docker/Dockerfile.native + ports: + - "8080:8080" + environment: + PDGUARD_MAX_CONCURRENT: "2000" + PDGUARD_STORE_TTL_MINUTES: "30" + # Переменная не задана — вторая ступень выключена. Чтобы включить: + # PDGUARD_NER_MODEL: /work/models/ru-ner-person.bin + volumes: + - ./config:/work/config:ro + # Модель второй ступени монтируется томом: в образ она не входит. + # Собрать: ./tools/train-ner.sh full + - ./models:/work/models:ro + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 10s + timeout: 2s + retries: 3 + + redis: + profiles: ["cluster"] + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 2s + retries: 5 + + node-a: &node + profiles: ["cluster"] + build: + context: . + dockerfile: src/main/docker/Dockerfile.native + ports: + - "8081:8080" + environment: + PDGUARD_STORE_BACKEND: redis + QUARKUS_REDIS_HOSTS: redis://redis:6379 + PDGUARD_MAX_CONCURRENT: "2000" + volumes: + - ./config:/work/config:ro + depends_on: + redis: + condition: service_healthy + + node-b: + <<: *node + ports: + - "8082:8080" diff --git a/config/systems.json b/config/systems.json new file mode 100644 index 0000000..ed10b52 --- /dev/null +++ b/config/systems.json @@ -0,0 +1,27 @@ +{ + "default": { + "enabled": true, + "demask": true, + "maskMode": "MASK", + "types": ["*"], + "requireCompanion": ["CVV", "PIN", "DATE"] + }, + "crm": { + "enabled": true, + "demask": false, + "maskMode": "TOKEN", + "types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"] + }, + "analytics": { + "enabled": true, + "demask": false, + "maskMode": "SYNTHETIC", + "types": ["*"] + }, + "legacy-billing": { + "enabled": false, + "demask": false, + "maskMode": "MASK", + "types": ["*"] + } +} diff --git a/loadtest.js b/loadtest.js new file mode 100644 index 0000000..9e9c925 --- /dev/null +++ b/loadtest.js @@ -0,0 +1,58 @@ +// Нагрузка парами «маскирование → демаскирование», как её подаёт проверяющая +// система. Одна итерация — два запроса, поэтому rate задаётся вдвое меньше +// целевого RPS. +// k6 run -e RPS=1000 loadtest.js +import http from 'k6/http'; +import { check } from 'k6'; + +const URL = __ENV.URL || 'http://localhost:8080/process'; +// Второй узел для обратного шага: так проверяется худший случай на кластере — +// демаскирование всегда попадает не на тот узел, который маскировал. +const URL_BACK = __ENV.URL_BACK || URL; +const TARGET_RPS = Number(__ENV.RPS || 1000); +const HEADERS = { headers: { 'Content-Type': 'application/json' } }; + +const PAYLOADS = [ + 'Клиент Иванов Иван Иванович, паспорт 4509 123456 выдан ОУФМС России по г. Москве, дата рождения 12.05.1985', + 'Заявление от И.И. Петрова, ИНН 770301234550, адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15', + 'Карта 4111 1111 1111 1111, держатель IVAN PETROV, CVV 123, пин-код 4321', + 'Свяжитесь: ivan.petrov@mail.ru или +7 (916) 123-45-67, гражданство РФ', + 'Загранпаспорт 75 1234567, водительское удостоверение 9902 123456, СНИЛС 112-233-445 95', + 'Напиши стихотворение в духе Александра Пушкина про осень', +]; + +export const options = { + scenarios: { + pairs: { + executor: 'constant-arrival-rate', + rate: TARGET_RPS / 2, + timeUnit: '1s', + duration: __ENV.DURATION || '30s', + preAllocatedVUs: 300, + maxVUs: 2000, + }, + }, + thresholds: { + http_req_duration: ['p(95)<1000'], + http_req_failed: ['rate<0.01'], + checks: ['rate>0.99'], + }, +}; + +export default function () { + const id = `${__VU}-${__ITER}`; + const original = PAYLOADS[__ITER % PAYLOADS.length]; + + const masked = http.post(URL, JSON.stringify({ payload: original, payload_id: id }), HEADERS); + check(masked, { 'маскирование 200': (r) => r.status === 200 }); + if (masked.status !== 200) { + return; + } + + const restored = http.post(URL_BACK, + JSON.stringify({ payload: masked.json('result'), payload_id: id }), HEADERS); + check(restored, { + 'демаскирование 200': (r) => r.status === 200, + 'исходный текст восстановлен': (r) => r.json('result') === original, + }); +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7ff20a3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + ru.pdguard + pd-guard + 1.0.0 + + + UTF-8 + UTF-8 + 21 + io.quarkus.platform + quarkus-bom + 3.15.1 + 3.2.5 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + io.quarkus + quarkus-redis-client + + + org.apache.opennlp + opennlp-tools + 2.5.4 + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + + + + + native + + native + + + true + false + + + + diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..9dc627e --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,17 @@ +# Запасной вариант: тот же сервис на JVM, без сборки native. +# mvn package && docker build -f src/main/docker/Dockerfile.jvm -t pd-guard:jvm . +FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.20 +WORKDIR /deployments + +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ +COPY --chown=185 config /deployments/config + +EXPOSE 8080 +USER 185 + +ENV JAVA_OPTS_APPEND="-XX:+UseZGC -XX:MaxRAMPercentage=75 -Dquarkus.http.host=0.0.0.0" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" +ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..7bf372c --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,15 @@ +# Сборка образа с native-исполняемым файлом. +# mvn package -Dnative -Dquarkus.native.container-build=true +# docker build -f src/main/docker/Dockerfile.native -t pd-guard . +FROM quay.io/quarkus/quarkus-micro-image:2.0 +WORKDIR /work + +COPY --chown=1001:root target/*-runner /work/application +COPY --chown=1001:root config /work/config + +EXPOSE 8080 +USER 1001 + +ENV PDGUARD_SYSTEMS_FILE=/work/config/systems.json + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/java/ru/pdguard/api/AdminResource.java b/src/main/java/ru/pdguard/api/AdminResource.java new file mode 100644 index 0000000..b3293eb --- /dev/null +++ b/src/main/java/ru/pdguard/api/AdminResource.java @@ -0,0 +1,48 @@ +package ru.pdguard.api; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.config.SystemsConfig; +import ru.pdguard.detect.RuleRegistry; + +import java.util.List; +import java.util.Map; + +/** Просмотр действующих настроек и принудительное их перечитывание. */ +@Path("/admin") +public class AdminResource { + + private final SystemsConfig systems; + private final RuleRegistry registry; + + public AdminResource(SystemsConfig systems, RuleRegistry registry) { + this.systems = systems; + this.registry = registry; + } + + @GET + @Path("/config") + @Produces(MediaType.APPLICATION_JSON) + public Map config() { + return systems.current(); + } + + @GET + @Path("/types") + @Produces(MediaType.APPLICATION_JSON) + public List types() { + return registry.knownTypes(); + } + + @POST + @Path("/reload") + @Produces(MediaType.APPLICATION_JSON) + public Map reload() { + systems.reload(); + return systems.current(); + } +} diff --git a/src/main/java/ru/pdguard/api/HealthResource.java b/src/main/java/ru/pdguard/api/HealthResource.java new file mode 100644 index 0000000..c584a93 --- /dev/null +++ b/src/main/java/ru/pdguard/api/HealthResource.java @@ -0,0 +1,17 @@ +package ru.pdguard.api; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +/** Проба готовности для балансировщика и проверяющей системы. */ +@Path("/health") +public class HealthResource { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String health() { + return "OK"; + } +} diff --git a/src/main/java/ru/pdguard/api/ProcessResource.java b/src/main/java/ru/pdguard/api/ProcessResource.java new file mode 100644 index 0000000..8ce008c --- /dev/null +++ b/src/main/java/ru/pdguard/api/ProcessResource.java @@ -0,0 +1,107 @@ +package ru.pdguard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.smallrye.common.annotation.Blocking; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.config.SystemsConfig; +import ru.pdguard.core.Pipeline; + +import java.util.concurrent.Semaphore; + +/** + * Единственная точка входа контракта: маскирование и демаскирование по + * {@code payload_id}. + * + *

Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка + * нет или система неизвестна — применяются настройки {@code default}, поэтому + * контракт работает и без него. Система, выключенная в настройках, получает + * {@code 403}. + * + *

При перегрузке отвечает {@code 429} с {@code Retry-After} вместо того, + * чтобы копить запросы и упереться в таймаут вызывающей стороны. + */ +@Path("/process") +public class ProcessResource { + + private static final Logger LOG = Logger.getLogger(ProcessResource.class); + + /** Заголовок, которым система-потребитель себя называет. */ + public static final String SYSTEM_HEADER = "X-System-Id"; + + public record ProcessRequest( + @JsonProperty("payload") String payload, + @JsonProperty("payload_id") String payloadId) { + } + + public record ProcessResponse(@JsonProperty("result") String result) { + } + + private final Pipeline pipeline; + private final SystemsConfig systems; + private final Semaphore permits; + private final Counter rejected; + private final Counter malformed; + private final Counter forbidden; + + public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters, + @ConfigProperty(name = "pdguard.max-concurrent", defaultValue = "2000") + int maxConcurrent) { + this.pipeline = pipeline; + this.systems = systems; + this.permits = new Semaphore(maxConcurrent); + this.rejected = meters.counter("pdguard.requests.rejected", "reason", "overload"); + this.malformed = meters.counter("pdguard.requests.rejected", "reason", "malformed"); + this.forbidden = meters.counter("pdguard.requests.rejected", "reason", "system_disabled"); + } + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Blocking + public Response process(ProcessRequest request, @HeaderParam(SYSTEM_HEADER) String systemId) { + if (request == null || request.payload() == null + || request.payloadId() == null || request.payloadId().isBlank()) { + malformed.increment(); + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ProcessResponse("payload и payload_id обязательны")) + .build(); + } + + SystemPolicy policy = systems.policyFor(systemId); + if (!policy.enabled()) { + forbidden.increment(); + LOG.warnf("Системе %s обращение в модуль запрещено настройками", systemId); + return Response.status(Response.Status.FORBIDDEN) + .entity(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено")) + .build(); + } + + if (!permits.tryAcquire()) { + rejected.increment(); + return Response.status(429).header("Retry-After", "1").build(); + } + try { + String result = pipeline.process(request.payload(), request.payloadId(), policy); + return Response.ok(new ProcessResponse(result)).build(); + } catch (RuntimeException e) { + // Пять подряд невалидных ответов останавливают проверку, поэтому при + // внутреннем сбое возвращаем текст без изменений, а не 5xx. + LOG.errorf(e, "payload_id=%s обработка не удалась, текст возвращён без изменений", + request.payloadId()); + return Response.ok(new ProcessResponse(request.payload())).build(); + } finally { + permits.release(); + } + } +} diff --git a/src/main/java/ru/pdguard/config/SystemPolicy.java b/src/main/java/ru/pdguard/config/SystemPolicy.java new file mode 100644 index 0000000..28ce823 --- /dev/null +++ b/src/main/java/ru/pdguard/config/SystemPolicy.java @@ -0,0 +1,44 @@ +package ru.pdguard.config; + +import ru.pdguard.mask.MaskMode; + +import java.util.Set; + +/** + * Правила обработки для одной системы-потребителя. + * + * @param enabled разрешено ли системе обращаться в модуль + * @param demask выполняется ли для системы обратное преобразование + * @param maskMode вид замены: звёздочки, токен или синтетическое значение + * @param types типы ПД к маскированию; {@code "*"} — все известные + * @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: + * пин-код сам по себе безвреден, пин-код рядом с номером + * карты — уже нет; то же для даты без якорного слова + */ +public record SystemPolicy(boolean enabled, boolean demask, MaskMode maskMode, + Set types, Set requireCompanion) { + + public static final String ALL = "*"; + + /** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */ + public static final SystemPolicy DEFAULT = new SystemPolicy( + true, true, MaskMode.MASK, Set.of(ALL), Set.of("CVV", "PIN", "DATE")); + + public SystemPolicy { + types = Set.copyOf(types); + requireCompanion = Set.copyOf(requireCompanion); + } + + /** Политика только для перечисленных типов, с остальными настройками по умолчанию. */ + public static SystemPolicy forTypes(String... types) { + return new SystemPolicy(true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion()); + } + + public boolean allows(String type) { + return types.contains(ALL) || types.contains(type); + } + + public boolean needsCompanion(String type) { + return requireCompanion.contains(type); + } +} diff --git a/src/main/java/ru/pdguard/config/SystemsConfig.java b/src/main/java/ru/pdguard/config/SystemsConfig.java new file mode 100644 index 0000000..eb4b5fe --- /dev/null +++ b/src/main/java/ru/pdguard/config/SystemsConfig.java @@ -0,0 +1,137 @@ +package ru.pdguard.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import ru.pdguard.mask.MaskMode; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Список систем, которым разрешено обращаться в модуль, и правила для каждой. + * + *

Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл + * перечитывается сам, когда меняется время его изменения; проверка выполняется + * не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе. + * Файла нет — работают настройки по умолчанию, и сервис поднимается без него. + */ +@ApplicationScoped +public class SystemsConfig { + + private static final Logger LOG = Logger.getLogger(SystemsConfig.class); + + /** Имя политики, которая применяется к запросам без заголовка системы. */ + public static final String DEFAULT_SYSTEM = "default"; + + private static final long RECHECK_MILLIS = 1000; + + /** Описание одной системы в файле настроек. */ + @RegisterForReflection + public record SystemEntry(Boolean enabled, Boolean demask, String maskMode, + List types, List requireCompanion) { + } + + private final Path file; + private final ObjectMapper mapper; + + private volatile Map policies = Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT); + private volatile long fileTimestamp; + private volatile long lastCheck; + + @Inject + public SystemsConfig(@ConfigProperty(name = "pdguard.systems-file", defaultValue = "config/systems.json") + String path, ObjectMapper mapper) { + this.file = Path.of(path); + this.mapper = mapper; + reload(); + } + + /** Правила для системы; неизвестная система получает настройки по умолчанию. */ + public SystemPolicy policyFor(String systemId) { + refreshIfChanged(); + Map current = policies; + SystemPolicy policy = systemId == null ? null : current.get(systemId); + if (policy != null) { + return policy; + } + return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT); + } + + /** Известна ли система по имени. */ + public boolean isKnown(String systemId) { + refreshIfChanged(); + return systemId != null && policies.containsKey(systemId); + } + + /** Текущие настройки — для отдачи в административном интерфейсе. */ + public Map current() { + refreshIfChanged(); + return new TreeMap<>(policies); + } + + /** Перечитать файл настроек немедленно. */ + public final synchronized void reload() { + lastCheck = System.currentTimeMillis(); + if (!Files.isReadable(file)) { + LOG.infof("Файл настроек %s не найден, применяются настройки по умолчанию", file.toAbsolutePath()); + policies = Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT); + fileTimestamp = 0; + return; + } + try { + fileTimestamp = Files.getLastModifiedTime(file).toMillis(); + Map entries = mapper.readValue(Files.readAllBytes(file), + mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class)); + Map parsed = new TreeMap<>(); + entries.forEach((name, entry) -> parsed.put(name, toPolicy(entry))); + parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT); + policies = Map.copyOf(parsed); + LOG.infof("Настройки систем перечитаны из %s: %s", file.toAbsolutePath(), parsed.keySet()); + } catch (IOException | IllegalArgumentException e) { + // Битый файл не должен ронять работающий сервис: остаются прежние настройки. + LOG.errorf(e, "Не удалось прочитать %s, продолжаем с прежними настройками", file.toAbsolutePath()); + } + } + + private void refreshIfChanged() { + long now = System.currentTimeMillis(); + if (now - lastCheck < RECHECK_MILLIS) { + return; + } + lastCheck = now; + try { + if (!Files.isReadable(file)) { + return; + } + if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) { + reload(); + } + } catch (IOException e) { + LOG.debugf(e, "Не удалось проверить время изменения %s", file); + } + } + + private static SystemPolicy toPolicy(SystemEntry entry) { + SystemPolicy base = SystemPolicy.DEFAULT; + Set types = entry.types() == null ? base.types() : new HashSet<>(entry.types()); + Set companions = entry.requireCompanion() == null + ? base.requireCompanion() : new HashSet<>(entry.requireCompanion()); + MaskMode mode = entry.maskMode() == null + ? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT)); + return new SystemPolicy( + entry.enabled() == null || entry.enabled(), + entry.demask() == null || entry.demask(), + mode, types, companions); + } +} diff --git a/src/main/java/ru/pdguard/core/PayloadStore.java b/src/main/java/ru/pdguard/core/PayloadStore.java new file mode 100644 index 0000000..d3a4a7e --- /dev/null +++ b/src/main/java/ru/pdguard/core/PayloadStore.java @@ -0,0 +1,174 @@ +package ru.pdguard.core; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование. + * + *

Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — + * страховка на случай, если идентификатор до сервиса не доехал. + * + *

Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное + * время: персональные данные не должны залёживаться в памяти, а крупные тексты не + * должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо + * на записи — отдельного потока и внешней библиотеки кеширования не требуется. + * + *

Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, + * а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на + * нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял + * прямой. Локальная память при этом остаётся первым уровнем, и обычный путь + * обходится без обращения по сети. + */ +@ApplicationScoped +public class PayloadStore { + + /** Сколько протухших записей просматривается за одну операцию записи. */ + private static final int SWEEP_PER_PUT = 4; + + /** Пара «исходный текст — маска» с отпечатком и сроком жизни. */ + public record Entry(String original, String masked, String fingerprint, long expiresAt) { + + boolean alive(long now) { + return now < expiresAt; + } + + int weight() { + return original.length() + masked.length(); + } + } + + private final Map byId = new ConcurrentHashMap<>(); + private final Map byMaskFingerprint = new ConcurrentHashMap<>(); + private final ConcurrentLinkedQueue insertionOrder = new ConcurrentLinkedQueue<>(); + private final AtomicLong charsHeld = new AtomicLong(); + + private final long maxChars; + private final long ttlMillis; + private final SharedIndex shared; + + @Inject + public PayloadStore( + @ConfigProperty(name = "pdguard.store.max-chars", defaultValue = "134217728") long maxChars, + @ConfigProperty(name = "pdguard.store.ttl-minutes", defaultValue = "30") int ttlMinutes, + SharedIndex shared) { + this.maxChars = maxChars; + this.ttlMillis = ttlMinutes * 60_000L; + this.shared = shared; + } + + /** Конструктор для тестов: только локальная память, общий слой выключен. */ + public PayloadStore(long maxChars, int ttlMinutes) { + this(maxChars, ttlMinutes, SharedIndex.disabled()); + } + + public void put(String payloadId, String original, String masked) { + long now = System.currentTimeMillis(); + Entry entry = new Entry(original, masked, fingerprint(masked), now + ttlMillis); + + Entry replaced = byId.put(payloadId, entry); + byMaskFingerprint.put(entry.fingerprint(), entry); + insertionOrder.add(payloadId); + charsHeld.addAndGet(entry.weight() - (replaced == null ? 0 : replaced.weight())); + + sweepExpired(now); + evictWhileOverLimit(); + + shared.put(payloadId, original, masked, entry.fingerprint()); + } + + public Entry byId(String payloadId) { + Entry entry = byId.get(payloadId); + if (entry != null && entry.alive(System.currentTimeMillis())) { + return entry; + } + if (entry != null) { + forget(payloadId, entry); + } + SharedIndex.SharedEntry fromShared = shared.byId(payloadId); + if (fromShared == null) { + return null; + } + // Соседний узел уже выполнял прямой шаг: забираем соответствие к себе, + // чтобы повторное обращение обошлось без сети. + put(payloadId, fromShared.original(), fromShared.masked()); + return byId.get(payloadId); + } + + /** Исходный текст по самой маске — когда {@code payload_id} не совпал. */ + public String originalForMask(String masked) { + String fingerprint = fingerprint(masked); + Entry entry = byMaskFingerprint.get(fingerprint); + if (entry != null && entry.alive(System.currentTimeMillis())) { + return entry.original(); + } + return shared.originalForFingerprint(fingerprint); + } + + /** Сколько символов сейчас удерживается — для диагностики и тестов. */ + public long charsHeld() { + return charsHeld.get(); + } + + /** Убирает протухшие записи с головы очереди, не более нескольких за раз. */ + private void sweepExpired(long now) { + for (int i = 0; i < SWEEP_PER_PUT; i++) { + String oldest = insertionOrder.peek(); + if (oldest == null) { + return; + } + Entry entry = byId.get(oldest); + if (entry == null) { + insertionOrder.poll(); + continue; + } + if (entry.alive(now)) { + return; + } + insertionOrder.poll(); + forget(oldest, entry); + } + } + + private void evictWhileOverLimit() { + while (charsHeld.get() > maxChars) { + String oldest = insertionOrder.poll(); + if (oldest == null) { + return; + } + Entry entry = byId.get(oldest); + if (entry != null) { + // ponytail: если тот же payload_id записали повторно, в очереди остался + // старый след и здесь вытесняется свежая запись. Цена — одно лишнее + // обращение к маскированию; точный учёт потребовал бы двусвязного списка. + forget(oldest, entry); + } + } + } + + private void forget(String payloadId, Entry entry) { + if (byId.remove(payloadId, entry)) { + byMaskFingerprint.remove(entry.fingerprint(), entry); + charsHeld.addAndGet(-entry.weight()); + } + } + + private static String fingerprint(String value) { + try { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e); + } + } +} diff --git a/src/main/java/ru/pdguard/core/Pipeline.java b/src/main/java/ru/pdguard/core/Pipeline.java new file mode 100644 index 0000000..792bd13 --- /dev/null +++ b/src/main/java/ru/pdguard/core/Pipeline.java @@ -0,0 +1,233 @@ +package ru.pdguard.core; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.detect.NameCascade; +import ru.pdguard.detect.NameDictionary; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.MaskContext; +import ru.pdguard.mask.Masker; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; + +/** + * Обработка одного обращения: поиск ПД, маскирование и обратное преобразование. + * + *

Направление определяется по {@code payload_id}, а не по содержимому запроса: + *

    + *
  • идентификатор неизвестен — маскируем;
  • + *
  • пришёл ранее выданный нами текст маски — возвращаем исходный текст;
  • + *
  • пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.
  • + *
+ * Последний случай — повторная попытка проверяющей системы: ответ обязан + * совпасть с первым, иначе демаскирование по этому элементу развалится. + */ +@ApplicationScoped +public class Pipeline { + + private static final Logger LOG = Logger.getLogger(Pipeline.class); + + /** Грубая оценка числа токенов по числу символов — для метрики TPS. */ + private static final int CHARS_PER_TOKEN = 4; + + private final RuleRegistry registry; + private final Masker masker; + private final PayloadStore store; + private final MeterRegistry meters; + private final NameCascade cascade; + private final Timer maskTimer; + private final Timer unmaskTimer; + private final Counter tokensProcessed; + + @Inject + public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters, + NameCascade cascade) { + this.registry = registry; + this.masker = masker; + this.store = store; + this.meters = meters; + this.cascade = cascade; + this.maskTimer = Timer.builder("pdguard.process") + .description("Длительность обработки обращения") + .tag("direction", "mask") + .register(meters); + this.unmaskTimer = Timer.builder("pdguard.process") + .description("Длительность обработки обращения") + .tag("direction", "unmask") + .register(meters); + this.tokensProcessed = Counter.builder("pdguard.tokens.processed") + .description("Оценка числа обработанных токенов, для расчёта TPS") + .register(meters); + } + + /** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */ + public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) { + this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled()); + } + + /** Конструктор для тестов второй ступени. */ + public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) { + this(registry, masker, store, new SimpleMeterRegistry(), cascade); + } + + public String process(String payload, String payloadId, SystemPolicy policy) { + long started = System.nanoTime(); + tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN); + + PayloadStore.Entry known = store.byId(payloadId); + if (known != null) { + if (policy.demask() && payload.equals(known.masked())) { + LOG.debugf("payload_id=%s обратное преобразование по идентификатору", payloadId); + unmaskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS); + return known.original(); + } + if (payload.equals(known.original())) { + LOG.debugf("payload_id=%s повторная попытка, отдаём прежнюю маску", payloadId); + maskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS); + return known.masked(); + } + } + if (policy.demask()) { + String original = store.originalForMask(payload); + if (original != null) { + LOG.debugf("payload_id=%s обратное преобразование по отпечатку маски", payloadId); + unmaskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS); + return original; + } + } + return mask(payload, payloadId, policy, started); + } + + /** + * Фрагменты, которые будут замаскированы: поиск по правилам, разрешение + * перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции + * можно было измерить, не разбирая замаскированный текст обратно. + */ + public List findPersonalData(String text, SystemPolicy policy) { + List spans = resolveOverlaps(registry.detect(text, policy)); + if (policy.allows(RuleRegistry.FIO)) { + // Вторая ступень разбирает только то, что не покрыла первая. + spans = resolveOverlaps(cascade.addMissedNames(text, spans)); + } + spans = dropWellKnownNames(text, spans); + return dropLonelyCompanions(spans, policy); + } + + private String mask(String payload, String payloadId, SystemPolicy policy, long started) { + List spans = findPersonalData(payload, policy); + String masked = apply(payload, spans, policy); + store.put(payloadId, payload, masked); + + maskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS); + logFindings(payloadId, payload.length(), spans); + return masked; + } + + /** + * Оставляет непересекающиеся фрагменты: при конфликте побеждает более + * приоритетный, при равном приоритете — более длинный. + */ + static List resolveOverlaps(List spans) { + List candidates = new ArrayList<>(spans); + candidates.sort(Comparator.comparingInt(Span::priority).reversed() + .thenComparing(Comparator.comparingInt(Span::length).reversed()) + .thenComparingInt(Span::start)); + + // Принятые фрагменты не пересекаются и упорядочены по началу, поэтому + // кандидату достаточно сверить себя с ближайшим слева и ближайшим справа. + // Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов + // фрагментов набираются тысячи. + NavigableMap accepted = new TreeMap<>(); + for (Span candidate : candidates) { + Map.Entry before = accepted.floorEntry(candidate.start()); + if (before != null && before.getValue().overlaps(candidate)) { + continue; + } + Map.Entry after = accepted.ceilingEntry(candidate.start()); + if (after != null && after.getValue().overlaps(candidate)) { + continue; + } + accepted.put(candidate.start(), candidate); + } + return List.copyOf(accepted.values()); + } + + /** + * Убирает имена известных людей: «стихи Александра Пушкина» персональными + * данными не являются. Если же в тексте есть ПД другого типа, речь идёт о + * конкретном человеке, и имя остаётся замаскированным — однофамилец + * исторической фигуры защиту не теряет. + */ + static List dropWellKnownNames(String text, List spans) { + boolean otherPersonalDataPresent = spans.stream() + .anyMatch(span -> !RuleRegistry.FIO.equals(span.type())); + if (otherPersonalDataPresent) { + return spans; + } + return spans.stream() + .filter(span -> !RuleRegistry.FIO.equals(span.type()) + || !NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) + .toList(); + } + + /** + * Убирает типы, которые опасны только в сочетании с другими ПД. + * Пин-код в отрыве от номера карты не является персональными данными, + * рядом с номером карты — является. + */ + static List dropLonelyCompanions(List spans, SystemPolicy policy) { + Set present = new HashSet<>(); + for (Span span : spans) { + present.add(span.type()); + } + if (present.size() > 1) { + return spans; + } + return spans.stream().filter(span -> !policy.needsCompanion(span.type())).toList(); + } + + private String apply(String text, List spans, SystemPolicy policy) { + if (spans.isEmpty()) { + return text; + } + MaskContext context = new MaskContext(); + StringBuilder sb = new StringBuilder(text.length()); + int cursor = 0; + for (Span span : spans) { + sb.append(text, cursor, span.start()); + String value = text.substring(span.start(), span.end()); + sb.append(masker.mask(span.type(), value, policy.maskMode(), context)); + cursor = span.end(); + } + sb.append(text, cursor, text.length()); + return sb.toString(); + } + + /** + * В журнал и в метрики попадают только идентификатор, типы ПД и их количество. + * Сами значения не логируются ни на одном уровне. + */ + private void logFindings(String payloadId, int length, List spans) { + Map counts = new LinkedHashMap<>(); + for (Span span : spans) { + counts.merge(span.type(), 1, Integer::sum); + } + counts.forEach((type, count) -> meters.counter("pdguard.pd.detected", "type", type).increment(count)); + LOG.infof("payload_id=%s символов=%d найдено=%s", payloadId, length, counts); + } +} diff --git a/src/main/java/ru/pdguard/core/SharedIndex.java b/src/main/java/ru/pdguard/core/SharedIndex.java new file mode 100644 index 0000000..fde464f --- /dev/null +++ b/src/main/java/ru/pdguard/core/SharedIndex.java @@ -0,0 +1,167 @@ +package ru.pdguard.core; + +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.value.SetArgs; +import io.quarkus.redis.datasource.value.ValueCommands; +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах. + * + *

Маскирование — чистая функция, на любом узле даёт один и тот же результат. + * Обратное же преобразование требует состояния: если прямой запрос обработал + * один узел, а обратный попал на другой, соответствие должно быть общим. + * + *

Включается настройкой {@code pdguard.store.backend=redis}. Пока она не + * выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной. + * + *

Недоступность Redis не приводит к отказу: запись и чтение деградируют до + * локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не + * съедал время ответа, команды ограничены по времени настройкой + * {@code quarkus.redis.timeout}, а после нескольких подряд неудач общий слой + * временно перестают опрашивать вовсе. + */ +@ApplicationScoped +public class SharedIndex { + + private static final Logger LOG = Logger.getLogger(SharedIndex.class); + + private static final String KEY_BY_ID = "pdg:id:"; + private static final String KEY_BY_MASK = "pdg:mask:"; + + /** Сколько подряд неудач размыкает предохранитель. */ + private static final int FAILURES_TO_OPEN = 3; + + /** На сколько общий слой перестают опрашивать после размыкания. */ + private static final long OPEN_MILLIS = 5_000; + + /** Пара «исходный текст — маска», как она хранится в общем слое. */ + @RegisterForReflection + public record SharedEntry(String original, String masked) { + } + + private final boolean enabled; + private final Duration ttl; + private final Instance redisSource; + + private volatile ValueCommands pairs; + private volatile ValueCommands originals; + private final AtomicInteger consecutiveFailures = new AtomicInteger(); + private volatile long silentUntil; + private volatile boolean reported; + + public SharedIndex(Instance redisSource, + @ConfigProperty(name = "pdguard.store.backend", defaultValue = "memory") String backend, + @ConfigProperty(name = "pdguard.store.ttl-minutes", defaultValue = "30") int ttlMinutes) { + this.redisSource = redisSource; + this.enabled = "redis".equalsIgnoreCase(backend); + this.ttl = Duration.ofMinutes(ttlMinutes); + } + + /** Выключенный слой — для тестов и для сборки без Redis. */ + public static SharedIndex disabled() { + return new SharedIndex(null, "memory", 30); + } + + public boolean enabled() { + return enabled; + } + + public void put(String payloadId, String original, String masked, String maskFingerprint) { + if (unavailable()) { + return; + } + try { + SetArgs expiry = new SetArgs().ex(ttl); + commands().set(KEY_BY_ID + payloadId, new SharedEntry(original, masked), expiry); + originalCommands().set(KEY_BY_MASK + maskFingerprint, original, expiry); + noteSuccess(); + } catch (RuntimeException e) { + noteFailure("записать", e); + } + } + + public SharedEntry byId(String payloadId) { + if (unavailable()) { + return null; + } + try { + SharedEntry entry = commands().get(KEY_BY_ID + payloadId); + noteSuccess(); + return entry; + } catch (RuntimeException e) { + noteFailure("прочитать", e); + return null; + } + } + + public String originalForFingerprint(String maskFingerprint) { + if (unavailable()) { + return null; + } + try { + String original = originalCommands().get(KEY_BY_MASK + maskFingerprint); + noteSuccess(); + return original; + } catch (RuntimeException e) { + noteFailure("прочитать", e); + return null; + } + } + + /** + * Команды создаются при первом обращении: пока общий слой выключен, + * клиент Redis не создаётся и подключение не устанавливается. + */ + private ValueCommands commands() { + ValueCommands local = pairs; + if (local == null) { + local = redisSource.get().value(SharedEntry.class); + pairs = local; + } + return local; + } + + private ValueCommands originalCommands() { + ValueCommands local = originals; + if (local == null) { + local = redisSource.get().value(String.class); + originals = local; + } + return local; + } + + /** Общий слой выключен или предохранитель разомкнут. */ + private boolean unavailable() { + return !enabled || System.currentTimeMillis() < silentUntil; + } + + private void noteSuccess() { + if (consecutiveFailures.getAndSet(0) != 0) { + reported = false; + LOG.info("Общий слой снова доступен"); + } + } + + /** + * После нескольких неудач подряд общий слой перестают опрашивать на несколько + * секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а + * проверяющая система считает ответ дольше десяти секунд неответом. + */ + private void noteFailure(String action, RuntimeException cause) { + if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) { + silentUntil = System.currentTimeMillis() + OPEN_MILLIS; + } + if (!reported) { + reported = true; + LOG.errorf(cause, "Не удалось %s соответствие в общий слой, узел работает на своей памяти", action); + } + } +} diff --git a/src/main/java/ru/pdguard/core/Span.java b/src/main/java/ru/pdguard/core/Span.java new file mode 100644 index 0000000..5052f8c --- /dev/null +++ b/src/main/java/ru/pdguard/core/Span.java @@ -0,0 +1,26 @@ +package ru.pdguard.core; + +/** + * Найденный фрагмент персональных данных в исходном тексте. + * + * @param start индекс первого символа (включительно) + * @param end индекс за последним символом (исключительно) + * @param type тип ПД, например {@code CARD} или {@code EMAIL} + * @param priority приоритет при разрешении перекрытий: больше — важнее + */ +public record Span(int start, int end, String type, int priority) { + + public Span { + if (start < 0 || end <= start) { + throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end); + } + } + + public int length() { + return end - start; + } + + public boolean overlaps(Span other) { + return start < other.end && other.start < end; + } +} diff --git a/src/main/java/ru/pdguard/detect/NameCascade.java b/src/main/java/ru/pdguard/detect/NameCascade.java new file mode 100644 index 0000000..de0f138 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/NameCascade.java @@ -0,0 +1,205 @@ +package ru.pdguard.detect; + +import io.quarkus.runtime.Startup; +import jakarta.enterprise.context.ApplicationScoped; +import opennlp.tools.namefind.NameFinderME; +import opennlp.tools.namefind.TokenNameFinderModel; +import opennlp.tools.tokenize.SimpleTokenizer; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import ru.pdguard.core.Span; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Вторая ступень распознавания имён. + * + *

Правила и словарь разбирают подавляющее большинство случаев и стоят десятки + * микросекунд. Модель нужна там, где они бессильны: имена без русского + * словообразования и без отчества — «Нгуен Ван Ань», «Ким Сон Хо». + * + *

Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из + * двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в + * обычном запросе единицы, и на задержку это почти не влияет. + * + *

Модели нет — ступень выключена и поведение сервиса не меняется. Путь к файлу + * задаётся свойством {@code pdguard.ner.model}. + * + *

Сбой второй ступени не должен отражаться на первой: ошибка перехватывается + * здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно + * исключение обнуляло бы маскирование целиком. + */ +@Startup +@ApplicationScoped +public class NameCascade { + + private static final Logger LOG = Logger.getLogger(NameCascade.class); + + /** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться именем. */ + private static final Pattern CANDIDATE = Pattern.compile( + "\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}", + Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); + + /** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */ + private static final int PRIORITY = 73; + + /** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */ + private static final int CONTEXT_CHARS = 60; + + /** Сколько ждать свободный распознаватель, прежде чем обойтись правилами. */ + private static final long BORROW_TIMEOUT_MILLIS = 50; + + /** Текст для прогрева: важно не что в нём, а что модель отработала хотя бы раз. */ + private static final String[] WARMUP_WORDS = + {"Клиент", "Иванов", "Иван", "Иванович", "обратился", "в", "отделение"}; + + private final BlockingQueue pool; + private final int maxCandidates; + private final boolean enabled; + private volatile boolean broken; + + public NameCascade( + @ConfigProperty(name = "pdguard.ner.model") Optional modelPath, + @ConfigProperty(name = "pdguard.ner.max-candidates", defaultValue = "16") int maxCandidates, + @ConfigProperty(name = "pdguard.ner.pool-size", defaultValue = "16") int poolSize) { + this.maxCandidates = maxCandidates; + TokenNameFinderModel model = load(modelPath); + this.enabled = model != null; + this.pool = enabled ? warmedPool(model, Math.max(1, poolSize)) : null; + } + + /** Выключенная ступень — для тестов и для сборок без модели. */ + public static NameCascade disabled() { + return new NameCascade(Optional.empty(), 0, 1); + } + + public boolean enabled() { + return enabled; + } + + /** + * Добавляет имена, которые не нашла первая ступень. Уже принятые фрагменты + * не трогаются: модель разбирает только непокрытые участки. + */ + public List addMissedNames(String text, List accepted) { + if (!enabled || broken) { + return accepted; + } + NameFinderME finder = borrow(); + if (finder == null) { + // Все распознаватели заняты: отвечаем по правилам, а не копим очередь. + return accepted; + } + try { + List found = new ArrayList<>(accepted); + int examined = 0; + Matcher m = CANDIDATE.matcher(text); + while (m.find() && examined < maxCandidates) { + if (coveredBy(accepted, m.start(), m.end())) { + continue; + } + examined++; + recognise(finder, text, m.start(), m.end(), found); + } + return found; + } catch (RuntimeException e) { + broken = true; + LOG.errorf(e, "Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам"); + return accepted; + } finally { + finder.clearAdaptiveData(); + pool.offer(finder); + } + } + + private NameFinderME borrow() { + try { + return pool.poll(BORROW_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + + private static void recognise(NameFinderME finder, String text, + int candidateStart, int candidateEnd, List sink) { + int from = Math.max(0, candidateStart - CONTEXT_CHARS); + int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS); + String region = text.substring(from, to); + + opennlp.tools.util.Span[] tokens = SimpleTokenizer.INSTANCE.tokenizePos(region); + String[] words = new String[tokens.length]; + for (int i = 0; i < tokens.length; i++) { + words[i] = region.substring(tokens[i].getStart(), tokens[i].getEnd()); + } + + for (opennlp.tools.util.Span name : finder.find(words)) { + int start = from + tokens[name.getStart()].getStart(); + int end = from + tokens[name.getEnd() - 1].getEnd(); + // Берём только то, что пересекается с кандидатом: контекст добавлен + // ради качества разбора, а не для расширения находки. + if (start < candidateEnd && candidateStart < end) { + sink.add(new Span(start, end, RuleRegistry.FIO, PRIORITY)); + } + } + } + + private static boolean coveredBy(List accepted, int start, int end) { + return accepted.stream().anyMatch(span -> span.start() < end && start < span.end()); + } + + /** + * Готовые к работе распознаватели создаются на старте и сразу прогоняют текст. + * + *

{@link NameFinderME} хранит состояние между вызовами, поэтому одним + * экземпляром на несколько потоков пользоваться нельзя. Создание экземпляра + * вместе с первым разбором стоит сотни миллисекунд, и при создании по + * требованию эта цена доставалась первому запросу каждого рабочего потока. + * Пул снимает и то, и другое: к первому обращению всё создано и прогрето. + */ + private static BlockingQueue warmedPool(TokenNameFinderModel model, int size) { + long started = System.nanoTime(); + BlockingQueue ready = new ArrayBlockingQueue<>(size); + for (int i = 0; i < size; i++) { + NameFinderME finder = new NameFinderME(model); + finder.find(WARMUP_WORDS); + finder.clearAdaptiveData(); + ready.add(finder); + } + LOG.infof("Прогрев второй ступени: %d распознавателей за %d мс", + size, (System.nanoTime() - started) / 1_000_000); + return ready; + } + + private TokenNameFinderModel load(Optional modelPath) { + if (modelPath.isEmpty() || modelPath.get().isBlank()) { + LOG.info("Вторая ступень распознавания имён выключена: модель не задана"); + return null; + } + Path file = Path.of(modelPath.get()); + if (!Files.isReadable(file)) { + LOG.warnf("Модель %s недоступна, вторая ступень выключена", file.toAbsolutePath()); + return null; + } + try (InputStream in = Files.newInputStream(file)) { + TokenNameFinderModel model = new TokenNameFinderModel(in); + LOG.infof("Вторая ступень распознавания имён включена, модель %s", file.toAbsolutePath()); + return model; + } catch (IOException | RuntimeException e) { + // Испорченная модель не должна мешать сервису подняться: работают правила. + LOG.errorf(e, "Не удалось загрузить модель %s, вторая ступень выключена", file.toAbsolutePath()); + return null; + } + } +} diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java new file mode 100644 index 0000000..4341008 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -0,0 +1,124 @@ +package ru.pdguard.detect; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Словари для распознавания ФИО. + * + *

Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: + * «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени. + * + *

Список известных людей решает обратную задачу — упоминание Пушкина + * персональными данными не является. Ограничение осознанное: клиент по фамилии + * Пушкин в тексте без других ПД замаскирован не будет. + */ +public final class NameDictionary { + + private static final List GIVEN_NAME_STEMS = load("/names/given-names.txt").stream() + .map(NameDictionary::withoutInflectedEnding) + .distinct() + .sorted(Comparator.comparingInt(String::length).reversed()) + .toList(); + private static final List WELL_KNOWN_STEMS = load("/names/well-known.txt"); + + /** Не более скольких падежных букв дописывается к основе имени. */ + private static final int MAX_INFLECTION = 3; + + /** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */ + private static final Set SURNAME_SUFFIXES = Set.of( + "ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", + "ович", "евич", "овна", "евна", "овы", "евы", "ины"); + + private static final Set GIVEN_NAMES = GIVEN_NAME_STEMS.stream() + .map(stem -> stem.toLowerCase(Locale.ROOT)) + .collect(Collectors.toUnmodifiableSet()); + + private NameDictionary() { + } + + /** + * Отбрасывает у основы конечную гласную, которая меняется по падежам: + * Ольга → Ольг (Ольги, Ольге, Ольгой), Николай → Никола (Николая, Николаю). + */ + private static String withoutInflectedEnding(String stem) { + if (stem.length() >= 4 && "аяйь".indexOf(stem.charAt(stem.length() - 1)) >= 0) { + return stem.substring(0, stem.length() - 1); + } + return stem; + } + + /** + * Есть ли среди слов личное имя из словаря в любом падеже. + * + *

Проверка множеством, а не чередованием в регулярном выражении: сто с лишним + * веток пришлось бы перебирать в каждой позиции текста, здесь же на слово + * приходится не больше четырёх обращений к хеш-таблице. + */ + public static boolean containsGivenName(String value) { + for (String word : value.split("\\P{L}+")) { + String lower = word.toLowerCase(Locale.ROOT); + // Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов», + // но это имя, а не фамилия. + if (GIVEN_NAMES.contains(lower)) { + return true; + } + // По началу слова имя ищется с оглядкой на остаток: «Марина» это основа + // «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное + // «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за + // человека, а «Марина Шевченко» переставала бы им быть. + for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) { + if (GIVEN_NAMES.contains(lower.substring(0, length)) + && !SURNAME_SUFFIXES.contains(lower.substring(length))) { + return true; + } + } + } + return false; + } + + /** Содержит ли текст упоминание известного человека. */ + public static boolean isWellKnown(String value) { + for (String word : value.split("\\P{L}+")) { + String lower = word.toLowerCase(Locale.ROOT); + for (String stem : WELL_KNOWN_STEMS) { + if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) { + return true; + } + } + } + return false; + } + + /** + * Основы сортируются от длинных к коротким: в чередовании регулярного + * выражения побеждает первая подошедшая ветка, и короткая основа не должна + * перехватывать совпадение у длинной. + */ + private static List load(String resource) { + try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) { + if (in == null) { + throw new IllegalStateException("Словарь не найден в сборке: " + resource); + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return reader.lines() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .distinct() + .sorted(Comparator.comparingInt(String::length).reversed()) + .toList(); + } + } catch (IOException e) { + throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e); + } + } +} diff --git a/src/main/java/ru/pdguard/detect/OpenNlpReflection.java b/src/main/java/ru/pdguard/detect/OpenNlpReflection.java new file mode 100644 index 0000000..96046a4 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/OpenNlpReflection.java @@ -0,0 +1,45 @@ +package ru.pdguard.detect; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Классы, которые OpenNLP создаёт по имени, разбирая описание признаков внутри модели. + * + *

В обычной сборке это работает само, в native-образе — нет: класс, не упомянутый + * в коде, туда просто не попадает. Без регистрации загрузка модели проходит, а + * создание распознавателя падает с {@code ClassNotFoundException} на первом запросе. + * + *

Перечислены фабрики целиком, а не только те, что встречаются в текущей модели: + * набор признаков задаётся при обучении и может измениться без правки кода. + */ +@RegisterForReflection(classNames = { + "opennlp.tools.namefind.TokenNameFinderFactory", + "opennlp.tools.namefind.BioCodec", + "opennlp.tools.util.featuregen.AggregatedFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.BigramNameFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.BrownClusterBigramFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.BrownClusterTokenClassFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.BrownClusterTokenFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.CachedFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.CharacterNgramFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.DefinitionFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.DictionaryFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.DocumentBeginFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.POSTaggerNameFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.PosTaggerFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.PrefixFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.PreviousMapFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.SentenceFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.SuffixFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.TokenClassFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.TokenFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.TokenPatternFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.TrigramNameFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.WindowFeatureGeneratorFactory", + "opennlp.tools.util.featuregen.WordClusterFeatureGeneratorFactory" +}) +final class OpenNlpReflection { + + private OpenNlpReflection() { + } +} diff --git a/src/main/java/ru/pdguard/detect/Rule.java b/src/main/java/ru/pdguard/detect/Rule.java new file mode 100644 index 0000000..ca885f3 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/Rule.java @@ -0,0 +1,90 @@ +package ru.pdguard.detect; + +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Одно правило детекции персональных данных. + * + *

Добавление нового типа ПД — это добавление одного {@code Rule} в + * {@link RuleRegistry}; менять остальной код не требуется. + * + * @param type тип ПД, который распознаёт правило + * @param pattern регулярное выражение + * @param priority приоритет при разрешении перекрытий + * @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. + * Несколько групп нужны, когда значение разорвано словами: + * «серия 4509 номер 123456» + * @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); + * {@code null} — проверка не нужна + * @param veto шаблон окружения, при котором совпадение персональными данными не считается: + * адрес отделения банка не является ПД, хотя выглядит как адрес + * @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, + * где форма совпадения сама по себе слишком общая: «Невский проспект» + * это адрес рядом с домом и индексом и просто топоним в рассказе о городе + * @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. + * Проверка через {@code indexOf} на порядок дешевле запуска + * регулярного выражения и отсекает большинство правил на коротком + * запросе. Пустой список — правило запускается всегда + */ +public record Rule(String type, Pattern pattern, int priority, List groups, + Predicate validator, Pattern veto, Pattern context, List anchors) { + + /** + * Флаги компиляции для всех правил. + * + *

{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} + * и {@code \b} в Java охватывают только латиницу, и якорные слова вроде + * «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает + * {@code (?i)} корректным для кириллицы. + */ + private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE; + + /** Сколько символов слева и справа от совпадения просматривает вето-шаблон. */ + public static final int VETO_LOOKBEHIND = 80; + public static final int VETO_LOOKAHEAD = 40; + + /** Правило без проверок, маскируется всё совпадение. */ + public static Rule of(String type, String regex, int priority) { + return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of()); + } + + /** Маскировать только перечисленные группы, а не всё совпадение. */ + public Rule groups(Integer... indexes) { + return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors); + } + + /** Принять совпадение, только если значение прошло проверку. */ + public Rule validatedBy(Predicate check) { + return new Rule(type, pattern, priority, groups, check, veto, context, anchors); + } + + /** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */ + public Rule anchoredBy(String... required) { + return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required)); + } + + /** Есть ли в тексте хоть один из якорей правила. */ + public boolean mayMatch(String lowercasedText) { + if (anchors.isEmpty()) { + return true; + } + for (String anchor : anchors) { + if (lowercasedText.contains(anchor)) { + return true; + } + } + return false; + } + + /** Принять совпадение, только если рядом встретилось указанное слово. */ + public Rule requiringNear(String regex) { + return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors); + } + + /** Отбросить совпадение, если рядом встретилось указанное слово. */ + public Rule vetoedBy(String regex) { + return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors); + } +} diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java new file mode 100644 index 0000000..9e220d1 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -0,0 +1,421 @@ +package ru.pdguard.detect; + +import jakarta.enterprise.context.ApplicationScoped; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.Span; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; + +/** + * Реестр правил детекции и сам поиск ПД в тексте. + * + *

Правила разбиты на три уровня доверия: + *

    + *
  1. проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
  2. + *
  3. однозначные по формату — email, телефон;
  4. + *
  5. требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, + * где сама по себе последовательность знаков ни о чём не говорит.
  6. + *
+ * + *

Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен + * именно на них. На захватываемое значение регистронезависимость не распространяется: + * там, где значение опознаётся по заглавной букве, это существенно. + */ +@ApplicationScoped +public class RuleRegistry { + + public static final String EMAIL = "EMAIL"; + public static final String PHONE = "PHONE"; + public static final String CARD = "CARD"; + public static final String INN = "INN"; + public static final String SNILS = "SNILS"; + public static final String PASSPORT = "PASSPORT"; + public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER"; + public static final String PASSPORT_DATE = "PASSPORT_DATE"; + public static final String DEPT_CODE = "DEPT_CODE"; + public static final String DRIVER_LICENSE = "DRIVER_LICENSE"; + public static final String CITIZENSHIP = "CITIZENSHIP"; + public static final String BIRTH_PLACE = "BIRTH_PLACE"; + public static final String BIRTH_DATE = "BIRTH_DATE"; + public static final String DATE = "DATE"; + public static final String CVV = "CVV"; + public static final String PIN = "PIN"; + public static final String CARDHOLDER = "CARDHOLDER"; + public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY"; + public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE"; + public static final String ADDRESS_CITY = "ADDRESS_CITY"; + public static final String ADDRESS_STREET = "ADDRESS_STREET"; + public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE"; + public static final String ADDRESS_FLAT = "ADDRESS_FLAT"; + public static final String FIO = "FIO"; + public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT"; + public static final String MILITARY_ID = "MILITARY_ID"; + public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE"; + public static final String MEDICAL_POLICY = "MEDICAL_POLICY"; + + /** + * Слово с заглавной буквы; остальные буквы любого регистра, чтобы + * «ИВАНОВ» распознавался наравне с «Иванов». + */ + private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; + + /** + * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — + * «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно: + * без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт + * до вечера» оказывался под маской целиком. + */ + private static final String STREET_NAME = + "(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}"; + + /** + * Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. + * Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а. + */ + private static final String SURNAME = + "\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)" + + "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}"; + + /** + * Отчество: признак надёжный, ни одно другое слово так не оканчивается. + * Основы даны без падежного окончания — Иванович, Ивановича, Ивановне. + */ + private static final String PATRONYMIC = + "\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич" + + "|овн|евн|иничн|ичн)\\p{L}{0,2}"; + + /** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */ + private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}"; + + private static final String MONTH = + "(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*"; + + /** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */ + private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b"; + + /** «12 мая 1985 г.» */ + private static final String DATE_MONTH_WORD = + "\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?"; + + /** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */ + private static final String DATE_WORDS = + "\\b(?:(?iu:двадцать|тридцать)\\s+)?" + + "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат" + + "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)" + + "(?iu:ьего|ого|его)\\s+" + MONTH + + "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)"; + + /** Любая из трёх записей даты; внутри только незахватывающие группы. */ + private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")"; + + /** + * Слова, при которых адрес принадлежит организации, а не человеку: + * адрес отделения банка персональными данными не является. + */ + /** + * Части адреса рядом. Улица, упомянутая в рассказе о городе, адресом клиента не + * является — ровно как адрес отделения банка из технического задания. Требование + * стояло только у постфиксной формы правила, префиксная его не имела. + */ + private static final String ADDRESS_NEARBY = + "(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)"; + + private static final String ORGANISATION_NEARBY = + "(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)"; + + private static final List RULES = List.of( + + // --- Уровень 3: значение опознаётся только рядом с якорным словом --- + + Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92) + .groups(1) + .anchoredBy("cvv", "cvc", "код проверки", "защитный код"), + + Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92) + .groups(1) + .anchoredBy("пин", "pin"), + + // «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456» + Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?" + + "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90) + .groups(1) + .anchoredBy("паспорт"), + + // «серия 4509 номер 123456», «серии 45 09 № 123456» + // Между серией и номером помещается слово: «серия 4509 номер 123456», + // «серии 4509 за номером 123456», «серия 4509 № 123456». + Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90) + .groups(1, 2) + .anchoredBy("сери"), + + Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" + + "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89) + .groups(1) + .anchoredBy("водительск", "в/у", "вод.", "ву "), + + // --- Прочие документы, удостоверяющие личность --- + + Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" + + "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89) + .groups(1) + .anchoredBy("загранпаспорт", "заграничн"), + + Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" + + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89) + .groups(1) + .anchoredBy("военн"), + + Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)" + + "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89) + .groups(1) + .anchoredBy("свидетельств"), + + Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89) + .groups(1) + .anchoredBy("полис"), + + Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) + .groups(1) + .anchoredBy("подразделени", "к/п"), + + // --- Даты с явным якорем --- + + Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("рожден"), + + Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("родил"), + + Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("г.р", "г/р", "года рождения"), + + // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» + Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87) + .groups(1) + .validatedBy(Validators::date) + .anchoredBy("выдач"), + + Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" + + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) + .groups(1) + .anchoredBy("держател", "cardholder", "на имя"), + + // --- ФИО --- + + // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. + // Свободная тройка «любое слово с заглавной + имя + отчество» здесь + // сознательно не используется: она захватывает глагол в начале + // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. + // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. + Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), + + // Имя Отчество Фамилия — второй распространённый порядок слов. + Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79), + + // Иванов И.И. и И.И. Иванов + Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79), + Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79), + + // Имя Отчество без фамилии + Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77), + + // «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру + Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)" + + "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77) + .groups(1) + .anchoredBy("фио", "ф.и.о", "на имя"), + + // «клиент Иванов Иван», «плательщик Петрова» + Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик" + + "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител" + + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*" + + "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77) + .groups(1) + .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", + "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", + "заемщик", "заявител", "доверител", "вкладчик", "ответственн", + "контактное лицо", "исполнител", "держател"), + + // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы + // «Тверская улица» и тому подобное. Имя проверяется по множеству уже + // после совпадения — чередование из ста веток в шаблоне обходится дорого. + Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74) + .validatedBy(NameDictionary::containsGivenName), + Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74) + .validatedBy(NameDictionary::containsGivenName), + + // --- Уровень 1: подтверждается контрольной суммой --- + + Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85) + .validatedBy(Validators::luhn), + + Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84) + .groups(1) + .anchoredBy("инн"), + + Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84) + .groups(1) + .validatedBy(Validators::snils), + + // --- Уровень 2: формат однозначен сам по себе --- + + Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), + + Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80) + .anchoredBy("@"), + + // --- Уровень 3: свободный текст после якорного слова --- + + // «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит, + // её забирает отдельное правило. Приоритет выше городского, иначе от органа + // осталась бы замаскированной только его часть. + Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)" + + "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78) + .groups(1) + .anchoredBy("выдан"), + + Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76) + .groups(1) + .anchoredBy("рождения"), + + Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+" + + "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76) + .groups(1) + .anchoredBy("родил"), + + Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}" + + "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) + .groups(1) + .anchoredBy("гражданств"), + + Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+" + + "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) + .groups(1) + .anchoredBy("граждан"), + + // --- Адрес: каждая составляющая настраивается отдельно --- + + Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("индекс"), + + Rule.of(ADDRESS_POSTCODE, + "\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY), + + Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("г.", "гор", "город"), + + Rule.of(ADDRESS_STREET, + "(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*" + + "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)" + + "\\W{0,3}(" + STREET_NAME + ")", 73) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .requiringNear(ADDRESS_NEARBY) + .anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"), + + // «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому + // принимается только рядом с другими частями адреса: иначе под маску попал бы + // любой рассказ про Невский проспект. + Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+" + + "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .requiringNear(ADDRESS_NEARBY) + .anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"), + + Rule.of(ADDRESS_HOUSE, + "(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("д.", "дом"), + + Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("кв"), + + Rule.of(ADDRESS_COUNTRY, + "(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)" + + "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71) + .groups(1) + .vetoedBy(ORGANISATION_NEARBY) + .anchoredBy("стран"), + + // --- Значения без якоря: принимаются только вместе с другими ПД --- + + // ИНН физлица без якорного слова — только с верной контрольной суммой. + Rule.of(INN, "\\b\\d{12}\\b", 62) + .validatedBy(Validators::inn), + + // Дата без якорного слова персональными данными сама по себе не является: + // маскируется, только если в тексте есть ПД другого типа. + Rule.of(DATE, DATE_ANY, 58) + .validatedBy(Validators::date) + ); + + /** Все типы ПД, которые умеет распознавать сервис. */ + public List knownTypes() { + return RULES.stream().map(Rule::type).distinct().toList(); + } + + /** + * Находит все фрагменты ПД, разрешённые политикой системы. + * Перекрытия здесь не разрешаются — это делает вызывающая сторона. + */ + public List detect(String text, SystemPolicy policy) { + List found = new ArrayList<>(); + String lowercased = text.toLowerCase(Locale.ROOT); + for (Rule rule : RULES) { + if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) { + continue; + } + collect(rule, text, found); + } + return found; + } + + private static void collect(Rule rule, String text, List sink) { + Matcher m = rule.pattern().matcher(text); + while (m.find()) { + for (int group : rule.groups()) { + int start = m.start(group); + int end = m.end(group); + if (start < 0 || end <= start) { + continue; + } + if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) { + continue; + } + if (rule.veto() != null && rule.veto().matcher(surroundings(text, start, end)).find()) { + continue; + } + if (rule.context() != null && !rule.context().matcher(surroundings(text, start, end)).find()) { + continue; + } + sink.add(new Span(start, end, rule.type(), rule.priority())); + } + } + } + + private static String surroundings(String text, int start, int end) { + int from = Math.max(0, start - Rule.VETO_LOOKBEHIND); + int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD); + return text.substring(from, to); + } +} diff --git a/src/main/java/ru/pdguard/detect/Validators.java b/src/main/java/ru/pdguard/detect/Validators.java new file mode 100644 index 0000000..ed8873f --- /dev/null +++ b/src/main/java/ru/pdguard/detect/Validators.java @@ -0,0 +1,125 @@ +package ru.pdguard.detect; + +/** + * Проверки контрольных сумм. Отсекают случайные числовые последовательности, + * которые по форме похожи на ПД, но ими не являются. + */ +public final class Validators { + + private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8}; + private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8}; + private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8}; + + private Validators() { + } + + /** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */ + public static boolean luhn(String value) { + int sum = 0; + int digits = 0; + boolean doubled = false; + for (int i = value.length() - 1; i >= 0; i--) { + char c = value.charAt(i); + if (!Character.isDigit(c)) { + continue; + } + int d = c - '0'; + digits++; + if (doubled) { + d *= 2; + if (d > 9) { + d -= 9; + } + } + sum += d; + doubled = !doubled; + } + return digits >= 13 && digits <= 19 && sum % 10 == 0; + } + + /** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */ + public static boolean inn(String value) { + int[] d = digits(value); + if (d.length == 10) { + return d[9] == checksum(d, INN_10); + } + if (d.length == 12) { + return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B); + } + return false; + } + + /** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */ + public static boolean snils(String value) { + int[] d = digits(value); + if (d.length != 11) { + return false; + } + int sum = 0; + for (int i = 0; i < 9; i++) { + sum += d[i] * (9 - i); + } + int control = sum < 100 ? sum : (sum == 100 || sum == 101 ? 0 : sum % 101 % 100); + return control == d[9] * 10 + d[10]; + } + + /** + * Дата в числовой записи при любом порядке частей: {@code 12.05.1985}, + * {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме + * последовательности вроде {@code 192.168.1}. + */ + public static boolean date(String value) { + // Запись с названием месяца словом в дополнительной проверке не нуждается: + // «мая» само по себе однозначно указывает на дату. + for (int i = 0; i < value.length(); i++) { + if (Character.isLetter(value.charAt(i))) { + return true; + } + } + String[] parts = value.split("[.\\-/]"); + if (parts.length != 3) { + return false; + } + int[] n = new int[3]; + for (int i = 0; i < 3; i++) { + if (parts[i].isEmpty() || parts[i].length() > 4) { + return false; + } + n[i] = Integer.parseInt(parts[i]); + } + for (int y = 0; y < 3; y++) { + if (parts[y].length() == 4) { + return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]); + } + } + // Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре. + return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]); + } + + /** Пара чисел похожа на «день и месяц» в любом порядке. */ + private static boolean dayAndMonth(int a, int b) { + return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12); + } + + private static int checksum(int[] d, int[] weights) { + int sum = 0; + for (int i = 0; i < weights.length; i++) { + sum += d[i] * weights[i]; + } + return sum % 11 % 10; + } + + private static int[] digits(String value) { + int[] out = new int[value.length()]; + int n = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isDigit(c)) { + out[n++] = c - '0'; + } + } + int[] trimmed = new int[n]; + System.arraycopy(out, 0, trimmed, 0, n); + return trimmed; + } +} diff --git a/src/main/java/ru/pdguard/mask/MaskContext.java b/src/main/java/ru/pdguard/mask/MaskContext.java new file mode 100644 index 0000000..a768af6 --- /dev/null +++ b/src/main/java/ru/pdguard/mask/MaskContext.java @@ -0,0 +1,33 @@ +package ru.pdguard.mask; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * Состояние одной операции маскирования. + * + *

Одинаковые значения в пределах запроса получают одинаковую замену: если + * клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл + * запроса для модели сохранится. + * + *

Экземпляр живёт в рамках одного вызова и между потоками не разделяется. + */ +public final class MaskContext { + + /** Разделитель ключа; в названии типа ПД этот знак не встречается. */ + private static final char SEPARATOR = '#'; + + private final Map assigned = new HashMap<>(); + private final Map counters = new HashMap<>(); + + /** + * Замена для значения; при повторе возвращается ранее выданная. + * + * @param factory получает тип ПД и порядковый номер значения этого типа + */ + public String resolve(String type, String value, BiFunction factory) { + return assigned.computeIfAbsent(type + SEPARATOR + value, + key -> factory.apply(type, counters.merge(type, 1, Integer::sum))); + } +} diff --git a/src/main/java/ru/pdguard/mask/MaskMode.java b/src/main/java/ru/pdguard/mask/MaskMode.java new file mode 100644 index 0000000..a7345bc --- /dev/null +++ b/src/main/java/ru/pdguard/mask/MaskMode.java @@ -0,0 +1,14 @@ +package ru.pdguard.mask; + +/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */ +public enum MaskMode { + + /** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */ + MASK, + + /** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */ + TOKEN, + + /** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */ + SYNTHETIC +} diff --git a/src/main/java/ru/pdguard/mask/Masker.java b/src/main/java/ru/pdguard/mask/Masker.java new file mode 100644 index 0000000..abb4236 --- /dev/null +++ b/src/main/java/ru/pdguard/mask/Masker.java @@ -0,0 +1,70 @@ +package ru.pdguard.mask; + +import jakarta.enterprise.context.ApplicationScoped; +import ru.pdguard.detect.RuleRegistry; + +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * Превращает найденное значение в замену согласно настройкам системы. + * + *

Тип, для которого вид маски не задан, скрывается звёздочками целиком — + * безопасное поведение по умолчанию для вновь добавленных правил. + */ +@ApplicationScoped +public class Masker { + + private static final UnaryOperator EDGES = v -> Strategies.keepEdges(v, 2, 2); + private static final UnaryOperator SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2); + + private static final Map> BY_TYPE = Map.ofEntries( + Map.entry(RuleRegistry.EMAIL, Strategies::email), + Map.entry(RuleRegistry.PHONE, EDGES), + Map.entry(RuleRegistry.CARD, EDGES), + Map.entry(RuleRegistry.INN, EDGES), + Map.entry(RuleRegistry.SNILS, EDGES), + Map.entry(RuleRegistry.PASSPORT, EDGES), + Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES), + Map.entry(RuleRegistry.DEPT_CODE, EDGES), + // У этих документов серия короткая — две цифры или две буквы. Оставь мы + // первые два знака, серия оказалась бы открыта целиком, поэтому видны + // только последние. У паспорта РФ и водительского удостоверения серия + // из четырёх знаков, там открывается половина. + Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES), + Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES), + Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES), + Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES), + Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials), + Map.entry(RuleRegistry.FIO, Strategies::initials), + + // Код проверки и пин-код не показываем даже частично: у них слишком + // мало знаков, чтобы открывать хотя бы один. + Map.entry(RuleRegistry.CVV, Strategies::stars), + Map.entry(RuleRegistry.PIN, Strategies::stars), + + Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars), + + // У дат сохраняем разделители: модель видит, что это дата, но не какая. + Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation), + Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation), + Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation), + + Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars), + Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars), + Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars), + Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars), + Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars), + Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars), + Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars), + Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars) + ); + + public String mask(String type, String value, MaskMode mode, MaskContext context) { + return switch (mode) { + case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value); + case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]"); + case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n)); + }; + } +} diff --git a/src/main/java/ru/pdguard/mask/Strategies.java b/src/main/java/ru/pdguard/mask/Strategies.java new file mode 100644 index 0000000..4c69594 --- /dev/null +++ b/src/main/java/ru/pdguard/mask/Strategies.java @@ -0,0 +1,115 @@ +package ru.pdguard.mask; + +/** + * Способы преобразования найденного значения в маску. + * + *

Все стратегии сохраняют длину и разделители исходного значения: так + * замаскированный текст остаётся читаемым для LLM и минимально отличается + * от эталона при посимвольном сравнении. + */ +public final class Strategies { + + private static final char MASK = '*'; + + private Strategies() { + } + + /** Каждый непробельный символ заменяется на «*». */ + public static String stars(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + sb.append(Character.isWhitespace(c) ? c : MASK); + } + return sb.toString(); + } + + /** + * Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, + * {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, + * само значение — нет. + */ + public static String starsKeepingPunctuation(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + sb.append(Character.isLetterOrDigit(c) ? MASK : c); + } + return sb.toString(); + } + + /** + * Оставляет первые и последние значащие символы, остальные скрывает, + * разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}. + */ + public static String keepEdges(String value, int head, int tail) { + int significant = 0; + for (int i = 0; i < value.length(); i++) { + if (Character.isLetterOrDigit(value.charAt(i))) { + significant++; + } + } + if (significant <= head + tail) { + return stars(value); + } + StringBuilder sb = new StringBuilder(value.length()); + int seen = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!Character.isLetterOrDigit(c)) { + sb.append(c); + continue; + } + boolean visible = seen < head || seen >= significant - tail; + sb.append(visible ? c : MASK); + seen++; + } + return sb.toString(); + } + + /** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */ + public static String initials(String value) { + StringBuilder sb = new StringBuilder(); + boolean wordStart = true; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (Character.isLetter(c)) { + if (wordStart) { + if (!sb.isEmpty()) { + sb.append(' '); + } + sb.append(Character.toUpperCase(c)).append('.'); + wordStart = false; + } + } else { + wordStart = true; + } + } + return sb.isEmpty() ? stars(value) : sb.toString(); + } + + /** + * Адрес почты: видны первая буква имени ящика, первая буква домена и зона. + * {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru} + */ + public static String email(String value) { + int at = value.lastIndexOf('@'); + if (at <= 0 || at == value.length() - 1) { + return stars(value); + } + String local = value.substring(0, at); + String domain = value.substring(at + 1); + int dot = domain.lastIndexOf('.'); + if (dot <= 0) { + return hideTail(local) + '@' + hideTail(domain); + } + return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot); + } + + private static String hideTail(String part) { + if (part.length() <= 1) { + return part; + } + return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1); + } +} diff --git a/src/main/java/ru/pdguard/mask/Synthetic.java b/src/main/java/ru/pdguard/mask/Synthetic.java new file mode 100644 index 0000000..f9a24ba --- /dev/null +++ b/src/main/java/ru/pdguard/mask/Synthetic.java @@ -0,0 +1,95 @@ +package ru.pdguard.mask; + +import ru.pdguard.detect.RuleRegistry; + +/** + * Правдоподобные подставные значения вместо настоящих. + * + *

Модель получает текст, который выглядит естественно, и качество ответа + * страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же + * исходное значение всегда даёт одну и ту же подстановку. + */ +final class Synthetic { + + private static final String[] SURNAMES = + {"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"}; + private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"}; + private static final String[] PATRONYMICS = + {"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"}; + private static final String[] DOMAINS = {"example.com", "example.org", "example.net"}; + + private Synthetic() { + } + + static String forType(String type, String value, int ordinal) { + int seed = Math.abs(value.hashCode()); + return switch (type) { + case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) + + " " + pick(PATRONYMICS, seed >> 6); + case RuleRegistry.CARDHOLDER -> "IVAN PETROV"; + case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); + case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) + + "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2); + case RuleRegistry.CARD -> luhnCard(seed); + case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT, + RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); + case RuleRegistry.INN -> digits(seed, 12); + case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16); + case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) + + "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2); + case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed); + case RuleRegistry.ADDRESS_CITY -> "Зареченск"; + case RuleRegistry.ADDRESS_STREET -> "Сосновая"; + case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90)); + case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200)); + case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6); + case RuleRegistry.ADDRESS_COUNTRY -> "Заречье"; + case RuleRegistry.CVV -> digits(seed, 3); + case RuleRegistry.PIN -> digits(seed, 4); + // Для остальных типов правдоподобной замены нет — отдаём токен. + default -> "[" + type + "_" + ordinal + "]"; + }; + } + + private static String pick(String[] options, int seed) { + return options[Math.floorMod(seed, options.length)]; + } + + private static String syntheticDate(int seed) { + int day = 1 + Math.floorMod(seed, 28); + int month = 1 + Math.floorMod(seed >> 5, 12); + int year = 1960 + Math.floorMod(seed >> 9, 45); + return String.format("%02d.%02d.%d", day, month, year); + } + + private static String digits(int seed, int count) { + StringBuilder sb = new StringBuilder(count); + int value = Math.abs(seed); + for (int i = 0; i < count; i++) { + sb.append((char) ('0' + Math.floorMod(value, 10))); + value = value / 10 + (i + 1) * 7; + } + return sb.toString(); + } + + /** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */ + private static String luhnCard(int seed) { + StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); + int sum = 0; + boolean doubled = true; + for (int i = body.length() - 1; i >= 0; i--) { + int d = body.charAt(i) - '0'; + if (doubled) { + d *= 2; + if (d > 9) { + d -= 9; + } + } + sum += d; + doubled = !doubled; + } + body.append((10 - sum % 10) % 10); + return body.substring(0, 4) + " " + body.substring(4, 8) + " " + + body.substring(8, 12) + " " + body.substring(12); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..f0bfe05 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,50 @@ +quarkus.http.port=8080 +# Порт тестов уведён со стандартного 8081: его занимает узел кластера. +%test.quarkus.http.test-port=8089 +quarkus.http.host=0.0.0.0 + +# Обработка идёт на рабочих потоках: текст на 100 000 токенов не должен +# занимать поток цикла событий. +quarkus.vertx.worker-pool-size=200 +quarkus.http.limits.max-body-size=16M + +# Словари имён читаются из classpath — в образ native их надо включить явно. +quarkus.native.resources.includes=names/*.txt + +quarkus.log.level=INFO +quarkus.log.category."ru.pdguard".level=INFO + +# Метрики Prometheus: latency и RPS считаются по pdguard_process_seconds, +# TPS — по pdguard_tokens_processed_total. +quarkus.micrometer.export.prometheus.path=/metrics +quarkus.micrometer.binder.http-server.enabled=true + +# Общий слой соответствий для работы на нескольких узлах: memory или redis. +# При memory клиент Redis не создаётся и подключение не устанавливается. +pdguard.store.backend=memory +# Redis поднимаем сами, автоматический контейнер не нужен. +quarkus.redis.devservices.enabled=false +# Адрес требуется расширению уже на старте, но соединение устанавливается +# только при первой команде — а её не будет, пока backend=memory. +quarkus.redis.hosts=redis://localhost:6379 +# Общий слой не должен утяжелять ответ: при недоступности Redis узел уходит +# на свою память через 200 мс, а не через штатные десять секунд. +quarkus.redis.timeout=200ms + +# Список систем-потребителей. Файла нет — работают настройки по умолчанию. +pdguard.systems-file=config/systems.json +%test.pdguard.systems-file=src/test/resources/systems-test.json + +# Вторая ступень распознавания имён. Свойство pdguard.ner.model не задано — +# ступень выключена и работают только правила. Модель обучается отдельно, см. README. +# Каждый неразобранный кандидат стоит около 240 мкс, поэтому их число +# на один запрос ограничено. +pdguard.ner.max-candidates=16 +# Распознаватели создаются и прогреваются на старте, по одному на этот счётчик. +pdguard.ner.pool-size=16 + +# Порог, после которого сервис отвечает 429 вместо накопления очереди. +pdguard.max-concurrent=2000 +# Ограничения хранилища соответствий: суммарный объём строк и срок жизни. +pdguard.store.max-chars=134217728 +pdguard.store.ttl-minutes=30 diff --git a/src/main/resources/names/given-names.txt b/src/main/resources/names/given-names.txt new file mode 100644 index 0000000..83f781a --- /dev/null +++ b/src/main/resources/names/given-names.txt @@ -0,0 +1,167 @@ +# Основы русских личных имён. Правило дописывает до трёх строчных букв, +# поэтому падежные формы (Ивану, Иваном, Ивана) покрываются основой. +# Строка — одна основа; строки с # игнорируются. +Александр +Алексей +Анатолий +Андрей +Антон +Аркадий +Арсений +Артём +Артем +Артур +Богдан +Борис +Вадим +Валентин +Валерий +Василий +Виктор +Виталий +Владимир +Владислав +Вячеслав +Геннадий +Георгий +Герман +Глеб +Григорий +Даниил +Данил +Денис +Дмитрий +Евгений +Егор +Иван +Игорь +Илья +Кирилл +Константин +Леонид +Максим +Марк +Матвей +Михаил +Никита +Николай +Олег +Павел +Пётр +Петр +Роман +Руслан +Семён +Семен +Сергей +Станислав +Степан +Тимофей +Тимур +Фёдор +Федор +Эдуард +Юрий +Ярослав +Алёна +Алена +Алина +Алла +Анастасия +Ангелина +Анна +Антонина +Валентина +Валерия +Варвара +Вероника +Виктория +Галина +Дарья +Диана +Евгения +Екатерина +Елена +Елизавета +Жанна +Зинаида +Инна +Ирина +Карина +Кристина +Ксения +Лариса +Лидия +Любовь +Людмила +Маргарита +Марина +Мария +Надежда +Наталья +Наталия +Нина +Оксана +Ольга +Полина +Раиса +Регина +Светлана +София +Софья +Тамара +Татьяна +Ульяна +Юлия +Лев +Яков +Ян +Захар +Тарас +Савва +Мирон +Демид +Клим +Влас +Родион +Святослав +Всеволод +Игнат +Филипп +Лука +Назар +Платон +Прохор +Трофим +Фома +Эмиль +Юлиан +Тихон +Гавриил +Давид +Марат +Рустам +Яна +Алиса +Василиса +Агата +Злата +Милана +Дарина +Есения +Таисия +Инга +Вера +Эмма +Нелли +Алевтина +Клавдия +Лилия +Римма +Элина +Ева +Аделина +Амина +Динара +Лейла +Сабина diff --git a/src/main/resources/names/well-known.txt b/src/main/resources/names/well-known.txt new file mode 100644 index 0000000..f606867 --- /dev/null +++ b/src/main/resources/names/well-known.txt @@ -0,0 +1,45 @@ +# Известные исторические и культурные фигуры. Упоминание такого имени +# персональными данными не является — если рядом нет ПД другого типа. +# Сравнение идёт по началу слова, поэтому падежи покрываются основой. +Пушкин +Лермонтов +Толстой +Достоевский +Гоголь +Чехов +Тургенев +Некрасов +Есенин +Маяковский +Ахматова +Цветаева +Булгаков +Пастернак +Чайковский +Ломоносов +Менделеев +Гагарин +Королёв +Суворов +Кутузов +Шекспир +Эйнштейн +Ньютон +Моцарт +Бетховен +Рахманинов +Репин +Айвазовский +Циолковский +Онегин +Печорин +Раскольников +Обломов +Чичиков +Базаров +Болконский +Каренин +Чацкий +Мцыри +Хлестаков +Митрофанушка diff --git a/src/main/resources/systems.json b/src/main/resources/systems.json new file mode 100644 index 0000000..ed10b52 --- /dev/null +++ b/src/main/resources/systems.json @@ -0,0 +1,27 @@ +{ + "default": { + "enabled": true, + "demask": true, + "maskMode": "MASK", + "types": ["*"], + "requireCompanion": ["CVV", "PIN", "DATE"] + }, + "crm": { + "enabled": true, + "demask": false, + "maskMode": "TOKEN", + "types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"] + }, + "analytics": { + "enabled": true, + "demask": false, + "maskMode": "SYNTHETIC", + "types": ["*"] + }, + "legacy-billing": { + "enabled": false, + "demask": false, + "maskMode": "MASK", + "types": ["*"] + } +} diff --git a/src/test/java/ru/pdguard/BenchmarkTest.java b/src/test/java/ru/pdguard/BenchmarkTest.java new file mode 100644 index 0000000..ab3ddc7 --- /dev/null +++ b/src/test/java/ru/pdguard/BenchmarkTest.java @@ -0,0 +1,350 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.core.Span; +import ru.pdguard.detect.NameCascade; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Замер качества детекции на размеченных наборах. + * + *

Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому + * его оценка завышена и годится только как защита от ухудшений. + * {@code benchmark-holdout.txt} составлен независимо и на нём правила не + * настраивались — именно он показывает настоящее качество. + * + *

Метрики посимвольные: так они не зависят от того, где именно правило + * поставило границу совпадения, и напрямую соотносятся с посимвольным + * сравнением замаскированного текста с эталоном. + * + *

Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, + * а расхождение в названии типа (скажем, место рождения против города) на + * качество маскирования не влияет. + */ +class BenchmarkTest { + + /** Модель второй ступени; собирается отдельно, см. README. */ + private static final String MODEL_PATH = "models/ru-ner-person.bin"; + + private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}"); + + /** Размеченный пример: чистый текст и эталонные фрагменты. */ + private record Sample(String text, List gold) { + } + + /** Итог замера по одному набору. */ + private record Result(double fioF1, double overallPrecision, double overallRecall, + double falsePositiveRate, int foundFioSpans, int goldFioSpans) { + } + + /** Накопитель посимвольных совпадений по одному типу. */ + private static final class Score { + private int truePositive; + private int falsePositive; + private int falseNegative; + + private int gold() { + return truePositive + falseNegative; + } + + private double precision() { + int found = truePositive + falsePositive; + return found == 0 ? 1.0 : (double) truePositive / found; + } + + private double recall() { + return gold() == 0 ? 1.0 : (double) truePositive / gold(); + } + + private double f1() { + double p = precision(); + double r = recall(); + return p + r == 0 ? 0.0 : 2 * p * r / (p + r); + } + } + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); + + /** + * Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение + * означает, что сломалось то, что раньше работало. + */ + @Test + void detectionQualityOnTuningSet() { + Result result = measure("/benchmark.txt", "набор отладки"); + + assertTrue(result.fioF1() >= 0.95, + String.format("F1 по ФИО упал до %.3f", result.fioF1())); + assertTrue(result.overallRecall() >= 0.95, + String.format("полнота по всем типам упала до %.3f", result.overallRecall())); + assertTrue(result.falsePositiveRate() <= 0.05, + String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate())); + } + + /** + * Отложенный набор: правила на нём не настраивались. Пороги ниже — они + * отражают измеренное на нём качество, а не желаемое. + */ + @Test + void detectionQualityOnHoldoutSet() { + Result result = measure("/benchmark-holdout.txt", "отложенный набор"); + + assertTrue(result.fioF1() >= 0.75, + String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1())); + assertTrue(result.overallRecall() >= 0.75, + String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall())); + assertTrue(result.falsePositiveRate() <= 0.15, + String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate())); + } + + /** + * Контрольный набор. Правила по нему не настраиваются: он существует, чтобы + * показывать качество на данных, которых разработка не видела. Пороги здесь + * низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение. + * Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах. + */ + @Test + void detectionQualityOnSecondHoldoutSet() { + Pipeline stage = Files.isReadable(Path.of(MODEL_PATH)) + ? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), + new NameCascade(Optional.of(MODEL_PATH), 16, 4)) + : pipeline; + Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор"); + + assertTrue(result.fioF1() >= 0.70, + String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1())); + assertTrue(result.overallRecall() >= 0.70, + String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall())); + } + + /** + * Тот же отложенный набор, но со включённой второй ступенью. Модели нет — + * проверка пропускается: в сборке без модели сервис работает на одних правилах. + */ + @Test + void detectionQualityWithNameCascade() { + Path model = Path.of(MODEL_PATH); + assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана"); + + Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(), + new PayloadStore(10_000_000L, 30), new NameCascade(Optional.of(MODEL_PATH), 16, 4)); + Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена"); + + assertTrue(result.fioF1() >= 0.75, + String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1())); + } + + private Result measure(String resource, String title) { + return measure(pipeline, resource, title); + } + + private Result measure(Pipeline stage, String resource, String title) { + List samples = load(resource); + Map byType = new LinkedHashMap<>(); + Score anyType = new Score(); + + int cleanTexts = 0; + int cleanTextsWithFalseHit = 0; + int goldFioSpans = 0; + int foundFioSpans = 0; + List falseHits = new ArrayList<>(); + List missedFio = new ArrayList<>(); + List overMasked = new ArrayList<>(); + + for (Sample sample : samples) { + List found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT); + + String[] goldChars = paint(sample.text().length(), sample.gold()); + String[] foundChars = paint(sample.text().length(), found); + + for (int i = 0; i < sample.text().length(); i++) { + account(byType, goldChars[i], foundChars[i]); + accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null); + } + + if (sample.gold().isEmpty()) { + cleanTexts++; + if (!found.isEmpty()) { + cleanTextsWithFalseHit++; + falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text()); + } + } else { + collectOverMasked(sample.text(), goldChars, foundChars, overMasked); + } + + for (Span gold : sample.gold()) { + if (!RuleRegistry.FIO.equals(gold.type())) { + continue; + } + goldFioSpans++; + if (overlappedByFio(gold, found)) { + foundFioSpans++; + } else { + missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text()); + } + } + } + + report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans, + cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked); + + Score fio = byType.getOrDefault(RuleRegistry.FIO, new Score()); + double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts; + return new Result(fio.f1(), anyType.precision(), anyType.recall(), + falsePositiveRate, foundFioSpans, goldFioSpans); + } + + /** Раскрашивает каждый знак текста типом ПД, который его покрывает. */ + private static String[] paint(int length, List spans) { + String[] painted = new String[length]; + for (Span span : spans) { + for (int i = span.start(); i < Math.min(span.end(), length); i++) { + painted[i] = span.type(); + } + } + return painted; + } + + private static void account(Map byType, String gold, String found) { + if (gold != null) { + Score score = byType.computeIfAbsent(gold, t -> new Score()); + if (gold.equals(found)) { + score.truePositive++; + } else { + score.falseNegative++; + } + } + if (found != null && !found.equals(gold)) { + byType.computeIfAbsent(found, t -> new Score()).falsePositive++; + } + } + + private static void accountAnyType(Score score, boolean gold, boolean found) { + if (gold && found) { + score.truePositive++; + } else if (gold) { + score.falseNegative++; + } else if (found) { + score.falsePositive++; + } + } + + private static boolean overlappedByFio(Span gold, List found) { + return found.stream() + .anyMatch(span -> RuleRegistry.FIO.equals(span.type()) && span.overlaps(gold)); + } + + private static String fragment(String text, Span span) { + return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»"; + } + + /** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */ + private static void collectOverMasked(String text, String[] gold, String[] found, List sink) { + int from = -1; + for (int i = 0; i <= text.length(); i++) { + boolean extra = i < text.length() && found[i] != null && gold[i] == null; + if (extra && from < 0) { + from = i; + } else if (!extra && from >= 0) { + sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text); + from = -1; + } + } + } + + private void report(String title, int samples, Map byType, Score anyType, + int goldFio, int foundFio, int cleanTexts, int falseHitTexts, + List missedFio, List falseHits, List overMasked) { + StringBuilder out = new StringBuilder(4096); + out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n"); + out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1")); + + byType.entrySet().stream() + .sorted(Comparator.comparingInt((Map.Entry e) -> e.getValue().gold()).reversed()) + .forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", + e.getKey(), e.getValue().gold(), e.getValue().precision(), + e.getValue().recall(), e.getValue().f1()))); + + out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(), + anyType.precision(), anyType.recall(), anyType.f1())); + + out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n", + foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio)); + out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n", + falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts)); + + appendList(out, "\nНе найденные ФИО:", missedFio); + appendList(out, "\nЛожные срабатывания:", falseHits); + appendList(out, "\nЗамаскировано сверх эталона:", overMasked); + + System.out.println(out); + } + + private static void appendList(StringBuilder out, String title, List lines) { + if (lines.isEmpty()) { + return; + } + out.append(title).append('\n'); + lines.forEach(line -> out.append(" ").append(line).append('\n')); + } + + private static List load(String resource) { + List samples = new ArrayList<>(); + try (InputStream in = BenchmarkTest.class.getResourceAsStream(resource); + BufferedReader reader = new BufferedReader( + new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + samples.add(parse(trimmed)); + } + } + } catch (IOException e) { + throw new IllegalStateException("Не удалось прочитать " + resource, e); + } + return samples; + } + + /** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */ + private static Sample parse(String line) { + StringBuilder text = new StringBuilder(line.length()); + List gold = new ArrayList<>(); + Matcher m = MARKUP.matcher(line); + int cursor = 0; + while (m.find()) { + text.append(line, cursor, m.start()); + int start = text.length(); + text.append(m.group(2)); + gold.add(new Span(start, text.length(), m.group(1), 0)); + cursor = m.end(); + } + text.append(line, cursor, line.length()); + return new Sample(text.toString(), gold); + } +} diff --git a/src/test/java/ru/pdguard/ContextDetectionTest.java b/src/test/java/ru/pdguard/ContextDetectionTest.java new file mode 100644 index 0000000..5b9464f --- /dev/null +++ b/src/test/java/ru/pdguard/ContextDetectionTest.java @@ -0,0 +1,137 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Типы ПД, которые опознаются только рядом с якорным словом. */ +class ContextDetectionTest { + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } + + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } + + @Test + void masksPassportInEveryNotation() { + assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456"); + assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456"); + assertHidden("ПАСПОРТ 4509123456", "4509123456"); + assertHidden("Серия 4509 номер 123456", "4509"); + assertHidden("серии 45 09 № 123456", "123456"); + } + + @Test + void masksPassportSeriesAndNumberSplitByWords() { + String masked = mask("Документ: серия 4509 номер 123456, выдан отделом"); + assertTrue(masked.contains("серия "), masked); + assertTrue(masked.contains("номер "), masked); + assertFalse(masked.contains("4509"), masked); + assertFalse(masked.contains("123456"), masked); + } + + @Test + void masksDepartmentCode() { + assertHidden("Код подразделения 770-001", "770-001"); + assertHidden("к/п 770001", "770001"); + } + + @Test + void masksIssuingAuthorityButNotTheDateAfterIt() { + String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015"); + assertFalse(masked.contains("ОУФМС"), masked); + assertFalse(masked.contains("12.05.2015"), masked); + assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked); + } + + @Test + void masksDriverLicense() { + assertHidden("Водительское удостоверение 9902 123456", "9902 123456"); + assertHidden("в/у 99 02 123456", "99 02 123456"); + } + + @Test + void masksCitizenship() { + assertHidden("Гражданство: РФ", "РФ"); + assertHidden("гражданство Республики Беларусь", "Беларусь"); + assertHidden("Гражданин России обратился", "России"); + } + + @Test + void masksBirthPlace() { + assertHidden("Место рождения: город Тверь, проживает в Москве", "город Тверь"); + assertHidden("Родился в Нижнем Новгороде", "Нижнем Новгороде"); + } + + @Test + void masksCardholderName() { + assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV"); + assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV"); + } + + @Test + void masksSecurityCodeAndPinCompletely() { + String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321"); + assertFalse(masked.contains("123,"), masked); + assertFalse(masked.contains("4321"), masked); + assertTrue(masked.contains("***"), masked); + } + + @Test + void doesNotMaskPinWithoutAnyOtherPersonalData() { + String text = "Пин-код 1234 введён неверно"; + assertEquals(text, mask(text), "одиночный пин-код персональными данными не является"); + } + + @Test + void masksPinWhenCardNumberIsAlsoPresent() { + assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от"); + } + + @Test + void anchorWordsAreCaseInsensitive() { + assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456"); + assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456"); + } + + @Test + void complexSentenceKeepsSurroundingWords() { + String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, " + + "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67"; + String masked = mask(original); + + assertTrue(masked.startsWith("Клиент, паспорт "), masked); + assertTrue(masked.contains("код подразделения"), masked); + assertTrue(masked.contains("телефон"), masked); + assertFalse(masked.contains("4509 123456"), masked); + assertFalse(masked.contains("770301234550"), masked); + } + + @Test + void unmaskingRestoresComplexSentence() { + String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, " + + "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111"; + String id = "complex-1"; + + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertFalse(masked.contains("4509 123456"), masked); + assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); + } +} diff --git a/src/test/java/ru/pdguard/DateAndAddressTest.java b/src/test/java/ru/pdguard/DateAndAddressTest.java new file mode 100644 index 0000000..1a2d089 --- /dev/null +++ b/src/test/java/ru/pdguard/DateAndAddressTest.java @@ -0,0 +1,143 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Даты во всех вариантах записи и составляющие адреса. */ +class DateAndAddressTest { + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } + + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } + + @Test + void masksBirthDateInAnyPartOrder() { + assertHidden("Дата рождения 12.05.1985", "12.05.1985"); + assertHidden("дата рождения: 05/12/1985", "05/12/1985"); + assertHidden("Дата рождения 1985-12-05", "1985-12-05"); + assertHidden("Родился 12-05-1985", "12-05-1985"); + assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985"); + } + + @Test + void masksBirthDateWrittenWithWords() { + assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985"); + assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года", + "двенадцатого мая"); + assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа"); + } + + @Test + void keepsSeparatorsInMaskedDate() { + String masked = mask("Дата рождения 12.05.1985"); + assertTrue(masked.endsWith("**.**.****"), masked); + } + + @Test + void masksPassportIssueDate() { + assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015"); + } + + @Test + void doesNotMaskDateWithoutAnyOtherPersonalData() { + String text = "Встреча перенесена на 12.05.2025, подтвердите"; + assertEquals(text, mask(text), "дата сама по себе персональными данными не является"); + } + + @Test + void masksBareDateWhenOtherPersonalDataIsPresent() { + assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015"); + } + + @Test + void doesNotTreatVersionOrAddressLikeNumbersAsDate() { + String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута"; + assertEquals(text, mask(text)); + } + + @Test + void masksAddressComponentsSeparately() { + String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15"); + assertFalse(masked.contains("125009"), masked); + assertFalse(masked.contains("Москва"), masked); + assertFalse(masked.contains("Тверская"), masked); + assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked); + assertTrue(masked.contains("ул. "), masked); + } + + @Test + void streetNameDoesNotSwallowTheRestOfTheSentence() { + String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта"); + assertTrue(masked.contains("перекрыта из-за ремонта"), + "название улицы это одно-три слова, а не остаток предложения: " + masked); + assertFalse(masked.contains("Сосновая"), masked); + } + + @Test + void doesNotMaskStreetMentionedOutsideAnAddress() { + assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера")); + assertEquals("Улица Весенняя названа в честь праздника", + mask("Улица Весенняя названа в честь праздника")); + } + + @Test + void masksMultiWordStreetName() { + String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4"); + assertFalse(masked.contains("Малая Никитская"), masked); + } + + @Test + void masksIndexByAnchorWord() { + assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009"); + } + + @Test + void doesNotMaskBankBranchAddress() { + String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00"; + assertEquals(text, mask(text), "адрес отделения банка персональными данными не является"); + } + + @Test + void doesNotMaskOfficeAddress() { + String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1"; + assertEquals(text, mask(text)); + } + + @Test + void addressTypesAreConfigurableSeparately() { + SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY); + String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity); + + assertFalse(masked.contains("Москва"), masked); + assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked); + } + + @Test + void unmaskingRestoresTextWithDateAndAddress() { + String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, " + + "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456"; + String id = "date-addr-1"; + + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertFalse(masked.contains("12.05.1985"), masked); + assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); + } +} diff --git a/src/test/java/ru/pdguard/FioTest.java b/src/test/java/ru/pdguard/FioTest.java new file mode 100644 index 0000000..deb63a4 --- /dev/null +++ b/src/test/java/ru/pdguard/FioTest.java @@ -0,0 +1,131 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** ФИО и защита от ложных срабатываний. */ +class FioTest { + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + + private String mask(String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } + + private void assertHidden(String text, String secret) { + String masked = mask(text); + assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } + + private void assertUnchanged(String text) { + assertEquals(text, mask(text), "ложное срабатывание"); + } + + @Test + void masksFullNameAsInitials() { + assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился")); + } + + @Test + void masksNameAndPatronymicWithoutSurname() { + assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича"); + } + + @Test + void masksFemalePatronymic() { + assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна"); + assertHidden("Мария Никитична ждёт ответа", "Мария Никитична"); + } + + @Test + void masksSurnameWithInitialsInBothOrders() { + assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И."); + assertHidden("Подписал И.И. Иванов", "И.И. Иванов"); + } + + @Test + void masksSurnameNextToKnownGivenName() { + assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей"); + assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров"); + assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой"); + } + + @Test + void masksLowercaseNameAfterExplicitAnchor() { + assertHidden("ФИО: иванов иван иванович", "иванов иван иванович"); + assertHidden("Карта оформлена на имя петров сергей", "петров сергей"); + } + + @Test + void masksNameAfterRoleAnchor() { + assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей"); + assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва"); + } + + @Test + void doesNotMaskWellKnownPerson() { + assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень"); + assertUnchanged("Сравни Толстого и Достоевского как прозаиков"); + assertUnchanged("Когда Гагарин полетел в космос"); + } + + @Test + void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() { + assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин"); + } + + @Test + void doesNotMaskPlaceNamesThatLookLikeSurnames() { + assertUnchanged("Московский Кремль открыт для посещения"); + assertUnchanged("Экскурсия в Нижний Новгород перенесена"); + assertUnchanged("Смоленская площадь закрыта на ремонт"); + } + + @Test + void doesNotMaskOrdinaryCapitalisedWords() { + assertUnchanged("Банк Открытие подтвердил лимит"); + assertUnchanged("В Понедельник Отдел Согласует Договор"); + } + + @Test + void identificationIgnoresCase() { + assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ"); + assertHidden("фио: петрова анна ивановна", "петрова анна ивановна"); + } + + @Test + void unmaskingRestoresNames() { + String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, " + + "дата рождения 12.05.1985, телефон +7 916 123-45-67"; + String id = "fio-1"; + + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertFalse(masked.contains("Иванов Иван Иванович"), masked); + assertTrue(masked.contains("И. И. И."), masked); + assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); + } + + @Test + void namesStayFastOnLargeText() { + String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. "; + String large = block.repeat(4000); + + long started = System.nanoTime(); + String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT); + long millis = (System.nanoTime() - started) / 1_000_000; + + assertFalse(masked.contains("Иванов Иван Иванович")); + assertTrue(millis < 1000, "обработка заняла " + millis + " мс"); + } +} diff --git a/src/test/java/ru/pdguard/IdentityDocumentTest.java b/src/test/java/ru/pdguard/IdentityDocumentTest.java new file mode 100644 index 0000000..6355f8b --- /dev/null +++ b/src/test/java/ru/pdguard/IdentityDocumentTest.java @@ -0,0 +1,70 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** Документы, удостоверяющие личность, помимо паспорта РФ. */ +class IdentityDocumentTest { + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + + private void assertHidden(String text, String secret) { + String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); + } + + private void assertMasked(String text, String payloadId, String expected) { + assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT)); + } + + @Test + void masksForeignPassport() { + assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567"); + } + + @Test + void masksMilitaryId() { + assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567"); + } + + @Test + void masksBirthCertificate() { + assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456"); + } + + @Test + void masksMedicalPolicy() { + assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456"); + } + + /** + * У загранпаспорта, военного билета и свидетельства о рождении серия короткая — + * две цифры или две буквы. Открой маска первые два знака, серия была бы видна + * целиком, поэтому у этих документов открыты только последние знаки номера. + */ + @Test + void hidesShortDocumentSeriesCompletely() { + assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67"); + assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67"); + assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1", + "Свидетельство о рождении **-** № ****56"); + } + + /** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */ + @Test + void keepsHalfOfFourCharacterSeries() { + assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56"); + assertMasked("Водительское удостоверение 9902 123456", "dl-1", + "Водительское удостоверение 99** ****56"); + } +} diff --git a/src/test/java/ru/pdguard/MaskModeTest.java b/src/test/java/ru/pdguard/MaskModeTest.java new file mode 100644 index 0000000..1e1f100 --- /dev/null +++ b/src/test/java/ru/pdguard/MaskModeTest.java @@ -0,0 +1,85 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.detect.Validators; +import ru.pdguard.mask.MaskMode; +import ru.pdguard.mask.Masker; + +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Виды замены: звёздочки, токены, правдоподобные значения. */ +class MaskModeTest { + + private final Pipeline pipeline = + new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + + private SystemPolicy policy(MaskMode mode) { + return new SystemPolicy(true, true, mode, Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion()); + } + + private String mask(MaskMode mode, String text) { + return pipeline.process(text, UUID.randomUUID().toString(), policy(mode)); + } + + @Test + void tokenModeNumbersEachType() { + String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru"); + assertTrue(masked.contains("[FIO_1]"), masked); + assertTrue(masked.contains("[EMAIL_1]"), masked); + } + + @Test + void sameValueGetsSameTokenWithinRequest() { + String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru"); + assertEquals(2, count(masked, "[EMAIL_1]"), masked); + assertEquals(1, count(masked, "[EMAIL_2]"), masked); + } + + @Test + void syntheticModeProducesPlausibleValues() { + String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана"); + + assertFalse(masked.contains("4111 1111 1111 1111"), masked); + assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked); + + Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked); + assertTrue(card.find(), masked); + assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна"); + } + + @Test + void syntheticValuesAreStable() { + String text = "Почта ivan@mail.ru, паспорт 4509 123456"; + assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text)); + } + + @Test + void unmaskingWorksInEveryMode() { + String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru"; + for (MaskMode mode : MaskMode.values()) { + String id = "mode-" + mode; + String masked = pipeline.process(original, id, policy(mode)); + assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked); + assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name()); + } + } + + private static int count(String text, String fragment) { + int n = 0; + for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) { + n++; + } + return n; + } +} diff --git a/src/test/java/ru/pdguard/NameCascadeTest.java b/src/test/java/ru/pdguard/NameCascadeTest.java new file mode 100644 index 0000000..d365379 --- /dev/null +++ b/src/test/java/ru/pdguard/NameCascadeTest.java @@ -0,0 +1,56 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.NameCascade; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** Вторая ступень не должна вредить первой. */ +class NameCascadeTest { + + private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; + + private String mask(NameCascade cascade, String payloadId) { + Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), + new PayloadStore(1_000_000L, 30), cascade); + return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT); + } + + @Test + void withoutModelTheStageIsOff() { + NameCascade cascade = NameCascade.disabled(); + assertFalse(cascade.enabled()); + assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1")); + } + + @Test + void missingModelFileDoesNotBreakMasking(@TempDir Path dir) { + NameCascade cascade = new NameCascade(Optional.of(dir.resolve("нет-модели.bin").toString()), 16, 4); + assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень"); + assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1")); + } + + @Test + void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException { + Path broken = dir.resolve("испорченная.bin"); + Files.writeString(broken, "это не модель", StandardCharsets.UTF_8); + + NameCascade cascade = new NameCascade(Optional.of(broken.toString()), 16, 4); + assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень"); + assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"), + "маскирование по правилам обязано работать и без второй ступени"); + } +} diff --git a/src/test/java/ru/pdguard/PayloadStoreTest.java b/src/test/java/ru/pdguard/PayloadStoreTest.java new file mode 100644 index 0000000..66e6733 --- /dev/null +++ b/src/test/java/ru/pdguard/PayloadStoreTest.java @@ -0,0 +1,53 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.core.PayloadStore; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Ограничения хранилища соответствий: объём и срок жизни. */ +class PayloadStoreTest { + + @Test + void returnsWhatWasStored() { + PayloadStore store = new PayloadStore(1_000_000L, 30); + store.put("id", "исходный текст", "маска"); + + PayloadStore.Entry entry = store.byId("id"); + assertNotNull(entry); + assertEquals("исходный текст", entry.original()); + assertEquals("маска", entry.masked()); + assertEquals("исходный текст", store.originalForMask("маска")); + } + + @Test + void forgetsEntriesAfterTheirLifetime() { + PayloadStore store = new PayloadStore(1_000_000L, 0); + store.put("id", "исходный текст", "маска"); + + assertNull(store.byId("id"), "запись с истёкшим сроком жизни не должна отдаваться"); + assertNull(store.originalForMask("маска")); + } + + @Test + void evictsOldestWhenOverSizeLimit() { + PayloadStore store = new PayloadStore(100L, 30); + for (int i = 0; i < 50; i++) { + store.put("id" + i, "текст номер " + i, "маска номер " + i); + } + + assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld()); + assertNull(store.byId("id0"), "самая старая запись должна быть вытеснена"); + assertNotNull(store.byId("id49"), "последняя запись должна остаться"); + } + + @Test + void unknownKeysReturnNothing() { + PayloadStore store = new PayloadStore(1_000_000L, 30); + assertNull(store.byId("нет такого")); + assertNull(store.originalForMask("нет такой маски")); + } +} diff --git a/src/test/java/ru/pdguard/PipelineTest.java b/src/test/java/ru/pdguard/PipelineTest.java new file mode 100644 index 0000000..bc92b21 --- /dev/null +++ b/src/test/java/ru/pdguard/PipelineTest.java @@ -0,0 +1,138 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */ +class PipelineTest { + + private static final String VALID_CARD = "4111 1111 1111 1111"; + private static final String VALID_INN_12 = "770301234550"; + private static final String VALID_SNILS = "112-233-445 95"; + + private Pipeline pipeline() { + return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); + } + + private String mask(Pipeline pipeline, String text) { + return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); + } + + @Test + void masksCardNumber() { + String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла"); + assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked); + assertTrue(masked.contains("41** **** **** **11"), masked); + assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked); + } + + @Test + void keepsNumberThatFailsLuhn() { + String text = "Заказ 1234 5678 9012 3456 отгружен"; + assertEquals(text, mask(pipeline(), text)); + } + + @Test + void masksEmailKeepingTopLevelDomain() { + String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи"); + assertEquals("Почта i**********@m***.ru для связи", masked); + } + + @Test + void masksPhoneInAnyNotation() { + Pipeline pipeline = pipeline(); + for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) { + String masked = mask(pipeline, "Телефон " + phone); + assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked); + assertTrue(masked.endsWith("67"), masked); + } + } + + @Test + void masksInnByContextAndByChecksum() { + Pipeline pipeline = pipeline(); + assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12)); + assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12)); + } + + @Test + void masksSnils() { + String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS); + assertFalse(masked.contains(VALID_SNILS), masked); + } + + @Test + void unmaskingRestoresOriginalText() { + Pipeline pipeline = pipeline(); + String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67"; + String id = "pair-1"; + + String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertNotEquals(original, masked); + + String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT); + assertEquals(original, restored); + } + + @Test + void retryReturnsSameMask() { + Pipeline pipeline = pipeline(); + String original = "Карта " + VALID_CARD; + String id = "retry-1"; + + String first = pipeline.process(original, id, SystemPolicy.DEFAULT); + String second = pipeline.process(original, id, SystemPolicy.DEFAULT); + assertEquals(first, second); + } + + @Test + void unmasksWhenPayloadIdIsUnknown() { + Pipeline pipeline = pipeline(); + String original = "Почта ivan@mail.ru"; + String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT); + + assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT)); + } + + @Test + void textWithoutPersonalDataIsUnchanged() { + String text = "Расскажи о погоде в Москве завтра"; + assertEquals(text, mask(pipeline(), text)); + } + + @Test + void systemPolicyDisablesSelectedTypes() { + Pipeline pipeline = pipeline(); + SystemPolicy onlyEmail = SystemPolicy.forTypes(RuleRegistry.EMAIL); + String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail); + + assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked); + assertFalse(masked.contains("ivan@mail.ru"), masked); + } + + @Test + void handlesLargeText() { + Pipeline pipeline = pipeline(); + String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". "; + String large = block.repeat(4000); + + long started = System.nanoTime(); + String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT); + long millis = (System.nanoTime() - started) / 1_000_000; + + assertFalse(masked.contains("ivan@mail.ru")); + assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT)); + assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс"); + } +} diff --git a/src/test/java/ru/pdguard/ProcessResourceTest.java b/src/test/java/ru/pdguard/ProcessResourceTest.java new file mode 100644 index 0000000..7d3da41 --- /dev/null +++ b/src/test/java/ru/pdguard/ProcessResourceTest.java @@ -0,0 +1,118 @@ +package ru.pdguard; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */ +@QuarkusTest +class ProcessResourceTest { + + private String post(String payload, String payloadId) { + return given() + .contentType("application/json") + .body(Map.of("payload", payload, "payload_id", payloadId)) + .when().post("/process") + .then().statusCode(200) + .extract().path("result"); + } + + @Test + void maskAndUnmaskPair() { + String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67"; + String id = "8a77d363c7c044b49b41d7b8a448243a"; + + String masked = post(original, id); + org.junit.jupiter.api.Assertions.assertNotEquals(original, masked); + org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id)); + } + + @Test + void rejectsRequestWithoutRequiredFields() { + given() + .contentType("application/json") + .body(Map.of("payload", "текст")) + .when().post("/process") + .then().statusCode(400); + } + + @Test + void healthProbeResponds() { + given().when().get("/health").then().statusCode(200).body(equalTo("OK")); + } + + @Test + void disabledSystemIsRefused() { + given() + .contentType("application/json") + .header("X-System-Id", "disabled") + .body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1")) + .when().post("/process") + .then().statusCode(403); + } + + @Test + void systemPolicySelectsMaskMode() { + String masked = given() + .contentType("application/json") + .header("X-System-Id", "crm") + .body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2")) + .when().post("/process") + .then().statusCode(200) + .extract().path("result"); + + org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked); + } + + @Test + void unknownSystemFallsBackToDefaultPolicy() { + given() + .contentType("application/json") + .header("X-System-Id", "неизвестная-система") + .body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3")) + .when().post("/process") + .then().statusCode(200) + .body("result", equalTo("почта i***@m***.ru")); + } + + @Test + void metricsExposeLatencyAndTokenCounters() { + post("Клиент Иванов Иван Иванович", "metrics-1"); + + String body = given().when().get("/metrics").then().statusCode(200).extract().asString(); + org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки"); + org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS"); + org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД"); + } + + @Test + void metricsDoNotLeakPersonalData() { + post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2"); + + String body = given().when().get("/metrics").then().statusCode(200).extract().asString(); + org.junit.jupiter.api.Assertions.assertFalse(body.contains("Иванов"), "ПД попали в метрики"); + org.junit.jupiter.api.Assertions.assertFalse(body.contains("4111"), "ПД попали в метрики"); + } + + @Test + void adminShowsSystemsAndTypes() { + given().when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN")); + given().when().get("/admin/types").then().statusCode(200); + } + + @Test + void textWithoutPersonalDataIsReturnedAsIs() { + given() + .contentType("application/json") + .body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1")) + .when().post("/process") + .then().statusCode(200) + .body("result", equalTo("тестовая строка")) + .body("result", not(equalTo(""))); + } +} diff --git a/src/test/java/ru/pdguard/SystemsConfigTest.java b/src/test/java/ru/pdguard/SystemsConfigTest.java new file mode 100644 index 0000000..3fa885b --- /dev/null +++ b/src/test/java/ru/pdguard/SystemsConfigTest.java @@ -0,0 +1,90 @@ +package ru.pdguard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.config.SystemsConfig; +import ru.pdguard.mask.MaskMode; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Чтение и горячая перезагрузка списка систем. */ +class SystemsConfigTest { + + private static final String CONTENT = """ + { + "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] }, + "crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] }, + "old": { "enabled": false } + } + """; + + private SystemsConfig configAt(Path file) { + return new SystemsConfig(file.toString(), new ObjectMapper()); + } + + @Test + void readsPoliciesFromFile(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + + SystemsConfig config = configAt(file); + SystemPolicy crm = config.policyFor("crm"); + + assertEquals(MaskMode.TOKEN, crm.maskMode()); + assertFalse(crm.demask()); + assertTrue(crm.allows("FIO")); + assertFalse(crm.allows("CARD")); + assertFalse(config.policyFor("old").enabled()); + } + + @Test + void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + + SystemPolicy policy = configAt(file).policyFor("никому-не-известная"); + assertTrue(policy.enabled()); + assertTrue(policy.allows("CARD")); + } + + @Test + void worksWithoutConfigFile(@TempDir Path dir) { + SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json")); + assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая")); + } + + @Test + void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + SystemsConfig config = configAt(file); + assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode()); + + Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8); + config.reload(); + + assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode()); + } + + @Test + void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException { + Path file = dir.resolve("systems.json"); + Files.writeString(file, CONTENT, StandardCharsets.UTF_8); + SystemsConfig config = configAt(file); + + Files.writeString(file, "{ это не json", StandardCharsets.UTF_8); + config.reload(); + + assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(), + "сломанный файл не должен ронять работающий сервис"); + } +} diff --git a/src/test/java/ru/pdguard/tools/NerTrainer.java b/src/test/java/ru/pdguard/tools/NerTrainer.java new file mode 100644 index 0000000..0dca6de --- /dev/null +++ b/src/test/java/ru/pdguard/tools/NerTrainer.java @@ -0,0 +1,66 @@ +package ru.pdguard.tools; + +import opennlp.tools.namefind.NameFinderME; +import opennlp.tools.namefind.NameSample; +import opennlp.tools.namefind.NameSampleDataStream; +import opennlp.tools.namefind.TokenNameFinderFactory; +import opennlp.tools.namefind.TokenNameFinderModel; +import opennlp.tools.util.InputStreamFactory; +import opennlp.tools.util.ObjectStream; +import opennlp.tools.util.PlainTextByLineStream; +import opennlp.tools.util.TrainingParameters; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Обучение модели для второй ступени распознавания имён. + * + *

Не часть сервиса: инструмент запускается вручную, результат кладётся рядом + * с приложением и подключается свойством {@code pdguard.ner.model}. Подготовка + * обучающего набора — в {@code tools/prepare-ner-corpus.py}. + * + *

+ * mvn -q test-compile
+ * java -cp target/test-classes:$(cat cp.txt) ru.pdguard.tools.NerTrainer train.txt models/ru-ner-person.bin
+ * 
+ */ +public final class NerTrainer { + + private static final int ITERATIONS = Integer.getInteger("ner.iterations", 300); + private static final int CUTOFF = Integer.getInteger("ner.cutoff", 1); + + private NerTrainer() { + } + + public static void main(String[] args) throws IOException { + if (args.length < 2) { + throw new IllegalArgumentException("Нужны два аргумента: обучающий набор и путь к модели"); + } + Path data = Path.of(args[0]); + Path modelFile = Path.of(args[1]); + + TrainingParameters parameters = new TrainingParameters(); + parameters.put(TrainingParameters.ITERATIONS_PARAM, ITERATIONS); + parameters.put(TrainingParameters.CUTOFF_PARAM, CUTOFF); + parameters.put(TrainingParameters.ALGORITHM_PARAM, + System.getProperty("ner.algorithm", "MAXENT")); + + InputStreamFactory source = () -> Files.newInputStream(data); + TokenNameFinderModel model; + try (ObjectStream lines = new PlainTextByLineStream(source, StandardCharsets.UTF_8); + ObjectStream samples = new NameSampleDataStream(lines)) { + model = NameFinderME.train("ru", "person", samples, parameters, new TokenNameFinderFactory()); + } + + Files.createDirectories(modelFile.toAbsolutePath().getParent()); + try (OutputStream out = Files.newOutputStream(modelFile)) { + model.serialize(out); + } + System.out.printf("Модель сохранена: %s, %d КБ%n", + modelFile.toAbsolutePath(), Files.size(modelFile) / 1024); + } +} diff --git a/src/test/resources/benchmark-holdout.txt b/src/test/resources/benchmark-holdout.txt new file mode 100644 index 0000000..6c18092 --- /dev/null +++ b/src/test/resources/benchmark-holdout.txt @@ -0,0 +1,106 @@ +# Отложенный набор: составлен независимо от правил и не использовался для их настройки. +# Имена, формулировки и отрицательные примеры намеренно не пересекаются с основным +# набором. Формат тот же: {{ТИП:значение}}, строка без разметки — текст без ПД. + +# --- ФИО: фамилии нетипичного словообразования --- +Поручитель {{FIO:Черных Андрей Петрович}} подтвердил согласие +Созаёмщик {{FIO:Долгих Мария Сергеевна}} подписала договор +Контактное лицо — {{FIO:Асланов Рустам Маратович}} +Ответственный исполнитель {{FIO:Петров-Водкин Сергей Иванович}} +Обратился {{FIO:Ким Сон Хо}} по вопросу перевода +Заявку подал {{FIO:Нгуен Ван Ань}} +Согласовано с {{FIO:Гаджиевым Артуром Рашидовичем}} +В списке значится {{FIO:Оганесян Карина Ашотовна}} + +# --- ФИО: редкие отчества --- +Распоряжение подписал {{FIO:Владимир Ильич Ульянов}} +Приём ведёт {{FIO:Сергей Кузьмич Носов}} +Ответ дала {{FIO:Анна Фоминична Белова}} +Документ заверил {{FIO:Аполлинарий Никитич Греков}} +Обращение от {{FIO:Евлампии Саввичны Тороповой}} + +# --- ФИО: падежи и порядок слов --- +Перевод в пользу {{FIO:Тихомировой Елены Владимировны}} +Доверенность на имя {{FIO:Родиона Захаровича Мельникова}} +Счёт открыт {{FIO:Яковом Демидовичем Соболевым}} +О {{FIO:Ларисе Тарасовне Гуровой}} сведений нет +{{FIO:Ульянова Злата}} записалась на консультацию +Заявление {{FIO:Милана Игнатьева}} рассмотрено + +# --- ФИО: инициалы в разных написаниях --- +Утвердил {{FIO:Соболев Я. Д.}} +Исполнитель: {{FIO:Т.А. Гурова}} +Виза {{FIO:Мельников Р.З.}} получена +Согласование {{FIO:Е. В. Тихомирова}} завершено + +# --- ФИО: регистр и явные якоря --- +Ф.И.О. заявителя: {{FIO:соболев яков демидович}} +ФИО {{FIO:ГУРОВА ЛАРИСА ТАРАСОВНА}} +Карта выпущена на имя {{FIO:мельников родион}} +Представитель {{FIO:Торопова Евлампия}} прибудет завтра +Владелец {{FIO:Греков Аполлинарий Никитич}} + +# --- ФИО вместе с другими персональными данными --- +Клиент {{FIO:Носов Сергей Кузьмич}}, паспорт {{PASSPORT:45 09 № 123456}}, ИНН {{INN:7801234564}} +{{FIO:Белова Анна Фоминична}}, СНИЛС {{SNILS:216-048-357 45}}, телефон {{PHONE:8 (495) 123-45-67}} +Перевод от {{FIO:Тихомировой Е.В.}} на карту {{CARD:4276160012345675}} +{{FIO:Соболев Яков}}, дата рождения {{BIRTH_DATE:1985/05/12}}, место рождения {{BIRTH_PLACE:посёлок Лесной}} +Заявитель {{FIO:Гурова Л.Т.}}, почта {{EMAIL:L.GUROVA@EXAMPLE.CO.UK}} +{{FIO:Мельников Родион Захарович}}, адрес: г. {{ADDRESS_CITY:Санкт-Петербург}}, {{ADDRESS_STREET:Невский}} пр-т, д. {{ADDRESS_HOUSE:28}} +Держатель {{CARDHOLDER:PETR SOBOLEV}}, карта {{CARD:2200123456789019}}, код проверки {{CVV:456}} +{{FIO:Торопова Е.С.}}, водительское удостоверение {{DRIVER_LICENSE:78 12 345678}} +Клиент {{FIO:Греков А.Н.}}, гражданство {{CITIZENSHIP:Армении}}, дата выдачи {{PASSPORT_DATE:5 марта 1990 г.}} + +# --- Несколько ФИО в одном тексте --- +Договор между {{FIO:Носовым Сергеем Кузьмичом}} и {{FIO:Беловой Анной Фоминичной}} +Присутствовали {{FIO:Соболев Я.Д.}}, {{FIO:Гурова Л.Т.}} и {{FIO:Мельников Р.З.}} + +# --- Прочие типы ПД в новых написаниях --- +Телефон для связи {{PHONE:+7-916-123-45-67}}, запасной {{PHONE:89161234567}} +Почта {{EMAIL:ivan+bank@example.co.uk}} подтверждена +Паспорт серии {{PASSPORT:4509}} за номером {{PASSPORT:123456}} +Дата рождения {{BIRTH_DATE:12-05-1985}}, дата выдачи паспорта {{PASSPORT_DATE:2015.12.05}} +Родилась {{BIRTH_DATE:первого сентября 2001 года}} в {{BIRTH_PLACE:городе Вологда}} +Код подразделения {{DEPT_CODE:780 012}}, выдан {{PASSPORT_ISSUER:УМВД России по Санкт-Петербургу}} +Адрес регистрации: {{ADDRESS_POSTCODE:190000}}, город {{ADDRESS_CITY:Выборг}}, улица {{ADDRESS_STREET:Мира}}, дом {{ADDRESS_HOUSE:3}}, квартира {{ADDRESS_FLAT:12}} +Полис ОМС {{MEDICAL_POLICY:7801234567890123}} действителен +Загранпаспорт {{FOREIGN_PASSPORT:78 7654321}} получен +Военный билет {{MILITARY_ID:ВЕ 7654321}} сдан в архив +PIN {{PIN:4821}} к карте {{CARD:4276160012345675}} заблокирован + +# --- Тексты без персональных данных --- +Третьяковская галерея открыта до восемнадцати часов +Разбери роман Булгакова про Воланда и Маргариту +Институт имени Курчатова объявил конкурс проектов +Улица Академика Королёва находится рядом с телецентром +Матч Спартак — Динамо перенесён на воскресенье +Бизнес-центр Романов Двор сдаётся в аренду +Аэропорт Шереметьево работает в обычном режиме +Ленинградский вокзал, платформа три, поезд опаздывает +Тургеневская библиотека закрыта на ремонт +Компания Яндекс открыла вакансию аналитика +Сбербанк Онлайн не открывается на старом телефоне +Премьер-министр выступил в парламенте с докладом +Площадь Восстания перекрыта из-за ремонта +Гостиница Космос принимает заявки на корпоратив +Какой лимит по кредитной карте можно установить +Объясни разницу между вкладом и накопительным счётом +Сформируй письмо клиентам об изменении тарифов +Напиши SQL-запрос для выборки активных договоров +Ошибка 404 при открытии страницы личного кабинета +Переведи сто долларов в рубли по курсу на сегодня +Собрание состоится в 15:30 в большой переговорной +Отчёт за 2024 год готов и отправлен на согласование +Сколько стоит выпуск дополнительной карты +Купи молоко, хлеб и сыр по дороге домой +Составь чек-лист для проверки договора аренды +Сгенерируй описание вакансии для младшего аналитика +Что такое овернайт и чем он отличается от вклада +Проверь орфографию и пунктуацию в этом абзаце +Расскажи про историю Невского проспекта +Опиши правила оформления командировочных расходов +Заказ 4276 1600 1234 5670 не прошёл проверку +Версия приложения 3.14.15 вышла вчера +Температура в Выборге опустилась до минус пяти +Подготовь презентацию на двадцать слайдов +Сравни два тарифных плана для малого бизнеса diff --git a/src/test/resources/benchmark-holdout2.txt b/src/test/resources/benchmark-holdout2.txt new file mode 100644 index 0000000..ca31259 --- /dev/null +++ b/src/test/resources/benchmark-holdout2.txt @@ -0,0 +1,106 @@ +# КОНТРОЛЬНЫЙ НАБОР. Правила по нему не настраиваются — иначе он потеряет смысл, +# как это произошло с benchmark-holdout.txt. Замер по нему делается изредка, чтобы +# увидеть настоящее качество; дефекты, которые он вскрывает, чинятся по первым двум +# наборам или по собственным примерам, а не подгонкой под эти строки. +# +# Единственная правка, сделанная после его появления, — ограничение правила улицы: +# оно дожёвывало строку до конца и требовало адресного окружения. Дефект был виден +# без всякого набора, а числа по ФИО от него не изменились. +# +# Составлен после доработки правил по первому отложенному набору. Имена, формулировки +# и отрицательные примеры не пересекаются ни с одним из предыдущих наборов. +# +# Набор намеренно жёстче: сюда собраны случаи, которые раньше не проверялись — +# имена, совпадающие с обычными словами, двойные фамилии, подписи в письмах, +# короткие формы записи и внутренняя переписка банка. + +# --- ФИО: имена, совпадающие с нарицательными словами --- +Заявку оформила {{FIO:Вера Никитична Пахомова}} +Обращение приняла {{FIO:Надежда Сергеевна Ефимова}} +Договор подписала {{FIO:Любовь Аркадьевна Зотова}} +Счёт открыт на {{FIO:Веру Пахомову}} +Перевод получила {{FIO:Надежда Ефимова}} + +# --- ФИО: двойные и дефисные --- +Согласовано с {{FIO:Соловьёвым-Седым Игорем Львовичем}} +Заявитель {{FIO:Мамедов-оглы Рустам}} +Клиент {{FIO:Анна-Мария Ковач}} записалась на приём +Обращение от {{FIO:Петрова-Водкина С.И.}} + +# --- ФИО: подписи и обращения --- +Уважаемый {{FIO:Игорь Львович}}, ваша заявка одобрена +С уважением, {{FIO:Ефимова Н.С.}}, отдел сопровождения +Исполнил: {{FIO:Зотова Л.А.}}, тел {{PHONE:+7 916 1234567}} +Прошу согласовать. {{FIO:Пахомов В.Н.}} + +# --- ФИО: перечисления --- +В комиссию вошли {{FIO:Ефимов Р.Т.}}, {{FIO:Зотова Л.А.}}, {{FIO:Пахомов В.Н.}} +Ответственные: {{FIO:Игорь Соловьёв}} и {{FIO:Рустам Мамедов}} + +# --- ФИО: внутренняя переписка --- +Коллеги, клиент {{FIO:Ковач Анна}} жалуется на списание +Передал заявку {{FIO:Ефимовой Надежде Сергеевне}} на проверку +Свяжитесь с {{FIO:Зотовой}} по вопросу тарифа +Заявка закрыта, обращался {{FIO:Пахомов Вениамин Николаевич}} + +# --- ФИО вместе с другими персональными данными --- +Клиент {{FIO:Пахомов Вениамин Николаевич}}, паспорт {{PASSPORT:5003 987654}}, ИНН {{INN:502601234547}} +{{FIO:Ефимова Надежда Сергеевна}}, СНИЛС {{SNILS:389-104-256 97}}, карта {{CARD:5555 6666 7777 8884}} +{{FIO:Зотова Л.А.}}, дата рождения {{BIRTH_DATE:12.05.85}}, почта {{EMAIL:zotova_la@example.org}} +Перевод от {{FIO:Соловьёва Игоря}} на карту {{CARD:4173567812345677}} +{{FIO:Ковач Анна-Мария}}, адрес: {{ADDRESS_POSTCODE:141400}}, г. {{ADDRESS_CITY:Химки}}, ул. {{ADDRESS_STREET:Совхозная}}, д. {{ADDRESS_HOUSE:5}}, кв. {{ADDRESS_FLAT:88}} +Паспорт РФ {{PASSPORT:5003 987654}}, код подразделения {{DEPT_CODE:500-012}}, выдан {{PASSPORT_ISSUER:ГУ МВД России по Московской области}} +{{FIO:Мамедов Рустам}}, гражданство {{CITIZENSHIP:Азербайджана}}, место рождения {{BIRTH_PLACE:город Баку}} +Держатель {{CARDHOLDER:ANNA KOVACH}}, CVV {{CVV:731}}, карта {{CARD:5555 6666 7777 8884}} +{{FIO:Пахомов В.Н.}}, водительское удостоверение {{DRIVER_LICENSE:50 03 987654}} +Дата выдачи паспорта {{PASSPORT_DATE:2015-12-05}}, дата рождения {{BIRTH_DATE:тридцатого апреля 1978 года}} + +# --- Прочие типы в коротких и небрежных записях --- +Телефон {{PHONE:8-916-123-45-67}}, запасной {{PHONE:+7(916)1234567}} +Почта для связи {{EMAIL:v.pahomov+bank@example.co.uk}} +ИНН {{INN:7801234564}} проверен, карта {{CARD:4173567812345677}} активна +Полис {{MEDICAL_POLICY:5026012345470123}} действует до конца года +Загранпаспорт {{FOREIGN_PASSPORT:50 9876543}} продлён +Индекс {{ADDRESS_POSTCODE:141400}}, город {{ADDRESS_CITY:Химки}}, улица {{ADDRESS_STREET:Совхозная}} +Родился {{BIRTH_DATE:5 марта 1978 г.}} в {{BIRTH_PLACE:посёлке Новогорск}} +Серия {{PASSPORT:5003}} номер {{PASSPORT:987654}}, дата выдачи {{PASSPORT_DATE:05.12.2015}} + +# --- Тексты без персональных данных --- +Надежда умирает последней, как говорится +Любовь к искусству не измеряется деньгами +Вера в успех проекта у команды есть +Роза ветров показывает направление +Лилия расцвела в июне на клумбе +Ростов-на-Дону и Комсомольск-на-Амуре соединит рейс +Спартак Москва проиграл в гостях +Иван Грозный правил долго и жестоко +Пётр Первый основал город на Неве +Лада Веста подорожала на десять процентов +ООО Ромашка зарегистрировано в Москве +Иванов и партнёры — это юридическая фирма +Третий Рим — концепция шестнадцатого века +Красная площадь закрыта на репетицию +Нижний Тагил и Верхняя Пышма расположены рядом +Большой театр открывает сезон в сентябре +Институт Склифосовского принимает по скорой +Музей Тропинина работает по будням +Проспект Вернадского перекрыт до вечера +Улица Королёва названа в честь конструктора +Объясни, чем аннуитетный платёж отличается от дифференцированного +Составь скрипт разговора с клиентом по просроченной задолженности +Проверь, корректно ли заполнено платёжное поручение +Сформируй выгрузку по операциям за прошлый квартал +Напиши инструкцию по подключению эквайринга +Какие лимиты действуют на снятие наличных в банкомате +Опиши процесс рассмотрения заявки на ипотеку +Сравни условия по трём накопительным счетам +Подготовь ответ на претензию по списанию комиссии +Расшифруй аббревиатуру ОКВЭД и приведи пример +Сколько дней рассматривается заявление о возврате +Сгенерируй тестовые данные для проверки формы +Ошибка при оплате картой, код 05, что это значит +Переведи описание продукта на английский +Сократи текст уведомления до двухсот знаков +Проверь орфографию в шаблоне письма клиенту +Собрание перенесено на десятое число, кабинет 412 +Отчёт по кварталу готов, направлен руководителю diff --git a/src/test/resources/benchmark.txt b/src/test/resources/benchmark.txt new file mode 100644 index 0000000..89e4f5e --- /dev/null +++ b/src/test/resources/benchmark.txt @@ -0,0 +1,132 @@ +# Размеченный набор для замера качества детекции. +# +# Формат: персональные данные обёрнуты в {{ТИП:значение}}. Строка без разметки — +# текст, в котором ПД нет вовсе, и любое срабатывание на ней считается ложным. +# Строки, начинающиеся с #, и пустые строки пропускаются. +# +# Набор смещён в сторону ФИО: это самый сложный для распознавания тип и главный +# источник ложных срабатываний. + +# --- ФИО: полное имя с отчеством, разные падежи --- +Клиент {{FIO:Иванов Иван Иванович}} обратился в отделение +Заявление подал {{FIO:Петров Сергей Николаевич}} +{{FIO:Сидорова Анна Петровна}} подтвердила согласие на обработку +Договор подписан с {{FIO:Кузнецовым Дмитрием Алексеевичем}} +Передайте документы {{FIO:Смирнову Олегу Ивановичу}} +Встреча с {{FIO:Ивановым Иваном Ивановичем}} перенесена на следующую неделю +Ответственный — {{FIO:Николаев Юрий Витальевич}} +Доверенность выдана на {{FIO:Белова Глеба Артёмовича}} +{{FIO:Троицкий Лев Маркович}} назначен ответственным +{{FIO:Иванидзе Нина Георгиевна}} подала обращение +Поручение принято от {{FIO:Ясенева Марка Тимуровича}} +Согласовано с {{FIO:Лаврентьевой Ольгой Никитичной}} + +# --- ФИО: имя и отчество без фамилии --- +{{FIO:Иван Иванович}} ждёт ответа +Пригласите {{FIO:Ивана Сергеевича}} на встречу +Спросите у {{FIO:Марии Никитичны}} про заявку +Свяжитесь с {{FIO:Анной Ивановной}} по вопросу перевода +Документы передал {{FIO:Пётр Глебович}} + +# --- ФИО: фамилия с инициалами --- +Заявление от {{FIO:Иванов И.И.}} принято к рассмотрению +Подписал {{FIO:И.И. Иванов}} +Согласовано: {{FIO:Петрова А.С.}} +Исполнитель {{FIO:Кузнецов Д.А.}}, срок до конца недели +Отчёт подготовил {{FIO:С.Н. Мещеряков}} + +# --- ФИО: строчными и прописными буквами --- +ФИО: {{FIO:иванов иван иванович}} +фио: {{FIO:петрова анна ивановна}} +Ф.И.О. {{FIO:смирнов олег петрович}} +Карта оформлена на имя {{FIO:петров сергей}} +{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно +Заявитель: {{FIO:КУЗНЕЦОВА АННА СЕРГЕЕВНА}} + +# --- ФИО: фамилия рядом с личным именем --- +{{FIO:Сергей Петров}} подтвердил заявку +{{FIO:Петров Сергей}} подтвердил заявку +Обращение {{FIO:Ольги Ковалёвой}} рассмотрено +Перевод получил {{FIO:Артём Тихомиров}} +Заявку оформила {{FIO:Марина Шевченко}} +Поручителем выступает {{FIO:Тимур Мкртчян}} +В списке значится {{FIO:Екатерина Бурмистрова}} +Счёт открыт на {{FIO:Никиту Кольцова}} + +# --- ФИО: после ролевого слова --- +Клиент {{FIO:Петров Сергей}}, заявка одобрена +Плательщик {{FIO:Ковалёва}} подтвердила перевод +Сотрудник {{FIO:Николаев Юрий}} оформил заявку +Заказчик {{FIO:Шевченко Марина}} согласовал смету +Пациент {{FIO:Ким Елена}} записан на приём +Абонент {{FIO:Белов Глеб}} не отвечает +Владелец счёта {{FIO:Романов Артём Игоревич}} +Получатель {{FIO:Ясенев Марк}} подтвердил адрес +Действует в лице {{FIO:Иванова Ивана Ивановича}} + +# --- ФИО вместе с другими персональными данными --- +Клиент {{FIO:Иванов Иван Иванович}}, паспорт {{PASSPORT:4509 123456}}, тел {{PHONE:+7 916 123-45-67}} +{{FIO:Петров С.А.}}, ИНН {{INN:770301234550}}, карта {{CARD:4111 1111 1111 1111}} +Почта {{EMAIL:ivan.petrov@mail.ru}} принадлежит клиенту {{FIO:Петрову Ивану}} +{{FIO:Сидорова Анна Петровна}}, дата рождения {{BIRTH_DATE:12.05.1985}}, гражданство {{CITIZENSHIP:РФ}} +Клиент {{FIO:Кузнецов Дмитрий Алексеевич}}, СНИЛС {{SNILS:112-233-445 95}} +Оформлено на {{FIO:Смирнова Олега}}, водительское удостоверение {{DRIVER_LICENSE:9902 123456}} +{{FIO:Белова Анна Ивановна}}, место рождения {{BIRTH_PLACE:город Тверь}} +Заявитель {{FIO:Троицкий Лев}}, загранпаспорт {{FOREIGN_PASSPORT:75 1234567}} +Клиент {{FIO:Александр Пушкин}}, паспорт {{PASSPORT:4509 123456}} +Держатель {{CARDHOLDER:IVAN PETROV}}, карта {{CARD:4111 1111 1111 1111}}, CVV {{CVV:123}} + +# --- Несколько имён в одном тексте --- +Встречу проводят {{FIO:Иванов Иван Иванович}} и {{FIO:Петрова Анна Сергеевна}} +Согласовали {{FIO:Кузнецов Д.А.}} и {{FIO:Смирнов О.И.}}, возражений нет + +# --- Прочие типы ПД для замера точности в реальном окружении --- +Паспорт {{PASSPORT:4509 123456}} выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}}, код подразделения {{DEPT_CODE:770-001}} +Серия {{PASSPORT:4509}} номер {{PASSPORT:123456}}, дата выдачи {{PASSPORT_DATE:12.05.2015}} +Адрес: {{ADDRESS_POSTCODE:125009}}, г. {{ADDRESS_CITY:Москва}}, ул. {{ADDRESS_STREET:Тверская}}, д. {{ADDRESS_HOUSE:7}}, кв. {{ADDRESS_FLAT:15}} +Свяжитесь: {{EMAIL:ivan.petrov@mail.ru}} или {{PHONE:+7 (916) 123-45-67}} +Дата рождения {{BIRTH_DATE:12 мая 1985 года}} +Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}} +Военный билет {{MILITARY_ID:АБ 1234567}} предъявлен +Полис ОМС {{MEDICAL_POLICY:1234567890123456}} оформлен +Свидетельство о рождении {{BIRTH_CERTIFICATE:II-МЮ № 123456}} +ИНН {{INN:770301234550}} проверен в реестре +Карта {{CARD:5555 5555 5555 4444}} заблокирована +Гражданство {{CITIZENSHIP:Республики Беларусь}}, место рождения {{BIRTH_PLACE:город Минск}} + +# --- Тексты без персональных данных: любое срабатывание здесь ложное --- +Напиши стихотворение в духе Александра Пушкина про осень +Сравни Толстого и Достоевского как прозаиков +Когда Гагарин полетел в космос +Расскажи о теории Менделеева про периодический закон +Разбери стихотворение Лермонтова Парус +Кто написал оперу Евгений Онегин +Московский Кремль открыт для посещения +Экскурсия в Нижний Новгород перенесена на май +Смоленская площадь закрыта на ремонт +Улица Тверская и Кутузовский проспект перекрыты +Театр имени Вахтангова открыл сезон +Премия имени Ломоносова вручена в декабре +Площадь Гагарина находится на юго-западе +Отделение банка на улице Тверская, дом 7 работает до 20:00 +Дополнительный офис, г. Москва, ул. Арбат, д. 1 +Банкомат по адресу Ленинский проспект, дом 30 не работает +Банк Открытие подтвердил лимит по овердрафту +Сбербанк России опубликовал квартальный отчёт +Газпром и Роснефть подписали соглашение +Встреча перенесена на 12.05.2025, подтвердите участие +Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута +Заказ 1234 5678 9012 3456 отгружен со склада +Пин-код 1234 введён неверно, попробуйте снова +Компания выросла на 20 процентов в 2024 году +В Понедельник Отдел Согласует Договор +Расскажи о погоде в Москве завтра +Объясни разницу между дебетовой и кредитной картой +Подготовь план обучения для команды разработки +Какие документы нужны для открытия счёта юридическому лицу +Сколько стоит обслуживание карты в год +Переведи текст на английский язык +Сгенерируй описание вакансии для аналитика данных +Что такое эквайринг и как он работает +Составь список задач на следующий спринт +Проверь орфографию в этом абзаце diff --git a/src/test/resources/systems-test.json b/src/test/resources/systems-test.json new file mode 100644 index 0000000..f524439 --- /dev/null +++ b/src/test/resources/systems-test.json @@ -0,0 +1,6 @@ +{ + "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] }, + "crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO", "PHONE", "EMAIL"] }, + "analytics":{ "enabled": true, "demask": false, "maskMode": "SYNTHETIC", "types": ["*"] }, + "disabled": { "enabled": false, "demask": false, "maskMode": "MASK", "types": ["*"] } +} diff --git a/tools/conllu-to-opennlp.py b/tools/conllu-to-opennlp.py new file mode 100644 index 0000000..24eaad5 --- /dev/null +++ b/tools/conllu-to-opennlp.py @@ -0,0 +1,38 @@ +import sys +limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50000 +out, sent, inside, kept = [], [], False, 0 +def flush(): + global sent, inside + if sent: + if inside: + sent.append('') + out.append(' '.join(sent)) + sent, inside = [], False + +for line in sys.stdin: + line = line.rstrip('\n') + if not line: + flush() + if len(out) >= limit: + break + continue + if line.startswith('#'): + continue + cols = line.split('\t') + if len(cols) < 10 or '-' in cols[0]: + continue + word, tag = cols[1], cols[9] + person = tag.endswith('-PER') + begins = tag.startswith('Tag=B-PER') + if person and (not inside or begins): + if inside: + sent.append('') + sent.append('') + inside = True + elif not person and inside: + sent.append('') + inside = False + sent.append(word) +flush() +sys.stderr.write(f"предложений: {len(out)}, с персонами: {sum(1 for l in out if 'START:person' in l)}\n") +print('\n'.join(out[:limit])) diff --git a/tools/factrueval-to-opennlp.py b/tools/factrueval-to-opennlp.py new file mode 100644 index 0000000..6a0d2a0 --- /dev/null +++ b/tools/factrueval-to-opennlp.py @@ -0,0 +1,69 @@ +import glob, os, sys + +def load_doc(base): + order, text = [], {} + for line in open(base + '.tokens', encoding='utf-8'): + p = line.split() + if len(p) >= 4: + order.append(p[0]); text[p[0]] = ' '.join(p[3:]) + index = {tid: i for i, tid in enumerate(order)} + + spans = {} + for line in open(base + '.spans', encoding='utf-8'): + p = line.split('#')[0].split() + if len(p) >= 6: + spans[p[0]] = (p[4], int(p[5])) + + person = set() + for line in open(base + '.objects', encoding='utf-8'): + p = line.split('#')[0].split() + if len(p) < 3 or p[1] != 'Person': + continue + for sid in p[2:]: + if sid in spans: + first, cnt = spans[sid] + i = index.get(first) + if i is None: + continue + for j in range(i, min(i + cnt, len(order))): + person.add(order[j]) + return order, text, person + +def sentences(order, text, person, limit=40): + cur = [] + for tid in order: + cur.append(tid) + if text[tid] in ('.', '!', '?', '…') or len(cur) >= limit: + yield cur; cur = [] + if cur: + yield cur + +def emit(order, text, person): + out = [] + for sent in sentences(order, text, person): + words, inside = [], False + for tid in sent: + is_person = tid in person + if is_person and not inside: + words.append(''); inside = True + elif not is_person and inside: + words.append(''); inside = False + words.append(text[tid]) + if inside: + words.append('') + if any(t in person for t in sent) or len(out) % 3 == 0: + out.append(' '.join(words)) + return out + +root = sys.argv[1] +target = sys.argv[2] +lines, persons = [], 0 +for part in ('devset', 'testset'): + for tok in sorted(glob.glob(os.path.join(root, part, '*.tokens'))): + base = tok[:-len('.tokens')] + order, text, person = load_doc(base) + persons += len(person) + lines.extend(emit(order, text, person)) +with open(target, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines) + '\n') +print(f"предложений: {len(lines)}, размеченных токенов-персон: {persons}") diff --git a/tools/train-ner.sh b/tools/train-ner.sh new file mode 100755 index 0000000..3fe3185 --- /dev/null +++ b/tools/train-ner.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Обучение модели для второй ступени распознавания имён. +# +# Модель в репозиторий не кладётся: она весит мегабайты и собирается из открытых +# корпусов за несколько минут. Без модели сервис работает на одних правилах. +# +# ./tools/train-ner.sh # быстрый вариант, только factRuEval +# ./tools/train-ner.sh full # плюс префикс Nerus, качество заметно выше +set -euo pipefail +cd "$(dirname "$0")/.." + +MODE="${1:-quick}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "1. factRuEval-2016 — ручная разметка, 1965 предложений" +curl -sSL -o "$WORK/fre.tar.gz" \ + https://codeload.github.com/dialogue-evaluation/factRuEval-2016/tar.gz/refs/heads/master +tar xzf "$WORK/fre.tar.gz" -C "$WORK" +python3 tools/factrueval-to-opennlp.py "$WORK/factRuEval-2016-master" "$WORK/train.txt" + +if [ "$MODE" = "full" ]; then + echo "2. Nerus — автоматическая разметка, берём префикс потоком (400 тыс. предложений)" + curl -sS -r 0-400000000 \ + https://storage.yandexcloud.net/natasha-nerus/data/nerus_lenta.conllu.gz \ + | gunzip 2>/dev/null \ + | python3 tools/conllu-to-opennlp.py 400000 >> "$WORK/train.txt" +fi + +echo "3. Обучение, несколько минут" +mvn -q -B test-compile +mvn -q -B dependency:build-classpath -Dmdep.outputFile="$WORK/cp.txt" +mkdir -p models +java -Xmx6g -Dner.iterations=100 -Dner.cutoff=5 \ + -cp "target/test-classes:target/classes:$(cat "$WORK/cp.txt")" \ + ru.pdguard.tools.NerTrainer "$WORK/train.txt" models/ru-ner-person.bin + +echo +echo "Готово. Включить: pdguard.ner.model=models/ru-ner-person.bin"