Init
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
target/*
|
||||||
|
!target/*-runner
|
||||||
|
!target/quarkus-app
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
models/*
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
models/
|
||||||
@@ -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` | предел объёма хранилища |
|
||||||
@@ -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"
|
||||||
@@ -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": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>ru.pdguard</groupId>
|
||||||
|
<artifactId>pd-guard</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
|
<quarkus.platform.version>3.15.1</quarkus.platform.version>
|
||||||
|
<surefire-plugin.version>3.2.5</surefire-plugin.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>${quarkus.platform.artifact-id}</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-arc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-redis-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.opennlp</groupId>
|
||||||
|
<artifactId>opennlp-tools</artifactId>
|
||||||
|
<version>2.5.4</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>${quarkus.platform.group-id}</groupId>
|
||||||
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
|
<version>${quarkus.platform.version}</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
<goal>generate-code</goal>
|
||||||
|
<goal>generate-code-tests</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>${surefire-plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>native</id>
|
||||||
|
<activation>
|
||||||
|
<property><name>native</name></property>
|
||||||
|
</activation>
|
||||||
|
<properties>
|
||||||
|
<quarkus.native.enabled>true</quarkus.native.enabled>
|
||||||
|
<quarkus.package.jar.enabled>false</quarkus.package.jar.enabled>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
</project>
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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<String, SystemPolicy> config() {
|
||||||
|
return systems.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/types")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public List<String> types() {
|
||||||
|
return registry.knownTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/reload")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public Map<String, SystemPolicy> reload() {
|
||||||
|
systems.reload();
|
||||||
|
return systems.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
|
||||||
|
* нет или система неизвестна — применяются настройки {@code default}, поэтому
|
||||||
|
* контракт работает и без него. Система, выключенная в настройках, получает
|
||||||
|
* {@code 403}.
|
||||||
|
*
|
||||||
|
* <p>При перегрузке отвечает {@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> types, Set<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
|
||||||
|
*
|
||||||
|
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
|
||||||
|
* перечитывается сам, когда меняется время его изменения; проверка выполняется
|
||||||
|
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
|
||||||
|
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
|
||||||
|
*/
|
||||||
|
@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<String> types, List<String> requireCompanion) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Path file;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
private volatile Map<String, SystemPolicy> 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<String, SystemPolicy> 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<String, SystemPolicy> 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<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
|
||||||
|
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
|
||||||
|
Map<String, SystemPolicy> 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<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
|
||||||
|
Set<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
|
||||||
|
*
|
||||||
|
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
|
||||||
|
* страховка на случай, если идентификатор до сервиса не доехал.
|
||||||
|
*
|
||||||
|
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
|
||||||
|
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
|
||||||
|
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
|
||||||
|
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
|
||||||
|
*
|
||||||
|
* <p>Когда включён общий слой ({@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<String, Entry> byId = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentLinkedQueue<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
|
||||||
|
*
|
||||||
|
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
|
||||||
|
* <ul>
|
||||||
|
* <li>идентификатор неизвестен — маскируем;</li>
|
||||||
|
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
|
||||||
|
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
|
||||||
|
* </ul>
|
||||||
|
* Последний случай — повторная попытка проверяющей системы: ответ обязан
|
||||||
|
* совпасть с первым, иначе демаскирование по этому элементу развалится.
|
||||||
|
*/
|
||||||
|
@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<Span> findPersonalData(String text, SystemPolicy policy) {
|
||||||
|
List<Span> 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<Span> 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<Span> resolveOverlaps(List<Span> spans) {
|
||||||
|
List<Span> candidates = new ArrayList<>(spans);
|
||||||
|
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
|
||||||
|
.thenComparing(Comparator.comparingInt(Span::length).reversed())
|
||||||
|
.thenComparingInt(Span::start));
|
||||||
|
|
||||||
|
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
|
||||||
|
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
|
||||||
|
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
|
||||||
|
// фрагментов набираются тысячи.
|
||||||
|
NavigableMap<Integer, Span> accepted = new TreeMap<>();
|
||||||
|
for (Span candidate : candidates) {
|
||||||
|
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
|
||||||
|
if (before != null && before.getValue().overlaps(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map.Entry<Integer, Span> 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<Span> dropWellKnownNames(String text, List<Span> 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<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
|
||||||
|
Set<String> 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<Span> 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<Span> spans) {
|
||||||
|
Map<String, Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
|
||||||
|
*
|
||||||
|
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
|
||||||
|
* Обратное же преобразование требует состояния: если прямой запрос обработал
|
||||||
|
* один узел, а обратный попал на другой, соответствие должно быть общим.
|
||||||
|
*
|
||||||
|
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
|
||||||
|
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
|
||||||
|
*
|
||||||
|
* <p>Недоступность 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<RedisDataSource> redisSource;
|
||||||
|
|
||||||
|
private volatile ValueCommands<String, SharedEntry> pairs;
|
||||||
|
private volatile ValueCommands<String, String> originals;
|
||||||
|
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
||||||
|
private volatile long silentUntil;
|
||||||
|
private volatile boolean reported;
|
||||||
|
|
||||||
|
public SharedIndex(Instance<RedisDataSource> 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<String, SharedEntry> commands() {
|
||||||
|
ValueCommands<String, SharedEntry> local = pairs;
|
||||||
|
if (local == null) {
|
||||||
|
local = redisSource.get().value(SharedEntry.class);
|
||||||
|
pairs = local;
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ValueCommands<String, String> originalCommands() {
|
||||||
|
ValueCommands<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вторая ступень распознавания имён.
|
||||||
|
*
|
||||||
|
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
|
||||||
|
* микросекунд. Модель нужна там, где они бессильны: имена без русского
|
||||||
|
* словообразования и без отчества — «Нгуен Ван Ань», «Ким Сон Хо».
|
||||||
|
*
|
||||||
|
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
|
||||||
|
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в
|
||||||
|
* обычном запросе единицы, и на задержку это почти не влияет.
|
||||||
|
*
|
||||||
|
* <p>Модели нет — ступень выключена и поведение сервиса не меняется. Путь к файлу
|
||||||
|
* задаётся свойством {@code pdguard.ner.model}.
|
||||||
|
*
|
||||||
|
* <p>Сбой второй ступени не должен отражаться на первой: ошибка перехватывается
|
||||||
|
* здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
|
||||||
|
* исключение обнуляло бы маскирование целиком.
|
||||||
|
*/
|
||||||
|
@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<NameFinderME> pool;
|
||||||
|
private final int maxCandidates;
|
||||||
|
private final boolean enabled;
|
||||||
|
private volatile boolean broken;
|
||||||
|
|
||||||
|
public NameCascade(
|
||||||
|
@ConfigProperty(name = "pdguard.ner.model") Optional<String> 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<Span> addMissedNames(String text, List<Span> accepted) {
|
||||||
|
if (!enabled || broken) {
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
NameFinderME finder = borrow();
|
||||||
|
if (finder == null) {
|
||||||
|
// Все распознаватели заняты: отвечаем по правилам, а не копим очередь.
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Span> 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<Span> 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<Span> accepted, int start, int end) {
|
||||||
|
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовые к работе распознаватели создаются на старте и сразу прогоняют текст.
|
||||||
|
*
|
||||||
|
* <p>{@link NameFinderME} хранит состояние между вызовами, поэтому одним
|
||||||
|
* экземпляром на несколько потоков пользоваться нельзя. Создание экземпляра
|
||||||
|
* вместе с первым разбором стоит сотни миллисекунд, и при создании по
|
||||||
|
* требованию эта цена доставалась первому запросу каждого рабочего потока.
|
||||||
|
* Пул снимает и то, и другое: к первому обращению всё создано и прогрето.
|
||||||
|
*/
|
||||||
|
private static BlockingQueue<NameFinderME> warmedPool(TokenNameFinderModel model, int size) {
|
||||||
|
long started = System.nanoTime();
|
||||||
|
BlockingQueue<NameFinderME> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Словари для распознавания ФИО.
|
||||||
|
*
|
||||||
|
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
|
||||||
|
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
|
||||||
|
*
|
||||||
|
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
|
||||||
|
* персональными данными не является. Ограничение осознанное: клиент по фамилии
|
||||||
|
* Пушкин в тексте без других ПД замаскирован не будет.
|
||||||
|
*/
|
||||||
|
public final class NameDictionary {
|
||||||
|
|
||||||
|
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
|
||||||
|
.map(NameDictionary::withoutInflectedEnding)
|
||||||
|
.distinct()
|
||||||
|
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||||
|
.toList();
|
||||||
|
private static final List<String> WELL_KNOWN_STEMS = load("/names/well-known.txt");
|
||||||
|
|
||||||
|
/** Не более скольких падежных букв дописывается к основе имени. */
|
||||||
|
private static final int MAX_INFLECTION = 3;
|
||||||
|
|
||||||
|
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
||||||
|
private static final Set<String> SURNAME_SUFFIXES = Set.of(
|
||||||
|
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
|
||||||
|
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
|
||||||
|
|
||||||
|
private static final Set<String> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||||
|
*
|
||||||
|
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
|
||||||
|
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
|
||||||
|
* приходится не больше четырёх обращений к хеш-таблице.
|
||||||
|
*/
|
||||||
|
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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Классы, которые OpenNLP создаёт по имени, разбирая описание признаков внутри модели.
|
||||||
|
*
|
||||||
|
* <p>В обычной сборке это работает само, в native-образе — нет: класс, не упомянутый
|
||||||
|
* в коде, туда просто не попадает. Без регистрации загрузка модели проходит, а
|
||||||
|
* создание распознавателя падает с {@code ClassNotFoundException} на первом запросе.
|
||||||
|
*
|
||||||
|
* <p>Перечислены фабрики целиком, а не только те, что встречаются в текущей модели:
|
||||||
|
* набор признаков задаётся при обучении и может измениться без правки кода.
|
||||||
|
*/
|
||||||
|
@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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одно правило детекции персональных данных.
|
||||||
|
*
|
||||||
|
* <p>Добавление нового типа ПД — это добавление одного {@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<Integer> groups,
|
||||||
|
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Флаги компиляции для всех правил.
|
||||||
|
*
|
||||||
|
* <p>{@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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр правил детекции и сам поиск ПД в тексте.
|
||||||
|
*
|
||||||
|
* <p>Правила разбиты на три уровня доверия:
|
||||||
|
* <ol>
|
||||||
|
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
|
||||||
|
* <li>однозначные по формату — email, телефон;</li>
|
||||||
|
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
|
||||||
|
* где сама по себе последовательность знаков ни о чём не говорит.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Якорные слова распознаются без учёта регистра — флаг {@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<Rule> 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<String> knownTypes() {
|
||||||
|
return RULES.stream().map(Rule::type).distinct().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Находит все фрагменты ПД, разрешённые политикой системы.
|
||||||
|
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
|
||||||
|
*/
|
||||||
|
public List<Span> detect(String text, SystemPolicy policy) {
|
||||||
|
List<Span> 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<Span> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние одной операции маскирования.
|
||||||
|
*
|
||||||
|
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
|
||||||
|
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
|
||||||
|
* запроса для модели сохранится.
|
||||||
|
*
|
||||||
|
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
|
||||||
|
*/
|
||||||
|
public final class MaskContext {
|
||||||
|
|
||||||
|
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
|
||||||
|
private static final char SEPARATOR = '#';
|
||||||
|
|
||||||
|
private final Map<String, String> assigned = new HashMap<>();
|
||||||
|
private final Map<String, Integer> counters = new HashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замена для значения; при повторе возвращается ранее выданная.
|
||||||
|
*
|
||||||
|
* @param factory получает тип ПД и порядковый номер значения этого типа
|
||||||
|
*/
|
||||||
|
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
|
||||||
|
return assigned.computeIfAbsent(type + SEPARATOR + value,
|
||||||
|
key -> factory.apply(type, counters.merge(type, 1, Integer::sum)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
|
||||||
|
public enum MaskMode {
|
||||||
|
|
||||||
|
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
|
||||||
|
MASK,
|
||||||
|
|
||||||
|
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
|
||||||
|
TOKEN,
|
||||||
|
|
||||||
|
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
|
||||||
|
SYNTHETIC
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Превращает найденное значение в замену согласно настройкам системы.
|
||||||
|
*
|
||||||
|
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
|
||||||
|
* безопасное поведение по умолчанию для вновь добавленных правил.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class Masker {
|
||||||
|
|
||||||
|
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
|
||||||
|
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
|
||||||
|
|
||||||
|
private static final Map<String, UnaryOperator<String>> 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));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Способы преобразования найденного значения в маску.
|
||||||
|
*
|
||||||
|
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
|
||||||
|
* замаскированный текст остаётся читаемым для 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правдоподобные подставные значения вместо настоящих.
|
||||||
|
*
|
||||||
|
* <p>Модель получает текст, который выглядит естественно, и качество ответа
|
||||||
|
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
|
||||||
|
* исходное значение всегда даёт одну и ту же подстановку.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Основы русских личных имён. Правило дописывает до трёх строчных букв,
|
||||||
|
# поэтому падежные формы (Ивану, Иваном, Ивана) покрываются основой.
|
||||||
|
# Строка — одна основа; строки с # игнорируются.
|
||||||
|
Александр
|
||||||
|
Алексей
|
||||||
|
Анатолий
|
||||||
|
Андрей
|
||||||
|
Антон
|
||||||
|
Аркадий
|
||||||
|
Арсений
|
||||||
|
Артём
|
||||||
|
Артем
|
||||||
|
Артур
|
||||||
|
Богдан
|
||||||
|
Борис
|
||||||
|
Вадим
|
||||||
|
Валентин
|
||||||
|
Валерий
|
||||||
|
Василий
|
||||||
|
Виктор
|
||||||
|
Виталий
|
||||||
|
Владимир
|
||||||
|
Владислав
|
||||||
|
Вячеслав
|
||||||
|
Геннадий
|
||||||
|
Георгий
|
||||||
|
Герман
|
||||||
|
Глеб
|
||||||
|
Григорий
|
||||||
|
Даниил
|
||||||
|
Данил
|
||||||
|
Денис
|
||||||
|
Дмитрий
|
||||||
|
Евгений
|
||||||
|
Егор
|
||||||
|
Иван
|
||||||
|
Игорь
|
||||||
|
Илья
|
||||||
|
Кирилл
|
||||||
|
Константин
|
||||||
|
Леонид
|
||||||
|
Максим
|
||||||
|
Марк
|
||||||
|
Матвей
|
||||||
|
Михаил
|
||||||
|
Никита
|
||||||
|
Николай
|
||||||
|
Олег
|
||||||
|
Павел
|
||||||
|
Пётр
|
||||||
|
Петр
|
||||||
|
Роман
|
||||||
|
Руслан
|
||||||
|
Семён
|
||||||
|
Семен
|
||||||
|
Сергей
|
||||||
|
Станислав
|
||||||
|
Степан
|
||||||
|
Тимофей
|
||||||
|
Тимур
|
||||||
|
Фёдор
|
||||||
|
Федор
|
||||||
|
Эдуард
|
||||||
|
Юрий
|
||||||
|
Ярослав
|
||||||
|
Алёна
|
||||||
|
Алена
|
||||||
|
Алина
|
||||||
|
Алла
|
||||||
|
Анастасия
|
||||||
|
Ангелина
|
||||||
|
Анна
|
||||||
|
Антонина
|
||||||
|
Валентина
|
||||||
|
Валерия
|
||||||
|
Варвара
|
||||||
|
Вероника
|
||||||
|
Виктория
|
||||||
|
Галина
|
||||||
|
Дарья
|
||||||
|
Диана
|
||||||
|
Евгения
|
||||||
|
Екатерина
|
||||||
|
Елена
|
||||||
|
Елизавета
|
||||||
|
Жанна
|
||||||
|
Зинаида
|
||||||
|
Инна
|
||||||
|
Ирина
|
||||||
|
Карина
|
||||||
|
Кристина
|
||||||
|
Ксения
|
||||||
|
Лариса
|
||||||
|
Лидия
|
||||||
|
Любовь
|
||||||
|
Людмила
|
||||||
|
Маргарита
|
||||||
|
Марина
|
||||||
|
Мария
|
||||||
|
Надежда
|
||||||
|
Наталья
|
||||||
|
Наталия
|
||||||
|
Нина
|
||||||
|
Оксана
|
||||||
|
Ольга
|
||||||
|
Полина
|
||||||
|
Раиса
|
||||||
|
Регина
|
||||||
|
Светлана
|
||||||
|
София
|
||||||
|
Софья
|
||||||
|
Тамара
|
||||||
|
Татьяна
|
||||||
|
Ульяна
|
||||||
|
Юлия
|
||||||
|
Лев
|
||||||
|
Яков
|
||||||
|
Ян
|
||||||
|
Захар
|
||||||
|
Тарас
|
||||||
|
Савва
|
||||||
|
Мирон
|
||||||
|
Демид
|
||||||
|
Клим
|
||||||
|
Влас
|
||||||
|
Родион
|
||||||
|
Святослав
|
||||||
|
Всеволод
|
||||||
|
Игнат
|
||||||
|
Филипп
|
||||||
|
Лука
|
||||||
|
Назар
|
||||||
|
Платон
|
||||||
|
Прохор
|
||||||
|
Трофим
|
||||||
|
Фома
|
||||||
|
Эмиль
|
||||||
|
Юлиан
|
||||||
|
Тихон
|
||||||
|
Гавриил
|
||||||
|
Давид
|
||||||
|
Марат
|
||||||
|
Рустам
|
||||||
|
Яна
|
||||||
|
Алиса
|
||||||
|
Василиса
|
||||||
|
Агата
|
||||||
|
Злата
|
||||||
|
Милана
|
||||||
|
Дарина
|
||||||
|
Есения
|
||||||
|
Таисия
|
||||||
|
Инга
|
||||||
|
Вера
|
||||||
|
Эмма
|
||||||
|
Нелли
|
||||||
|
Алевтина
|
||||||
|
Клавдия
|
||||||
|
Лилия
|
||||||
|
Римма
|
||||||
|
Элина
|
||||||
|
Ева
|
||||||
|
Аделина
|
||||||
|
Амина
|
||||||
|
Динара
|
||||||
|
Лейла
|
||||||
|
Сабина
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Известные исторические и культурные фигуры. Упоминание такого имени
|
||||||
|
# персональными данными не является — если рядом нет ПД другого типа.
|
||||||
|
# Сравнение идёт по началу слова, поэтому падежи покрываются основой.
|
||||||
|
Пушкин
|
||||||
|
Лермонтов
|
||||||
|
Толстой
|
||||||
|
Достоевский
|
||||||
|
Гоголь
|
||||||
|
Чехов
|
||||||
|
Тургенев
|
||||||
|
Некрасов
|
||||||
|
Есенин
|
||||||
|
Маяковский
|
||||||
|
Ахматова
|
||||||
|
Цветаева
|
||||||
|
Булгаков
|
||||||
|
Пастернак
|
||||||
|
Чайковский
|
||||||
|
Ломоносов
|
||||||
|
Менделеев
|
||||||
|
Гагарин
|
||||||
|
Королёв
|
||||||
|
Суворов
|
||||||
|
Кутузов
|
||||||
|
Шекспир
|
||||||
|
Эйнштейн
|
||||||
|
Ньютон
|
||||||
|
Моцарт
|
||||||
|
Бетховен
|
||||||
|
Рахманинов
|
||||||
|
Репин
|
||||||
|
Айвазовский
|
||||||
|
Циолковский
|
||||||
|
Онегин
|
||||||
|
Печорин
|
||||||
|
Раскольников
|
||||||
|
Обломов
|
||||||
|
Чичиков
|
||||||
|
Базаров
|
||||||
|
Болконский
|
||||||
|
Каренин
|
||||||
|
Чацкий
|
||||||
|
Мцыри
|
||||||
|
Хлестаков
|
||||||
|
Митрофанушка
|
||||||
@@ -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": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замер качества детекции на размеченных наборах.
|
||||||
|
*
|
||||||
|
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
|
||||||
|
* его оценка завышена и годится только как защита от ухудшений.
|
||||||
|
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
|
||||||
|
* настраивались — именно он показывает настоящее качество.
|
||||||
|
*
|
||||||
|
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
|
||||||
|
* поставило границу совпадения, и напрямую соотносятся с посимвольным
|
||||||
|
* сравнением замаскированного текста с эталоном.
|
||||||
|
*
|
||||||
|
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
|
||||||
|
* а расхождение в названии типа (скажем, место рождения против города) на
|
||||||
|
* качество маскирования не влияет.
|
||||||
|
*/
|
||||||
|
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<Span> 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<Sample> samples = load(resource);
|
||||||
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
|
Score anyType = new Score();
|
||||||
|
|
||||||
|
int cleanTexts = 0;
|
||||||
|
int cleanTextsWithFalseHit = 0;
|
||||||
|
int goldFioSpans = 0;
|
||||||
|
int foundFioSpans = 0;
|
||||||
|
List<String> falseHits = new ArrayList<>();
|
||||||
|
List<String> missedFio = new ArrayList<>();
|
||||||
|
List<String> overMasked = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Sample sample : samples) {
|
||||||
|
List<Span> 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<Span> 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<String, Score> 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<Span> 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<String> 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<String, Score> byType, Score anyType,
|
||||||
|
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
|
||||||
|
List<String> missedFio, List<String> falseHits, List<String> 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<String, Score> 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<String> lines) {
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.append(title).append('\n');
|
||||||
|
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Sample> load(String resource) {
|
||||||
|
List<Sample> 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<Span> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + " мс");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
"маскирование по правилам обязано работать и без второй ступени");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("нет такой маски"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + " мс");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
"сломанный файл не должен ронять работающий сервис");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обучение модели для второй ступени распознавания имён.
|
||||||
|
*
|
||||||
|
* <p>Не часть сервиса: инструмент запускается вручную, результат кладётся рядом
|
||||||
|
* с приложением и подключается свойством {@code pdguard.ner.model}. Подготовка
|
||||||
|
* обучающего набора — в {@code tools/prepare-ner-corpus.py}.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* mvn -q test-compile
|
||||||
|
* java -cp target/test-classes:$(cat cp.txt) ru.pdguard.tools.NerTrainer train.txt models/ru-ner-person.bin
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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<String> lines = new PlainTextByLineStream(source, StandardCharsets.UTF_8);
|
||||||
|
ObjectStream<NameSample> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 вышла вчера
|
||||||
|
Температура в Выборге опустилась до минус пяти
|
||||||
|
Подготовь презентацию на двадцать слайдов
|
||||||
|
Сравни два тарифных плана для малого бизнеса
|
||||||
@@ -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
|
||||||
|
Отчёт по кварталу готов, направлен руководителю
|
||||||
@@ -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 году
|
||||||
|
В Понедельник Отдел Согласует Договор
|
||||||
|
Расскажи о погоде в Москве завтра
|
||||||
|
Объясни разницу между дебетовой и кредитной картой
|
||||||
|
Подготовь план обучения для команды разработки
|
||||||
|
Какие документы нужны для открытия счёта юридическому лицу
|
||||||
|
Сколько стоит обслуживание карты в год
|
||||||
|
Переведи текст на английский язык
|
||||||
|
Сгенерируй описание вакансии для аналитика данных
|
||||||
|
Что такое эквайринг и как он работает
|
||||||
|
Составь список задач на следующий спринт
|
||||||
|
Проверь орфографию в этом абзаце
|
||||||
@@ -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": ["*"] }
|
||||||
|
}
|
||||||
@@ -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('<END>')
|
||||||
|
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('<END>')
|
||||||
|
sent.append('<START:person>')
|
||||||
|
inside = True
|
||||||
|
elif not person and inside:
|
||||||
|
sent.append('<END>')
|
||||||
|
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]))
|
||||||
@@ -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('<START:person>'); inside = True
|
||||||
|
elif not is_person and inside:
|
||||||
|
words.append('<END>'); inside = False
|
||||||
|
words.append(text[tid])
|
||||||
|
if inside:
|
||||||
|
words.append('<END>')
|
||||||
|
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}")
|
||||||
Executable
+39
@@ -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"
|
||||||
Reference in New Issue
Block a user