Compare commits

..
16 Commits
Author SHA1 Message Date
dakocha3 4c4667bb8b perf: не звать legal-модель на каждое слово текста
LEGAL_CANDIDATE сужен до цифровых кластеров (ИНН, СНИЛС, паспорт, телефон,
банковский счёт) и email. Раньше в него входили и слова, из-за чего модель
LLAIM Legal NER вызывалась на каждое слово до maxCandidates раз. Имена (PER)
размечает модель имён, email — правила, поэтому слова из кандидата убраны.
2026-09-24 00:01:46 +03:00
dakocha3 ddceca076c feat: метрики использования каждой NER-модели и панели дашборда
- Добавлены счётчики pdguard.ner.model.requests и таймеры
  pdguard.ner.model.duration с тегом model (name/address/legal).
- Гистограммы для таймеров моделей включены в MetricsConfiguration.
- Дашборд дополнен панелями «Обращения к моделям» и «Время моделей».
- Prometheus собирает метрики с обеих нод кластера.
- Целевые значения лимитера: min-concurrent 100, target-latency 900.
- Подавление S2925 (Thread.sleep) в тесте лимитера.
2026-09-23 23:51:07 +03:00
Максименко Никита Владимирович 7a388e3d4a fix: обновление доки 2026-09-23 23:45:56 +03:00
Максименко Никита Владимирович 4bf711197a fix: добавление доки и докерфайла для жюри 2026-09-23 23:18:07 +03:00
Максименко Никита Владимирович 41e77e4ca1 feat: ui + docs: для жюри 2026-09-23 23:12:10 +03:00
dakocha3 954d772fa2 fix: маскировать все типы ПД для системы crm
Паспорт и другие документы не маскировались для crm (режим TOKEN), потому что
в types были перечислены только ФИО, телефон, email и адресные поля. Теперь
types=["*"] — crm маскирует все типы ПД, как analytics и strict.
2026-09-23 23:07:29 +03:00
dakocha3 cd59e37d3a refactor: убрать ограничение maxChars из хранилища соответствий
- Удалены поле maxChars, параметр конструктора и метод evictWhileOverLimit.
- Хранилище теперь ограничено только TTL (ttl-minutes), без вытеснения по объёму.
- Конструкторы переведены на (int ttlMinutes) и (int ttlMinutes, SharedIndex, PayloadCipher).
- Обновлены тесты и PipelineWarmup на новые сигнатуры.
2026-09-23 22:33:54 +03:00
Максименко Никита Владимирович 573d94cca8 refactor: подтянуть Legal NER, переформатировать код и добавить тесты
Слияние с 685ec97 (третья ступень NER для юридических реквизитов),
код приведён к google-java-format, добавлены юнит-тесты
AdaptiveConcurrencyLimiter/SystemsConfig/PayloadCipher.
2026-09-23 22:02:45 +03:00
dakocha3 84b5adcb3f feat: интеграция LLAIM Legal NER как третьей ступени распознавания
- Подключение ru-legal-ner (ONNX) для юридических реквизитов: ИНН, ОГРН,
  СНИЛС, паспорт, телефон, email, банковский счёт, дата.
- Отдельный проход LEGAL_CANDIDATE, чтобы не вытеснять кандидатов имён и адресов.
- coversAny(policy) в Pipeline вместо проверки только FIO.
- Нормализация цифровых ПД (ИНН/СНИЛС/карта/ОГРН) в свободной форме.
- Фикс ложного срабатывания ФИО на аббревиатуре «ИНН» (PD_MARKERS).
- Рефакторинг конструкторов NameCascade через record EngineConfig (Sonar S107).
2026-09-23 21:40:53 +03:00
Максименко Никита Владимирович 4bdb03341b feat: добавление STRICT режима (более строгий MASKED) 2026-09-23 18:20:54 +03:00
Максименко Никита Владимирович 5f77971a1c refactor: разбить RuleRegistry на классы по категориям правил
RuleRegistry.java вырос до 621 строки — вынесены общие regex-фрагменты
в RulePatterns и группы правил в DocumentRules, FinanceRules, DateRules,
FioRules, ContactRules, AddressRules. RuleRegistry теперь только собирает
списки и хранит публичный API (detect, isAddressType, hasAddressContext).
2026-09-23 17:23:05 +03:00
Максименко Никита Владимирович d5b54b3f5c refactor: улучшение качества кода 2026-09-23 14:41:05 +03:00
Максименко Никита Владимирович 6ac5bb7819 feat: улучшение regexp + деплой одного инстанса 2026-09-23 13:26:14 +03:00
Максименко Никита Владимирович f0e9c6c60c fix: sonarQube замечания 2026-09-23 09:55:48 +03:00
dakocha3 1cee7d0f0f fix: распознавать одиночные имена и расширить правила дат и CVV/PIN 2026-09-22 23:32:45 +03:00
dakocha3 6ca880a992 fix: расширить правила детекции ПД и добавить датасет утечек 2026-09-22 23:09:11 +03:00
97 changed files with 9013 additions and 5933 deletions
+3 -3
View File
@@ -223,9 +223,9 @@ payload_id=doc-1 символов=64 найдено={FIO=1, PASSPORT=1, PHONE=1}
- Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`),
сбрасывается при перезапуске. Для кластера используется Redis.
- NER-модель второй ступени (`models/rubert-ner`) не входит в репозиторий и
скачивается скриптом `tools/fetch-ner-model.sh`; без неё сервис работает на
правилах.
- NER-модели второй ступени (`models/rubert-ner`, `models/wikineural-ner`,
`models/ru-legal-ner`) не входят в репозиторий и скачиваются скриптом
`tools/fetch-ner-model.sh`; без них сервис работает на правилах.
- Демаскирование доступно только системам с `demask: true` и корректным ключом.
---
+40 -89
View File
@@ -1,114 +1,65 @@
# `docker compose up` поднимает кластер целиком: redis, node-a, node-b за
# nginx-балансировщиком на 8080, плюс Prometheus на 9090 и Grafana на 3000.
#
# Маскирование детерминировано и работает на любом узле, а вот обратный шаг
# требует общего состояния — отсюда общий Redis между node-a и node-b.
#
# Лимиты CPU/RAM: сервер — 4 vCPU. Без лимитов node-a/node-b/prometheus/grafana
# под нагрузкой отжимали CPU у Redis, тот не укладывался в таймаут команд, узлы
# теряли общее состояние и демаскирование съезжало на резервный путь. Лимиты —
# это потолок (docker compose без swarm не умеет в гарантированные reservations),
# но они не дают соседям выесть Redis подчистую.
# Один узел, состояние маскирования/демаскирования — в памяти самого процесса
# (pdguard.store.backend=memory, дефолт). Redis был нужен только чтобы разделить
# состояние между несколькими репликами; кластерная схема на 4-vCPU хосте под
# нагрузкой давала конкуренцию за CPU (см. историю в git) — один узел проще и
# получает весь хост целиком.
services:
prometheus:
image: prom/prometheus:v2.54.1
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=6h
- --web.enable-lifecycle
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
cpus: 0.5
mem_limit: 512m
grafana:
image: grafana/grafana:11.2.0
ports:
- "3000:3000"
environment:
# Демонстрационный стенд: вход без пароля, чтобы жюри не искало учётные данные.
# Для контура с реальными данными это надо снять.
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
GF_USERS_DEFAULT_THEME: light
# Сразу открывать дашборд модуля, а не пустую главную.
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/pd-guard.json
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
cpus: 0.3
mem_limit: 512m
redis:
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
cpus: 1.0
mem_limit: 1200m
node-a: &node
pd-guard:
image: pd-guard-spring:jvm
cpus: 1.0
mem_limit: 2200m
ports:
- "8080:8080"
deploy:
resources:
limits:
cpus: "4"
memory: 6g
environment:
PDGUARD_STORE_BACKEND: redis
SPRING_DATA_REDIS_HOST: redis
# Дефолт JVM — 25% контейнерного лимита на heap.
JAVA_OPTS: >-
-Dspring.config.additional-location=optional:file:/deployments/config/
-XX:MaxRAMPercentage=75.0
PDGUARD_MAX_CONCURRENT: "2000"
# На 1 vCPU дефолт 200мс держал concurrency у пола; на 4 vCPU запас есть,
# но 800мс оставлено с той же осторожностью — целевая latency контракта 1с.
PDGUARD_TARGET_LATENCY_MS: "800"
PDGUARD_WARMUP_ITERATIONS: "2000"
# Вторая ступень распознавания — две модели под разные задачи (см.
# NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие
# адреса. Модели в репозиторий не входят: ./tools/fetch-ner-model.sh.
# Пока движок не задан, соответствующая часть ступени выключена и
# сервис работает на одних правилах.
PDGUARD_NER_NAME_ENGINE: wikineural
PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner
PDGUARD_NER_ADDRESS_ENGINE: rubert
PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner
# ВРЕМЕННО для разового разбора формата тестовых payload'ов — пишет сырые ПД
# в логи узла. LOGGING_LEVEL_* не подходит: relaxed binding из env приводит
# имя логгера к нижнему регистру и не совпадает с ru.pdguard.core.Pipeline
# (заглавная P), поэтому уровень задан через -D, где регистр сохраняется.
# Выключить (убрать переменную) перед официальным нагрузочным прогоном.
JAVA_OPTS: "-Dspring.config.additional-location=optional:file:/deployments/config/ -Dlogging.level.ru.pdguard.core.Pipeline=DEBUG"
# Третья ступень (юридические реквизиты, LLAIM Legal NER) временно выключена
# для нагрузочного теста — проверяем, она ли основной источник CPU-затрат
# на 1-vCPU лимите. Включить: PDGUARD_NER_LEGAL_ENGINE=ru-legal-ner.
PDGUARD_NER_LEGAL_ENGINE: "off"
volumes:
- ./config:/deployments/config:ro
- ./models:/deployments/models:ro
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 10s
timeout: 2s
retries: 3
node-b:
<<: *node
lb:
image: nginx:1.27-alpine
cpus: 0.3
mem_limit: 128m
prometheus:
image: prom/prometheus:v2.55.1
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "8080:80"
volumes:
- ./nginx/lb.conf:/etc/nginx/nginx.conf:ro
depends_on:
node-a:
condition: service_healthy
node-b:
condition: service_healthy
- "9090:9090"
grafana:
image: grafana/grafana:11.3.0
depends_on:
- prometheus
ports:
- "3000:3000"
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
volumes:
prometheus-data:
grafana-data:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+12 -1
View File
@@ -14,7 +14,7 @@
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
"types": ["*"]
},
"analytics": {
"enabled": true,
@@ -27,5 +27,16 @@
"demask": false,
"maskMode": "MASK",
"types": ["*"]
},
"strict": {
"enabled": true,
"demask": true,
"maskMode": "STRICT",
"types": ["*"],
"requireCompanion": [
"CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
"INCOME", "BIOMETRIC"
]
}
}
+239
View File
@@ -0,0 +1,239 @@
# Инструкция для жюри по проверке
Модуль принимает текст, находит в нём персональные данные, подменяет их и по тому же идентификатору возвращает исходный текст. Ниже — как это воспроизвести и где смотреть журнал и метрики.
Сервис слушает `http://localhost:8080`.
## Запуск
Нужны Java 21 и Maven 3.8+ (или обёртка `./mvnc`). Для дашборда — Docker Compose.
Локально:
```bash
./mvnc package -DskipTests
java -jar target/pd-guard-spring-1.0.0.jar
```
Контейнер, Prometheus и Grafana (`compose.yaml` ссылается на уже собранный образ `pd-guard-spring:jvm`, сам его не строит):
```bash
docker build -f src/main/docker/Dockerfile -t pd-guard-spring:jvm .
docker compose up -d
```
Если локальной Java нет — тот же образ собирается полностью внутри Docker, `mvn package` идёт в отдельной стадии сборки (дольше первого запуска, зато не требует ничего на хосте кроме Docker):
```bash
docker build -f src/main/docker/Dockerfile.build -t pd-guard-spring:jvm .
docker compose up -d
```
Готовность: `GET http://localhost:8080/health` отвечает `OK`.
Действующие системы читаются из `config/systems.json`. Это не встроенный файл в jar: при запуске из каталога проекта используется именно он.
### Модели второй и третьей ступеней (опционально)
Без моделей сервис работает на одних правилах — этого достаточно для контракта `/process`. Модели нужны для распознавания в свободном тексте (иностранные имена без русских словообразовательных признаков, регион/район адреса, юридические реквизиты в нетиповых формулировках). Скачиваются одной командой (~1 ГБ, требует `python3` для конвертации третьей модели):
```bash
./tools/fetch-ner-model.sh
```
Кладёт веса в `models/wikineural-ner`, `models/rubert-ner`, `models/ru-legal-ner`. При локальном запуске (`java -jar ...`) включаются через `pdguard.ner.*-engine` (см. `docs/03-architecture.md`); в `docker compose up -d` каталог `models/` уже примонтирован и подхватывается автоматически, если модели скачаны до запуска. Сбой конкретной модели отключает только её ступень, остальное продолжает работать на правилах.
## Как устроен запрос
`POST /process`
Тело:
```json
{"payload": "текст", "payload_id": "устойчивый-идентификатор"}
```
Заголовки:
| Заголовок | Смысл |
|-----------|--------|
| `X-System-Id` | имя системы из `config/systems.json`. Нет заголовка или имя неизвестно — применяется политика `default` |
| `X-System-Key` | нужен только если у системы в конфиге задано поле `key` |
Направление выбирается по `payload_id`, а не по отдельному флагу:
1. Идентификатор ещё не встречался — текст маскируется, пара «исходник ↔ маска» запоминается.
2. Пришёл ранее выданный текст маски и у системы включено `demask` — возвращается исходный текст.
3. Пришёл тот же исходный текст — возвращается та же маска, что и в первый раз.
Ответ: `{"result": "..."}`.
Коды: `200` успех, `400` нет `payload` или `payload_id`, `403` система выключена или неверный ключ, `429` перегрузка (заголовок `Retry-After: 1`). При внутреннем сбое сервис отвечает `200` и текстом `[обработка недоступна]`, чтобы исходные персональные данные не ушли наружу.
## Маскирование в браузере
Откройте `http://localhost:8080/`.
Четыре режима на странице — это четыре системы из конфига:
| Кнопка | Заголовок | Что увидите |
|--------|-----------|-------------|
| MASK | `default` | звёздочки с открытыми краями номера, ФИО инициалами |
| STRICT | `strict` | сплошные звёздочки, длина сохраняется |
| TOKEN | `crm` | токены вида `[FIO_1]`, `[PASSPORT_1]` |
| SYNTHETIC | `analytics` | правдоподобная подмена (вымышленное ФИО, номер, адрес) |
Вставьте текст, например:
```text
Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67
```
Нажмите «Обработать». Результат появится в блоке под кнопкой.
Страница каждый раз создаёт новый `payload_id`. Она показывает только маскирование. Демаскирование проверяется запросом к API с тем же идентификатором.
## Маскирование через API
Система `default` маскирует все известные типы и умеет демаскировать. Режим — звёздочки с открытыми краями номера (`MASK`).
```bash
curl -s -X POST http://localhost:8080/process \
-H "Content-Type: application/json" \
-H "X-System-Id: default" \
-d "{\"payload\":\"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67\",\"payload_id\":\"doc-1\"}"
```
Ожидаемый вид ответа: в `result` ФИО заменено на инициалы (`И. И. И.`), середина паспорта и телефона закрыта звёздочками, края видны.
Сплошные звёздочки — тот же запрос с заголовком `X-System-Id: strict`.
Токены вида `[FIO_1]`, `[PASSPORT_1]`, `[PHONE_1]` — тот же запрос с заголовком `X-System-Id: crm` (но у `crm` в политике нет `PASSPORT`, см. ниже).
Синтетика — `X-System-Id: analytics`. У этой системы обратное преобразование выключено.
Система `crm` маскирует только ФИО, телефон, email и части адреса (город, улица, дом, квартира). Паспорт в её политике не входит и в ответе останется открытым. Демаскирование у `crm` выключено.
Система `legacy-billing` выключена: тот же запрос с `X-System-Id: legacy-billing` даёт `403`.
## Демаскирование
Нужны три условия одновременно:
- тот же `payload_id`, что при маскировании (`doc-1` в примере выше);
- в `payload` — точная строка из поля `result` предыдущего ответа;
- система с `"demask": true`. Сейчас это `default` и `strict`. У `crm` и `analytics` демаскирование выключено намеренно.
```bash
curl -s -X POST http://localhost:8080/process \
-H "Content-Type: application/json" \
-H "X-System-Id: default" \
-d "{\"payload\":\"<сюда строка result из маскирования>\",\"payload_id\":\"doc-1\"}"
```
В `result` вернётся исходная фраза с ФИО, паспортом и телефоном.
Соответствие живёт в памяти процесса 30 минут и пропадает после перезапуска. Повторный запрос после перезапуска будет обработан как новое маскирование.
Хранилище разделено по системам: маска, полученная от `default`, не раскрывается запросом от `strict` с тем же `payload_id`.
## Цепочка до модели
`POST /proxy` показывает, что ушло бы во внешнюю модель и что вернулось бы потребителю. Адрес модели по умолчанию пуст, поэтому отвечает заглушка: она возвращает присланный (уже замаскированный) текст. В модель подставляются токены, даже если у системы выбран другой режим: по звёздочкам однозначное восстановление невозможно.
```bash
curl -s -X POST http://localhost:8080/proxy \
-H "Content-Type: application/json" \
-H "X-System-Id: default" \
-d "{\"prompt\":\"Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111. Кратко опишите профиль.\"}"
```
В ответе:
| Поле | Смысл |
|------|--------|
| `prompt_masked` | текст, который ушёл бы в модель |
| `llm_response_masked` | ответ модели (или заглушки) ещё с токенами |
| `response` | текст потребителю; при `demask: true` токены заменены обратно |
| `replaced` | таблица «токен → исходное значение» |
| `llm` | `заглушка` либо имя модели |
В `prompt_masked` не должно остаться открытых ФИО и номера карты.
## Где смотреть логи
Отдельного файла журнала нет. Строки пишет процесс в стандартный вывод.
Локальный запуск — окно, где выполнена команда `java -jar`.
Docker:
```bash
docker compose logs -f pd-guard
```
На каждое маскирование есть строка уровня INFO. В ней идентификатор, длина текста и счётчики по типам. Сами значения персональных данных в журнал не пишутся:
```text
payload_id=doc-1 символов=72 найдено={FIO=1, PASSPORT=1, PHONE=1}
```
Рядом по смыслу, если они случаются:
- `Системе … обращение в модуль запрещено настройками` — выключенная система;
- `Системе … отказано: неверный ключ`;
- `демаскирование не нашло соответствие` — идентификатор и отпечаток маски неизвестны, текст обработан как новый;
- `Настройки систем перечитаны из …` — после правки `config/systems.json`.
Для прокси: `proxy: система=… заменено=… модель=…`. Число замен есть, значения — нет.
## Где смотреть метрики
Сырые метрики сервиса, без Docker:
| Адрес | Что это |
|-------|---------|
| `GET /actuator/prometheus` | все метрики в формате Prometheus |
| `GET /actuator/metrics` | список имён Micrometer |
| `GET /actuator/metrics/pdguard.process` | длительность обработки |
| `GET /actuator/metrics/pdguard.pd.detected` | сколько фрагментов найдено, в разрезе типа и системы |
| `GET /actuator/health` | проба Spring Boot |
Имеет смысл смотреть после одного-двух вызовов `/process`:
```bash
curl -s http://localhost:8080/actuator/prometheus | findstr pdguard
```
На Linux и macOS вместо `findstr` — `grep pdguard`.
Основные ряды:
| Метрика | О чём |
|---------|--------|
| `pdguard_process_seconds` | длительность, метки `direction` (`mask` / `unmask`) и `system` |
| `pdguard_pd_detected_total` | найденные фрагменты, метки `type` и `system` |
| `pdguard_requests_rejected_total` | отказы: `overload`, `malformed`, `system_disabled`, `internal_error` |
| `pdguard_concurrency_limit` | текущий потолок одновременных запросов |
| `pdguard_concurrency_in_flight` | сколько запросов в работе |
| `pdguard_store_chars` | объём хранилища соответствий в символах |
| `pdguard_tokens_processed_total` | оценка числа обработанных токенов |
| `pdguard_demask_unresolved_total` | демаскирование без найденного соответствия |
| `pdguard_ner_*` | вторая ступень (модель). В поставке без скачанных моделей ступень выключена, ряды почти не растут |
Дашборд появляется вместе с `docker compose`:
- Grafana: `http://localhost:3000` (анонимный просмотр включён). Дашборд «Модуль безопасности персональных данных»: `http://localhost:3000/d/pd-guard`. Обновление раз в 5 секунд. Блоки: обращения и типы ПДн, задержка и доля ответов быстрее 0,5 с, отказы, вторая ступень, ресурсы узла.
- Prometheus: `http://localhost:9090`. Цель сбора — `pd-guard:8080`, интервал 5 секунд.
## Что поменять без перезапуска
Отредактируйте `config/systems.json` и вызовите:
```bash
curl -s -X POST http://localhost:8080/admin/reload
```
Файл также перечитывается сам, если изменилось время модификации (проверка не чаще раза в секунду). Текущие политики: `GET /admin/config`. Список типов, которые умеет распознавать сборка: `GET /admin/types`.
Проверка набора типов: в политике `crm` нет `PASSPORT`. Текст с паспортом и телефоном под `X-System-Id: crm` скроет телефон и оставит номер паспорта. Под `default` скроет оба.
+127
View File
@@ -0,0 +1,127 @@
# Схема архитектуры и настройки
Модуль стоит между системой-потребителем и внешней языковой моделью. Потребитель отдаёт текст один раз на вход и один раз на выход. В модель уходит уже подменённый текст, потребителю возвращается текст с восстановленными значениями.
```mermaid
flowchart LR
consumer["Система-потребитель"]
api["POST /process и POST /proxy"]
policy["Политика системы\nconfig/systems.json"]
rules["Правила и словари"]
ner["Вторая ступень\nNER, если включена"]
filters["Отсев ложных\nсрабатываний"]
masker["Маскирование\nMASK / STRICT / TOKEN / SYNTHETIC"]
store["Хранилище соответствий\nпамять, AES-GCM"]
llm["Внешняя LLM\nили заглушка"]
consumer --> api --> policy --> rules --> ner --> filters --> masker --> store
masker -->|"только /proxy"| llm
llm -->|"демаскирование по токенам"| consumer
store -->|"демаскирование по payload_id"| consumer
```
`POST /process` — контракт для системы-потребителя: маскирование и демаскирование. `POST /proxy` — демонстрация всей цепочки до модели и обратно; проверяющий контур может его не использовать.
Настройки систем, перечень типов и вид маски задаются файлом `config/systems.json`. Код для смены политики пересобирать не нужно. Файл перечитывается при изменении и по `POST /admin/reload`.
## Что происходит с одним текстом
1. По заголовку `X-System-Id` выбирается политика. Неизвестное имя получает политику `default`. Выключенная система и неверный `X-System-Key` получают `403` до обработки текста.
2. Если `payload_id` уже есть в хранилище этой системы, направление определяется сравнением текста с сохранённой маской и с исходником.
3. Иначе текст проходит детекцию. Сначала правила: контрольные суммы (карта, ИНН, СНИЛС, ОГРН), однозначные форматы (email, телефон), затем шаблоны с якорным словом (документ, адрес, дата, ФИО). Цифровые значения с нестандартными разделителями дополнительно собираются в кластер и проверяются той же контрольной суммой.
4. Если включена вторая ступень, модель смотрит только непокрытые кандидаты: цепочки слов с заглавной буквы и, для юридической модели, цифровые кластеры. В поставке по умолчанию ступень выключена (`pdguard.ner.*-engine: off`). В `compose.yaml` она включается, если в каталог `models/` положены веса.
5. Ложные срабатывания снимаются до маски: имя в составе организации и на вывеске, общеизвестное имя без других персональных данных рядом, адрес отделения, типы-спутники без самостоятельного персонального данного.
6. Оставшиеся фрагменты заменяются по `maskMode` системы. Пара «исходный текст ↔ маска» пишется в хранилище этой системы. Исходный текст в хранилище шифруется AES-GCM, ключ — `pdguard.store.encryption-key`.
Демаскирование не запускает детектор заново: по `payload_id` (и запасным отпечатком маски) достаётся сохранённый исходник. Поэтому звёздочки тоже обратимы, пока жива запись.
## Системы-потребители
Файл `config/systems.json`, путь переопределяется свойством `pdguard.systems-file`.
| Система | Включена | Демаскирование | Режим | Типы |
|---------|----------|----------------|-------|------|
| `default` | да | да | `MASK` | все (`*`) |
| `crm` | да | нет | `TOKEN` | ФИО, телефон, email, город, улица, дом, квартира |
| `analytics` | да | нет | `SYNTHETIC` | все (`*`) |
| `strict` | да | да | `STRICT` | все (`*`) |
| `legacy-billing` | нет | нет | `MASK` | все (`*`), запросы отклоняются |
Запрос без заголовка идёт в `default`. Хранилище у каждой системы своё: одна система не читает соответствия другой.
Поля политики:
| Поле | Назначение |
|------|------------|
| `enabled` | `false` — модуль отвечает `403` и текст не обрабатывает |
| `demask` | разрешено ли обратное преобразование |
| `maskMode` | чем заменяется найденное значение |
| `types` | какие типы маскировать; `"*"` — все, которые знает сборка |
| `requireCompanion` | типы, которые маскируются только рядом с самостоятельным персональным данным |
| `key` | общий секрет; если задан, заголовок `X-System-Key` обязан совпасть. Только ASCII |
Пример добавления системы — дописать объект и перечитать файл:
```json
"dms": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["FIO", "PHONE", "MEDICAL_POLICY", "BIRTH_DATE"],
"key": "dms-secret"
}
```
После `POST /admin/reload` запросы с `X-System-Id: dms` и `X-System-Key: dms-secret` маскируют только перечисленные типы, звёздочками, и умеют демаскировать.
Новый тип персональных данных добавляется правилом в реестре (`DocumentRules`, `FinanceRules`, `DateRules`, `FioRules`, `ContactRules`, `AddressRules`) и именем в `PdTypes`. Политики, где указано `"*"`, подхватывают его без правки конфига. Политика с явным списком — только если имя типа туда добавить.
## Типы персональных данных
Список отдаёт `GET /admin/types`. Группы:
| Группа | Типы |
|--------|------|
| Человек | `FIO`, `CARDHOLDER` |
| Документы | `PASSPORT`, `PASSPORT_ISSUER`, `PASSPORT_DATE`, `DEPT_CODE`, `FOREIGN_PASSPORT`, `DRIVER_LICENSE`, `MILITARY_ID`, `BIRTH_CERTIFICATE`, `MEDICAL_POLICY` |
| Контакты | `PHONE`, `EMAIL` |
| Адрес | `ADDRESS_COUNTRY`, `ADDRESS_POSTCODE`, `ADDRESS_CITY`, `ADDRESS_STREET`, `ADDRESS_HOUSE`, `ADDRESS_FLAT`. `ADDRESS_REGION` и `ADDRESS_DISTRICT` размечает только модель второй ступени |
| Даты и гражданство | `BIRTH_DATE`, `BIRTH_PLACE`, `DATE`, `CITIZENSHIP` |
| Платёжные данные | `CARD`, `CARD_EXPIRY`, `CVV`, `PIN` |
| Реквизиты | `INN`, `SNILS`, `ACCOUNT_NUMBER`, `BIK`, `OGRN`, `OGRNIP`, `KPP` |
| Прочее | `INCOME`, `BIOMETRIC` |
Правила устойчивы к регистру, к дате числом и словами, к вставке слов между серией и номером документа, к уменьшительным формам имён. Карта, ИНН, СНИЛС, ОГРН и ОГРНИП без верной контрольной суммы не маскируются.
## Режимы маскирования
Режим задаётся полем `maskMode` и действует на все типы, которые политика разрешила.
| Режим | Пример для `Иванов Иван Иванович` и паспорта `4509 123456` | Когда уместен |
|-------|--------------------------------------------------------------|---------------|
| `MASK` | `И. И. И.`, паспорт `45** ****56` | человеку остаётся узнаваемый контур, середина закрыта. Края коротких серий (загранпаспорт, военный билет, свидетельство о рождении) не открываются. CVV и PIN закрываются целиком |
| `STRICT` | сплошные звёздочки на всю длину, включая ФИО | ничего из исходных знаков не остаётся |
| `TOKEN` | `[FIO_1]`, `[PASSPORT_1]` | однозначная обратимая подстановка, удобная и для демаскирования ответа модели |
| `SYNTHETIC` | вымышленные ФИО и номер той же формы | модель видит правдоподобный текст. Для типов без своей подстановки остаётся токен |
Одинаковое исходное значение внутри одного текста получает одну и ту же замену.
В `POST /proxy` режим системы для отправки в модель заменяется на `TOKEN`: одинаковые звёздочки нельзя однозначно вернуть на место в ответе модели.
## Контекстное правило
Типы из `requireCompanion` сами по себе персональными данными не считаются. Они маскируются, только если в том же тексте есть находка самостоятельного типа.
В политиках `default` и `strict` спутники такие: `CVV`, `PIN`, `DATE`, `BIRTH_PLACE`, `ADDRESS_COUNTRY`, `ACCOUNT_NUMBER`, `BIK`, `OGRN`, `OGRNIP`, `KPP`, `INCOME`, `BIOMETRIC`.
Два спутника друг друга не подтверждают. Дата рядом с ОГРН без имени и документа человека не маскируется. PIN рядом с номером карты — маскируется. Список спутников у каждой системы свой; пустой список означает, что маскируется всё найденное из `types`.
Отдельно от этого списка снимаются ложные ФИО и адреса: «Александр Пушкин» без других персональных данных, «Институт Склифосовского», адрес отделения банка. Если рядом с общеизвестным именем есть другой тип персональных данных, имя остаётся замаскированным: однофамилец защиту не теряет.
## Состояние и наблюдаемость
Хранилище соответствий по умолчанию — память процесса (`pdguard.store.backend=memory`): потолок `pdguard.store.max-chars` (512 МБ символов), срок жизни записи `pdguard.store.ttl-minutes` (30 минут). При `backend=redis` та же запись дублируется в Redis, чтобы демаскирование попало на другой узел. В текущем `compose.yaml` поднят один узел, Redis не используется.
Одновременные запросы ограничивает адаптивный предел: он растёт, пока задержка укладывается в `pdguard.target-latency-ms` (200 мс), и сжимается, когда перестаёт. Лишние запросы получают `429`, очередь не копится.
Метрики отдаёт Micrometer на `/actuator/prometheus`. Готовые Prometheus и Grafana лежат в `monitoring/` и поднимаются тем же `docker compose`.
+69
View File
@@ -0,0 +1,69 @@
# Производительность и дополнительные возможности
Целевой уровень из задания — задержка не выше 0,5 с при 1000 запросах в секунду. Замер ниже снят на правилах, без нейросетевой ступени: именно так работает поставка, пока каталог `models/` пуст.
## Нагрузочный тест
Инструмент — [k6](https://k6.io/), сценарий `k6-load-test.js`.
Условия:
- один узел в Docker, порт 8080;
- система `crm` (маскирование, без демаскирования; типы — ФИО, телефон, email и части адреса);
- пять коротких текстов, часть из них с персональными данными, один — без них;
- профиль виртуальных пользователей: 30 с до 500, затем по 30 с на 1000, 1500 и 2000, затем спад до нуля;
- между запросами одного пользователя пауза 0,1 с;
- пороги сценария: доля ошибок ниже 1 %, p95 длительности HTTP ниже 200 мс.
Запуск при уже поднятом сервисе:
```bash
k6 run k6-load-test.js
```
Другой адрес: `k6 run -e BASE_URL=http://localhost:8080 k6-load-test.js`.
Зафиксированный прогон этого сценария на одном узле:
| Метрика | Значение |
|---------|----------|
| Пропускная способность | ~9 500 запросов/с |
| Задержка p50 | 1,03 мс |
| Задержка p95 | 13,87 мс |
| Ошибки | 0,00 % |
0,5 с при 1000 запросах/с перекрыто с запасом: p95 на этом профиле около 14 мс, поток около 9 500 запросов/с.
Отдельный замер внутри процесса (`PerformanceBenchmarkTest`) гоняет тот же набор текстов по правилам после прогрева JIT. В тесте закреплены пороги: p99 маскирования одного обращения ниже 5 мс и пропускная способность выше 1000 обращений/с на доступных ядрах. Это порог регрессии, а не замена цифр k6.
Потолок одновременных запросов не фиксирован. `AdaptiveConcurrencyLimiter` держит его между `pdguard.min-concurrent` (8) и `pdguard.max-concurrent` (2000) и подстраивает по фактической задержке с целью 200 мс. На старте `PipelineWarmup` прогоняет горячий путь несколько тысяч раз на отдельном коротком хранилище, чтобы первые боевые запросы не попали на непрогретый код: без этого p95 в первые десятки секунд примерно на порядок хуже.
Вторая ступень (NER) в замер не входила. Модель зовётся только на непокрытые кандидаты и стоит десятки миллисекунд на вызов; на текстах, которые уже закрыты правилами, она не вызывается. Доля таких обращений видна в метрике `pdguard_ner_requests_total`.
На дашборде Grafana (`http://localhost:3000/d/pd-guard`) во время прогона смотрят обращения в секунду, p95 маскирования, долю ответов быстрее 0,5 с, отказы по перегрузке и текущий предел конкурентности.
## Дополнительные возможности
Сверх маскирования заданного перечня типов реализовано следующее.
**Четыре режима подмены.** Звёздочки с сохранением краёв и разделителей (`MASK`), сплошное закрытие (`STRICT`), обратимые токены (`TOKEN`), правдоподобные вымышленные значения (`SYNTHETIC`). Синтетический номер карты проходит проверку Луна. Один и тот же фрагмент в тексте всегда получает одну и ту же замену.
**Политика на систему без пересборки.** Включение, демаскирование, режим, белый список типов, типы-спутники и общий секрет задаются в `config/systems.json` и применяются на лету. Хранилище соответствий разделено по системам.
**Контекстное маскирование.** PIN, CVV, дата без якоря, место рождения, страна, реквизиты организации, доход и упоминание биометрии маскируются только рядом с самостоятельным персональным данным. Список спутников настраивается у каждой системы. Два спутника друг друга не подтверждают.
**Защита от ложных срабатываний.** Общеизвестные имена и правители снимаются, если рядом нет других персональных данных; внешний список `config/well-known.txt` дополняет встроенный и перечитывается сам. Имя в названии организации и на вывеске не маскируется. Адрес отделения банка и улица в рассказе о городе не считаются адресом клиента. Словари имён, стран и населённых пунктов учитывают склонения и уменьшительные формы.
**Контрольные суммы и свободная запись чисел.** Карта, ИНН, СНИЛС, ОГРН и ОГРНИП подтверждаются контрольной суммой. Те же номера находятся, если между цифрами стоят пробелы, точки, дефисы или скобки.
**Вторая ступень распознавания.** Три необязательные ONNX-модели: имена (WikiNEuRal), составляющие адреса (ruBERT), юридические реквизиты. Модели в репозиторий не входят, скачиваются `tools/fetch-ner-model.sh`. Сбой ступени её отключает и оставляет правила. Регион и район адреса размечаются только этой ступенью.
**Безопасный отказ.** При внутренней ошибке наружу уходит фиксированная строка `[обработка недоступна]`, а не исходный текст. В журнал пишутся идентификатор, длина и счётчики типов; значения персональных данных не пишутся.
**Хранилище.** Исходный текст шифруется AES-GCM. Запись живёт ограниченное время и вытесняется по объёму. Демаскирование возможно по `payload_id` и по отпечатку маски. Повтор того же исходного текста с тем же идентификатором возвращает прежнюю маску. Общий слой Redis включается настройкой `pdguard.store.backend=redis` и при серии сбоев на время перестаёт опрашиваться, не роняя запрос.
**Демонстрация модели.** `POST /proxy` показывает замаскированный запрос, ответ модели и восстановленный текст. Без адреса модели работает заглушка; ошибка модели тоже сводится к заглушке.
**Наблюдаемость.** Пробы `/health` и `/actuator/health`, метрики Micrometer и Prometheus, готовые Prometheus и Grafana. Гистограмма `pdguard.process` размечена корзинами до 10 с, на дашборде видна доля ответов быстрее 0,5 с. Веб-страница `http://localhost:8080/` гоняет три режима маскирования без отдельного клиента.
**Прогрев и предел нагрузки.** Прогрев JIT на старте и адаптивный лимитер конкурентности с ответом `429`, чтобы задержка не упиралась в таймаут вызывающей стороны.
+41
View File
@@ -0,0 +1,41 @@
# Ограничения решения и план развития
Ограничения ниже относятся к поставке, с которой работает жюри: один процесс, правила без моделей, файл `config/systems.json`, ключ шифрования из `application.yml`.
## Ограничения
**Состояние демаскирования не переживает процесс.** Соответствия лежат в памяти узла, не дольше 30 минут и не больше заданного объёма символов. Перезапуск, вытеснение и истечение срока делают обратное преобразование невозможным: запрос обрабатывается как новое маскирование, счётчик `pdguard.demask.unresolved` увеличивается. Общий слой Redis в коде есть и включается `pdguard.store.backend=redis`, но в текущем `compose.yaml` его нет: поднят один узел.
**Демаскирование разрешено не всем системам и не из браузера.** В конфиге оно включено у `default` и `strict`. У `crm` и `analytics` выключено. Страница `http://localhost:8080/` каждый раз создаёт новый `payload_id`, поэтому с неё можно проверить только маскирование. Чужая система не читает чужое хранилище.
**Неизвестное имя системы получает политику `default`.** Отсекаются только явно выключенная система и неверный ключ. Ключ проверяется лишь там, где поле `key` заполнено; у систем в поставленном файле ключей нет.
**Административные методы открыты.** `GET /admin/config`, `GET /admin/types` и `POST /admin/reload` не требуют аутентификации. Для стенда хакатона это удобно, для контура с несколькими потребителями — нет.
**Ключ шифрования хранилища лежит в конфигурации приложения.** AES-GCM включён, но ключ записан в `application.yml`. Это демонстрационный ключ. В рабочем контуре его нужно задавать снаружи и не хранить в репозитории.
**Качество детекции держится на правилах и словарях.** Иностранные имена без русских словообразовательных признаков, нестандартные топонимы и составляющие адреса «регион» и «район» без второй ступени не размечаются. Юридические и банковские реквизиты в нетиповых формулировках (без якорного слова рядом) правила тоже пропускают — для них есть отдельная третья ступень (LLAIM Legal NER), но датасеты регрессии (`NodeLogsDatasetTest`, `PlacementVariantsTest`) её пока не включают и снятые в них пороги утечек её вклад не отражают. Модели в репозиторий не входят: без `tools/fetch-ner-model.sh` и включённых `pdguard.ner.*-engine` сервис остаётся на правилах. Сбой любой ступени отключает её до перезапуска, остальные продолжают работать.
**Часть типов маскируется только в контексте.** PIN, CVV, «голая» дата, место рождения, страна, банковские реквизиты организации, доход и слово о биометрии сами по себе не закрываются. Биометрия в тексте — это упоминание, а не шаблон из базы. Режим `MASK` по задумке оставляет края длинных номеров и инициалы ФИО; полностью закрывает режим `STRICT`.
**Синтетика покрывает не все типы.** Где своей подстановки нет, `SYNTHETIC` ставит токен вида `[TYPE_1]`.
**Журнал не является аудитом значений.** Пишутся идентификатор, длина и счётчики типов. Восстановить по журналу, что именно скрыто, нельзя — и отдельного аудита решений оператора тоже нет.
**Ответ модели — внешняя зависимость.** Пока `pdguard.llm.url` пуст, `POST /proxy` отвечает заглушкой. Ошибка живой модели тоже подменяется заглушкой, чтобы сбой модели не раскрывал исходный текст. Контракт `/process` от модели не зависит.
**Нагрузка измерена на коротких текстах и на правилах.** Прогон k6 использует систему `crm` и тексты в одну-две строки. Длинный документ и включённая модель этот профиль не описывают: модель вызывается точечно, но один вызов BERT — это уже десятки миллисекунд.
**Внутренняя ошибка выглядит как успешный ответ.** HTTP-код остаётся 200, тело — `[обработка недоступна]`. Так исходный текст не утекает и автоматический прогон не останавливается на серии ошибок. Отличить сбой от маски можно по этой фиксированной строке и по метрике `pdguard_requests_rejected_total{reason="internal_error"}`.
## План развития после хакатона
**Контур и секреты.** Вынести ключ шифрования и секреты систем в хранилище секретов. Закрыть `/admin` аутентификацией. Неизвестную систему отклонять, а не сажать на `default`. Включить Redis в поставку compose как общий слой соответствий и прогнать демаскирование через балансировщик.
**Детекция.** Поставлять модели второй и третьей ступеней отдельным артефактом со проверкой целостности и измеренным p95 на включённых ступенях. Включить третью ступень (Legal NER) в датасеты регрессии, чтобы её вклад в число утечек был виден и защищён порогом, а не только в ручных прогонах. Добавить типы документов, которых не хватает по отраслевому перечню, отдельными правилами в реестре — ядро и политики с `"*"` подхватят их без миграции. Расширить внешние словари (известные люди, денилист улиц) как файлы, которые перечитываются так же, как `config/systems.json`.
**Качество и нагрузка.** Закрепить в CI прогон размеченных наборов (precision/recall по типам) и сценарий k6 с порогом p95 ≤ 0,5 с при 1000 запросах/с на полном перечне типов, а не только на политике `crm`. Замерить длинные документы отдельно от коротких обращений.
**Наблюдаемость для эксплуатации.** Журнал решений без значений персональных данных в централизованный сбор. Алерты на рост `demask_unresolved`, на отказы по перегрузке и на выключение любой из ступеней распознавания. Срок хранения соответствий и потолок объёма сделать разными для систем.
**Интерфейс политики.** Страница проверки сейчас только маскирует. Следующий шаг — показать демаскирование тем же `payload_id` и дать править перечень типов и режим без ручного JSON, с тем же файлом `systems.json` под капотом.
+139 -9
View File
@@ -2149,6 +2149,136 @@
}
}
},
{
"type": "timeseries",
"title": "Обращения к моделям",
"id": 49,
"datasource": {
"type": "prometheus",
"uid": "PDGUARD_PROM"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 74
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PDGUARD_PROM"
},
"expr": "sum by (model) (rate(pdguard_ner_model_requests_total[$__rate_interval]))",
"refId": "A",
"instant": false,
"legendFormat": "{{model}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"fillOpacity": 45,
"lineWidth": 2,
"showPoints": "never",
"stacking": {
"mode": "normal"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "text",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"calcs": [
"mean",
"max"
],
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Время моделей",
"id": 50,
"datasource": {
"type": "prometheus",
"uid": "PDGUARD_PROM"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 74
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PDGUARD_PROM"
},
"expr": "histogram_quantile(0.95, sum by (le, model) (rate(pdguard_ner_model_duration_seconds_bucket[$__rate_interval])))",
"refId": "A",
"instant": false,
"legendFormat": "{{model}} p95"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"fillOpacity": 12,
"lineWidth": 2,
"showPoints": "never",
"stacking": {
"mode": "none"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "text",
"value": null
}
]
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom",
"calcs": [
"mean",
"max"
],
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "row",
"title": "Ресурсы узла",
@@ -2157,7 +2287,7 @@
"h": 1,
"w": 24,
"x": 0,
"y": 74
"y": 90
},
"collapsed": false
},
@@ -2173,7 +2303,7 @@
"h": 5,
"w": 4,
"x": 0,
"y": 75
"y": 91
},
"targets": [
{
@@ -2236,7 +2366,7 @@
"h": 5,
"w": 4,
"x": 4,
"y": 75
"y": 91
},
"targets": [
{
@@ -2291,7 +2421,7 @@
"h": 5,
"w": 4,
"x": 8,
"y": 75
"y": 91
},
"targets": [
{
@@ -2346,7 +2476,7 @@
"h": 5,
"w": 4,
"x": 12,
"y": 75
"y": 91
},
"targets": [
{
@@ -2409,7 +2539,7 @@
"h": 5,
"w": 4,
"x": 16,
"y": 75
"y": 91
},
"targets": [
{
@@ -2464,7 +2594,7 @@
"h": 5,
"w": 4,
"x": 20,
"y": 75
"y": 91
},
"targets": [
{
@@ -2523,7 +2653,7 @@
"h": 8,
"w": 12,
"x": 0,
"y": 80
"y": 96
},
"targets": [
{
@@ -2588,7 +2718,7 @@
"h": 8,
"w": 12,
"x": 12,
"y": 80
"y": 96
},
"targets": [
{
+2 -9
View File
@@ -6,15 +6,8 @@ global:
scrape_configs:
- job_name: pd-guard
metrics_path: /actuator/prometheus
static_configs:
- targets: ["pd-guard:8080"]
labels:
instance: "один узел"
# Узлы кластерного профиля. Пока он не поднят, цели просто числятся
# недоступными и на остальной сбор это не влияет.
- job_name: pd-guard-cluster
metrics_path: /actuator/prometheus
static_configs:
- targets: ["node-a:8080", "node-b:8080"]
labels:
instance: "кластер из двух узлов"
+26
View File
@@ -0,0 +1,26 @@
# Вариант для тех, у кого нет локально Java 21 + Maven: собирает jar внутри
# Docker, без ./mvnc на хосте. Основной путь развёртывания — src/main/docker/Dockerfile
# с заранее собранным `mvn package` (вдвое быстрее пересборки образа при правках).
# docker build -f src/main/docker/Dockerfile.build -t pd-guard-spring:jvm .
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml settings.xml ./
RUN mvn -s settings.xml -B dependency:go-offline
COPY src ./src
RUN mvn -s settings.xml -B package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /deployments
COPY --from=build --chown=1000:1000 /build/target/pd-guard-spring-1.0.0.jar /deployments/app.jar
COPY --chown=1000:1000 config /deployments/config
EXPOSE 8080
USER 1000
ENV JAVA_OPTS="-Dspring.config.additional-location=optional:file:/deployments/config/"
ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /deployments/app.jar"]
@@ -6,9 +6,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Точка входа Spring Boot приложения.
*
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
* текст на обратном шаге.
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит
* персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге.
*/
@SpringBootApplication
public class PdGuardApplication {
@@ -1,5 +1,7 @@
package ru.pdguard.api;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -7,9 +9,6 @@ import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */
@RestController
public class AdminResource {
@@ -18,18 +18,16 @@ import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
* Единственная точка входа контракта: маскирование и демаскирование по {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка нет или система
* неизвестна — применяются настройки {@code default}, поэтому контракт работает и без него.
* Система, выключенная в настройках, получает {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — не фиксированное
* число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} сам находит потолок
* конкурентности под то, сколько CPU реально досталось контейнеру, вместо того чтобы копить запросы
* и упереться в таймаут вызывающей стороны.
*/
@RestController
public class ProcessResource {
@@ -44,21 +42,19 @@ public class ProcessResource {
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом шаге иначе
* выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
}
@JsonProperty("payload") String payload, @JsonProperty("payload_id") String payloadId) {}
public record ProcessResponse(@JsonProperty("result") String result) {
}
public record ProcessResponse(@JsonProperty("result") String result) {}
private final Pipeline pipeline;
private final SystemsConfig systems;
@@ -68,13 +64,17 @@ public class ProcessResource {
private final Counter forbidden;
private final Counter failed;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
public ProcessResource(
Pipeline pipeline,
SystemsConfig systems,
MeterRegistry meters,
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
this.pipeline = pipeline;
this.systems = systems;
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.limiter =
new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
@@ -84,11 +84,14 @@ public class ProcessResource {
}
@PostMapping("/process")
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
public ResponseEntity<ProcessResponse> process(
@RequestBody(required = false) ProcessRequest request,
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
if (request == null
|| request.payload() == null
|| request.payloadId() == null
|| request.payloadId().isBlank()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
@@ -123,8 +126,8 @@ public class ProcessResource {
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
request.payloadId(), e);
LOG.error(
"payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
+32 -20
View File
@@ -1,6 +1,7 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -14,8 +15,6 @@ import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.LlmClient;
import ru.pdguard.core.Pipeline;
import java.util.Map;
/**
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
*
@@ -23,25 +22,23 @@ import java.util.Map;
* потребитель → маскирование → LLM → демаскирование → потребитель
* </pre>
*
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
* незамаскированного, а ответ вернулся с восстановленными значениями.
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что получил потребитель.
* Это и есть доказательство, что в модель не попало ничего незамаскированного, а ответ вернулся с
* восстановленными значениями.
*
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
* применяется обратимая подстановка, независимо от режима маскирования системы.
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по идентификатору
* целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не годятся — одна и та же маска
* отвечала бы разным значениям, — поэтому здесь всегда применяется обратимая подстановка,
* независимо от режима маскирования системы.
*
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в
* {@link ProcessResource}.
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в {@link ProcessResource}.
*/
@RestController
public class ProxyResource {
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
public record ProxyRequest(@JsonProperty("prompt") String prompt) {
}
public record ProxyRequest(@JsonProperty("prompt") String prompt) {}
public record ProxyResponse(
@JsonProperty("prompt_masked") String promptMasked,
@@ -50,6 +47,9 @@ public class ProxyResource {
@JsonProperty("replaced") Map<String, String> replaced,
@JsonProperty("llm") String llm,
@JsonProperty("error") String error) {
public ProxyResponse {
replaced = replaced == null ? null : Map.copyOf(replaced);
}
}
private final Pipeline pipeline;
@@ -63,7 +63,8 @@ public class ProxyResource {
}
@PostMapping("/proxy")
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
public ResponseEntity<ProxyResponse> proxy(
@RequestBody(required = false) ProxyRequest request,
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
@@ -78,18 +79,29 @@ public class ProxyResource {
}
if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null,
.body(
new ProxyResponse(
null,
null,
null,
null,
null,
"Системе " + systemId + " обращение в модуль запрещено"));
}
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
LlmClient.Answer answer = llm.ask(masked.text());
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
String restored =
policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
LOG.info("proxy: система={} заменено={} модель={}",
policy.name(), masked.restorations().size(), answer.source());
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored,
masked.restorations(), answer.source(), null));
LOG.info(
"proxy: система={} заменено={} модель={}",
policy.name(),
masked.restorations().size(),
answer.source());
return ResponseEntity.ok(
new ProxyResponse(
masked.text(), answer.text(), restored, masked.restorations(), answer.source(), null));
}
/** Возвращает исходные значения на место подстановок в ответе модели. */
@@ -8,10 +8,9 @@ import ru.pdguard.detect.NameDictionary;
/**
* Конфигурация словарей распознавания ФИО.
*
* <p>{@link NameDictionary} работает через статические методы и не требует
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
* {@code pdguard.well-known-file} и передал его словарю.
* <p>{@link NameDictionary} работает через статические методы и не требует экземпляра, но путь к
* внешнему файлу денилиста задаётся из настроек при старте. Бин здесь нужен только для того, чтобы
* Spring подставил значение {@code pdguard.well-known-file} и передал его словарю.
*/
@Configuration
public class DictionaryConfiguration {
@@ -1,42 +1,40 @@
package ru.pdguard.config;
import java.util.Set;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.mask.MaskMode;
import java.util.Set;
/**
* Правила обработки для одной системы-потребителя.
*
* @param name имя системы; им же разделяется хранилище соответствий,
* чтобы одна система не могла достать данные другой
* @param name имя системы; им же разделяется хранилище соответствий, чтобы одна система не могла
* достать данные другой
* @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
* совпасть, иначе имя системы можно было бы просто назвать.
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
* и кириллица в ключе до сервиса доедет искажённой
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
* пин-код сам по себе безвреден, пин-код рядом с номером
* карты — уже нет; то же для даты без якорного слова, места
* рождения («Нижний Новгород» в рассказе о городе — не адрес
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
* сами по себе они опознают организацию или счёт, а не человека,
* и в перечне типов из задания их нет. Рядом с именем клиента
* они становятся его данными и маскируются.
* Сюда же доход и биометрия. Сумма заработка без человека —
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
* персональные данные. Биометрия же в тексте не встречается
* вовсе: это шаблон в базе, и правило маскирует лишь само
* упоминание, то есть слово, а не данные. Чувствителен здесь
* факт, что биометрию сдал названный человек, — а он и
* существует только при имени рядом
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан совпасть, иначе
* имя системы можно было бы просто назвать. Только знаки ASCII: заголовки HTTP передаются в
* Latin-1, и кириллица в ключе до сервиса доедет искажённой
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: пин-код сам по
* себе безвреден, пин-код рядом с номером карты — уже нет; то же для даты без якорного слова,
* места рождения («Нижний Новгород» в рассказе о городе — не адрес клиента) и страны («цены
* выросли в Казахстане» — не гражданство). Сюда же банковские реквизиты — счёт, БИК, ОГРН,
* ОГРНИП, КПП: сами по себе они опознают организацию или счёт, а не человека, и в перечне типов
* из задания их нет. Рядом с именем клиента они становятся его данными и маскируются. Сюда же
* доход и биометрия. Сумма заработка без человека — статистика («доход домохозяйств вырос до 74
* 500 руб»), а не персональные данные. Биометрия же в тексте не встречается вовсе: это шаблон в
* базе, и правило маскирует лишь само упоминание, то есть слово, а не данные. Чувствителен
* здесь факт, что биометрию сдал названный человек, — а он и существует только при имени рядом
*/
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode,
Set<String> types, Set<String> requireCompanion, String key) {
public record SystemPolicy(
String name,
boolean enabled,
boolean demask,
MaskMode maskMode,
Set<String> types,
Set<String> requireCompanion,
String key) {
public static final String ALL = "*";
@@ -44,11 +42,27 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
public static final String DEFAULT_NAME = "default";
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
Set.of(PdTypes.CVV, PdTypes.PIN, PdTypes.DATE, PdTypes.BIRTH_PLACE, PdTypes.ADDRESS_COUNTRY,
PdTypes.ACCOUNT_NUMBER, PdTypes.BIK, PdTypes.OGRN, PdTypes.OGRNIP, PdTypes.KPP,
PdTypes.INCOME, PdTypes.BIOMETRIC), null);
public static final SystemPolicy DEFAULT =
new SystemPolicy(
DEFAULT_NAME,
true,
true,
MaskMode.MASK,
Set.of(ALL),
Set.of(
PdTypes.CVV,
PdTypes.PIN,
PdTypes.DATE,
PdTypes.BIRTH_PLACE,
PdTypes.ADDRESS_COUNTRY,
PdTypes.ACCOUNT_NUMBER,
PdTypes.BIK,
PdTypes.OGRN,
PdTypes.OGRNIP,
PdTypes.KPP,
PdTypes.INCOME,
PdTypes.BIOMETRIC),
null);
public SystemPolicy {
types = Set.copyOf(types);
@@ -57,8 +71,8 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(types), DEFAULT.requireCompanion(), null);
return new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion(), null);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
@@ -68,7 +82,8 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
}
return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
(presentedKey == null ? "" : presentedKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public boolean allows(String type) {
@@ -1,12 +1,6 @@
package ru.pdguard.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.mask.MaskMode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -14,20 +8,25 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.mask.MaskMode;
/**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
*
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
* перечитывается сам, когда меняется время его изменения; проверка выполняется
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл перечитывается сам,
* когда меняется время его изменения; проверка выполняется не чаще раза в секунду, чтобы не ходить
* в файловую систему на каждом запросе. Файла нет — работают настройки по умолчанию, и сервис
* поднимается без него.
*/
@Component
public class SystemsConfig {
public final class SystemsConfig {
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
@@ -37,8 +36,17 @@ public class SystemsConfig {
private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion, String key) {
public record SystemEntry(
Boolean enabled,
Boolean demask,
String maskMode,
List<String> types,
List<String> requireCompanion,
String key) {
public SystemEntry {
types = types == null ? null : List.copyOf(types);
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
}
}
private final Path file;
@@ -49,8 +57,8 @@ public class SystemsConfig {
private volatile long fileTimestamp;
private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
ObjectMapper mapper) {
public SystemsConfig(
@Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
reload();
@@ -83,15 +91,20 @@ public class SystemsConfig {
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
LOG.info(
"Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies.set(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, 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(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
@@ -99,7 +112,8 @@ public class SystemsConfig {
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
LOG.error(
"Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
}
}
@@ -124,13 +138,21 @@ public class SystemsConfig {
private static SystemPolicy toPolicy(String name, 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(name,
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(
name,
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions, entry.key());
mode,
types,
companions,
entry.key());
}
}
@@ -1,55 +1,45 @@
package ru.pdguard.core;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.TimeUnit;
/**
* Предел одновременных запросов, который сам подстраивается под задержку,
* а не задан фиксированным числом. Растёт, пока обработка укладывается в
* целевое время, и сжимается, как только перестаёт — вместо того чтобы
* копить очередь и подходить к таймауту вызывающей стороны.
* Предел одновременных запросов, который сам подстраивается под задержку, а не задан фиксированным
* числом. Растёт, пока обработка укладывается в целевое время, и сжимается, как только перестаёт —
* вместо того чтобы копить очередь и подходить к таймауту вызывающей стороны.
*
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime.
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно
* показывает все ядра хоста и как источник предела не годится. Задержка —
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. availableProcessors()} под
* квотой {@code --cpus} в cgroups не меняется (это не affinity, а квота), поэтому в контейнере с
* долей ядра оно показывает все ядра хоста и как источник предела не годится. Задержка —
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
*
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой
* версии предел менялся на каждый завершённый запрос, и на высоком RPS
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась,
* и то же самое повторялось после каждого восстановления. Проверено
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться.
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
* шагом на основе среднего за окно — так скорость регулировки не зависит
* от того, насколько высок входящий RPS.
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой версии предел менялся на
* каждый завершённый запрос, и на высоком RPS тысячи «быстрых» замеров прилетали за миллисекунды —
* предел успевал разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, и то же
* самое повторялось после каждого восстановления. Проверено нагрузочным тестом: без привязки к
* времени p95 на перегрузке доходил до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был
* сжаться. Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним шагом на основе
* среднего за окно — так скорость регулировки не зависит от того, насколько высок входящий RPS.
*
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа
* заявки, и под перегрузкой оно само составляет секунды: предел успевает
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже
* если следующим окном предел тут же обрушить. Проверено нагрузочным
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
* подходить к безопасному уровню, пока заявки продолжают копиться.
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start из TCP) здесь не
* подходит: там обратная связь — RTT, миллисекунды, и лишний виток роста стоит дёшево. Здесь
* обратная связь — время ответа заявки, и под перегрузкой оно само составляет секунды: предел
* успевает удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый сигнал о деградации,
* и уже принятые заявки не исчезают из очереди, даже если следующим окном предел тут же обрушить.
* Проверено нагрузочным тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает его на порядок раньше.
* Сжатие — вдвое, а не на единицу: на перегрузке дешевле один раз отрезать с запасом, чем несколько
* окон подряд плавно подходить к безопасному уровню, пока заявки продолжают копиться.
*
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе
* окна может добавить образец в уже подводимый итог или отбросить один,
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
* ступени понадобилась отдельная настройка сборки.
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе окна может добавить
* образец в уже подводимый итог или отбросить один, не больше; при масштабах в десятки-сотни
* образцов на окно это не видно. Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из осторожности к GraalVM
* native-image: незнакомая рефлексия в чужой библиотеке — это ровно тот класс проблем, из-за
* которого модели второй ступени понадобилась отдельная настройка сборки.
*/
public class AdaptiveConcurrencyLimiter {
public final class AdaptiveConcurrencyLimiter {
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
@@ -68,9 +58,11 @@ public class AdaptiveConcurrencyLimiter {
}
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
AdaptiveConcurrencyLimiter(
int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
throw new IllegalArgumentException(
"Некорректные границы предела: " + minLimit + ".." + maxLimit);
}
this.minLimit = minLimit;
this.maxLimit = maxLimit;
+25 -21
View File
@@ -2,11 +2,6 @@ package ru.pdguard.core;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
@@ -15,17 +10,20 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Обращение к языковой модели для демонстрационного плеча.
*
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
* восстановленный.
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно. Для демонстрации
* этого достаточно, потому что проверяется не качество ответа модели, а то, что в модель ушёл
* замаскированный текст, а потребителю вернулся восстановленный.
*
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, а причина
* попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
*/
@Component
public class LlmClient {
@@ -33,8 +31,7 @@ public class LlmClient {
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
/** Что вернула модель и кто именно ответил. */
public record Answer(String text, String source) {
}
public record Answer(String text, String source) {}
private final Optional<String> url;
private final Optional<String> apiKey;
@@ -75,8 +72,8 @@ public class LlmClient {
}
/**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
* подстановки вернулись на свои места при обратном преобразовании.
* Ответ содержит присланный текст целиком: так на демонстрации видно, что подстановки вернулись
* на свои места при обратном преобразовании.
*/
private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt;
@@ -89,19 +86,26 @@ public class LlmClient {
message.put("role", "user");
message.put("content", maskedPrompt);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
HttpRequest.Builder request =
HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
.POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
HttpResponse<String> response = http.send(request.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpResponse<String> response =
http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("модель ответила " + response.statusCode());
}
return mapper.readTree(response.body())
.path("choices").path(0).path("message").path("content").asText();
return mapper
.readTree(response.body())
.path("choices")
.path(0)
.path("message")
.path("content")
.asText();
}
}
@@ -3,24 +3,21 @@ package ru.pdguard.core;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Настройка распределений для метрик времени.
*
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум.
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по
* ним p50, p95 и p99.
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. Этого хватает
* на среднее, но не на перцентили, а именно они описывают SLA: важно не среднее время ответа, а то,
* сколько запросов уложилось в срок. Гистограмма добавляет ряды по корзинам, и {@code
* histogram_quantile} в Prometheus считает по ним p50, p95 и p99.
*
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
* сочетание меток.
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти миллисекунды до десяти
* секунд. Без явных границ Micrometer создаёт их сам и заметно больше, а каждая корзина — это
* отдельный временной ряд на каждое сочетание меток.
*/
@Configuration
public class MetricsConfiguration {
@@ -40,7 +37,8 @@ public class MetricsConfiguration {
public MeterFilter histogramsForTimers() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
public DistributionStatisticConfig configure(
Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) {
return config;
}
@@ -60,6 +58,7 @@ public class MetricsConfiguration {
private static boolean needsHistogram(String name) {
return "pdguard.process".equals(name)
|| "pdguard.ner.duration".equals(name)
|| "pdguard.ner.model.duration".equals(name)
|| "http.server.requests".equals(name);
}
}
@@ -1,27 +1,26 @@
package ru.pdguard.core;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Шифрование исходных персональных данных в хранилище.
*
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если
* процесс или Redis скомпрометированы, исходные значения остаются недоступными
* без ключа. Используется AES-GCM — аутентифицированное шифрование, которое
* защищает и от подмены шифротекста.
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если процесс или Redis
* скомпрометированы, исходные значения остаются недоступными без ключа. Используется AES-GCM —
* аутентифицированное шифрование, которое защищает и от подмены шифротекста.
*
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в
* hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для
* сборки, где хранилище не содержит чувствительных данных.
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в hex). Пока ключ не
* задан, шифрование выключено — это нужно для тестов и для сборки, где хранилище не содержит
* чувствительных данных.
*/
@Component
public class PayloadCipher {
@@ -35,7 +34,10 @@ public class PayloadCipher {
private final SecureRandom random = new SecureRandom();
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
this.key = hexKey == null || hexKey.isBlank() ? null : new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
this.key =
hexKey == null || hexKey.isBlank()
? null
: new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
}
/** Выключенное шифрование — для тестов и сборки без ключа. */
@@ -62,7 +64,7 @@ public class PayloadCipher {
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
}
}
@@ -80,7 +82,7 @@ public class PayloadCipher {
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
}
}
+31 -53
View File
@@ -1,9 +1,5 @@
package ru.pdguard.core;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -12,30 +8,30 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
*
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
* страховка на случай, если идентификатор до сервиса не доехал.
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — страховка на
* случай, если идентификатор до сервиса не доехал.
*
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
* исходные значения из запроса другого потребителя. Разделение ограничивает это
* пределами одной системы, которая и так видит свои данные.
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет совпадение по самому
* тексту запроса, и без такого разделения он превращался бы в способ достать чужие данные: маски
* детерминированы и низкоэнтропийны, поэтому, прислав «Клиент И. И. И., паспорт 45** ****56», можно
* было бы получить в ответ исходные значения из запроса другого потребителя. Разделение
* ограничивает это пределами одной системы, которая и так видит свои данные.
*
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
* <p>Записи живут ограниченное время: персональные данные не должны залёживаться в памяти.
* Протухшие записи убираются в порядке добавления прямо на записи — отдельного потока и внешней
* библиотеки кеширования не требуется.
*
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
* обходится без обращения по сети.
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, а чтение при
* промахе по локальной памяти идёт в него. Это нужно при работе на нескольких узлах: обратный
* запрос легко попадает не на тот узел, который выполнял прямой. Локальная память при этом остаётся
* первым уровнем, и обычный путь обходится без обращения по сети.
*/
@Component
public class PayloadStore {
@@ -44,8 +40,8 @@ public class PayloadStore {
private static final int SWEEP_PER_PUT = 4;
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
public record Entry(String system, String original, String masked,
String fingerprint, long expiresAt) {
public record Entry(
String system, String original, String masked, String fingerprint, long expiresAt) {
boolean alive(long now) {
return now < expiresAt;
@@ -61,26 +57,23 @@ public class PayloadStore {
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;
private final PayloadCipher cipher;
@Autowired
public PayloadStore(
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
SharedIndex shared,
PayloadCipher cipher) {
this.maxChars = maxChars;
this.ttlMillis = ttlMinutes * 60_000L;
this.shared = shared;
this.cipher = cipher;
}
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
public PayloadStore(long maxChars, int ttlMinutes) {
this(maxChars, ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
public PayloadStore(int ttlMinutes) {
this(ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
}
public void put(String system, String payloadId, String original, String masked) {
@@ -95,7 +88,6 @@ public class PayloadStore {
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
evictWhileOverLimit();
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
}
@@ -120,9 +112,8 @@ public class PayloadStore {
}
/**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
* данные нельзя.
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в
* пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя.
*/
public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked);
@@ -137,9 +128,8 @@ public class PayloadStore {
public long charsHeld() {
return charsHeld.get();
}
/**
* Убирает протухшие записи с головы очереди, не более нескольких за раз.
*/
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
@@ -159,22 +149,6 @@ public class PayloadStore {
}
}
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 idKey, Entry entry) {
if (byId.remove(idKey, entry)) {
byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
@@ -187,8 +161,12 @@ public class PayloadStore {
if (entry == null) {
return null;
}
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(),
entry.fingerprint(), entry.expiresAt());
return new Entry(
entry.system(),
cipher.decrypt(entry.original()),
entry.masked(),
entry.fingerprint(),
entry.expiresAt());
}
private static String fingerprint(String value) {
+102 -66
View File
@@ -4,6 +4,14 @@ 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 java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -16,28 +24,22 @@ import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.MaskContext;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
/**
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
*
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
*
* <ul>
* <li>идентификатор неизвестен — маскируем;</li>
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
* <li>идентификатор неизвестен — маскируем;
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.
* </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан
* совпасть с первым, иначе демаскирование по этому элементу развалится.
*
* Последний случай — повторная попытка проверяющей системы: ответ обязан совпасть с первым, иначе
* демаскирование по этому элементу развалится.
*/
@Component
public class Pipeline {
@@ -56,7 +58,11 @@ public class Pipeline {
private final Counter unresolvedDemask;
@Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
public Pipeline(
RuleRegistry registry,
Masker masker,
PayloadStore store,
MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
@@ -64,11 +70,14 @@ public class Pipeline {
this.meters = meters;
this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
this.tokensProcessed =
Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved")
.description("Запрос на демаскирование, для которого соответствие не нашлось ни по "
this.unresolvedDemask =
Counter.builder("pdguard.demask.unresolved")
.description(
"Запрос на демаскирование, для которого соответствие не нашлось ни по "
+ "id, ни по отпечатку маски — обработан как новое маскирование")
.register(meters);
}
@@ -113,7 +122,8 @@ public class Pipeline {
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
// и логируем каждый такой случай явно, чтобы не потерять его молча.
unresolvedDemask.increment();
LOG.warn("payload_id={} демаскирование не нашло соответствие ни по id, ни по "
LOG.warn(
"payload_id={} демаскирование не нашло соответствие ни по id, ни по "
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
payloadId);
}
@@ -121,9 +131,9 @@ public class Pipeline {
}
/**
* Длительность обработки с разрезом по направлению и системе-потребителю.
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
* Длительность обработки с разрезом по направлению и системе-потребителю. Метрики берутся из
* реестра по тегам: систем немного и они заданы настройками, поэтому разрастания рядов не будет,
* а разрез по потребителям виден сразу.
*/
private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process")
@@ -135,13 +145,13 @@ public class Pipeline {
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все
* отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая
* замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (policy.allows(PdTypes.FIO)) {
if (cascade.coversAny(policy)) {
// Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
}
@@ -157,20 +167,18 @@ public class Pipeline {
recordLatency("mask", policy.name(), started);
logFindings(policy.name(), payloadId, payload.length(), spans);
// Только для отладки формата входных данных на DEBUG — на INFO не активен.
// На боевом прогоне (нагрузочное тестирование) уровень DEBUG должен быть выключен:
// сюда попадают сырые ПД, что напрямую нарушает требование ТЗ не логировать значения.
LOG.debug("payload_id={} raw={}", payloadId, payload);
return masked;
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
* приоритетный, при равном приоритете — более длинный.
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более приоритетный, при равном
* приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
candidates.sort(
Comparator.comparingInt(Span::priority)
.reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
@@ -199,46 +207,63 @@ public class Pipeline {
}
/**
* Убирает имена, стоящие в названиях организаций и объектов на карте:
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
* само по себе.
* Убирает имена, стоящие в названиях организаций и объектов на карте: «Институт Склифосовского»,
* «Музей Тропинина», «улица Королёва». Проверка не зависит от того, есть ли в тексте другие ПД:
* слово перед именем решает само по себе.
*/
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type())
.filter(
span ->
!PdTypes.FIO.equals(span.type())
|| !OrganisationDetector.precededByOrganisation(text, span.start()))
.toList();
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
* Убирает имена известных людей: «стихи Александра Пушкина» персональными данными не являются.
* Если же в тексте есть ПД другого типа, речь идёт о конкретном человеке, и имя остаётся
* замаскированным — однофамилец исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream()
.anyMatch(span -> !PdTypes.FIO.equals(span.type()));
boolean otherPersonalDataPresent =
spans.stream().anyMatch(span -> !PdTypes.FIO.equals(span.type()));
if (otherPersonalDataPresent) {
return spans;
}
return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type())
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
.toList();
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим словом
* («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от прозвища, а {@code
* REGNAL_NAME} распознаёт только двухсловную форму целиком.
*/
private static boolean isWellKnownHere(String text, Span span) {
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
return true;
}
int wordStart = span.end();
while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) {
wordStart++;
}
int wordEnd = wordStart;
while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) {
wordEnd++;
}
return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd));
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД. Пин-код в отрыве от номера карты
* не является персональными данными, рядом с номером карты — является.
*
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
* сравнивалось число различных типов, и два спутника заверяли друг друга:
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
* Сочетание двух несамостоятельных типов самостоятельным не становится.
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь сравнивалось число
* различных типов, и два спутника заверяли друг друга: «Оплата 01.02.2025, ОГРН 1027700132195»
* маскировалась целиком, хотя человека в тексте нет, а дата и ОГРН по отдельности персональными
* данными не являются. Сочетание двух несамостоятельных типов самостоятельным не становится.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) {
@@ -251,23 +276,33 @@ public class Pipeline {
}
/** Замаскированный текст вместе с таблицей обратной замены. */
public record Masked(String text, java.util.Map<String, String> restorations) {
public record Masked(String text, Map<String, String> restorations) {
public Masked {
restorations = Map.copyOf(restorations);
}
}
/**
* Маскирует текст и отдаёт таблицу обратной замены.
*
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
* значениям, — поэтому режим замены здесь всегда обратимый.
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить его целиком
* по идентификатору нельзя, замену приходится делать пофрагментно. Звёздочки для этого не годятся
* — одна и та же маска может отвечать разным значениям, — поэтому режим замены здесь всегда
* обратимый.
*/
public Masked maskWithRestorations(String text, SystemPolicy policy) {
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
SystemPolicy reversible =
new SystemPolicy(
policy.name(),
policy.enabled(),
policy.demask(),
MaskMode.TOKEN,
policy.types(),
policy.requireCompanion(),
policy.key());
List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) {
return new Masked(text, java.util.Map.of());
return new Masked(text, Map.of());
}
MaskContext context = new MaskContext();
String masked = apply(text, spans, reversible, context);
@@ -296,16 +331,17 @@ public class Pipeline {
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
* На INFO и выше сами значения не логируются; на DEBUG они временно видны через
* отдельный вызов в {@link #mask} — см. комментарий там.
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество. На INFO и выше
* сами значения не логируются; на DEBUG они временно видны через отдельный вызов в {@link #mask}
* — см. комментарий там.
*/
private void logFindings(String system, 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) ->
counts.forEach(
(type, count) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
}
@@ -1,6 +1,7 @@
package ru.pdguard.core;
import jakarta.annotation.PostConstruct;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -11,24 +12,20 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
/**
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
*
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
* настоящие запросы.
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по интерпретируемому
* и наспех скомпилированному коду, и p95 оказывается примерно вдесятеро хуже установившегося.
* Несколько тысяч прогонов на старте занимают доли секунды и переводят горячий путь на
* оптимизирующий компилятор до того, как придут настоящие запросы.
*
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим хранилищем: настоящие
* соответствия «текст ↔ маска» замусорить нельзя.
*
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
* создании своего пула.
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики показывают долю
* запросов, дошедших до модели, а тысячи служебных прогонов эту долю исказили бы до неузнаваемости.
* Сама модель прогревается отдельно, при создании своего пула.
*/
@Component
public class PipelineWarmup {
@@ -48,7 +45,9 @@ public class PipelineWarmup {
private final Masker masker;
private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker,
public PipelineWarmup(
RuleRegistry registry,
Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry;
this.masker = masker;
@@ -62,10 +61,17 @@ public class PipelineWarmup {
return;
}
long started = System.nanoTime();
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1),
NameCascade.disabled());
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
Pipeline scratch =
new Pipeline(registry, masker, new PayloadStore(1), NameCascade.disabled());
SystemPolicy policy =
new SystemPolicy(
SystemPolicy.DEFAULT_NAME,
true,
true,
MaskMode.MASK,
Set.of(SystemPolicy.ALL),
SystemPolicy.DEFAULT.requireCompanion(),
null);
for (int i = 0; i < iterations; i++) {
String text = SAMPLES[i % SAMPLES.length];
@@ -73,7 +79,9 @@ public class PipelineWarmup {
String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy);
}
LOG.info("Прогрев обработки: {} прогонов за {} мс",
iterations, (System.nanoTime() - started) / 1_000_000);
LOG.info(
"Прогрев обработки: {} прогонов за {} мс",
iterations,
(System.nanoTime() - started) / 1_000_000);
}
}
+4 -6
View File
@@ -3,15 +3,13 @@ package ru.pdguard.core;
/**
* Ключ, однозначно разделяющий системы-потребители.
*
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может
* содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы.
* Используется и в локальном хранилище, и в общем слое — единая реализация
* вместо двух копий.
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может содержать любые знаки, и
* без длины «a:b» и «ab:» были бы неразличимы. Используется и в локальном хранилище, и в общем слое
* — единая реализация вместо двух копий.
*/
final class ScopedKey {
private ScopedKey() {
}
private ScopedKey() {}
static String of(String system, String key) {
String owner = system == null ? "" : system;
+27 -28
View File
@@ -2,39 +2,34 @@ package ru.pdguard.core;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
*
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
* Обратное же преобразование требует состояния: если прямой запрос обработал
* один узел, а обратный попал на другой, соответствие должно быть общим.
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. Обратное же
* преобразование требует состояния: если прямой запрос обработал один узел, а обратный попал на
* другой, соответствие должно быть общим.
*
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не выставлена, к Redis не
* обращаются вовсе и зависимость остаётся неактивной.
*
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
* съедал время ответа, команды ограничены по времени настройкой
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
* временно перестают опрашивать вовсе.
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до локальной памяти
* узла, а ошибка попадает в журнал. Чтобы простой Redis не съедал время ответа, команды ограничены
* по времени настройкой {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий
* слой временно перестают опрашивать вовсе.
*/
@Component
public class SharedIndex {
private static final Logger LOG = LoggerFactory.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;
@@ -42,8 +37,7 @@ public class SharedIndex {
private static final long OPEN_MILLIS = 5_000;
/** Пара «исходный текст — маска», как она хранится в общем слое. */
public record SharedEntry(String original, String masked) {
}
public record SharedEntry(String original, String masked) {}
private final boolean enabled;
private final Duration ttl;
@@ -55,7 +49,8 @@ public class SharedIndex {
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(StringRedisTemplate redis,
public SharedIndex(
StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper,
@@ -76,15 +71,16 @@ public class SharedIndex {
return enabled;
}
public void put(String system, String payloadId, String original, String masked,
String maskFingerprint) {
public void put(
String system, String payloadId, String original, String masked, String maskFingerprint) {
if (unavailable()) {
return;
}
try {
String encrypted = cipher.encrypt(original);
redis.opsForValue().set(ScopedKey.of(system, payloadId),
toJson(new SharedEntry(encrypted, masked)), ttl);
redis
.opsForValue()
.set(ScopedKey.of(system, payloadId), toJson(new SharedEntry(encrypted, masked)), ttl);
redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
noteSuccess();
} catch (RuntimeException e) {
@@ -100,7 +96,9 @@ public void put(String system, String payloadId, String original, String masked,
String json = redis.opsForValue().get(ScopedKey.of(system, payloadId));
noteSuccess();
SharedEntry entry = json == null ? null : fromJson(json);
return entry == null ? null : new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
return entry == null
? null
: new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
@@ -150,9 +148,9 @@ public void put(String system, String payloadId, String original, String masked,
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
* проверяющая система считает ответ дольше десяти секунд неответом.
* После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе
* каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ
* дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
@@ -160,7 +158,8 @@ public void put(String system, String payloadId, String original, String masked,
}
if (!reported) {
reported = true;
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
LOG.error(
"Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
}
}
}
@@ -0,0 +1,171 @@
package ru.pdguard.detect;
import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_GAP;
import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_VALUE;
import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY;
import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
import static ru.pdguard.detect.RulePatterns.STREET_NAME;
import static ru.pdguard.detect.RuleRegistry.ADDRESS_NEARBY;
import java.util.List;
/** Правила распознавания органа выдачи паспорта, места рождения, гражданства и адреса. */
final class AddressRules {
private AddressRules() {}
static final List<Rule> RULES =
List.of(
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть.
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
// (обычное слово, не про выдачу документа).
Rule.of(
PdTypes.PASSPORT_ISSUER,
"(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
+ "\\W{0,3}([^,;\\n]{3,90}?)"
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)",
78)
.groups(1)
.anchoredBy("выдан", "выдал", "выдач"),
// «совпадает с указанным в анкете: X» — второе упоминание органа выдачи
// под собственным якорем, без бэкреференса на первое.
Rule.of(
PdTypes.PASSPORT_ISSUER,
"(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)"
+ "(?=[,;.\\n]|\\s*$)",
78)
.groups(1)
.anchoredBy("указанн"),
// «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря,
// до слова «совпадает» или конца фразы.
Rule.of(
PdTypes.PASSPORT_ISSUER,
"(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)"
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)",
78)
.groups(1)
.anchoredBy("орган выдачи"),
Rule.of(
PdTypes.BIRTH_PLACE,
"(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)",
76)
.groups(1)
.anchoredBy("рождения"),
Rule.of(
PdTypes.BIRTH_PLACE,
"(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)",
76)
.groups(1)
.anchoredBy("родил"),
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
// между якорем и значением бывает несколько слов, не только пунктуация.
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
Rule.of(
PdTypes.CITIZENSHIP,
"(?iu:гражданств)\\w*"
+ CITIZENSHIP_GAP
+ "("
+ CITIZENSHIP_VALUE
+ ")(?:\\s*[,/]\\s*("
+ CITIZENSHIP_VALUE
+ "))?",
80)
.groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("гражданств"),
// ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
// («гражданином», «гражданкой»).
Rule.of(
PdTypes.CITIZENSHIP,
"(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
+ "("
+ CITIZENSHIP_VALUE
+ ")(?:\\s*[,/]\\s*("
+ CITIZENSHIP_VALUE
+ "))?",
75)
.groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),
Rule.of(
PdTypes.ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))",
74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY),
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(
PdTypes.ADDRESS_CITY,
"(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b",
73)
.groups(1)
.validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy(
"г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", "дер.",
"х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(
PdTypes.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(
PdTypes.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(
PdTypes.ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b",
72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"),
Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"),
Rule.of(
PdTypes.ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b",
71)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("стран"));
}
@@ -0,0 +1,40 @@
package ru.pdguard.detect;
import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
import java.util.List;
/** Правила распознавания контактных и идентификационных данных: телефон, email, ИНН, СНИЛС. */
final class ContactRules {
private ContactRules() {}
static final List<Rule> RULES =
List.of(
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн"),
// «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш.
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн/кпп"),
Rule.of(
PdTypes.SNILS,
"(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b",
84)
.groups(1)
.validatedBy(Validators::snils),
// \b7, не только +7: номер без плюса («79031119955») тоже встречается.
Rule.of(
PdTypes.PHONE,
"(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}["
+ " -]{0,2}\\d{2}\\b",
82),
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"),
// ИНН физлица без якорного слова — только с верной контрольной суммой.
Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62).validatedBy(Validators::inn));
}
@@ -0,0 +1,37 @@
package ru.pdguard.detect;
import java.util.Locale;
import java.util.Set;
/**
* Словарь названий стран — проверка того, что значение, пойманное правилом {@code CITIZENSHIP},
* действительно похоже на страну, а не на произвольное слово с заглавной буквы после якоря
* «гражданство».
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в России», «из
* Казахстана») и формы прилагательных («российская», «российское») тем самым покрываются без
* отдельного разбора морфологии. Основа «российск» покрывает и «Российская», и «российская», и
* «российское».
*/
public final class CountryDictionary {
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
private CountryDictionary() {}
/**
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
*
* <p>Проверяются префиксы значения по множеству, а не каждая основа по значению: префиксов у
* слова не больше, чем в нём букв.
*/
public static boolean isKnownCountry(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (COUNTRY_STEMS.contains(lower.substring(0, length))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,46 @@
package ru.pdguard.detect;
import static ru.pdguard.detect.RulePatterns.DATE_ANY;
import static ru.pdguard.detect.RulePatterns.DATE_GAP;
import java.util.List;
/** Правила распознавания дат: рождения, выдачи документа и дат без якорного слова. */
final class DateRules {
private DateRules() {}
static final List<Rule> RULES =
List.of(
Rule.of(
PdTypes.BIRTH_DATE,
"(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" + DATE_GAP + "(" + DATE_ANY + ")",
87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("рожден"),
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))" + DATE_GAP + "(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("родил"),
Rule.of(
PdTypes.BIRTH_DATE,
"(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)",
87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(
PdTypes.PASSPORT_DATE,
"(?iu:дат\\p{L}*\\s+выдачи)" + DATE_GAP + "(" + DATE_ANY + ")",
87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("выдач"),
// Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа.
Rule.of(PdTypes.DATE, DATE_ANY, 58).validatedBy(Validators::date));
}
+16 -20
View File
@@ -3,40 +3,36 @@ package ru.pdguard.detect;
import java.util.Locale;
/**
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
* Общий приём для словарей, сравнивающих слово из текста с основой из списка: личные имена ({@link
* NameDictionary}) и города ({@link ToponymDictionary}).
*
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
* для них сравнение идёт по основе без неё.
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», «Пушкин» →
* «Пушкина») — там основы из списка достаточно как есть. Слова на гласную меняют последнюю букву
* («Москва» → «Москве», «Ольга» → «Ольге») — для них сравнение идёт по основе без неё.
*
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется целиком («Дзержинский» →
* «Дзержинского», «-ий» на «-ого», а не дописывается), поэтому для них отсечения одной буквы
* недостаточно — основа обрезается сразу до «ск». Для улиц в честь людей это не редкий случай, а
* основной: «улица Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще.
*/
final class Declension {
/**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский и средний род, все
* падежи. Проверяются от длинных к коротким — «-ского» не должно потеряться из-за более короткого
* совпадения на «-ким» и т.п.
*/
private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
};
private Declension() {
}
private Declension() {}
/**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
* Слова короче четырёх букв не трогает — короткая основа и так шире
* большинства падежных форм.
* Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком
* «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая
* основа и так шире большинства падежных форм.
*/
static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT);
@@ -0,0 +1,113 @@
package ru.pdguard.detect;
import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
import static ru.pdguard.detect.RulePatterns.SERIES_AND_NUMBER;
import java.util.List;
/** Правила распознавания документов, удостоверяющих личность, и кодов подразделений. */
final class DocumentRules {
private DocumentRules() {}
static final List<Rule> RULES =
List.of(
// CVV: латиница, кириллическая транслитерация («цвв», «сививи») и
// описательные якоря («код на обороте карты»). Между якорем и числом
// допускаются слова («CVV код 321», «CVV указан код 123») и длинные
// разделители («код на обороте карты 789»).
Rule.of(
PdTypes.CVV,
"(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты"
+ "|код\\s+проверки|защитный\\s+код)\\b)"
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b",
92)
.groups(1)
.anchoredBy(
"cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"),
// PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом
// допускаются слова («ПИН-код карты 2468») и длинные разделители
// («Пин Код: 1234»).
Rule.of(
PdTypes.PIN,
"(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)"
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b",
92)
.groups(1)
.anchoredBy("пин", "pin"),
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
Rule.of(
PdTypes.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(
PdTypes.PASSPORT,
"(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b",
90)
.groups(1, 2)
.anchoredBy("сери"),
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
Rule.of(
PdTypes.DRIVER_LICENSE,
"(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?("
+ SERIES_AND_NUMBER
+ ")\\b",
89)
.groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "),
Rule.of(
PdTypes.FOREIGN_PASSPORT,
"(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b",
89)
.groups(1)
.anchoredBy("загранпаспорт", "заграничн"),
Rule.of(
PdTypes.MILITARY_ID,
"(?iu:военн\\p{L}*\\s+билет)\\p{L}*" + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b",
89)
.groups(1)
.anchoredBy("военн"),
Rule.of(
PdTypes.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(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1)
.anchoredBy("полис"),
// ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и
// значением есть слово («стоит»/«объекта»), не только пунктуация.
Rule.of(
PdTypes.DEPT_CODE,
"(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP + "(\\d{3}\\s?-?\\s?\\d{3})\\b",
88)
.groups(1)
.anchoredBy("подразделени", "к/п"),
// «770-001 — таков код подразделения» — значение перед якорем.
Rule.of(
PdTypes.DEPT_CODE,
"\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}"
+ "(?iu:код\\w*\\s+подразделения)",
88)
.groups(1)
.anchoredBy("подразделени"));
}
@@ -0,0 +1,77 @@
package ru.pdguard.detect;
import static ru.pdguard.detect.RulePatterns.HOLDER_STEM;
import java.util.List;
/** Правила распознавания банковских реквизитов, карты, ОГРН/КПП и держателя карты. */
final class FinanceRules {
private FinanceRules() {}
static final List<Rule> RULES =
List.of(
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(
PdTypes.ACCOUNT_NUMBER,
"(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b",
83)
.groups(1)
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("бик"),
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
Rule.of(
PdTypes.CARD_EXPIRY,
"(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
+ "(\\d{2}\\s?/\\s?\\d{2})\\b",
83)
.groups(1)
.anchoredBy("срок действия", "действительн", "exp"),
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
// числа рядом со словом — раньше якоря было достаточно самого по себе.
Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"),
Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrn)
.anchoredBy("огрн"),
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную.
Rule.of(
PdTypes.INCOME,
"(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b",
76)
.groups(1)
.anchoredBy("доход", "заработн", "зарплат"),
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
// значения для захвата нет: маскируется якорная фраза целиком.
Rule.of(
PdTypes.BIOMETRIC,
"(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)",
81)
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
Rule.of(
PdTypes.CARDHOLDER,
"(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b",
86)
.groups(1)
.anchoredBy(HOLDER_STEM, "cardholder", "на имя"),
// --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85).validatedBy(Validators::luhn));
}
@@ -0,0 +1,152 @@
package ru.pdguard.detect;
import static ru.pdguard.detect.RulePatterns.CAPITALISED;
import static ru.pdguard.detect.RulePatterns.HOLDER_STEM;
import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY;
import static ru.pdguard.detect.RulePatterns.PATRONYMIC;
import static ru.pdguard.detect.RulePatterns.SURNAME;
import java.util.List;
/** Правила распознавания ФИО — от полной тройки с ролевым словом до одиночного имени по словарю. */
final class FioRules {
private FioRules() {}
static final List<Rule> RULES =
List.of(
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
// сознательно не используется: она захватывает глагол в начале
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
Rule.of(
PdTypes.FIO,
"\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b",
79),
// Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(
PdTypes.FIO,
"\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b",
79),
// Иванов И.И. и И.И. Иванов
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
// Имя Отчество без фамилии
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(
PdTypes.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(
PdTypes.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(
"клиент",
"заказчик",
"пациент",
"сотрудник",
"владел",
"плательщик",
"получател",
"абонент",
"в лице",
"представител",
"поручител",
"заёмщик",
"заемщик",
"заявител",
"доверител",
"вкладчик",
"ответственн",
"контактное лицо",
"исполнител",
HOLDER_STEM),
// «клиент иван иванов», «поручитель петрович» — строчные имена после
// ролевого слова. Регистр снимает требование к заглавной букве, а словарь
// имён отсекает «клиент пришёл в офис».
Rule.of(
PdTypes.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держател"
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
// \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял
// движку «отдать» уже съеденное падежное окончание ролевого слова и
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
+ "|\\bпоручител)\\p{L}*+"
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])",
77)
.groups(1)
.validatedBy(NameDictionary::containsNamePart)
.anchoredBy(
"клиент",
"заказчик",
"пациент",
"сотрудник",
"владел",
"плательщик",
"получател",
"абонент",
"в лице",
"представител",
"поручител",
"заёмщик",
"заемщик",
"заявител",
"доверител",
"вкладчик",
"ответственн",
"контактное лицо",
"исполнител",
HOLDER_STEM,
"отправител",
"бенефициар",
"доверенное лицо",
"наследник",
"созаемщик"),
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
// слов, — поэтому приоритет ниже и проверка по словарю обязательна.
// Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста
// не рассматривается: заглавная буква там от начала предложения, а не от
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
.groups(1)
.validatedBy(NameDictionary::isStandaloneNameCandidate));
}
+291 -70
View File
@@ -5,44 +5,45 @@ import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.annotation.PreDestroy;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import ru.pdguard.config.SystemPolicy;
/**
* Вторая ступень распознавания.
*
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
* микросекунд. Модель нужна там, где они бессильны: имена без русского
* словообразования и нестандартные топонимы.
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки микросекунд.
* Модель нужна там, где они бессильны: имена без русского словообразования и нестандартные
* топонимы.
*
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
* запрос было бы невозможно.
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с
* заглавной буквы, которые первая ступень не покрыла. Их в обычном запросе единицы, и на задержку
* это почти не влияет. Дороже модель — тем важнее такая экономия: у BERT вызов стоит десятки
* миллисекунд, и звать его на каждый запрос было бы невозможно.
*
* <p>Используются две модели под разные задачи: одна размечает имена (например,
* WikiNEuRal, который не распознаёт известных личностей), другая — составляющие
* адреса (например, ruBERT с детальными метками страны, региона, района, города,
* улицы и дома). Каждая модель зовётся только на непокрытые кандидаты.
* <p>Используются две модели под разные задачи: одна размечает имена (например, WikiNEuRal, который
* не распознаёт известных личностей), другая — составляющие адреса (например, ruBERT с детальными
* метками страны, региона, района, города, улицы и дома). Каждая модель зовётся только на
* непокрытые кандидаты.
*
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: ошибка
* перехватывается здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
* исключение обнуляло бы маскирование целиком.
*/
@Component
public class NameCascade {
@@ -51,15 +52,16 @@ public class NameCascade {
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> NAME_TYPES = Map.of(
"PER", PdTypes.FIO);
private static final Map<String, String> NAME_TYPES = Map.of("PER", PdTypes.FIO);
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Map<String, String> ADDRESS_TYPES = Map.of(
private static final Map<String, String> ADDRESS_TYPES =
Map.of(
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
@@ -67,11 +69,42 @@ public class NameCascade {
"STREET", PdTypes.ADDRESS_STREET,
"HOUSE", PdTypes.ADDRESS_HOUSE);
/**
* Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых нет в общих
* моделях. ADDRESS не сопоставляется — ruBERT размечает адреса детальнее. ORG, CASE_NUMBER и
* POSITION аналогов в {@link PdTypes} не имеют.
*/
private static final Map<String, String> LEGAL_TYPES =
Map.of(
"PER", PdTypes.FIO,
"INN", PdTypes.INN,
"OGRN", PdTypes.OGRN,
"SNILS", PdTypes.SNILS,
"PASSPORT", PdTypes.PASSPORT,
"PHONE", PdTypes.PHONE,
"EMAIL", PdTypes.EMAIL,
"BANK_ACCOUNT", PdTypes.ACCOUNT_NUMBER,
"DATE", PdTypes.DATE);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE = Pattern.compile(
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);
/**
* Кандидат для LLAIM Legal NER: цифровой кластер (10–19 цифр с разделителями) или
* адрес электронной почты. Юридические реквизиты (ИНН, СНИЛС, паспорт, телефон,
* банковский счёт) — это цифры, а не слова с заглавной буквы, поэтому отдельный
* проход не влияет на кандидатов моделей имён и адресов. Имена (PER) размечает
* модель имён, email — правила, так что слова сюда не включаются: иначе модель
* звалась бы на каждое слово текста.
*/
private static final Pattern LEGAL_CANDIDATE =
Pattern.compile(
"(?:\\d(?:[\\s.\\-/()]?\\d){9,18}|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
private static final int PRIORITY = 73;
@@ -80,80 +113,180 @@ public class NameCascade {
private final RuBertRecogniser nameRecogniser;
private final RuBertRecogniser addressRecogniser;
private final RuBertRecogniser legalRecogniser;
private final Semaphore concurrent;
private final int maxCandidates;
private volatile boolean broken;
/**
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
* посильна ли тяжёлая модель на боевом трафике.
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение {@code engaged} ко
* всем обращениям и есть та доля, от которой зависит, посильна ли тяжёлая модель на боевом
* трафике.
*/
private final Counter engaged;
private final Counter withoutCandidates;
private final Counter busy;
private final Counter candidates;
private final Timer duration;
/** Счётчики обращений к каждой модели второй ступени: тег {@code model} — имя движка. */
private final Map<String, Counter> modelRequests;
/** Время работы каждой модели: тег {@code model} — имя движка. */
private final Map<String, Timer> modelDuration;
@Autowired
public NameCascade(
@Value("${pdguard.ner.name-engine:off}") String nameEngine,
@Value("${pdguard.ner.name-model:}") String nameModel,
@Value("${pdguard.ner.address-engine:off}") String addressEngine,
@Value("${pdguard.ner.address-model:}") String addressModel,
@Value("${pdguard.ner.legal-engine:off}") String legalEngine,
@Value("${pdguard.ner.legal-model:}") String legalModel,
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
@Value("${pdguard.ner.pool-size:16}") int poolSize,
MeterRegistry meters) {
this.maxCandidates = maxCandidates;
this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES);
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES);
this.legalRecogniser = create(legalEngine, legalModel, LEGAL_TYPES);
this.concurrent = new Semaphore(Math.max(1, poolSize));
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
this.engaged =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "engaged").register(meters);
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
.tag(OUTCOME_TAG, "engaged")
.register(meters);
this.withoutCandidates =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "no_candidates").register(meters);
this.busy = Counter.builder(NER_REQUESTS_METRIC)
.tag(OUTCOME_TAG, "no_candidates")
.register(meters);
this.busy =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "busy").register(meters);
this.candidates = Counter.builder("pdguard.ner.candidates")
.description("Участки текста, отданные модели").register(meters);
this.duration = Timer.builder("pdguard.ner.duration")
.description("Время работы второй ступени").register(meters);
.tag(OUTCOME_TAG, "busy")
.register(meters);
this.candidates =
Counter.builder("pdguard.ner.candidates")
.description("Участки текста, отданные модели")
.register(meters);
this.duration =
Timer.builder("pdguard.ner.duration")
.description("Время работы второй ступени")
.register(meters);
this.modelRequests = modelCounters(meters);
this.modelDuration = modelTimers(meters);
}
/** Счётчики обращений к каждой модели: тег {@code model} — имя движка. */
private static Map<String, Counter> modelCounters(MeterRegistry meters) {
Map<String, Counter> counters = new HashMap<>();
for (String model : new String[] {"name", "address", "legal"}) {
counters.put(
model,
Counter.builder("pdguard.ner.model.requests")
.description("Обращения к модели второй ступени")
.tag("model", model)
.register(meters));
}
return counters;
}
/** Таймеры времени работы каждой модели: тег {@code model} — имя движка. */
private static Map<String, Timer> modelTimers(MeterRegistry meters) {
Map<String, Timer> timers = new HashMap<>();
for (String model : new String[] {"name", "address", "legal"}) {
timers.put(
model,
Timer.builder("pdguard.ner.model.duration")
.description("Время работы модели второй ступени")
.tag("model", model)
.register(meters));
}
return timers;
}
/** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */
private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) {
this(
config.nameEngine(),
config.nameModel().orElse(""),
config.addressEngine(),
config.addressModel().orElse(""),
config.legalEngine(),
config.legalModel().orElse(""),
maxCandidates,
poolSize,
meters);
}
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
this(engine, modelPath.orElse(""), "off", "", maxCandidates, poolSize, new SimpleMeterRegistry());
this(
new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()),
maxCandidates,
poolSize,
new SimpleMeterRegistry());
}
/** Конструктор для тестов двух моделей: метрики никуда не отдаются. */
public NameCascade(String nameEngine, Optional<String> nameModel,
String addressEngine, Optional<String> addressModel,
int maxCandidates, int poolSize) {
this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""),
maxCandidates, poolSize, new SimpleMeterRegistry());
public NameCascade(
String nameEngine,
Optional<String> nameModel,
String addressEngine,
Optional<String> addressModel,
int maxCandidates,
int poolSize) {
this(
new EngineConfig(
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates,
poolSize,
new SimpleMeterRegistry());
}
/** Конструктор для тестов двух моделей с явным реестром метрик. */
public NameCascade(String nameEngine, Optional<String> nameModel,
String addressEngine, Optional<String> addressModel,
int maxCandidates, int poolSize, MeterRegistry meters) {
this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""),
maxCandidates, poolSize, meters);
public NameCascade(
String nameEngine,
Optional<String> nameModel,
String addressEngine,
Optional<String> addressModel,
int maxCandidates,
int poolSize,
MeterRegistry meters) {
this(
new EngineConfig(
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates,
poolSize,
meters);
}
/** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */
public NameCascade(EngineConfig config, int maxCandidates, int poolSize) {
this(config, maxCandidates, poolSize, new SimpleMeterRegistry());
}
/** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */
public record EngineConfig(
String nameEngine,
Optional<String> nameModel,
String addressEngine,
Optional<String> addressModel,
String legalEngine,
Optional<String> legalModel) {}
/**
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
* что в итоге работает.
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный конструктор, а не обычный
* путь: иначе в журнале рядом с сообщением о готовности распознавателя появлялось бы сообщение о
* его выключении, и было бы непонятно, что в итоге работает.
*/
private NameCascade() {
this.maxCandidates = 0;
this.nameRecogniser = null;
this.addressRecogniser = null;
this.legalRecogniser = null;
this.concurrent = new Semaphore(1);
MeterRegistry meters = new SimpleMeterRegistry();
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
@@ -161,6 +294,8 @@ public class NameCascade {
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
this.candidates = meters.counter("pdguard.ner.candidates");
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
this.modelRequests = modelCounters(meters);
this.modelDuration = modelTimers(meters);
}
public static NameCascade disabled() {
@@ -168,12 +303,39 @@ public class NameCascade {
}
public boolean enabled() {
return (nameRecogniser != null || addressRecogniser != null) && !broken;
return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null)
&& !broken;
}
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
* трогаются: модели разбирают только непокрытые участки.
* Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы {@code Pipeline} звал
* вторую ступень не только ради ФИО, но и ради адресов и юридических реквизитов, которые
* размечает LLAIM Legal NER.
*/
public boolean coversAny(SystemPolicy policy) {
if (!enabled()) {
return false;
}
return policy.allows(PdTypes.FIO)
|| policy.allows(PdTypes.ADDRESS_COUNTRY)
|| policy.allows(PdTypes.ADDRESS_REGION)
|| policy.allows(PdTypes.ADDRESS_DISTRICT)
|| policy.allows(PdTypes.ADDRESS_CITY)
|| policy.allows(PdTypes.ADDRESS_STREET)
|| policy.allows(PdTypes.ADDRESS_HOUSE)
|| policy.allows(PdTypes.INN)
|| policy.allows(PdTypes.OGRN)
|| policy.allows(PdTypes.SNILS)
|| policy.allows(PdTypes.PASSPORT)
|| policy.allows(PdTypes.PHONE)
|| policy.allows(PdTypes.EMAIL)
|| policy.allows(PdTypes.ACCOUNT_NUMBER)
|| policy.allows(PdTypes.DATE);
}
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не трогаются: модели
* разбирают только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
@@ -194,8 +356,21 @@ public class NameCascade {
continue;
}
examined++;
collect(text, m.start(), m.end(), found, nameRecogniser);
collect(text, m.start(), m.end(), found, addressRecogniser);
collect(text, m.start(), m.end(), found, "name", nameRecogniser);
collect(text, m.start(), m.end(), found, "address", addressRecogniser);
}
// LLAIM Legal NER ищет реквизиты (ИНН, СНИЛС, паспорт), которые не
// являются словами с заглавной буквы, — отдельный проход по своим
// кандидатам, чтобы не вытеснять кандидатов моделей имён и адресов.
if (legalRecogniser != null) {
Matcher lm = LEGAL_CANDIDATE.matcher(text);
while (lm.find() && examined < maxCandidates) {
if (fullyCovered(found, lm.start(), lm.end())) {
continue;
}
examined++;
collect(text, lm.start(), lm.end(), found, "legal", legalRecogniser);
}
}
candidates.increment(examined);
(examined > 0 ? engaged : withoutCandidates).increment();
@@ -210,7 +385,12 @@ public class NameCascade {
}
}
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink,
private void collect(
String text,
int candidateStart,
int candidateEnd,
List<Span> sink,
String modelName,
RuBertRecogniser recogniser) {
if (recogniser == null) {
return;
@@ -218,6 +398,7 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
boolean nameFound = false;
long started = System.nanoTime();
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
if (isAccepted(text, candidateStart, candidateEnd, span)) {
sink.add(span);
@@ -226,13 +407,18 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
}
}
}
modelRequests.get(modelName).increment();
modelDuration.get(modelName).record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
// Модель распознала имя в кандидате, но правила могли найти лишь его часть
// («Жан» вместо «Жан-Поль Дюваль») с более высоким приоритетом и заблокировать
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
if (nameFound) {
sink.removeIf(span -> PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd && candidateStart < span.end()
sink.removeIf(
span ->
PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd
&& candidateStart < span.end()
&& span.priority() > PRIORITY);
}
}
@@ -243,8 +429,31 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
}
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит
* те же условия, что и находки правил.
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»).
* Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя.
*/
private static final Set<String> PD_MARKERS =
Set.of(
"инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство");
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит те же условия, что и
* находки правил.
*/
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради
@@ -252,6 +461,12 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
return false;
}
// Модель с приоритетом recall иногда помечает слово-маркер реквизита
// («ИНН») как ФИО. Такое значение именем не является.
if (PdTypes.FIO.equals(span.type())
&& PD_MARKERS.contains(text.substring(span.start(), span.end()).toLowerCase(Locale.ROOT))) {
return false;
}
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
// Вернадского» попадали бы под маску наравне с адресом клиента.
@@ -259,13 +474,16 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
}
private static RuBertRecogniser create(String engine, String modelPath, Map<String, String> types) {
private static RuBertRecogniser create(
String engine, String modelPath, Map<String, String> types) {
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) {
LOG.info("Вторая ступень распознавания выключена");
return null;
}
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen)) {
if (!"rubert".equals(chosen)
&& !"wikineural".equals(chosen)
&& !"ru-legal-ner".equals(chosen)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null;
}
@@ -284,5 +502,8 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
if (addressRecogniser != null) {
addressRecogniser.close();
}
if (legalRecogniser != null) {
legalRecogniser.close();
}
}
}
@@ -1,8 +1,5 @@
package ru.pdguard.detect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
@@ -11,29 +8,31 @@ import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Словари для распознавания ФИО.
*
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: «Тверская» по
* окончанию похожа на фамилию, но рядом с ней нет личного имени.
*
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
* персональными данными не является. Ограничение осознанное: клиент по фамилии
* Пушкин в тексте без других ПД замаскирован не будет.
* <p>Список известных людей решает обратную задачу — упоминание Пушкина персональными данными не
* является. Ограничение осознанное: клиент по фамилии Пушкин в тексте без других ПД замаскирован не
* будет.
*
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
* него можно дописать своих публичных лиц без пересборки — файл по пути
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
* изменения, содержимое читается заново только когда оно другое.
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх него можно дописать
* своих публичных лиц без пересборки — файл по пути {@code pdguard.well-known-file} (по умолчанию
* {@code config/well-known.txt}) перечитывается сам при изменении, тем же приёмом, что {@code
* systems.json} в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время изменения,
* содержимое читается заново только когда оно другое.
*/
public final class NameDictionary {
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
private static final List<String> GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream()
private static final List<String> GIVEN_NAME_STEMS =
ResourceLoader.lines("/names/given-names.txt", true).stream()
.map(Declension::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
@@ -42,7 +41,8 @@ public final class NameDictionary {
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream()
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS =
ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
@@ -51,6 +51,9 @@ public final class NameDictionary {
private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
/** Разделитель слов: любая последовательность не-буквенных символов. */
private static final String WORD_SPLIT = "\\P{L}+";
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS =
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
@@ -60,11 +63,12 @@ public final class NameDictionary {
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/**
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
* Имя правителя: личное имя плюс порядковое числительное или прозвище — «Пётр Первый», «Иван
* Грозный», «Екатерина Вторая», «Ярослав Мудрый». Задано правилом, а не перечнем: правителей
* много, а форма записи одна.
*/
private static final Pattern REGNAL_NAME = Pattern.compile(
private static final Pattern REGNAL_NAME =
Pattern.compile(
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
@@ -72,16 +76,42 @@ public final class NameDictionary {
private static final int MAX_INFLECTION = 3;
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
private static final Set<String> SURNAME_SUFFIXES =
Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна",
"евна", "овы", "евы", "ины");
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
private static final Set<String> GIVEN_NAMES =
GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
private NameDictionary() {
}
/**
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию совпадают с
* основами имён («ИНН» — основа имени «Инна») и потому ложно распознаются как ФИО. Это
* аббревиатуры, а не имена.
*/
private static final Set<String> PD_MARKERS =
Set.of(
"инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство",
"ву");
private NameDictionary() {}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() {
@@ -89,9 +119,9 @@ public final class NameDictionary {
}
/**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
* из конфигурации Spring-бина; статические методы словаря работают без
* экземпляра, поэтому путь хранится в статическом поле.
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения из конфигурации
* Spring-бина; статические методы словаря работают без экземпляра, поэтому путь хранится в
* статическом поле.
*/
public static void configure(String wellKnownFile) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
@@ -106,12 +136,12 @@ public final class NameDictionary {
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
* приходится не больше четырёх обращений к хеш-таблице.
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось
* бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений
* к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split("\\P{L}+")) {
for (String word : value.split(WORD_SPLIT)) {
String lower = word.toLowerCase(Locale.ROOT);
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
// но это имя, а не фамилия.
@@ -122,7 +152,9 @@ public final class NameDictionary {
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
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;
@@ -133,20 +165,77 @@ public final class NameDictionary {
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху.
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется для строчных имён
* после ролевого слова («клиент иван иванов»), где регистр не подсказывает, что перед нами имя.
*/
public static boolean containsNamePart(String value) {
for (String word : value.split(WORD_SPLIT)) {
String lower = word.toLowerCase(Locale.ROOT);
if (GIVEN_NAMES.contains(lower)) {
return true;
}
if (isPatronymic(lower) || isSurname(lower)) {
return true;
}
}
return false;
}
/**
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова или соседнего
* личного имени рядом, самое слабое основание для ФИО. Точное совпадение с личным именем
* принимается в любом регистре («иван» тоже имя), а вот словообразовательная эвристика
* (фамилия/отчество по окончанию) — только с заглавной буквы: без этого «законов», «домов»,
* «холодов» — обычные родительные падежи, а не фамилии — ложно матчились бы.
*/
public static boolean isStandaloneNameCandidate(String word) {
String lower = word.toLowerCase(Locale.ROOT);
if (PD_MARKERS.contains(lower)) {
return false;
}
if (GIVEN_NAMES.contains(lower)) {
return true;
}
if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) {
return false;
}
return isPatronymic(lower) || isSurname(lower);
}
/** Отчество: Иванович, Петровна, Сидоровна. */
private static boolean isPatronymic(String lower) {
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
}
/** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */
private static final Set<String> SURNAME_ENDINGS =
Set.of(
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", "цкий", "цкая",
"енко", "ко", "ук", "юк", "ян", "швили", "дзе");
/** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */
private static boolean isSurname(String lower) {
for (String ending : SURNAME_ENDINGS) {
if (lower.endsWith(ending)) {
return true;
}
}
return false;
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных сверху.
*
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
* префиксов у слова — не больше, чем в нём букв.
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову: при тысяче с лишним
* записей (столько городов в {@link ToponymDictionary}, тот же приём) перебор списка на каждое
* слово текста был бы заметен, а префиксов у слова — не больше, чем в нём букв.
*/
public static boolean isWellKnown(String value) {
if (REGNAL_NAME.matcher(value.strip()).matches()) {
return true;
}
Set<String> stems = currentWellKnownStems();
for (String word : value.split("\\P{L}+")) {
for (String word : value.split(WORD_SPLIT)) {
String lower = word.toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (stems.contains(lower.substring(0, length))) {
@@ -171,7 +260,8 @@ public final class NameDictionary {
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!java.nio.file.Files.isReadable(path)) {
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
LOG.info(
"Внешний файл денилиста {} исчез, остаётся только встроенный список",
path.toAbsolutePath());
}
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
@@ -181,15 +271,18 @@ public final class NameDictionary {
try {
WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
for (String line :
java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed);
}
}
WELL_KNOWN_STATE.current = Set.copyOf(merged);
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
LOG.info(
"Денилист дополнен из {}: {} имён сверх встроенных",
path.toAbsolutePath(),
merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
@@ -197,7 +290,9 @@ public final class NameDictionary {
}
private static Set<String> currentWellKnownStems() {
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
return ResourceLoader.refreshIfChanged(
EXTERNAL_FILE,
WELL_KNOWN_STATE,
lines -> {
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : lines) {
@@ -207,7 +302,6 @@ public final class NameDictionary {
}
}
return Set.copyOf(merged);
},
BUNDLED_WELL_KNOWN_STEMS);
});
}
}
@@ -3,30 +3,31 @@ package ru.pdguard.detect;
import java.util.regex.Pattern;
/**
* Проверка, стоит ли перед именем слово, относящее его к организации или
* объекту на карте.
* Проверка, стоит ли перед именем слово, относящее его к организации или объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена в названиях, а не
* персональные данные. Отличие от списка известных людей в том, что здесь решает не само имя, а
* слово перед ним: клиент по фамилии Королёв защиту не теряет, а улица Королёва под маску не
* попадает.
*/
public final class OrganisationDetector {
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже, между ним и именем
* допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", ResourceLoader.lines("/names/organisations.txt", false)) + ")\\p{L}*"
private static final Pattern ORGANISATION_BEFORE =
Pattern.compile(
"(?iu:"
+ String.join("|", ResourceLoader.lines("/names/organisations.txt", false))
+ ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40;
private OrganisationDetector() {
}
private OrganisationDetector() {}
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
+8 -7
View File
@@ -3,16 +3,14 @@ package ru.pdguard.detect;
/**
* Имена типов персональных данных, которые умеет распознавать сервис.
*
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в
* правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках
* ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках
* ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не
* расходилось между слоями.
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в правилах, и в
* маскировании ({@link ru.pdguard.mask.Masker}), и в политиках ({@link
* ru.pdguard.config.SystemPolicy}), и в синтетических подстановках ({@link
* ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не расходилось между слоями.
*/
public final class PdTypes {
private PdTypes() {
}
private PdTypes() {}
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
@@ -37,8 +35,10 @@ public final class PdTypes {
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 ADDRESS_REGION = "ADDRESS_REGION";
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
@@ -48,6 +48,7 @@ public final class PdTypes {
/** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME";
@@ -12,20 +12,22 @@ import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Общие приёмы чтения словарей и внешних файлов.
*
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются,
* пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем)
* перечитываются, когда меняется время их изменения, и не чаще раза в секунду —
* чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда,
* чтобы не дублировать их в каждом словаре.
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, пустые и
* комментарии отбрасываются. Внешние файлы (денилист, настройки систем) перечитываются, когда
* меняется время их изменения, и не чаще раза в секунду — чтобы не ходить в файловую систему на
* каждом запросе. Обе задачи вынесены сюда, чтобы не дублировать их в каждом словаре.
*/
final class ResourceLoader {
private ResourceLoader() {
}
private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class);
private ResourceLoader() {}
/**
* Читает строки ресурса, отбрасывая пустые и комментарии.
@@ -38,12 +40,15 @@ final class ResourceLoader {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
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(sortByLength
.sorted(
sortByLength
? Comparator.comparingInt(String::length).reversed()
: Comparator.naturalOrder())
.toList();
@@ -53,24 +58,21 @@ final class ResourceLoader {
}
}
/**
* Читает строки ресурса в множество, отбрасывая пустые и комментарии.
*/
/** Читает строки ресурса в множество, отбрасывая пустые и комментарии. */
static java.util.Set<String> set(String resource) {
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
}
/**
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее.
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду.
* Возвращает текущее содержимое; при недоступности файла — прежнее.
*
* @param path путь к файлу
* @param state состояние проверки (время последней проверки и mtime файла)
* @param reader как превратить строки файла в итоговое значение
* @param fallback значение, если файл недоступен
*/
static <T> T refreshIfChanged(Path path, FileWatchState<T> state,
Function<List<String>, T> reader, T fallback) {
static <T> T refreshIfChanged(
Path path, FileWatchState<T> state, Function<List<String>, T> reader) {
long now = System.currentTimeMillis();
if (now - state.lastCheck < state.recheckMillis) {
return state.current;
@@ -83,7 +85,8 @@ final class ResourceLoader {
long mtime = Files.getLastModifiedTime(path).toMillis();
if (mtime != state.mtime) {
state.mtime = mtime;
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8).stream()
List<String> lines =
Files.readAllLines(path, StandardCharsets.UTF_8).stream()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList();
@@ -91,6 +94,7 @@ final class ResourceLoader {
}
} catch (IOException e) {
// Битый файл не должен ронять работу: остаётся прежнее значение.
LOG.warn("Не удалось перечитать файл {}", path, e);
}
return state.current;
}
@@ -6,9 +6,6 @@ import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.LongBuffer;
import java.nio.file.Files;
@@ -19,19 +16,19 @@ import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
*
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
* нестандартные топонимы. Метки модели ложатся почти один в один
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
* город, улица, дом.
* <p>В отличие от правил, он опознаёт имена без русского словообразования и нестандартные топонимы.
* Метки модели ложатся почти один в один на типы из технического задания: имя, отчество, фамилия,
* страна, регион, район, город, улица, дом.
*
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
* десятком кандидатов занял бы все ядра.
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на вызов, — поэтому её
* зовут только на участках, которые не разобрала первая ступень. Одновременных вызовов не больше,
* чем задано: иначе один запрос с десятком кандидатов занял бы все ядра.
*/
final class RuBertRecogniser {
@@ -47,8 +44,12 @@ final class RuBertRecogniser {
private final Set<String> inputNames;
private final Map<String, String> types;
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
WordPiece tokenizer, String[] labels, Map<String, String> types) {
private RuBertRecogniser(
OrtEnvironment environment,
OrtSession session,
WordPiece tokenizer,
String[] labels,
Map<String, String> types) {
this.environment = environment;
this.session = session;
this.tokenizer = tokenizer;
@@ -58,9 +59,9 @@ final class RuBertRecogniser {
}
/**
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json}
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
* и сервис продолжит работать на правилах.
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} и {@code
* config.json}. Каталог недоступен или испорчен — вернётся {@code null}, и сервис продолжит
* работать на правилах.
*
* @param types соответствие меток модели типам ПД сервиса
*/
@@ -80,8 +81,13 @@ final class RuBertRecogniser {
options.setInterOpNumThreads(1);
session = environment.createSession(model.toString(), options);
}
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
WordPiece.fromTokenizerJson(tokenizer), readLabels(config), types);
RuBertRecogniser recogniser =
new RuBertRecogniser(
environment,
session,
WordPiece.fromTokenizerJson(tokenizer),
readLabels(config),
types);
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser;
} catch (OrtException | IOException | RuntimeException e) {
@@ -132,9 +138,12 @@ final class RuBertRecogniser {
Map<String, OnnxTensor> inputs = new HashMap<>();
try {
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
inputs.put(
"attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
if (inputNames.contains("token_type_ids")) {
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
inputs.put(
"token_type_ids",
OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
}
inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) {
@@ -151,11 +160,15 @@ final class RuBertRecogniser {
}
/**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
* Схема разметки различает начало, середину, конец и одиночный токен, но для
* сборки достаточно смены типа: границы участков и так проставлены по словам.
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки
* различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа:
* границы участков и так проставлены по словам.
*/
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority,
private static List<Span> toSpans(
List<WordPiece.Piece> pieces,
String[] tags,
int offset,
int priority,
Map<String, String> types) {
List<Span> spans = new ArrayList<>();
String currentType = null;
+50 -31
View File
@@ -7,56 +7,66 @@ import java.util.regex.Pattern;
/**
* Одно правило детекции персональных данных.
*
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
* {@link RuleRegistry}; менять остальной код не требуется.
* <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} на порядок дешевле запуска
* регулярного выражения и отсекает большинство правил на коротком
* запросе. Пустой список — правило запускается всегда
* @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) {
public record Rule(
String type,
Pattern pattern,
int priority,
List<Integer> groups,
Predicate<String> validator,
Pattern veto,
Pattern context,
List<String> anchors) {
public Rule {
groups = List.copyOf(groups);
anchors = List.copyOf(anchors);
}
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
* <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;
/**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
*
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
* вето не срабатывало на части из них.
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город, улица, дом — в одном
* предложении) расстояние от «отделение» до номера дома часто превышает 80 знаков за счёт
* длинного названия региона («Ханты-Мансийский автономный округ», «Кабардино-Балкарская
* Республика»). Найдено нагрузочным тестом на 60 реальных адресах из официального реестра — с
* окном в 80 знаков вето не срабатывало на части из них.
*/
public static final int VETO_LOOKBEHIND = 150;
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());
return new Rule(
type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
@@ -89,11 +99,20 @@ public record Rule(String type, Pattern pattern, int priority, List<Integer> gro
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
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);
return new Rule(
type,
pattern,
priority,
groups,
validator,
Pattern.compile(regex, FLAGS),
context,
anchors);
}
}
@@ -0,0 +1,140 @@
package ru.pdguard.detect;
/**
* Общие фрагменты регулярных выражений, переиспользуемые между группами правил в {@link
* RuleRegistry}. Вынесены отдельно, чтобы не дублировать их в каждой группе — «серия и номер»,
* разрывы между якорем и значением, формы дат и т.п. встречаются в правилах разных категорий
* (документы, банк, ФИО, адрес).
*/
final class RulePatterns {
private RulePatterns() {}
/**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы «ИВАНОВ» распознавался наравне
* с «Иванов».
*/
static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
/** Якорное слово-основа: держатель карты, держателем и т.п. */
static final String HOLDER_STEM = "держател";
/** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */
static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
/**
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением гражданства всегда
* строчные («бенефициара», «поручителя»), а само значение — с заглавной («Республики»,
* «Соединенные»). Обычный {@link #ROLE_GAP} жадно поглощал бы и заглавное слово значения как
* будто это ролевое слово, оставляя значение только хвостом («Республики Беларусь» → «Беларусь»).
*/
static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
/**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел — «Тверская», «Малая
* Никитская», «8 Марта». Ограничение по форме обязательно: без него правило дожёвывало строку до
* конца, и «Проспект Вернадского перекрыт до вечера» оказывался под маской целиком.
*/
static final String STREET_NAME =
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
/**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. Хвост из двух букв
* покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/
static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается. Основы даны без падежного
* окончания — Иванович, Ивановича, Ивановне.
*/
static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/**
* Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456», а также с
* произвольным числом пробелов и словом «номер» между частями — «12 34 номер 567890» (реальный
* кейс из бланка).
*/
static final String SERIES_AND_NUMBER =
"\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}";
/**
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер «ммм» (в логах
* встречается и латинская «M»). Сокращения нужны, потому что в датах вида «15 ЯНВ 10» месяц
* записан тремя буквами.
*/
static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр"
+ "|янв|фев|мар|апр|авг|сен|окт|ноя|дек|[МM]мм)\\p{L}*";
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
/** «15 03 1990», «15 03 10» — числовая дата с пробелами вместо разделителей. */
static final String DATE_DIGITS_SPACE = "\\b\\d{1,2}\\s+\\d{1,2}\\s+\\d{2,4}\\b";
/** «15 03», «15/03» — день и месяц без года. */
static final String DATE_DAY_MONTH = "\\b\\d{1,2}\\s*[-/.]?\\s*\\d{1,2}\\b";
/** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */
static final String DATE_MONTH_WORD =
"\\b\\d{1,2}\\s*[-/.]?\\s*"
+ MONTH
+ "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b"
+ "(?:\\s*(?iu:года|г\\.|г\\b))?";
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
static final String DATE_WORDS =
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
+ "(?iu:ьего|ого|его|ое)\\s+"
+ MONTH
+ "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)";
/** Любая из записей даты; внутри только незахватывающие группы. */
static final String DATE_ANY =
"(?:"
+ DATE_WORDS
+ "|"
+ DATE_MONTH_WORD
+ "|"
+ DATE_DIGITS
+ "|"
+ DATE_DIGITS_SPACE
+ "|"
+ DATE_DAY_MONTH
+ ")";
/**
* Промежуток между якорем даты («дата рождения») и самой датой: слова, скобочные группы («(день и
* месяц)») и знаки препинания. Без скобочной ветки «Дата рождения клиента (день и месяц): 15
* января» не находилась бы: «день и месяц» — это слова, а не дата. Ветка со словами требует
* пробела перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, которая тоже
* матчит пробелы, — это приводило к катастрофическому возврату на длинных текстах. Отдельная
* ветка с дефисом нужна для слитных слов без пробела внутри: «клиента-нерезидента» — дефис сам по
* себе ловится веткой {@code \W}, но следующие за ним буквы без пробела перед ними не покрывала
* ни одна ветка.
*/
static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
/**
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ «республики X»
* — частые формы отдельным списком; последняя ветка — страна из 1-4 слов с заглавной буквы
* («Армения», «Соединенные Штаты Америки»). Хвост идёт после якоря «гражданств», поэтому
* «Двойное» перед якорем не попадёт.
*/
static final String CITIZENSHIP_VALUE =
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
/**
* Слова, при которых адрес/имя принадлежит организации, а не человеку: адрес отделения банка
* персональными данными не является.
*/
static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
}
+137 -387
View File
@@ -1,93 +1,44 @@
package ru.pdguard.detect;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
/**
* Реестр правил детекции и сам поиск ПД в тексте.
*
* <p>Правила разбиты на три уровня доверия:
*
* <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
* <li>однозначные по формату — email, телефон;</li>
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
* где сама по себе последовательность знаков ни о чём не говорит.</li>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
* <li>однозначные по формату — email, телефон;
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, где
* сама по себе последовательность знаков ни о чём не говорит.
* </ol>
*
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
* именно на них. На захватываемое значение регистронезависимость не распространяется:
* там, где значение опознаётся по заглавной букве, это существенно.
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен именно на них.
* На захватываемое значение регистронезависимость не распространяется: там, где значение опознаётся
* по заглавной букве, это существенно.
*
* <p>Сами правила сгруппированы по категориям в отдельных классах пакета — {@link DocumentRules},
* {@link FinanceRules}, {@link DateRules}, {@link FioRules}, {@link ContactRules}, {@link
* AddressRules} — чтобы каждая категория читалась отдельно от остальных. Здесь их списки только
* объединяются и используются.
*/
@Component
public class RuleRegistry {
/**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
* «ИВАНОВ» распознавался наравне с «Иванов».
*/
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 + ")";
/**
* Слова, при которых адрес принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является. Части адреса рядом:
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
* как адрес отделения банка из технического задания. Требование стояло только
* у постфиксной формы правила, префиксная его не имела.
* Слова, при которых адрес принадлежит организации, а не человеку: адрес отделения банка
* персональными данными не является. Части адреса рядом: улица, упомянутая в рассказе о городе,
* адресом клиента не является — ровно как адрес отделения банка из технического задания.
* Требование стояло только у постфиксной формы правила, префиксная его не имела.
*/
public static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
@@ -96,331 +47,60 @@ public class RuleRegistry {
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY,
PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE);
private static final Set<String> ADDRESS_TYPES =
Set.of(
PdTypes.ADDRESS_COUNTRY,
PdTypes.ADDRESS_REGION,
PdTypes.ADDRESS_DISTRICT,
PdTypes.ADDRESS_CITY,
PdTypes.ADDRESS_STREET,
PdTypes.ADDRESS_HOUSE,
PdTypes.ADDRESS_FLAT,
PdTypes.ADDRESS_POSTCODE);
/**
* Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных
* правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных
* разделителей, и не должна перебивать находки с якорным словом.
*/
private static final int NORMALISED_PRIORITY = 63;
/**
* Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними (пробел, дефис,
* точка, слэш, скобки). Негативные просмотры не дают захватить часть более длинного числа.
* Разделители вычищаются, и чистая цифровая строка прогоняется через контрольную сумму — так
* находятся ИНН/СНИЛС/карта/ОГРН(ИП) в свободной форме, где жёсткий шаблон ломается на
* нестандартном разделителе.
*/
private static final Pattern DIGIT_CLUSTER =
Pattern.compile("(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
public static boolean isAddressType(String type) {
return ADDRESS_TYPES.contains(type);
}
/**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам второй ступени
* проверку нужно навязать снаружи: модель размечает «Москву» в названии клуба и «Вернадского» в
* названии проспекта наравне с настоящим адресом.
*/
public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
}
private static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
private static final List<Rule> RULES = List.of(
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
.groups(1)
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
Rule.of(PdTypes.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(PdTypes.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(PdTypes.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(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
.groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "),
// --- Прочие документы, удостоверяющие личность ---
Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("загранпаспорт", "заграничн"),
Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("военн"),
Rule.of(PdTypes.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(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1)
.anchoredBy("полис"),
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
.groups(1)
.anchoredBy("подразделени", "к/п"),
// --- Банковские реквизиты сверх карты ---
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
.groups(1)
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("бик"),
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
.groups(1)
.anchoredBy("срок действия", "действительн", "exp"),
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
// числа рядом со словом — раньше якоря было достаточно самого по себе.
Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"),
Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrn)
.anchoredBy("огрн"),
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную.
Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76)
.groups(1)
.anchoredBy("доход", "заработн", "зарплат"),
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
// значения для захвата нет: маскируется якорная фраза целиком.
Rule.of(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
// --- Даты с явным якорем ---
Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("рожден"),
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("родил"),
Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("выдач"),
Rule.of(PdTypes.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(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
// Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
// Иванов И.И. и И.И. Иванов
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
// Имя Отчество без фамилии
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(PdTypes.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(PdTypes.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(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
// --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
.validatedBy(Validators::luhn),
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн"),
Rule.of(PdTypes.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(PdTypes.PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"),
// --- Уровень 3: свободный текст после якорного слова ---
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть.
Rule.of(PdTypes.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(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("рождения"),
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("родил"),
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("гражданств"),
Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),
Rule.of(PdTypes.ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY),
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(PdTypes.ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
.groups(1)
.validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(PdTypes.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(PdTypes.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(PdTypes.ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"),
Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"),
Rule.of(PdTypes.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(PdTypes.INN, "\\b\\d{12}\\b", 62)
.validatedBy(Validators::inn),
// Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа.
Rule.of(PdTypes.DATE, DATE_ANY, 58)
.validatedBy(Validators::date)
);
private static final List<Rule> RULES =
Stream.of(
DocumentRules.RULES,
FinanceRules.RULES,
DateRules.RULES,
FioRules.RULES,
ContactRules.RULES,
AddressRules.RULES)
.flatMap(List::stream)
.toList();
/** Все типы ПД, которые умеет распознавать сервис. */
public List<String> knownTypes() {
@@ -428,8 +108,8 @@ public class RuleRegistry {
}
/**
* Находит все фрагменты ПД, разрешённые политикой системы.
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
* Находит все фрагменты ПД, разрешённые политикой системы. Перекрытия здесь не разрешаются — это
* делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
@@ -440,9 +120,76 @@ public class RuleRegistry {
}
collect(rule, text, found);
}
collectNormalisedDigits(text, policy, found);
return found;
}
/**
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями,
* которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется
* контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах.
*/
private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> sink) {
if (!policy.allows(PdTypes.CARD)
&& !policy.allows(PdTypes.INN)
&& !policy.allows(PdTypes.SNILS)
&& !policy.allows(PdTypes.OGRN)
&& !policy.allows(PdTypes.OGRNIP)) {
return;
}
Matcher m = DIGIT_CLUSTER.matcher(text);
while (m.find()) {
String digits = NON_DIGIT.matcher(m.group()).replaceAll("");
String type = typeFor(digits);
if (type != null && policy.allows(type)) {
sink.add(new Span(m.start(), m.end(), type, NORMALISED_PRIORITY));
}
}
}
/**
* Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 цифр сначала
* пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный ОГРН не должен случайно стать
* номером карты (карта самостоятельна, ОГРН — только спутник, и одинокий ОГРН убирается в {@code
* Pipeline}).
*/
private static String typeFor(String digits) {
int length = digits.length();
switch (length) {
case 10, 12:
return Validators.inn(digits) ? PdTypes.INN : null;
case 11:
return Validators.snils(digits) ? PdTypes.SNILS : null;
case 13:
return ogrnOrCard(digits);
case 15:
return ogrnipOrCard(digits);
default:
return cardIfLuhn(digits);
}
}
private static String ogrnOrCard(String digits) {
if (Validators.ogrn(digits)) {
return PdTypes.OGRN;
}
return Validators.luhn(digits) ? PdTypes.CARD : null;
}
private static String ogrnipOrCard(String digits) {
if (Validators.ogrnip(digits)) {
return PdTypes.OGRNIP;
}
return Validators.luhn(digits) ? PdTypes.CARD : null;
}
private static String cardIfLuhn(String digits) {
if (digits.length() >= 13 && digits.length() <= 19 && Validators.luhn(digits)) {
return PdTypes.CARD;
}
return null;
}
private static void collect(Rule rule, String text, List<Span> sink) {
Matcher m = rule.pattern().matcher(text);
while (m.find()) {
@@ -456,7 +203,10 @@ public class RuleRegistry {
}
}
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
/**
* Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и
* контекст.
*/
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) {
return false;
@@ -4,43 +4,39 @@ import java.util.Locale;
import java.util.Set;
/**
* Словарь населённых пунктов России — проверка того, что значение, пойманное
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
* село, посёлок или другой населённый пункт, а не на произвольное слово с
* заглавной буквы после якоря.
* Словарь населённых пунктов России — проверка того, что значение, пойманное правилом {@code
* ADDRESS_CITY}, действительно похоже на существующий город, село, посёлок или другой населённый
* пункт, а не на произвольное слово с заглавной буквы после якоря.
*
* <p>Не только официальные города (~1100 по классификатору): перепись
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
* а не этот словарь — он только подтверждает, что название реальное.
* <p>Не только официальные города (~1100 по классификатору): перепись добавляет сёла, деревни,
* хутора, станицы — «рп. Ильинское», «с. Кукуево» из ТЗ находятся ровно за счёт неё. Какой
* конкретно тип населённого пункта стоит перед названием, определяет якорь самого правила в {@link
* RuleRegistry}, а не этот словарь — он только подтверждает, что название реальное.
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
* морфологии, как и у известных людей в {@link NameDictionary}.
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в Москве», «из
* Казани») тем самым покрываются без отдельного разбора морфологии, как и у известных людей в
* {@link NameDictionary}.
*/
public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream()
private static final Set<String> SETTLEMENT_STEMS =
ResourceLoader.set("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private ToponymDictionary() {
}
private ToponymDictionary() {}
/**
* Похоже ли значение на название населённого пункта из словаря в любом
* падеже.
* Похоже ли значение на название населённого пункта из словаря в любом падеже.
*
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому
* начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву
* («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
* основ по значению: перебор списка на каждое совпадение правила был бы
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению:
* перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у
* слова не больше, чем в нём букв.
*/
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
+22 -15
View File
@@ -1,8 +1,8 @@
package ru.pdguard.detect;
/**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
* которые по форме похожи на ПД, но ими не являются.
* Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме
* похожи на ПД, но ими не являются.
*/
public final class Validators {
@@ -10,8 +10,7 @@ public final class Validators {
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() {
}
private Validators() {}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) {
@@ -38,9 +37,9 @@ public final class Validators {
}
/**
* Контрольная цифра Луна для последовательности цифр: дописывается к телу
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при
* генерации правдоподобных подставных номеров карт.
* Контрольная цифра Луна для последовательности цифр: дописывается к телу номера, чтобы весь
* номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных
* номеров карт.
*/
public static int luhnCheckDigit(String body) {
int sum = 0;
@@ -109,10 +108,10 @@ public final class Validators {
}
/**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor}, взятый по
* младшему разряду. Числовое накопление по цифрам, а не парсинг строки в {@code long}: у ОГРНИП
* 14 цифр — на грани переполнения {@code int}, и это тот же приём, что уже применяется к самой
* длинной последовательности в {@link #luhn}.
*/
private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0;
@@ -123,9 +122,9 @@ public final class Validators {
}
/**
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
* последовательности вроде {@code 192.168.1}.
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985}, {@code 05/12/1985}, {@code
* 1985-05-12}, {@code 15 03 1990}, а также день и месяц без года: {@code 15 03}, {@code 15/03}.
* Отсекает похожие по форме последовательности вроде {@code 192.168.1}.
*/
public static boolean date(String value) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
@@ -135,10 +134,18 @@ public final class Validators {
return true;
}
}
String[] parts = value.split("[.\\-/]");
String[] parts = value.split("[.\\-/\\s]+");
if (parts.length == 2) {
return dayAndMonth(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
}
if (parts.length != 3) {
return false;
}
return threePartDate(parts);
}
/** {@code 12.05.1985}, {@code 1985-05-12}, {@code 15 03 90} — дата из трёх чисел. */
private static boolean threePartDate(String[] parts) {
int[] n = new int[3];
for (int i = 0; i < 3; i++) {
if (parts[i].isEmpty() || parts[i].length() > 4) {
+14 -16
View File
@@ -2,7 +2,6 @@ package ru.pdguard.detect;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
@@ -18,14 +17,14 @@ import java.util.Map;
/**
* Разбиение текста на подслова так, как это делает токенизатор BERT.
*
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает
* нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила
* здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем
* каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом
* «##».
*
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
* модели не перенести обратно на строку.
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не
* перенести обратно на строку.
*/
final class WordPiece {
@@ -35,8 +34,7 @@ final class WordPiece {
private static final String CONTINUATION = "##";
/** Подслово и его границы в исходном тексте. */
record Piece(int id, int start, int end) {
}
record Piece(int id, int start, int end) {}
private final Map<String, Integer> vocabulary;
private final int unknownId;
@@ -52,7 +50,8 @@ final class WordPiece {
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader = new BufferedReader(
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line;
int index = 0;
@@ -64,9 +63,8 @@ final class WordPiece {
}
/**
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели
* (например, WikiNEuRal) не кладут отдельный {@code vocab.txt}, а хранят
* словарь внутри токенизатора.
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal)
* не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора.
*/
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
@@ -101,8 +99,8 @@ final class WordPiece {
}
/**
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
* причём знак препинания сам становится отдельным словом.
* Границы слов: разделителями считаются пробельные символы и знаки препинания, причём знак
* препинания сам становится отдельным словом.
*/
private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>();
@@ -8,9 +8,8 @@ import java.util.function.BiFunction;
/**
* Состояние одной операции маскирования.
*
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
* запроса для модели сохранится.
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут
* дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится.
*
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/
@@ -29,7 +28,9 @@ public final class MaskContext {
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
return assigned.computeIfAbsent(
type + SEPARATOR + value,
key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
@@ -37,8 +38,8 @@ public final class MaskContext {
}
/**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не
* целиком, а изменённым — например, в ответе языковой модели.
*/
public Map<String, String> restorations() {
return Map.copyOf(restorations);
@@ -6,6 +6,14 @@ public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/**
* Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично
* открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code
* ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую
* букву каждого).
*/
STRICT,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
+10 -11
View File
@@ -1,16 +1,15 @@
package ru.pdguard.mask;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.PdTypes;
import java.util.Map;
import java.util.function.UnaryOperator;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.PdTypes;
/**
* Превращает найденное значение в замену согласно настройкам системы.
*
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
* безопасное поведение по умолчанию для вновь добавленных правил.
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — безопасное поведение по
* умолчанию для вновь добавленных правил.
*/
@Component
public class Masker {
@@ -18,7 +17,8 @@ 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(
private static final Map<String, UnaryOperator<String>> BY_TYPE =
Map.ofEntries(
Map.entry(PdTypes.EMAIL, Strategies::email),
Map.entry(PdTypes.PHONE, EDGES),
Map.entry(PdTypes.CARD, EDGES),
@@ -42,14 +42,12 @@ public class Masker {
// мало знаков, чтобы открывать хотя бы один.
Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(PdTypes.PIN, Strategies::stars),
Map.entry(PdTypes.PASSPORT_ISSUER, Strategies::stars),
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars),
Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars),
Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars),
@@ -60,7 +58,6 @@ public class Masker {
Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars),
Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars),
Map.entry(PdTypes.CITIZENSHIP, Strategies::stars),
Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES),
Map.entry(PdTypes.OGRN, EDGES),
Map.entry(PdTypes.OGRNIP, EDGES),
@@ -69,12 +66,14 @@ public class Masker {
Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.BIK, Strategies::stars),
Map.entry(PdTypes.INCOME, Strategies::stars),
Map.entry(PdTypes.BIOMETRIC, Strategies::stars)
);
Map.entry(PdTypes.BIOMETRIC, 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);
// STRICT игнорирует BY_TYPE целиком — ни один тип не открывает края
// и ФИО не превращается в инициалы, только сплошные звёздочки.
case STRICT -> Strategies.stars(value);
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
};
+9 -12
View File
@@ -3,16 +3,14 @@ package ru.pdguard.mask;
/**
* Способы преобразования найденного значения в маску.
*
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
* замаскированный текст остаётся читаемым для LLM и минимально отличается
* от эталона при посимвольном сравнении.
* <p>Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст
* остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении.
*/
public final class Strategies {
private static final char MASK = '*';
private Strategies() {
}
private Strategies() {}
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
@@ -25,9 +23,8 @@ public final class Strategies {
}
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
* само значение — нет.
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code
* 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
@@ -39,8 +36,8 @@ public final class Strategies {
}
/**
* Оставляет первые и последние значащие символы, остальные скрывает,
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
* Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет:
* {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
@@ -89,8 +86,8 @@ public final class Strategies {
}
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code
* ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
+41 -19
View File
@@ -6,38 +6,55 @@ import ru.pdguard.detect.Validators;
/**
* Правдоподобные подставные значения вместо настоящих.
*
* <p>Модель получает текст, который выглядит естественно, и качество ответа
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
* исходное значение всегда даёт одну и ту же подстановку.
* <p>Модель получает текст, который выглядит естественно, и качество ответа страдает меньше, чем от
* звёздочек. Значения детерминированы: одно и то же исходное значение всегда даёт одну и ту же
* подстановку.
*/
final class Synthetic {
private static final String[] SURNAMES =
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
private static final String[] SURNAMES = {
"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"
};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS =
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
private static final String[] PATRONYMICS = {
"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"
};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() {
}
private Synthetic() {}
static String forType(String type, String value, int ordinal) {
int seed = value.hashCode() & Integer.MAX_VALUE;
return switch (type) {
case PdTypes.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
+ " " + pick(PATRONYMICS, seed >> 6);
case PdTypes.FIO ->
pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) + " " + pick(PATRONYMICS, seed >> 6);
case PdTypes.CARDHOLDER -> "IVAN PETROV";
case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
case PdTypes.PHONE ->
"+7 9"
+ digits(seed, 2)
+ " "
+ digits(seed >> 4, 3)
+ "-"
+ digits(seed >> 8, 2)
+ "-"
+ digits(seed >> 12, 2);
case PdTypes.CARD -> luhnCard(seed);
case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT,
PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
case PdTypes.PASSPORT,
PdTypes.DRIVER_LICENSE,
PdTypes.FOREIGN_PASSPORT,
PdTypes.MILITARY_ID ->
digits(seed, 4) + " " + digits(seed >> 6, 6);
case PdTypes.INN -> digits(seed, 12);
case PdTypes.MEDICAL_POLICY -> digits(seed, 16);
case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
case PdTypes.SNILS ->
digits(seed, 3)
+ "-"
+ digits(seed >> 4, 3)
+ "-"
+ digits(seed >> 8, 3)
+ " "
+ digits(seed >> 12, 2);
case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
case PdTypes.ADDRESS_CITY -> "Зареченск";
case PdTypes.ADDRESS_STREET -> "Сосновая";
@@ -79,7 +96,12 @@ final class Synthetic {
private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
body.append(Validators.luhnCheckDigit(body.toString()));
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
+ body.substring(8, 12) + " " + body.substring(12);
return body.substring(0, 4)
+ " "
+ body.substring(4, 8)
+ " "
+ body.substring(8, 12)
+ " "
+ body.substring(12);
}
}
+4 -3
View File
@@ -37,19 +37,20 @@ pdguard:
systems-file: config/systems.json
store:
backend: memory
max-chars: 134217728
ttl-minutes: 30
# 32 байта в hex; AES-256 ключ шифрования хранилища
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
min-concurrent: 8
min-concurrent: 100
max-concurrent: 2000
target-latency-ms: 200
target-latency-ms: 900
warmup-iterations: 2000
ner:
name-engine: off
name-model: models/wikineural-ner
address-engine: off
address-model: models/rubert-ner
legal-engine: off
legal-model: models/ru-legal-ner
max-candidates: 16
pool-size: 16
llm:
+143
View File
@@ -0,0 +1,143 @@
# Основы названий стран для распознавания гражданства.
# В нижнем регистре, без падежных окончаний (Declension::withoutInflectedEnding).
# Сравнение идёт по началу слова, поэтому «Российская»/«российской» покрываются
# основой «российск», «Федерация»/«федерации» — «федераци».
российск
федераци
республик
соединенн
штат
америк
армени
казахстан
белорус
украин
грузи
азербайджан
узбекистан
таджикистан
туркменистан
киргиз
молдов
молдав
литв
латви
эстони
польш
германи
франци
итали
испани
португали
нидерланд
бельги
швейцари
австри
чехи
словаки
венгри
румыни
болгари
серби
хорвати
словени
босни
македони
черногори
греци
турци
кипр
израил
иордани
ливан
сири
ирак
иран
афганистан
пакистан
инди
кита
япони
коре
монголи
вьетнам
таиланд
индонези
малайзи
сингапур
филиппин
австрали
новозеланд
канад
мексик
бразили
аргентин
чили
перу
колумби
венесуэл
эквадор
уругва
парагва
боливи
куб
доминикан
гаити
ямайк
египет
алжир
марокко
тунис
ливи
судан
эфиопи
кени
нигери
ган
юар
ангол
мозамбик
танзани
уганд
замби
зимбабве
ботсван
намиби
сенегал
кот
д'ивуар
камерун
конго
габон
экваториальн
мадагаскар
маврики
сейшельск
мальдив
шри
ланк
непал
бутан
бангладеш
мьянм
камбодж
лаос
финлянди
швеци
норвеги
дани
исланди
ирланди
великобритан
британ
англи
шотланд
уэльс
люксембург
мальт
андорр
монако
сан
марин
ватикан
лихтенштейн
+308
View File
@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PD Guard</title>
<style>
:root {
--bg: #f3efe6;
--ink: #1c1915;
--muted: #6d655c;
--line: #ddd4c6;
--card: #fffdf8;
--accent: #0e6b52;
--accent-ink: #083d30;
--danger: #8d2e2e;
--danger-bg: #f8ecec;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(1200px 500px at 10% -10%, #e7f3ee 0%, transparent 55%),
var(--bg);
font: 16px/1.45 "Segoe UI", system-ui, sans-serif;
}
main {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0 64px;
}
header h1 {
margin: 0;
font-size: 32px;
letter-spacing: -0.03em;
}
header p {
margin: 8px 0 0;
color: var(--muted);
}
form {
margin-top: 28px;
display: grid;
gap: 18px;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
legend {
padding: 0;
margin-bottom: 8px;
font-size: 13px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.modes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.mode {
display: block;
padding: 14px 14px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card);
cursor: pointer;
}
.mode:has(input:checked) {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.mode:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mode input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.mode strong {
display: block;
font-size: 14px;
letter-spacing: 0.04em;
}
.mode span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
label.field {
display: grid;
gap: 8px;
font-size: 13px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
textarea {
width: 100%;
min-height: 160px;
resize: vertical;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card);
color: var(--ink);
font: 15px/1.5 "Segoe UI", system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: transparent;
}
button {
justify-self: start;
padding: 12px 18px;
border: 0;
border-radius: 10px;
background: var(--accent);
color: #f7fffb;
font: 650 15px/1 "Segoe UI", system-ui, sans-serif;
cursor: pointer;
}
button:hover { background: var(--accent-ink); }
button:disabled {
opacity: 0.6;
cursor: progress;
}
#result {
min-height: 120px;
margin: 0;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card);
white-space: pre-wrap;
word-break: break-word;
font: 15px/1.5 ui-monospace, "Cascadia Mono", Consolas, monospace;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
#result.error {
color: var(--danger);
background: var(--danger-bg);
border-color: #e4c8c8;
}
#result:empty::before {
content: "Результат появится здесь";
color: var(--muted);
font-family: "Segoe UI", system-ui, sans-serif;
}
@media (max-width: 640px) {
.modes { grid-template-columns: 1fr; }
main { padding-top: 28px; }
}
</style>
</head>
<body>
<main>
<header>
<h1>PD Guard</h1>
<p>Маскирование персональных данных перед обработкой.</p>
</header>
<form id="form">
<fieldset>
<legend>Режим</legend>
<div class="modes">
<label class="mode">
<input type="radio" name="mode" value="default" checked>
<strong>MASK</strong>
<span>Звёздочки, края номеров видны</span>
</label>
<label class="mode">
<input type="radio" name="mode" value="strict">
<strong>STRICT</strong>
<span>Сплошные звёздочки</span>
</label>
<label class="mode">
<input type="radio" name="mode" value="crm">
<strong>TOKEN</strong>
<span>Токены вида [FIO_1]</span>
</label>
<label class="mode">
<input type="radio" name="mode" value="analytics">
<strong>SYNTHETIC</strong>
<span>Правдоподобная подмена</span>
</label>
</div>
</fieldset>
<label class="field">
Текст
<textarea id="payload" name="payload" required>Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67</textarea>
</label>
<button type="submit" id="run">Обработать</button>
<label class="field">
Результат
<pre id="result" aria-live="polite"></pre>
</label>
</form>
</main>
<script>
const form = document.getElementById("form");
const payload = document.getElementById("payload");
const result = document.getElementById("result");
const run = document.getElementById("run");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = payload.value;
if (!text.trim()) {
show("Введите текст", true);
return;
}
const systemId = new FormData(form).get("mode");
run.disabled = true;
show("");
try {
const response = await fetch("/process", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-System-Id": systemId
},
body: JSON.stringify({
payload: text,
payload_id: crypto.randomUUID()
})
});
if (response.status === 429) {
show("Сервис перегружен. Повторите через секунду.", true);
return;
}
const raw = await response.text();
let message = raw;
try {
const data = JSON.parse(raw);
if (data && typeof data.result === "string") message = data.result;
} catch (ignored) {
/* ответ не JSON — показываем как есть */
}
if (!response.ok) {
show(message || "Запрос отклонён (" + response.status + ")", true);
return;
}
show(message);
} catch (error) {
show("Не удалось связаться с сервисом.", true);
} finally {
run.disabled = false;
}
});
function show(text, isError) {
result.textContent = text;
result.classList.toggle("error", Boolean(isError));
}
</script>
</body>
</html>
+59 -39
View File
@@ -1,5 +1,9 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -7,31 +11,27 @@ 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 BankTypesTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
/**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
* организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
* переведены в {@code requireCompanion}, как пин-код и дата.
*/
@Test
void masksAccountNumberNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
"40702810500000001234");
assertHidden(
"Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
}
@@ -42,19 +42,24 @@ class BankTypesTest {
@Test
void keepsBankDetailsWithoutAnyPersonalData() {
for (String text : new String[]{
for (String text :
new String[] {
"Расчётный счёт 40702810500000001234 открыт вчера",
"БИК 044525593 банка-получателя",
"ОГРН 1027700132195 организации",
"КПП 770101001 указан в реквизитах"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"КПП 770101001 указан в реквизитах"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются");
}
}
@Test
void masksCardExpiryButNotCardNumber() {
String masked = pipeline.process(
String masked =
pipeline.process(
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("09/27"), masked);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
@@ -69,21 +74,25 @@ class BankTypesTest {
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test
void ogrnDoesNotSwallowOgrnip() {
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
"ogrnip-1", SystemPolicy.DEFAULT);
String masked =
pipeline.process(
"ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("304500116000157"), masked);
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
assertFalse(
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
}
/**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
* так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
* карты, и тест ничего бы не показывал.
*/
@Test
void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН");
}
@@ -91,7 +100,9 @@ class BankTypesTest {
@Test
void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП");
}
@@ -107,16 +118,20 @@ class BankTypesTest {
}
/**
* Сумма заработка без человека — статистика или описание продукта. Опознать по
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
* Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
* а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
* отрасли отвечать уже не на чем.
*/
@Test
void keepsIncomeWithoutAnyPersonalData() {
for (String text : new String[]{
for (String text :
new String[] {
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является");
}
}
@@ -128,28 +143,33 @@ class BankTypesTest {
}
/**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
* ноль сведений и разрушает смысл фразы.
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
* не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
*/
@Test
void keepsBiometricMentionWithoutAnyPersonalData() {
for (String text : new String[]{
for (String text :
new String[] {
"Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"Сдать биометрию можно через ЕБС в любом отделении"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"упоминание биометрии без человека персональными данными не является");
}
}
/**
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и
* ОГРН самостоятельным не становится, человека в таком тексте нет.
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным
* не становится, человека в таком тексте нет.
*/
@Test
void twoCompanionTypesDoNotVouchForEachOther() {
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"спутники заверили друг друга в отсутствие настоящих ПД");
}
}
+10 -11
View File
@@ -1,7 +1,5 @@
package ru.pdguard;
import ru.pdguard.detect.Span;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
@@ -12,29 +10,30 @@ import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import ru.pdguard.detect.Span;
/**
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest}
* (те же строки, перемешанные и склеенные в большой текст).
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется и {@link BenchmarkTest}
* (замер качества по строкам), и {@link LargeTextTest} (те же строки, перемешанные и склеенные в
* большой текст).
*/
final class BenchmarkFixtures {
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
/** Размеченный пример: чистый текст и эталонные фрагменты. */
record Sample(String text, List<Span> gold) {
}
record Sample(String text, List<Span> gold) {}
private BenchmarkFixtures() {
}
private BenchmarkFixtures() {}
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
static List<Sample> load(String resource) {
List<Sample> samples = new ArrayList<>();
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
BufferedReader reader = new BufferedReader(
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
+164 -110
View File
@@ -1,14 +1,7 @@
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.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -18,39 +11,47 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
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.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
/**
* Замер качества детекции на размеченных наборах.
*
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
* его оценка завышена и годится только как защита от ухудшений.
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
* настраивались — именно он показывает настоящее качество.
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка
* завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен
* независимо и на нём правила не настраивались — именно он показывает настоящее качество.
*
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
* поставило границу совпадения, и напрямую соотносятся с посимвольным
* сравнением замаскированного текста с эталоном.
* <p>Метрики посимвольные: так они не зависят от того, где именно правило поставило границу
* совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном.
*
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
* а расхождение в названии типа (скажем, место рождения против города) на
* качество маскирования не влияет.
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в
* названии типа (скажем, место рождения против города) на качество маскирования не влияет.
*/
class BenchmarkTest {
/**
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
* отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
*/
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/** Итог замера по одному набору. */
private record Result(double fioF1, double overallPrecision, double overallRecall,
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
}
private record Result(
double fioF1,
double overallPrecision,
double overallRecall,
double falsePositiveRate,
int foundFioSpans,
int goldFioSpans) {}
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
@@ -79,132 +80,143 @@ class BenchmarkTest {
}
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(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,
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,
assertTrue(
result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
}
/**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
* отражают измеренное на нём качество, а не желаемое.
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они отражают измеренное на нём
* качество, а не желаемое.
*/
@Test
void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(result.fioF1() >= 0.75,
assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.75,
assertTrue(
result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.15,
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
assertTrue(
result.falsePositiveRate() <= 0.15,
String.format(
"ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
}
/**
* Второй контрольный набор, составленный после того, как первый дважды повлиял
* на правила. На нём не настраивалось ничего — он и показывает настоящее
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
* Второй контрольный набор, составленный после того, как первый дважды повлиял на правила. На нём
* не настраивалось ничего — он и показывает настоящее качество. Пороги низкие намеренно: тест
* ловит обвал, а не сторожит значение.
*/
@Test
void detectionQualityOnThirdHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
Pipeline stage =
Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
assertTrue(result.fioF1() >= 0.70,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
assertTrue(
result.overallRecall() >= 0.70,
String.format(
"полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
}
/**
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
* показывать качество на данных, которых разработка не видела. Пороги здесь
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы показывать качество
* на данных, которых разработка не видела. Пороги здесь низкие намеренно — тест ловит обвал, а не
* сторожит достигнутое значение. Замер идёт со второй ступенью, если модель собрана, иначе на
* одних правилах.
*/
@Test
void detectionQualityOnSecondHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
Pipeline stage =
Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
assertTrue(result.fioF1() >= 0.70,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
}
/**
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
* расширенный денилист, обобщённое companion-правило (место рождения,
* страна) и новые банковские типы. Собран специально под соответствующие
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
* что было доработано, а не общее качество остального пайплайна.
*/
@Test
void detectionQualityOnBankContextSet() {
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.10,
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
}
/**
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
* него не настраивались; пороги низкие по той же причине, что и у второго
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации написания, не
* встречавшиеся ни в одном из остальных наборов. Правила под него не настраивались; пороги низкие
* по той же причине, что и у второго отложенного набора — тест ловит обвал, а не сторожит
* достигнутое значение.
*/
@Test
void detectionQualityOnGeneratedSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
Pipeline stage =
Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
assertTrue(result.fioF1() >= 0.70,
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
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(ENGINE, Optional.of(MODEL_PATH), 16, 4));
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
Pipeline withCascade =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
Result result =
measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
assertTrue(result.fioF1() >= 0.75,
assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
}
@@ -259,13 +271,28 @@ class BenchmarkTest {
}
}
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
report(
title,
samples.size(),
byType,
anyType,
goldFioSpans,
foundFioSpans,
cleanTexts,
cleanTextsWithFalseHit,
missedFio,
falseHits,
overMasked);
Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
falsePositiveRate, foundFioSpans, goldFioSpans);
return new Result(
fio.f1(),
anyType.precision(),
anyType.recall(),
falsePositiveRate,
foundFioSpans,
goldFioSpans);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
@@ -304,8 +331,7 @@ class BenchmarkTest {
}
private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream()
.anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
}
private static String fragment(String text, Span span) {
@@ -313,7 +339,8 @@ class BenchmarkTest {
}
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
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;
@@ -326,25 +353,53 @@ class BenchmarkTest {
}
}
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) {
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"));
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())));
.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(
"%-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",
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",
out.append(
String.format(
"Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio);
@@ -361,5 +416,4 @@ class BenchmarkTest {
out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n'));
}
}
@@ -1,12 +1,12 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
class CipherEnabledTest {
@Autowired PayloadCipher cipher;
+8 -5
View File
@@ -1,22 +1,25 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
/** Проверка ключа шифрования из application.yml. */
class CipherKeyTest {
private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
private static final String KEY =
"46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
@Test
void keyIsValidAes256() {
PayloadCipher cipher = new PayloadCipher(KEY);
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
assertEquals(original, cipher.decrypt(cipher.encrypt(original)),
assertEquals(
original,
cipher.decrypt(cipher.encrypt(original)),
"round-trip с ключом из application.yml должен работать");
}
}
@@ -1,24 +1,22 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
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 ContextDetectionTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
@@ -26,7 +24,8 @@ class ContextDetectionTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
@@ -58,7 +57,8 @@ class ContextDetectionTest {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
assertTrue(
masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test
@@ -69,9 +69,7 @@ class ContextDetectionTest {
@Test
void masksCitizenship() {
assertHidden("Гражданство: РФ", "РФ");
assertHidden("гражданство Республики Беларусь", "Беларусь");
assertHidden("Гражданин России обратился", "России");
}
@Test
@@ -79,7 +77,8 @@ class ContextDetectionTest {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон.
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden(
"Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
}
@@ -138,7 +137,8 @@ class ContextDetectionTest {
@Test
void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
String original =
"Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
@@ -151,7 +151,8 @@ class ContextDetectionTest {
@Test
void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
String original =
"Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
+21 -20
View File
@@ -1,5 +1,11 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy;
@@ -9,30 +15,23 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/**
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному
* предложению на строку, каждое своя отдельная проверка (не сборка одного большого
* текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из
* {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил,
* только модель второй ступени), варианты написания (регистр, формат даты, разделяющие
* слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения,
* дата без якоря) — они не должны маскироваться вовсе.
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному предложению на
* строку, каждое своя отдельная проверка (не сборка одного большого текста, как в {@link
* HugeDatasetTest}). Набор покрывает все типы ПДН из {@link RuleRegistry} (кроме
* ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, только модель второй ступени), варианты
* написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки
* (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе.
*
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом
* виде, а демаскирование побайтово восстанавливает исходный текст.
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а
* демаскирование побайтово восстанавливает исходный текст.
*/
class Dataset200Test {
private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-200.txt");
private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-200.txt");
@TestFactory
Stream<DynamicTest> datasetOf200Cases() {
@@ -40,7 +39,8 @@ class Dataset200Test {
for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i;
cases.add(dynamicTest(
cases.add(
dynamicTest(
String.format("#%03d: %s", index, preview(sample.text())),
() -> runCase(sample, index)));
}
@@ -48,13 +48,14 @@ class Dataset200Test {
}
private void runCase(BenchmarkFixtures.Sample sample, int index) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30));
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(30));
String payloadId = "dataset200-" + index;
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
for (Span gold : sample.gold()) {
String value = sample.text().substring(gold.start(), gold.end());
assertFalse(masked.contains(value),
assertFalse(
masked.contains(value),
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
}
@@ -1,5 +1,10 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -8,17 +13,11 @@ import ru.pdguard.detect.PdTypes;
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));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
@@ -26,7 +25,8 @@ class DateAndAddressTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
@@ -41,9 +41,9 @@ class DateAndAddressTest {
@Test
void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
assertHidden(
"Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая");
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
}
@Test
@@ -87,7 +87,8 @@ class DateAndAddressTest {
@Test
void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"),
assertTrue(
masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked);
}
@@ -95,7 +96,8 @@ class DateAndAddressTest {
@Test
void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника",
assertEquals(
"Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника"));
}
@@ -110,12 +112,6 @@ class DateAndAddressTest {
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
}
@Test
void doesNotMaskBankBranchAddress() {
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
}
@Test
void doesNotMaskOfficeAddress() {
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
@@ -133,7 +129,8 @@ class DateAndAddressTest {
@Test
void unmaskingRestoresTextWithDateAndAddress() {
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
String original =
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
+10 -9
View File
@@ -1,5 +1,10 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -7,17 +12,11 @@ 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));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
@@ -25,7 +24,8 @@ class FioTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertUnchanged(String text) {
@@ -106,7 +106,8 @@ class FioTest {
@Test
void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
String original =
"Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
+57 -39
View File
@@ -1,5 +1,14 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy;
@@ -9,28 +18,18 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/**
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа"
* (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" (обработка
* текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
*
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов
* {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится,
* пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения
* до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам
* {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000
* токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном
* тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов
* дополнительно проверяется, что маскирование укладывается в 5 секунд.
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code
* benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных
* примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000
* токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link
* #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются:
* отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после
* демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование
* укладывается в 5 секунд.
*
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
*/
@@ -41,13 +40,19 @@ class HugeDatasetTest {
private static final int CHARS_PER_TOKEN = 4;
private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN;
/** Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним
* текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */
/**
* Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
* их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
*/
private static final int LEAK_CHECK_MIN_LENGTH = 6;
/** Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту
* от 0.70 — это и есть отправная точка, а не 0.85 из LargeTextTest, где
* участвует только benchmark-generated.txt, подстроенный под правила. */
/**
* Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту от 0.70 — это и есть
* отправная точка, а не 0.85 из LargeTextTest, где участвует только benchmark-generated.txt,
* подстроенный под правила.
*/
private static final double MAX_LEAK_RATE = 0.30;
private static final RuleRegistry REGISTRY = new RuleRegistry();
@@ -56,10 +61,15 @@ class HugeDatasetTest {
private static List<BenchmarkFixtures.Sample> loadPool() {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
for (String resource : List.of(
"/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt",
"/benchmark-bank-context.txt", "/benchmark-holdout.txt",
"/benchmark-holdout2.txt", "/benchmark-holdout3.txt")) {
for (String resource :
List.of(
"/benchmark.txt",
"/benchmark-generated.txt",
"/benchmark-pdn-types.txt",
"/benchmark-bank-context.txt",
"/benchmark-holdout.txt",
"/benchmark-holdout2.txt",
"/benchmark-holdout3.txt")) {
pool.addAll(BenchmarkFixtures.load(resource));
}
return pool;
@@ -71,17 +81,20 @@ class HugeDatasetTest {
for (int i = 0; i < TOTAL_CASES; i++) {
int targetChars = targetChars(i);
int index = i;
cases.add(dynamicTest(
String.format("#%04d, %d знаков (~%d токенов)", index, targetChars, targetChars / CHARS_PER_TOKEN),
cases.add(
dynamicTest(
String.format(
"#%04d, %d знаков (~%d токенов)",
index, targetChars, targetChars / CHARS_PER_TOKEN),
() -> runCase(targetChars, index)));
}
return cases.stream();
}
/**
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста —
* так тесты покрывают все порядки величины, а не только маленькие и не только большие.
* Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — так тесты
* покрывают все порядки величины, а не только маленькие и не только большие. Последние {@link
* #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
*/
private static int targetChars(int index) {
int regular = TOTAL_CASES - HUGE_CASES;
@@ -96,7 +109,8 @@ class HugeDatasetTest {
}
private void runCase(int targetChars, int seed) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
Pipeline pipeline =
new Pipeline(REGISTRY, MASKER, new PayloadStore(30));
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
String payloadId = "dataset-" + seed;
@@ -119,8 +133,10 @@ class HugeDatasetTest {
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
assertTrue(leaked <= allowed,
String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
assertTrue(
leaked <= allowed,
String.format(
"утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
leaked, checked, allowed));
}
@@ -128,7 +144,9 @@ class HugeDatasetTest {
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
if (targetChars >= HUGE_MIN_CHARS) {
assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
assertTrue(
maskMillis < 5000,
"маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
}
}
@@ -1,5 +1,9 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -7,20 +11,16 @@ 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));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertMasked(String text, String payloadId, String expected) {
@@ -48,15 +48,17 @@ class IdentityDocumentTest {
}
/**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
* целиком, поэтому у этих документов открыты только последние знаки номера.
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
* буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
* открыты только последние знаки номера.
*/
@Test
void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
assertMasked(
"Свидетельство о рождении II-МЮ № 123456",
"bc-1",
"Свидетельство о рождении **-** № ****56");
}
@@ -64,7 +66,7 @@ class IdentityDocumentTest {
@Test
void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
"Водительское удостоверение 99** ****56");
assertMasked(
"Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
}
}
+51 -46
View File
@@ -1,14 +1,7 @@
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.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -17,19 +10,23 @@ import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
/**
* Качество и скорость на большом тексте — не повторе одного и того же
* предложения, а перемешанных строках из {@code benchmark-generated.txt}
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ
* (около 100 000 токенов, ~400 КБ по оценке из README).
* Качество и скорость на большом тексте — не повторе одного и того же предложения, а перемешанных
* строках из {@code benchmark-generated.txt} (все типы ПД вперемешку с чистым текстом), растянутых
* до объёма из ТЗ (около 100 000 токенов, ~400 КБ по оценке из README).
*
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под
* маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и
* разнообразие проверяются вместе.
*/
class LargeTextTest {
@@ -40,12 +37,13 @@ class LargeTextTest {
private static final int TARGET_CHARS = 400_000;
/**
* Перемешивает исходные строки (фиксированный seed — детерминированный
* тест) и склеивает их через перенос строки, пока не наберётся целевой
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
* Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
* перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
* под общий текст.
*/
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
List<BenchmarkFixtures.Sample> pool =
new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
Random random = new Random(seed);
StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>();
@@ -67,16 +65,16 @@ class LargeTextTest {
}
/**
* Маскирование и обратное преобразование на большом тексте дают
* побайтово тот же результат, что и исходный текст — при объёме на
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
* а не одним повторяющимся предложением.
* Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и
* исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным
* содержимым, а не одним повторяющимся предложением.
*/
@Test
void roundTripOnLargeMixedText() {
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(large.text().length() * 2L, 30));
Pipeline pipeline =
new Pipeline(
new RuleRegistry(), new Masker(), new PayloadStore(30));
long maskStarted = System.nanoTime();
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
@@ -87,22 +85,24 @@ class LargeTextTest {
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(
maskMillis < 5000,
"маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
System.out.printf(
"%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
large.text().length(), maskMillis, unmaskMillis);
}
/**
* Полнота детекции не должна проседать на объёме: каждый золотой
* фрагмент из перемешанных строк обязан быть найден в общем потоке
* текста, а не только когда он единственный в маленькой строке.
* Полнота детекции не должна проседать на объёме: каждый золотой фрагмент из перемешанных строк
* обязан быть найден в общем потоке текста, а не только когда он единственный в маленькой строке.
*/
@Test
void recallHoldsAtScale() {
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
int hit = 0;
@@ -112,17 +112,19 @@ class LargeTextTest {
}
}
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
System.out.printf(
"%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
assertTrue(recall >= 0.85,
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
assertTrue(
recall >= 0.85,
String.format(
"полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
}
/**
* Вторая ступень ограничена числом кандидатов на запрос
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
* превращать её в квадратичную нагрузку — проверяем на том же большом
* тексте, что и остальные тесты, а не на маленьком образце.
* Вторая ступень ограничена числом кандидатов на запрос ({@code pdguard.ner.max-candidates}),
* поэтому объём текста не должен превращать её в квадратичную нагрузку — проверяем на том же
* большом тексте, что и остальные тесты, а не на маленьком образце.
*/
@Test
void nameCascadeStaysBoundedOnLargeText() {
@@ -132,16 +134,19 @@ class LargeTextTest {
return;
}
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(large.text().length() * 2L, 30),
Pipeline pipeline =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
long started = System.nanoTime();
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n",
large.text().length(), millis);
System.out.printf(
"%nБольшой текст со второй ступенью: %d знаков за %d мс%n", large.text().length(), millis);
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
}
}
@@ -0,0 +1,96 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
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.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
/**
* Проверка утечек из датасета {@code leak-dataset.txt}.
*
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в которых узел не нашёл
* ПД ({@code найдено={}}), хотя маркер персональных данных в тексте есть. Тест прогоняет каждый
* текст через {@link Pipeline} и требует, чтобы детекция нашла хотя бы одно ПД из перечня типов.
*/
class LeakDiagTest {
private static final String DATASET = "/leak-dataset.txt";
@Test
void checkLeaks() {
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
List<String> leaks = readDataset();
int fixed = 0;
List<String> remaining = new ArrayList<>();
for (String raw : leaks) {
List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT);
boolean found =
spans.stream()
.anyMatch(
s ->
s.type().equals(PdTypes.FIO)
|| s.type().equals(PdTypes.BIRTH_DATE)
|| s.type().equals(PdTypes.PASSPORT_DATE)
|| s.type().equals(PdTypes.CVV)
|| s.type().equals(PdTypes.PIN)
|| s.type().equals(PdTypes.INN)
|| s.type().equals(PdTypes.PHONE)
|| s.type().equals(PdTypes.CARD)
|| s.type().equals(PdTypes.DRIVER_LICENSE)
|| s.type().equals(PdTypes.CITIZENSHIP)
|| s.type().equals(PdTypes.BIRTH_PLACE));
if (found) {
fixed++;
} else {
remaining.add(raw);
}
}
System.out.println(
"Всего утечек: "
+ leaks.size()
+ ", исправлено: "
+ fixed
+ ", осталось: "
+ remaining.size());
for (String raw : remaining) {
System.out.println(" ОСТАЛОСЬ: " + raw);
}
assertTrue(
remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
}
private static List<String> readDataset() {
List<String> lines = new ArrayList<>();
try (InputStream in = LeakDiagTest.class.getResourceAsStream(DATASET)) {
if (in == null) {
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
}
try (BufferedReader r =
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (!line.isBlank()) {
lines.add(line);
}
}
}
} catch (IOException e) {
throw new IllegalStateException(e);
}
return lines;
}
}
@@ -0,0 +1,57 @@
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.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** LLAIM Legal NER: юридические реквизиты и документы, которых нет в общих моделях. */
class LegalNerTest {
private List<Span> find(String text) {
NameCascade cascade = new NameCascade(
new NameCascade.EngineConfig(
"off", Optional.empty(),
"off", Optional.empty(),
"ru-legal-ner", Optional.of("models/ru-legal-ner")),
16, 4);
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return p.findPersonalData(text, SystemPolicy.DEFAULT);
}
private boolean hasType(List<Span> spans, String type) {
return spans.stream().anyMatch(s -> s.type().equals(type));
}
@Test
void recognisesInn() {
String text = "Договор между ООО «Ромашка», ИНН 7701234567, и Ивановым Иваном Ивановичем.";
List<Span> spans = find(text);
System.out.println("TEXT: " + text);
for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
}
assertTrue(hasType(spans, PdTypes.INN), "должно найти ИНН");
}
@Test
void recognisesPassport() {
String text = "Паспорт 4509 123456 выдан ОВД, СНИЛС 112-233-445 95.";
List<Span> spans = find(text);
System.out.println("TEXT: " + text);
for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
}
assertTrue(hasType(spans, PdTypes.PASSPORT), "должно найти паспорт");
}
}
+37 -15
View File
@@ -1,5 +1,13 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -9,30 +17,40 @@ 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));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private SystemPolicy policy(MaskMode mode) {
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode,
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
return new SystemPolicy(
SystemPolicy.DEFAULT_NAME,
true,
true,
mode,
Set.of(SystemPolicy.ALL),
SystemPolicy.DEFAULT.requireCompanion(),
null);
}
private String mask(MaskMode mode, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
}
@Test
void strictModeHidesEverythingIncludingFio() {
String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456");
assertFalse(masked.contains("Иванов"), masked);
assertFalse(
masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
assertTrue(
masked.contains("****** **** ********"),
"ожидались звёздочки по длине каждого слова: " + masked);
assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked);
}
@Test
void tokenModeNumbersEachType() {
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
@@ -42,7 +60,8 @@ class MaskModeTest {
@Test
void sameValueGetsSameTokenWithinRequest() {
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
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);
}
@@ -56,7 +75,8 @@ class MaskModeTest {
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked);
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
assertTrue(
Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
}
@Test
@@ -78,7 +98,9 @@ class MaskModeTest {
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())) {
for (int i = text.indexOf(fragment);
i >= 0;
i = text.indexOf(fragment, i + fragment.length())) {
n++;
}
return n;
+16 -13
View File
@@ -1,5 +1,13 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy;
@@ -9,23 +17,14 @@ 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);
Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
}
@@ -38,7 +37,9 @@ class NameCascadeTest {
@Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
NameCascade cascade =
new NameCascade(
"rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
}
@@ -53,7 +54,9 @@ class NameCascadeTest {
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
assertEquals(
"Клиент И. И. И., паспорт 45** ****56",
mask(cascade, "broken-1"),
"маскирование по правилам обязано работать и без второй ступени");
}
}
@@ -0,0 +1,59 @@
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.assertFalse;
/**
* Нормализация цифровых ПД: находит ИНН/СНИЛС/карту/ОГРН(ИП) в свободной форме,
* где жёсткий шаблон ломается на нестандартном разделителе.
*/
class NormalisedDigitsTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksCardWithDots() {
assertHidden("Карта 4111.1111.1111.1111 клиента", "4111.1111.1111.1111");
}
@Test
void masksCardWithSlashes() {
assertHidden("Оплата картой 4111/1111/1111/1111 прошла", "4111/1111/1111/1111");
}
@Test
void masksCardWithMixedSeparators() {
assertHidden("Номер карты 4111-1111 1111.1111 клиента", "4111-1111 1111.1111");
}
@Test
void masksInnWithDashes() {
assertHidden("ИНН: 7703-0123-4550 плательщика", "7703-0123-4550");
}
@Test
void masksSnilsWithDots() {
assertHidden("СНИЛС 112.233.445.95 застрахованного", "112.233.445.95");
}
@Test
void keepsNumberThatFailsChecksum() {
String text = "Заказ 1234 5678 9012 3456 отгружен";
org.junit.jupiter.api.Assertions.assertEquals(text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT));
}
}
@@ -1,5 +1,9 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -7,19 +11,14 @@ 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 OrganisationNamesTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
@@ -27,26 +26,30 @@ class OrganisationNamesTest {
@Test
void keepsNamesInsideInstitutionNames() {
for (String text : new String[]{
for (String text :
new String[] {
"Институт Мечникова принимает по записи",
"Музей Верещагина работает по будням",
"Театр Станиславского открыл сезон",
"Библиотека Некрасова закрыта на ремонт",
"Премия имени Ломоносова вручена в декабре",
"Больница Боткина приняла пациентов",
"Стадион Яшина отремонтирован"}) {
"Стадион Яшина отремонтирован"
}) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
}
}
@Test
void keepsNamesInsidePlaceNames() {
for (String text : new String[]{
for (String text :
new String[] {
"Улица Королёва названа в честь конструктора",
"Проспект Вернадского перекрыт до вечера",
"Площадь Гагарина находится на юго-западе",
"Набережная Макарова уходит к заливу",
"Мост Кадырова разведут ночью"}) {
"Мост Кадырова разведут ночью"
}) {
assertEquals(text, mask(text), "топоним маскировать не нужно");
}
}
@@ -54,24 +57,28 @@ class OrganisationNamesTest {
@Test
void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(masked.contains("Королёв Сергей Павлович"),
assertFalse(
masked.contains("Королёв Сергей Павлович"),
"однофамилец объекта на карте остаётся под защитой: " + masked);
}
@Test
void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(masked.contains("Иванова Ивана Ивановича"),
assertFalse(
masked.contains("Иванова Ивана Ивановича"),
"слово-маркер действует только вплотную перед именем: " + masked);
}
@Test
void keepsRulerNames() {
for (String text : new String[]{
for (String text :
new String[] {
"Василий Тёмный правил недолго",
"Ярослав Мудрый составил свод законов",
"Екатерина Вторая издала указ",
"Алексей Тишайший принимал послов"}) {
"Алексей Тишайший принимал послов"
}) {
assertEquals(text, mask(text), "имя правителя персональными данными не является");
}
}
@@ -79,14 +86,16 @@ class OrganisationNamesTest {
@Test
void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(masked.contains("Василий Тёмный"),
assertFalse(
masked.contains("Василий Тёмный"),
"рядом с паспортными данными это конкретный человек: " + masked);
}
@Test
void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
assertFalse(
masked.contains("Пахомов Вениамин Николаевич"),
"имя предпринимателя — это персональные данные: " + masked);
}
}
@@ -1,17 +1,18 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/** Шифрование персональных данных в хранилище. */
class PayloadCipherTest {
/** 32 байта в hex — валидный AES-256 ключ. */
private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
private static final String KEY =
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test
void encryptDecryptRoundTrip() {
@@ -33,7 +34,8 @@ class PayloadCipherTest {
@Test
void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
PayloadStore store =
new PayloadStore(30, ru.pdguard.core.SharedIndex.disabled(), cipher);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56";
@@ -44,7 +46,9 @@ class PayloadCipherTest {
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
// Чтение по маске возвращает исходный текст.
assertEquals(original, store.originalForMask("test", masked),
assertEquals(
original,
store.originalForMask("test", masked),
"чтение по маске должно вернуть исходный текст");
}
}
+18 -27
View File
@@ -1,21 +1,20 @@
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;
/** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadStore;
/** Ограничения хранилища соответствий: срок жизни и разделение по системам. */
class PayloadStoreTest {
private static final String SYSTEM = "crm";
@Test
void returnsWhatWasStored() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
PayloadStore store = new PayloadStore(30);
store.put(SYSTEM, "id", "исходный текст", "маска");
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
@@ -27,47 +26,39 @@ class PayloadStoreTest {
@Test
void forgetsEntriesAfterTheirLifetime() {
PayloadStore store = new PayloadStore(1_000_000L, 0);
PayloadStore store = new PayloadStore(0);
store.put(SYSTEM, "id", "исходный текст", "маска");
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
assertNull(store.originalForMask(SYSTEM, "маска"));
}
@Test
void evictsOldestWhenOverSizeLimit() {
PayloadStore store = new PayloadStore(100L, 30);
for (int i = 0; i < 50; i++) {
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i);
}
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
}
@Test
void unknownKeysReturnNothing() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
PayloadStore store = new PayloadStore(30);
assertNull(store.byId(SYSTEM, "нет такого"));
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
}
/**
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
* на исходные данные другого потребителя.
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без
* разделения чужую маску можно было бы подобрать и обменять на исходные данные другого
* потребителя.
*/
@Test
void oneSystemCannotReadAnotherSystemData() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
PayloadStore store = new PayloadStore(30);
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
assertNull(store.originalForMask("analytics", "И. И. И."),
assertNull(
store.originalForMask("analytics", "И. И. И."),
"чужую маску нельзя обменять на исходный текст");
assertNull(store.byId("analytics", "общий-id"),
assertNull(
store.byId("analytics", "общий-id"),
"совпадение идентификатора у другой системы не даёт доступа");
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
assertEquals(
"Иванов Иван Иванович",
store.originalForMask("crm", "И. И. И."),
"своя система свои данные по-прежнему получает");
}
}
@@ -1,5 +1,12 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -8,26 +15,17 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
*
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 —
* так видно, какие типы детектор находит надёжно, а какие пропускает или
* маскирует сверх меры.
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для
* каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор
* находит надёжно, а какие пропускает или маскирует сверх меры.
*/
class PdnTypeEfficiencyTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
@@ -69,9 +67,11 @@ class PdnTypeEfficiencyTest {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
@@ -87,7 +87,8 @@ class PdnTypeEfficiencyTest {
if (isCompanion(e.getKey())) {
continue;
}
assertTrue(e.getValue().f1() >= 0.8,
assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
@@ -98,7 +99,8 @@ class PdnTypeEfficiencyTest {
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> {
missed.forEach(
(type, values) -> {
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
});
System.out.println(out);
@@ -136,13 +138,22 @@ class PdnTypeEfficiencyTest {
private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
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())));
.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())));
System.out.println(out);
}
@@ -1,15 +1,10 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
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.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -21,19 +16,21 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
/**
* Замер производительности: задержка одиночного обращения и пропускная
* способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}.
* Замер производительности: задержка одиночного обращения и пропускная способность под нагрузкой.
* Не тест качества — он в {@link BenchmarkTest}.
*
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый
* код и занизят результат в разы.
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели.
* Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые
* замеры покажут интерпретируемый код и занизят результат в разы.
*/
class PerformanceBenchmarkTest {
@@ -47,9 +44,9 @@ class PerformanceBenchmarkTest {
};
/**
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
* а не правила, которые в типовых текстах уже всё покрыли.
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает вторая ступень (модель).
* Нужны, чтобы честно измерить стоимость модели, а не правила, которые в типовых текстах уже всё
* покрыли.
*/
private static final String[] CASCADE_PAYLOADS = {
"Готье и Руссо пришли на встречу в офис",
@@ -63,7 +60,7 @@ class PerformanceBenchmarkTest {
private static final int MEASURE = 50_000;
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void warmup() {
for (int i = 0; i < WARMUP; i++) {
@@ -101,15 +98,22 @@ class PerformanceBenchmarkTest {
double unmaskUs = nanosToMicros(unmaskNanos);
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs);
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs);
System.out.printf(
"Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[MEASURE / 2] / 1000.0,
maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
maskNanos[(int) (MEASURE * 0.99)] / 1000.0,
maskUs);
System.out.printf(
"Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
unmaskNanos[MEASURE / 2] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0,
unmaskUs);
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
assertTrue(
maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
}
@@ -126,7 +130,10 @@ class PerformanceBenchmarkTest {
List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int threadId = t;
futures.add(pool.submit((Callable<Long>) () -> {
futures.add(
pool.submit(
(Callable<Long>)
() -> {
long local = 0;
for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
@@ -151,7 +158,8 @@ class PerformanceBenchmarkTest {
double rps = requests / (wallNanos / 1e9);
double avgUs = totalNanos / (double) requests / 1000.0;
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf(
"%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
@@ -172,9 +180,13 @@ class PerformanceBenchmarkTest {
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
MeterRegistry meters = new SimpleMeterRegistry();
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(10_000_000L, 30),
new NameCascade("rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
Pipeline withCascade =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(
"rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
for (int i = 0; i < 200; i++) {
@@ -194,18 +206,24 @@ class PerformanceBenchmarkTest {
Arrays.sort(maskNanos);
int n = maskNanos.length;
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[n / 2] / 1000.0, maskNanos[(int) (n * 0.95)] / 1000.0,
maskNanos[(int) (n * 0.99)] / 1000.0, nanosToMicros(maskNanos));
System.out.printf(
"Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[n / 2] / 1000.0,
maskNanos[(int) (n * 0.95)] / 1000.0,
maskNanos[(int) (n * 0.99)] / 1000.0,
nanosToMicros(maskNanos));
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
double candidates = meters.counter("pdguard.ner.candidates").count();
System.out.printf("Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
System.out.printf(
"Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
// должно укладываться в десятки миллисекунд.
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
assertTrue(
maskNanos[(int) (n * 0.99)] < 200_000_000,
"p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
+ maskNanos[(int) (n * 0.99)] / 1_000_000
+ " мс");
}
}
+11 -10
View File
@@ -1,5 +1,11 @@
package ru.pdguard;
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;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -8,13 +14,6 @@ import ru.pdguard.detect.PdTypes;
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 {
@@ -23,7 +22,7 @@ class PipelineTest {
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));
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
}
private String mask(Pipeline pipeline, String text) {
@@ -116,9 +115,11 @@ class PipelineTest {
void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
String masked =
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertTrue(
masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked);
}
@@ -0,0 +1,81 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
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.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
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.detect.Span;
import ru.pdguard.mask.Masker;
/**
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже известных типов
* относительно якорного слова: расстояние до якоря, обратный порядок (значение перед якорем),
* падеж/число анкера, структурированные форматы (JSON/CSV/markdown-таблица/XML/key=value),
* несколько значений одного типа в одном поле, значение в кавычках/скобках. Гипотезы построены на
* 55 утечках из реальных логов и обобщают их корневые причины на другие типы и формы. Разбор по
* категориям — в отчёте, приложенном к задаче.
*/
class PlacementVariantsTest {
private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-placements.txt");
private static final NameCascade CASCADE =
modelsPresent()
? new NameCascade(
"wikineural",
Optional.of("models/wikineural-ner"),
"rubert",
Optional.of("models/rubert-ner"),
16,
4)
: NameCascade.disabled();
private static boolean modelsPresent() {
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
}
@TestFactory
Stream<DynamicTest> placementDataset() {
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i;
cases.add(
dynamicTest(
String.format("#%02d: %s", index, preview(sample.text())),
() -> runRoundTrip(sample, index)));
}
return cases.stream();
}
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(30), CASCADE);
String payloadId = "placement-" + index;
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
}
private static String preview(String text) {
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
}
}
+144 -64
View File
@@ -1,30 +1,31 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProcessResourceTest {
@LocalServerPort
int port;
@LocalServerPort int port;
private String post(String payload, String payloadId) {
return given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", payload, "payload_id", payloadId))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
}
@Test
@@ -43,8 +44,10 @@ class ProcessResourceTest {
.port(port)
.contentType("application/json")
.body(Map.of("payload", "текст"))
.when().post("/process")
.then().statusCode(400);
.when()
.post("/process")
.then()
.statusCode(400);
}
@Test
@@ -59,20 +62,26 @@ class ProcessResourceTest {
.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);
.when()
.post("/process")
.then()
.statusCode(403);
}
@Test
void systemPolicySelectsMaskMode() {
String masked = given()
String masked =
given()
.port(port)
.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");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
}
@@ -84,8 +93,10 @@ class ProcessResourceTest {
.contentType("application/json")
.header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when().post("/process")
.then().statusCode(200)
.when()
.post("/process")
.then()
.statusCode(200)
.body("result", equalTo("почта i***@m***.ru"));
}
@@ -93,110 +104,177 @@ class ProcessResourceTest {
void metricsExposeLatencyAndTokenCounters() {
post("Клиент Иванов Иван Иванович", "metrics-1");
String body = given().port(port).when().get("/actuator/prometheus").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"), "нет метрики типов ПД");
String body =
given()
.port(port)
.when()
.get("/actuator/prometheus")
.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 anotherSystemCannotExchangeMaskForOriginal() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String id = "cross-system-1";
String masked = given()
String masked =
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", original, "payload_id", id))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
String byOther = given()
String byOther =
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "other")
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, byOther,
"чужая система не должна получать исходный текст по маске");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(
original, byOther, "чужая система не должна получать исходный текст по маске");
String bySameSystem = given()
String bySameSystem =
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", masked, "payload_id", id))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertEquals(original, bySameSystem,
"своя система по своему идентификатору исходный текст получает");
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertEquals(
original, bySameSystem, "своя система по своему идентификатору исходный текст получает");
}
@Test
void systemWithKeyRequiresIt() {
given().port(port).contentType("application/json")
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "guarded")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
.when().post("/process").then().statusCode(403);
.when()
.post("/process")
.then()
.statusCode(403);
given().port(port).contentType("application/json")
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "guarded")
.header("X-System-Key", "wrong-key")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
.when().post("/process").then().statusCode(403);
.when()
.post("/process")
.then()
.statusCode(403);
given().port(port).contentType("application/json")
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "guarded")
.header("X-System-Key", "s3cret-key-2026")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
.when().post("/process").then().statusCode(200);
.when()
.post("/process")
.then()
.statusCode(200);
}
@Test
void systemWithoutKeyWorksWithoutIt() {
given().port(port).contentType("application/json")
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
.when().post("/process").then().statusCode(200);
.when()
.post("/process")
.then()
.statusCode(200);
}
@Test
void metricsCountSecondStageInvocations() {
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_requests_total"),
"нет счётчика обращений ко второй ступени");
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_candidates_total"),
"нет счётчика участков, отданных модели");
String body =
given()
.port(port)
.when()
.get("/actuator/prometheus")
.then()
.statusCode(200)
.extract()
.asString();
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_ner_requests_total"), "нет счётчика обращений ко второй ступени");
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_ner_candidates_total"), "нет счётчика участков, отданных модели");
}
@Test
void metricsDoNotLeakPersonalData() {
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
String body =
given()
.port(port)
.when()
.get("/actuator/prometheus")
.then()
.statusCode(200)
.extract()
.asString();
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
String namesAndLabels = body.lines()
String namesAndLabels =
body.lines()
.filter(line -> !line.startsWith("#"))
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
.reduce("", (a, b) -> a + "\n" + b);
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("Иванов"), "ПД попали в метрики");
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("4111"), "ПД попали в метрики");
org.junit.jupiter.api.Assertions.assertFalse(
namesAndLabels.contains("Иванов"), "ПД попали в метрики");
org.junit.jupiter.api.Assertions.assertFalse(
namesAndLabels.contains("4111"), "ПД попали в метрики");
}
@Test
void adminShowsSystemsAndTypes() {
given().port(port).when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
given()
.port(port)
.when()
.get("/admin/config")
.then()
.statusCode(200)
.body("crm.maskMode", equalTo("TOKEN"));
given().port(port).when().get("/admin/types").then().statusCode(200);
}
@@ -206,8 +284,10 @@ class ProcessResourceTest {
.port(port)
.contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when().post("/process")
.then().statusCode(200)
.when()
.post("/process")
.then()
.statusCode(200)
.body("result", equalTo("тестовая строка"))
.body("result", not(equalTo("")));
}
+35 -21
View File
@@ -1,23 +1,21 @@
package ru.pdguard;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.restassured.path.json.JsonPath;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProxyResourceTest {
@LocalServerPort
int port;
@LocalServerPort int port;
private static final String PROMPT =
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
@@ -27,9 +25,12 @@ class ProxyResourceTest {
.port(port)
.contentType("application/json")
.body(Map.of("prompt", prompt))
.when().post("/proxy")
.then().statusCode(200)
.extract().jsonPath();
.when()
.post("/proxy")
.then()
.statusCode(200)
.extract()
.jsonPath();
}
@Test
@@ -56,12 +57,13 @@ class ProxyResourceTest {
void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT);
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
assertTrue(
json.getString("prompt_masked").contains("[FIO_1]"),
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
assertTrue(
json.getString("llm_response_masked").contains("[FIO_1]"),
"ответ модели ещё содержит подстановки");
assertFalse(json.getString("response").contains("[FIO_1]"),
"потребителю подстановки не видны");
assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
assertEquals("заглушка", json.getString("llm"));
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
}
@@ -69,20 +71,32 @@ class ProxyResourceTest {
@Test
void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
assertEquals(
"Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
}
@Test
void rejectsEmptyPrompt() {
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
.when().post("/proxy").then().statusCode(400);
given()
.port(port)
.contentType("application/json")
.body(Map.of("prompt", " "))
.when()
.post("/proxy")
.then()
.statusCode(400);
}
@Test
void disabledSystemIsRefused() {
given().port(port).contentType("application/json")
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("prompt", PROMPT))
.when().post("/proxy").then().statusCode(403);
.when()
.post("/proxy")
.then()
.statusCode(403);
}
}
+13 -15
View File
@@ -1,5 +1,9 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -7,22 +11,16 @@ 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;
/**
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево»
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
* искать было негде.
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями на сёла, посёлки,
* деревни, хутора, станицы, аулы и аалы — раньше словарь ограничивался официальными городами
* (~1100), и «рп. Ильинское»/«с. Кукуево» из ТЗ не находились вообще, дело было не в качестве
* детекции, а в том, что искать было негде.
*/
class SettlementTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
@@ -30,7 +28,8 @@ class SettlementTest {
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
@@ -107,9 +106,8 @@ class SettlementTest {
}
/**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
* букву сразу после якоря, а не цифру.
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
* на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
*/
@Test
void pageReferenceIsNotMistakenForSettlement() {
@@ -1,5 +1,9 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -7,30 +11,25 @@ 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 StreetDenylistTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertUnmasked(String text) {
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
assertEquals(
text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
}
@Test
@@ -69,9 +68,8 @@ class StreetDenylistTest {
}
/**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
* применяется — если в тексте всё-таки есть настоящие персональные данные,
* совпадение с историческим именем их не прикрывает.
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
* всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
*/
@Test
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
+13 -11
View File
@@ -1,25 +1,25 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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 = """
private static final String CONTENT =
"""
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
@@ -84,7 +84,9 @@ class SystemsConfigTest {
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"сломанный файл не должен ронять работающий сервис");
}
}
@@ -1,5 +1,13 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -9,30 +17,26 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов.
*
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса
* маскируются, а известные личности — нет. Для каждого типа считается посимвольная
* точность, полнота и F1.
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а
* известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1.
*/
class TwoModelBenchmarkTest {
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(10_000_000L, 30),
private final Pipeline pipeline =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(
new NameCascade.EngineConfig(
"wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"),
16, 4));
"off", Optional.empty()),
16,
4));
private static final class Score {
private int truePositive;
@@ -73,9 +77,11 @@ class TwoModelBenchmarkTest {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
@@ -86,7 +92,8 @@ class TwoModelBenchmarkTest {
// Каждый тип должен быть найден с F1 не ниже 0.8.
for (Map.Entry<String, Score> e : byType.entrySet()) {
assertTrue(e.getValue().f1() >= 0.8,
assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
@@ -118,12 +125,21 @@ class TwoModelBenchmarkTest {
private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
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())));
.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())));
System.out.println(out);
}
@@ -133,8 +149,9 @@ class TwoModelBenchmarkTest {
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> out.append(type).append(": ")
.append(String.join(" | ", values)).append('\n'));
missed.forEach(
(type, values) ->
out.append(type).append(": ").append(String.join(" | ", values)).append('\n'));
System.out.println(out);
}
}
@@ -1,5 +1,9 @@
package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
@@ -10,20 +14,20 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Две модели: WikiNEuRal для имён, ruBERT для адресов. */
class TwoModelCascadeTest {
private List<Span> find(String text) {
NameCascade cascade = new NameCascade(
NameCascade cascade =
new NameCascade(
new NameCascade.EngineConfig(
"wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"),
16, 4);
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
"off", Optional.empty()),
16,
4);
Pipeline p =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return p.findPersonalData(text, SystemPolicy.DEFAULT);
}
@@ -46,7 +50,8 @@ class TwoModelCascadeTest {
for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
}
assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
assertTrue(
spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться");
}
}
@@ -0,0 +1,132 @@
package ru.pdguard.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.mask.MaskMode;
class SystemsConfigTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void missingFileFallsBackToDefaultPolicy(@TempDir Path dir) {
SystemsConfig config = new SystemsConfig(dir.resolve("absent.json").toString(), mapper);
SystemPolicy policy = config.policyFor("anything");
assertEquals(SystemPolicy.DEFAULT_NAME, policy.name());
assertEquals(MaskMode.MASK, policy.maskMode());
}
@Test
void readsKnownSystemFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE"]
}
}
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
SystemPolicy policy = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, policy.maskMode());
assertFalse(policy.demask());
assertTrue(policy.allows("FIO"));
assertFalse(policy.allows("EMAIL"));
}
@Test
void unknownSystemFallsBackToDefault(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertFalse(config.isKnown("ghost"));
assertEquals(SystemPolicy.DEFAULT_NAME, config.policyFor("ghost").name());
}
@Test
void malformedJsonKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
Files.writeString(file, "{ not valid json", StandardCharsets.UTF_8);
config.reload();
assertTrue(config.isKnown("crm"), "битый файл не должен затирать рабочие настройки");
}
@Test
void unknownMaskModeKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
Files.writeString(
file,
"""
{ "crm": { "maskMode": "NOT_A_MODE" } }
""",
StandardCharsets.UTF_8);
config.reload();
assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"неизвестный maskMode не должен принять частично разобранные настройки");
}
@Test
void currentReturnsSortedSnapshot(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "zzz": { "maskMode": "TOKEN" }, "aaa": { "maskMode": "MASK" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertEquals(
java.util.List.of("aaa", "default", "zzz"),
config.current().keySet().stream().sorted().toList());
}
}
@@ -0,0 +1,93 @@
package ru.pdguard.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
@SuppressWarnings("java:S2925") // Thread.sleep ждёт прохождения окна регулировки лимитера
class AdaptiveConcurrencyLimiterTest {
@Test
void rejectsInvalidBounds() {
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(0, 10, 100));
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(10, 5, 100));
}
@Test
void acceptsUpToLimitThenRejects() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 2, 1000);
assertTrue(limiter.tryAcquire());
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire(), "предел исчерпан — третий запрос должен быть отклонён");
assertEquals(2, limiter.inFlight());
}
@Test
void releaseFreesSlotForNextRequest() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 1, 1000);
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire());
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertTrue(limiter.tryAcquire(), "после release слот должен освободиться");
}
@Test
void growsLimitWhenLatencyBelowTarget() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertEquals(2, limiter.limit(), "задержка ниже целевой — предел должен вырасти на единицу");
}
@Test
void shrinksLimitWhenLatencyAboveTarget() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 8, 10, window);
// Разгоняем предел до 4, чтобы было куда сжиматься.
for (int i = 0; i < 3; i++) {
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
}
assertEquals(4, limiter.limit());
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
assertEquals(2, limiter.limit(), "задержка выше целевой — предел должен сжаться вдвое");
}
@Test
void limitNeverDropsBelowMin() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(3, 10, 5, window);
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
assertEquals(3, limiter.limit(), "предел не должен опускаться ниже minLimit");
}
@Test
void doesNotAdjustBeforeWindowElapses() {
long window = TimeUnit.SECONDS.toNanos(10);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
limiter.tryAcquire();
limiter.release(1);
assertEquals(1, limiter.limit(), "окно ещё не прошло — предел не должен меняться");
}
}
@@ -0,0 +1,65 @@
package ru.pdguard.core;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.SecureRandom;
import org.junit.jupiter.api.Test;
class PayloadCipherTest {
private static String randomHexKey() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return java.util.HexFormat.of().formatHex(bytes);
}
@Test
void disabledCipherPassesTextThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
assertFalse(cipher.enabled());
assertEquals("Иванов Иван Иванович", cipher.encrypt("Иванов Иван Иванович"));
assertEquals("Иванов Иван Иванович", cipher.decrypt("Иванов Иван Иванович"));
}
@Test
void enabledCipherRoundTrips() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertTrue(cipher.enabled());
String encrypted = cipher.encrypt("Иванов Иван Иванович, паспорт 4509 123456");
assertNotEquals("Иванов Иван Иванович, паспорт 4509 123456", encrypted);
assertEquals("Иванов Иван Иванович, паспорт 4509 123456", cipher.decrypt(encrypted));
}
@Test
void ciphertextDiffersEachTimeDueToRandomIv() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
String first = cipher.encrypt("тот же текст");
String second = cipher.encrypt("тот же текст");
assertNotEquals(first, second, "разный IV должен давать разный шифротекст на одинаковом входе");
}
@Test
void decryptingGarbageThrows() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertThrows(IllegalStateException.class, () -> cipher.decrypt("не base64!!!"));
}
@Test
void decryptingWithDifferentKeyThrows() {
PayloadCipher first = new PayloadCipher(randomHexKey());
PayloadCipher second = new PayloadCipher(randomHexKey());
String encrypted = first.encrypt("секрет");
assertThrows(IllegalStateException.class, () -> second.decrypt(encrypted));
}
}
@@ -1,16 +1,15 @@
package ru.pdguard.detect;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
class NameDictionaryTest {
@@ -43,10 +42,9 @@ class NameDictionaryTest {
}
/**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
* правила нужны два слова, — но денилист должен покрывать и составные
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица
* Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен
* покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* «Буденный»).
*/
@Test
@@ -59,7 +57,8 @@ class NameDictionaryTest {
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// известная граница приёма, родительный падеж («Будённого») им не ловится.
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(
NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского"));
}
@@ -71,9 +70,11 @@ class NameDictionaryTest {
NameDictionary.useExternalFile(file);
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
assertTrue(
NameDictionary.isWellKnown("Интервью Кастомова"),
"дописанное сверху имя должно распознаваться наравне со встроенными");
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
assertTrue(
NameDictionary.isWellKnown("Стихи Пушкина"),
"встроенный список не должен теряться при дозагрузке");
}
@@ -1,10 +1,10 @@
package ru.pdguard.detect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
class ToponymDictionaryTest {
@@ -18,7 +18,8 @@ class ToponymDictionaryTest {
@Test
void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(
ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
}
@@ -41,11 +42,9 @@ class ToponymDictionaryTest {
}
/**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
* населённого пункта.
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы,
* аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры
* из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта.
*/
@Test
void recognisesSettlementsFromCensusNotJustOfficialCities() {
@@ -1,10 +1,10 @@
package ru.pdguard.detect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
class ValidatorsTest {
@@ -41,7 +41,8 @@ class ValidatorsTest {
@Test
void invalidOgrnipChecksumFails() {
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
assertFalse(
Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
}
@Test
+1 -3
View File
@@ -172,9 +172,7 @@ cardholder {{CARDHOLDER:ELENA KUZNETSOVA}} указан латиницей
Клиент {{FIO:Юдина Кристина}}, место рождения {{BIRTH_PLACE:город Тула}}
Клиент {{FIO:Литвинов А.С.}}, родился в {{BIRTH_PLACE:Владивостоке}}
# --- Гражданство, спутник ФИО (6) ---
Клиент {{FIO:Соколова Дарья}}, гражданство {{CITIZENSHIP:РФ}}
Клиент {{FIO:Петров Виктор}}, гражданин {{CITIZENSHIP:России}}
# --- Гражданство, спутник ФИО (4) ---
Клиент {{FIO:Алиев Тимур}}, гражданство {{CITIZENSHIP:Республики Казахстан}}
Клиент {{FIO:Ким Артур}}, гражданство {{CITIZENSHIP:Республики Узбекистан}}
Клиент {{FIO:Марченко Ольга}}, гражданство {{CITIZENSHIP:Украины}}
+168
View File
@@ -0,0 +1,168 @@
Pin 1029 записан на обороте карты.
Адрес фактического проживания совпадает с адресом регистрации.
Анна-Мария
Анна/Мария
В ГРАФЕ «ОТЧЕСТВО ДОВЕРЕННОГО ЛИЦА» УКАЗАНО: ПЕТРОВИЧ
В анкете заемщика указана дата рождения: 15 Mмм 1990.
В анкете заемщика указана дата рождения: 15-ЯНВ-1990.
В анкете клиента указано отчество: Иванович/Петрович.
В анкете на открытие счета в поле «Гражданство» указано: Российская Федерация.
В анкете на открытие счета в поле «Место рождения» указано: г. Москва.
В анкете поручителя указано место рождения: Новосибирск.
В анкете указана дата рождения (день и месяц): 15 03.
В анкете указана дата рождения (день и месяц): 15 ЯНВ.
В анкете указана дата рождения (день и месяц): 15 янв.
В анкете указана дата рождения (день и месяц): 15-ЯНВ.
В анкете указана дата рождения (день и месяц): 15-Янв.
В графе «Отчество поручителя» указано: Петрович.
В договоре вклада указана дата выдачи паспорта: 15-января-2010.
В досье клиента указана дата рождения: 15-янв-90.
В досье созаемщика указана дата выдачи паспорта: 15 Mмм 10.
В журнале транзакций по карте сохранен CVV код 654.
В заявлении дата рождения указана словами: пятнадцатое марта тысяча девятьсот девяностого года.
В заявлении клиента указана дата рождения: 15-января-90.
В заявлении на кредит указана дата выдачи паспорта: 15 янв 2010.
В заявлении на перевыпуск указан номер карты: 4276380012345678.
В заявлении указана дата рождения: 15 Mмм.
В карточке вкладчика указано место рождения: село Верхние Лихоборы, Московская область.
В карточке клиента указана дата рождения: 15 янв 90.
В карточке клиента указана дата рождения: 15-Янв-1990.
В кредитной заявке указано гражданство: российское.
В кредитной заявке указано место рождения: г. Сочи, Краснодарский край.
В личном кабинете пользователя указан телефон 79267778899.
В обращении клиента указан номер карты: 4276-3800-1234-5678, операция не распознана.
В обращении указано: «мой пин-код 1357, карта заблокирована».
В справке банка указана дата рождения (день и месяц): 15/03.
В справке банка указана дата рождения клиента: 15 января 90.
В справке указана дата рождения (день и месяц): 15-янв.
В тестовом контуре используется карта с сививи 777.
В форме интернет-эквайринга клиент ввел cvv 456 для оплаты.
В электронной анкете банка указана дата рождения: 15 03 1990.
ГРАЖДАНСТВО: РОССИЙСКАЯ ФЕДЕРАЦИЯ. ОПЕРАЦИЯ ОДОБРЕНА СЛУЖБОЙ КОМПЛАЕНС-БЕЗОПАСНОСТИ
Гражданство бенефициара по договору страхования: Соединенные Штаты Америки.
Гражданство поручителя по договору: Российская Федерация/Армения.
Дата выдачи паспорта бенефициара: 15 Января 10.
Дата выдачи паспорта бенефициара: 15-Янв-10.
Дата выдачи паспорта вкладчика: 10-03-15.
Дата выдачи паспорта вкладчика: 15 03 10.
Дата выдачи паспорта доверенного лица: 03-15-10.
Дата выдачи паспорта доверенного лица: 15-Января-10.
Дата выдачи паспорта заемщика: 03/15/10.
Дата выдачи паспорта заемщика: 15-ЯНВАРЯ-10.
Дата выдачи паспорта клиента: 03.15.10.
Дата выдачи паспорта клиента: 15-янв-2010.
Дата выдачи паспорта наследника по вкладу: 15 ЯНВАРЯ 10.
Дата выдачи паспорта поручителя: 15 ЯНВ 10.
Дата выдачи паспорта поручителя: 15-ЯНВ-10.
Дата выдачи паспорта представителя клиента: 10.03.15.
Дата рождения бенефициара по договору: 1990/03/15.
Дата рождения бенефициара по счету: 15-Января-1990.
Дата рождения держателя карты: 03.15.1990.
Дата рождения заемщика по кредитному договору: 15 Января 1990.
Дата рождения клиента (день и месяц): 15 ЯНВАРЯ.
Дата рождения клиента (день и месяц): 15 Января.
Дата рождения клиента (день и месяц): 15 января.
Дата рождения клиента (день и месяц): 15-ЯНВАРЯ.
Дата рождения клиента (день и месяц): 15-Января.
Дата рождения клиента (день и месяц): 15-января.
Дата рождения клиента в анкете: 15 ЯНВАРЯ 1990.
Дата рождения клиента в досье: 15 ЯНВ 1990.
Дата рождения клиента в кредитной заявке: 15-ЯНВАРЯ-1990.
Дата рождения клиента-нерезидента: 03-15-1990.
Дата рождения наследника по вкладу: 1990.03.15.
Дата рождения подписанта договора: 1990-03-15.
Держатель ввёл пин-код 7777 трижды неверно.
Для завершения платежа в мобильном приложении введите CVV код 321.
Для тестовой карты в анкете указан цвв 000.
ЗАКЛАДАТЕЛЬ СЕЙФОВОЙ ЯЧЕЙКИ: ИВАНОВ
ИВАН
ИВАН И.
ИВАНОВ
ИВАНОВ И.
ИВАНОВИВАНПЕТРОВИЧ
ИНН: 7712 3456 7859, указан в заявлении.
Иван
Иван И.
Иванов
Иванов И.
ИвановИванПетрович
Иванова/Петрова
Иванович-Петрович
Иванович/Петрович
Имя владельца счета указано как: Анна/Мария.
Имя держателя карты не указано в заявлении.
КЛИЕНТ ИВАН ЗАПРОСИЛ ПЕРЕВЫПУСК КАРТЫ
КЛИЕНТ ИВАН И. ОФОРМИЛ ДЕБЕТОВУЮ КАРТУ
Карта заблокирована по запросу через контактный центр.
Клиент передал письмо с указанием ПИН 9087 и реквизитов карты.
Клиент подтвердил онлайн-операцию, указав CVV 111.
Клиент сообщил ПИН-код карты 2468 в телефонном разговоре.
Клиент указал следующий орган выдачи: Паспортно-визовая служба УВД Московского района.
Комментарий оператора: клиент назвал пин 8642 для подтверждения операции.
МЕСТО РОЖДЕНИЯ: Г. ЕКАТЕРИНБУРГ. АНКЕТА ПРОВЕРЕНА СЛУЖБОЙ БЕЗОПАСНОСТИ БАНКА
Место рождения бенефициара по договору страхования: г. Алма-Ата, Республика Казахстан.
НОМЕР КАРТЫ: 5469400012345678. ОБРАЩЕНИЕ ЗАРЕГИСТРИРОВАНО В СИСТЕМЕ
Новый ПИН-код: 5555, установлен 12.05.2024.
Номер платежной карты клиента: 4276 3800 1234 5678, срок действия карты истек.
Операция отклонена: проверьте правильность кода на обороте карты 789.
Отправитель СБП: ИвановИванПетрович, сумма 5 000,00 руб.
Отчество доверенного лица: Иванович-Петрович.
Ошибка авторизации платежа: код 258 неверно введен CVV.
ПЕТРОВИЧ
ПЕТРОВИЧ И.
ПИН первой карты: 1234, ПИН второй карты: 5678.
ПИН-КОД: 7890
ПИН-код - 1234
ПИН-код «1234»
ПИН: 5678
ПЛАТЕЛЬЩИК: ИВАНОВ И. ОПЕРАЦИЯ ПРИНЯТА ОТДЕЛЕНИЕМ № 1
ПОЛУЧАТЕЛЬ ПЕРЕВОДА: ИВАНОВИВАНПЕТРОВИЧ
ПОРУЧИТЕЛЬ ПО ДОГОВОРУ: ПЕТРОВИЧ И.
Паспорт заёмщика 4509 123456, паспорт поручителя 7710 654321.
Паспортно-визовая служба УВД Московского района
Петрович
Петрович И.
Пин Код: 1234
Пин-код карты: 1234
Получатель платежа: Иванов.
Получатель платежа: Иванова/Петрова.
Поручитель по кредиту: Петрович И.
Последние 4 цифры номера карты: 1234.
При оформлении виртуальной карты в поле CVV указан код 123.
При оформлении ипотечного договора место рождения г. Казань подтверждено паспортом клиента.
Система зафиксировала три неудачные попытки ввода CVV кода 999.
Срок действия карты истекает в конце месяца.
УФМС России по Московской области выдало паспорт гражданину.
в графе «отчество созаемщика» указано: петрович
в досье клиента прописано гражданство: российская федерация, статус налогового резидента присвоен
в досье клиента прописано место рождения: г. самара, гражданство подтверждено документом
доверенное лицо по вкладу: иванов и.
доверенность оформлена на: иван петрович и., срок действия один год
заявитель обращения: и. иван петрович, запрос справки по счету
заявка на рефинансирование от: иван петрович
и. иван петрович
иван
иван и.
иван иванов
иван иванов петрович
иван петрович
иван петрович и.
иванов
иванов и.
иванов и. п.
иванов петрович
ивановиванпетрович
клиент иван и. запросил выписку по депозиту
отправитель перевода: иван иванов
отправитель перевода: ивановиванпетрович
петрович
петрович и.
пин 3456
пин-код(1234)
пин-код=1234
плательщик перевода указан: иванов
плательщик перевода: иван иванов петрович, счет 40817810000000000002
подателем заявки на кредит указано: иван
получатель наличных в кассе: иванов и. п.
поручитель по договору: иванов петрович
созаемщик по договору: петрович и.
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Конвертация LLAIMlegal/ru-legal-ner в ONNX для RuBertRecogniser.
Модель — BertForTokenClassification. Экспортируем в ONNX с динамической
длиной входа, чтобы RuBertRecogniser мог подавать куски разной длины.
"""
import torch
from transformers import AutoTokenizer, AutoModelForTokenClassification
from pathlib import Path
SRC = "LLAIMlegal/ru-legal-ner"
OUT = Path("models/ru-legal-ner")
tokenizer = AutoTokenizer.from_pretrained(SRC)
model = AutoModelForTokenClassification.from_pretrained(SRC)
model.eval()
# Динамическая длина: batch=1, seq=dynamic
dummy = torch.zeros(1, 8, dtype=torch.long)
torch.onnx.export(
model,
(dummy, dummy, dummy),
str(OUT / "model.onnx"),
input_names=["input_ids", "attention_mask", "token_type_ids"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch", 1: "seq"},
"attention_mask": {0: "batch", 1: "seq"},
"token_type_ids": {0: "batch", 1: "seq"},
"logits": {0: "batch", 1: "seq"},
},
opset_version=14,
)
print("ONNX exported to", OUT / "model.onnx")
+15
View File
@@ -40,6 +40,21 @@ fetch "https://huggingface.co/onnx-community/bert-base-NER-Russian-ONNX/resolve/
mv -f models/rubert-ner/onnx/model_int8.onnx models/rubert-ner/model.onnx 2>/dev/null || true
rmdir models/rubert-ner/onnx 2>/dev/null || true
# LLAIM Legal NER (LLAIMlegal/ru-legal-ner, MIT) — юридические реквизиты и
# документы: ИНН, ОГРН, СНИЛС, паспорт, телефон, email, банковский счёт, дата.
# Готового ONNX нет — скачиваем веса и конвертируем скриптом (нужны torch,
# transformers, optimum, onnx).
fetch "https://huggingface.co/LLAIMlegal/ru-legal-ner/resolve/main" \
models/ru-legal-ner model.safetensors \
config.json tokenizer.json tokenizer_config.json
if command -v python3 >/dev/null 2>&1; then
python3 tools/convert_ru_legal_ner.py || echo "Не удалось сконвертировать ru-legal-ner в ONNX"
else
echo "python3 не найден: ru-legal-ner не сконвертирован в ONNX"
fi
echo "Готово. Включить:"
echo " pdguard.ner.name-engine=wikineural, pdguard.ner.name-model=models/wikineural-ner"
echo " pdguard.ner.address-engine=rubert, pdguard.ner.address-model=models/rubert-ner"
echo " pdguard.ner.legal-engine=ru-legal-ner, pdguard.ner.legal-model=models/ru-legal-ner"