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` и корректным ключом.
---
+41 -90
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
ports:
- "8080:80"
prometheus:
image: prom/prometheus:v2.55.1
volumes:
- ./nginx/lb.conf:/etc/nginx/nginx.conf:ro
depends_on:
node-a:
condition: service_healthy
node-b:
condition: service_healthy
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
volumes:
prometheus-data:
grafana-data:
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:
- ./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,14 +6,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Точка входа Spring Boot приложения.
*
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
* текст на обратном шаге.
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит
* персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге.
*/
@SpringBootApplication
public class PdGuardApplication {
public static void main(String[] args) {
SpringApplication.run(PdGuardApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(PdGuardApplication.class, args);
}
}
+21 -22
View File
@@ -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,34 +9,31 @@ 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 {
private final SystemsConfig systems;
private final RuleRegistry registry;
private final SystemsConfig systems;
private final RuleRegistry registry;
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems;
this.registry = registry;
}
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems;
this.registry = registry;
}
@GetMapping("/admin/config")
public Map<String, SystemPolicy> config() {
return systems.current();
}
@GetMapping("/admin/config")
public Map<String, SystemPolicy> config() {
return systems.current();
}
@GetMapping("/admin/types")
public List<String> types() {
return registry.knownTypes();
}
@GetMapping("/admin/types")
public List<String> types() {
return registry.knownTypes();
}
@PostMapping("/admin/reload")
public Map<String, SystemPolicy> reload() {
systems.reload();
return systems.current();
}
@PostMapping("/admin/reload")
public Map<String, SystemPolicy> reload() {
systems.reload();
return systems.current();
}
}
@@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthResource {
@GetMapping("/health")
public String health() {
return "OK";
}
@GetMapping("/health")
public String health() {
return "OK";
}
}
@@ -18,116 +18,119 @@ 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 {
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
public static final String KEY_HEADER = "X-System-Key";
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
public static final String KEY_HEADER = "X-System-Key";
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
private static final String REASON_TAG = "reason";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом шаге иначе
* выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest(
@JsonProperty("payload") String payload, @JsonProperty("payload_id") String payloadId) {}
public record ProcessResponse(@JsonProperty("result") String result) {}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final AdaptiveConcurrencyLimiter limiter;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
private final Counter failed;
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.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");
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
}
@PostMapping("/process")
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()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
}
public record ProcessResponse(@JsonProperty("result") String result) {
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
forbidden.increment();
LOG.warn("Системе {} отказано: неверный ключ", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Неверный ключ системы"));
}
if (!policy.enabled()) {
forbidden.increment();
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final AdaptiveConcurrencyLimiter limiter;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
private final Counter failed;
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.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");
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
if (!limiter.tryAcquire()) {
rejected.increment();
return ResponseEntity.status(429).header("Retry-After", "1").build();
}
@PostMapping("/process")
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()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
forbidden.increment();
LOG.warn("Системе {} отказано: неверный ключ", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Неверный ключ системы"));
}
if (!policy.enabled()) {
forbidden.increment();
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
}
if (!limiter.tryAcquire()) {
rejected.increment();
return ResponseEntity.status(429).header("Retry-After", "1").build();
}
long started = System.nanoTime();
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return ResponseEntity.ok(new ProcessResponse(result));
} catch (RuntimeException e) {
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
}
long started = System.nanoTime();
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return ResponseEntity.ok(new ProcessResponse(result));
} catch (RuntimeException e) {
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error(
"payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
}
}
}
+78 -66
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,81 +22,94 @@ 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);
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,
@JsonProperty("llm_response_masked") String llmResponseMasked,
@JsonProperty("response") String response,
@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;
private final SystemsConfig systems;
private final LlmClient llm;
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
this.pipeline = pipeline;
this.systems = systems;
this.llm = llm;
}
@PostMapping("/proxy")
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()) {
return ResponseEntity.badRequest()
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
}
public record ProxyResponse(
@JsonProperty("prompt_masked") String promptMasked,
@JsonProperty("llm_response_masked") String llmResponseMasked,
@JsonProperty("response") String response,
@JsonProperty("replaced") Map<String, String> replaced,
@JsonProperty("llm") String llm,
@JsonProperty("error") String error) {
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
}
if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
new ProxyResponse(
null,
null,
null,
null,
null,
"Системе " + systemId + " обращение в модуль запрещено"));
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final LlmClient llm;
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();
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
this.pipeline = pipeline;
this.systems = systems;
this.llm = llm;
}
@PostMapping("/proxy")
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()) {
return ResponseEntity.badRequest()
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
}
if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.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();
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));
}
/** Возвращает исходные значения на место подстановок в ответе модели. */
private static String restore(String text, Map<String, String> restorations) {
String result = text;
for (Map.Entry<String, String> entry : restorations.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
return result;
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));
}
/** Возвращает исходные значения на место подстановок в ответе модели. */
private static String restore(String text, Map<String, String> restorations) {
String result = text;
for (Map.Entry<String, String> entry : restorations.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
return result;
}
}
@@ -8,18 +8,17 @@ 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 {
@Bean
public NameDictionary nameDictionary(
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
NameDictionary.configure(wellKnownFile);
return NameDictionary.create();
}
@Bean
public NameDictionary nameDictionary(
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
NameDictionary.configure(wellKnownFile);
return NameDictionary.create();
}
}
@@ -1,81 +1,96 @@
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 enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
* совпасть, иначе имя системы можно было бы просто назвать.
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
* и кириллица в ключе до сервиса доедет искажённой
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
* пин-код сам по себе безвреден, пин-код рядом с номером
* карты — уже нет; то же для даты без якорного слова, места
* рождения («Нижний Новгород» в рассказе о городе — не адрес
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
* сами по себе они опознают организацию или счёт, а не человека,
* и в перечне типов из задания их нет. Рядом с именем клиента
* они становятся его данными и маскируются.
* Сюда же доход и биометрия. Сумма заработка без человека —
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
* персональные данные. Биометрия же в тексте не встречается
* вовсе: это шаблон в базе, и правило маскирует лишь само
* упоминание, то есть слово, а не данные. Чувствителен здесь
* факт, что биометрию сдал названный человек, — а он и
* существует только при имени рядом
* @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 руб»), а не персональные данные. Биометрия же в тексте не встречается вовсе: это шаблон в
* базе, и правило маскирует лишь само упоминание, то есть слово, а не данные. Чувствителен
* здесь факт, что биометрию сдал названный человек, — а он и существует только при имени рядом
*/
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 = "*";
public static final String ALL = "*";
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
public static final String DEFAULT_NAME = "default";
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
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);
requireCompanion = Set.copyOf(requireCompanion);
public SystemPolicy {
types = Set.copyOf(types);
requireCompanion = Set.copyOf(requireCompanion);
}
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion(), null);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
public boolean accepts(String presentedKey) {
if (key == null || key.isBlank()) {
return true;
}
return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(types), DEFAULT.requireCompanion(), null);
}
public boolean allows(String type) {
return types.contains(ALL) || types.contains(type);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
public boolean accepts(String presentedKey) {
if (key == null || key.isBlank()) {
return true;
}
return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public boolean allows(String type) {
return types.contains(ALL) || types.contains(type);
}
public boolean needsCompanion(String type) {
return requireCompanion.contains(type);
}
public boolean needsCompanion(String type) {
return requireCompanion.contains(type);
}
}
+128 -106
View File
@@ -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,123 +8,151 @@ 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);
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
/** Имя политики, которая применяется к запросам без заголовка системы. */
public static final String DEFAULT_SYSTEM = "default";
/** Имя политики, которая применяется к запросам без заголовка системы. */
public static final String DEFAULT_SYSTEM = "default";
private static final long RECHECK_MILLIS = 1000;
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;
private final ObjectMapper mapper;
private final Path file;
private final ObjectMapper mapper;
private final AtomicReference<Map<String, SystemPolicy>> policies =
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
private volatile long fileTimestamp;
private volatile long lastCheck;
private final AtomicReference<Map<String, SystemPolicy>> policies =
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
private volatile long fileTimestamp;
private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
public SystemsConfig(
@Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
reload();
}
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
public SystemPolicy policyFor(String systemId) {
refreshIfChanged();
Map<String, SystemPolicy> current = policies.get();
SystemPolicy policy = systemId == null ? null : current.get(systemId);
if (policy != null) {
return policy;
}
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
}
/** Известна ли система по имени. */
public boolean isKnown(String systemId) {
refreshIfChanged();
return systemId != null && policies.get().containsKey(systemId);
}
/** Текущие настройки — для отдачи в административном интерфейсе. */
public Map<String, SystemPolicy> current() {
refreshIfChanged();
return new TreeMap<>(policies.get());
}
/** Перечитать файл настроек немедленно. */
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
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, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
policies.set(Map.copyOf(parsed));
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error(
"Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
}
}
private void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(file)) {
return;
}
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
reload();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", file, e);
}
}
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
public SystemPolicy policyFor(String systemId) {
refreshIfChanged();
Map<String, SystemPolicy> current = policies.get();
SystemPolicy policy = systemId == null ? null : current.get(systemId);
if (policy != null) {
return policy;
}
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
}
/** Известна ли система по имени. */
public boolean isKnown(String systemId) {
refreshIfChanged();
return systemId != null && policies.get().containsKey(systemId);
}
/** Текущие настройки — для отдачи в административном интерфейсе. */
public Map<String, SystemPolicy> current() {
refreshIfChanged();
return new TreeMap<>(policies.get());
}
/** Перечитать файл настроек немедленно. */
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
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, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
policies.set(Map.copyOf(parsed));
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
}
}
private void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(file)) {
return;
}
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
reload();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", file, e);
}
}
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,
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions, entry.key());
}
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,
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode,
types,
companions,
entry.key());
}
}
@@ -1,129 +1,121 @@
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);
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicLong windowSumNanos = new AtomicLong();
private final AtomicInteger windowSamples = new AtomicInteger();
private final AtomicLong lastAdjustNanos;
private final int minLimit;
private final int maxLimit;
private final long targetLatencyNanos;
private final long adjustWindowNanos;
private final AtomicInteger limit;
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicLong windowSumNanos = new AtomicLong();
private final AtomicInteger windowSamples = new AtomicInteger();
private final AtomicLong lastAdjustNanos;
private final int minLimit;
private final int maxLimit;
private final long targetLatencyNanos;
private final long adjustWindowNanos;
private final AtomicInteger limit;
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
}
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(
int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException(
"Некорректные границы предела: " + minLimit + ".." + maxLimit);
}
this.minLimit = minLimit;
this.maxLimit = maxLimit;
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
this.adjustWindowNanos = adjustWindowNanos;
this.limit = new AtomicInteger(minLimit);
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
}
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
}
this.minLimit = minLimit;
this.maxLimit = maxLimit;
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
this.adjustWindowNanos = adjustWindowNanos;
this.limit = new AtomicInteger(minLimit);
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
public boolean tryAcquire() {
if (inFlight.incrementAndGet() > limit.get()) {
inFlight.decrementAndGet();
return false;
}
return true;
}
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
public boolean tryAcquire() {
if (inFlight.incrementAndGet() > limit.get()) {
inFlight.decrementAndGet();
return false;
}
return true;
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
public void release(long elapsedNanos) {
inFlight.decrementAndGet();
windowSumNanos.addAndGet(elapsedNanos);
windowSamples.incrementAndGet();
long now = System.nanoTime();
long last = lastAdjustNanos.get();
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
adjust();
}
}
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
public void release(long elapsedNanos) {
inFlight.decrementAndGet();
windowSumNanos.addAndGet(elapsedNanos);
windowSamples.incrementAndGet();
long now = System.nanoTime();
long last = lastAdjustNanos.get();
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
adjust();
}
private void adjust() {
int samples = windowSamples.getAndSet(0);
long sum = windowSumNanos.getAndSet(0);
if (samples == 0) {
return;
}
long avg = sum / samples;
private void adjust() {
int samples = windowSamples.getAndSet(0);
long sum = windowSumNanos.getAndSet(0);
if (samples == 0) {
return;
}
long avg = sum / samples;
if (avg < targetLatencyNanos) {
limit.set(Math.min(maxLimit, limit.get() + 1));
} else {
limit.set(Math.max(minLimit, limit.get() / 2));
}
if (avg < targetLatencyNanos) {
limit.set(Math.min(maxLimit, limit.get() + 1));
} else {
limit.set(Math.max(minLimit, limit.get() / 2));
}
}
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
public int inFlight() {
return inFlight.get();
}
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
public int inFlight() {
return inFlight.get();
}
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
public int limit() {
return limit.get();
}
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
public int limit() {
return limit.get();
}
}
+79 -75
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,93 +10,102 @@ 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 {
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
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;
private final String model;
private final Duration timeout;
private final HttpClient http;
private final ObjectMapper mapper = new ObjectMapper();
public LlmClient(
@Value("${pdguard.llm.url:}") String url,
@Value("${pdguard.llm.api-key:}") String apiKey,
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
this.model = model;
this.timeout = Duration.ofSeconds(timeoutSeconds);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
if (this.url.isEmpty()) {
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
}
}
private final Optional<String> url;
private final Optional<String> apiKey;
private final String model;
private final Duration timeout;
private final HttpClient http;
private final ObjectMapper mapper = new ObjectMapper();
public LlmClient(
@Value("${pdguard.llm.url:}") String url,
@Value("${pdguard.llm.api-key:}") String apiKey,
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
this.model = model;
this.timeout = Duration.ofSeconds(timeoutSeconds);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
if (this.url.isEmpty()) {
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
}
public Answer ask(String maskedPrompt) {
if (url.isEmpty()) {
return new Answer(stub(maskedPrompt), "заглушка");
}
public Answer ask(String maskedPrompt) {
if (url.isEmpty()) {
return new Answer(stub(maskedPrompt), "заглушка");
}
try {
return new Answer(call(maskedPrompt), model);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e);
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
} catch (IOException e) {
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
}
try {
return new Answer(call(maskedPrompt), model);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e);
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
} catch (IOException e) {
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
}
}
/**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
* подстановки вернулись на свои места при обратном преобразовании.
*/
private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt;
}
/**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что подстановки вернулись
* на свои места при обратном преобразовании.
*/
private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt;
}
private String call(String maskedPrompt) throws IOException, InterruptedException {
ObjectNode body = mapper.createObjectNode();
body.put("model", model);
ObjectNode message = body.putArray("messages").addObject();
message.put("role", "user");
message.put("content", maskedPrompt);
private String call(String maskedPrompt) throws IOException, InterruptedException {
ObjectNode body = mapper.createObjectNode();
body.put("model", model);
ObjectNode message = body.putArray("messages").addObject();
message.put("role", "user");
message.put("content", maskedPrompt);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
HttpRequest.Builder request =
HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout)
.header("Content-Type", "application/json")
.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));
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("модель ответила " + response.statusCode());
}
return mapper.readTree(response.body())
.path("choices").path(0).path("message").path("content").asText();
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();
}
}
@@ -3,63 +3,62 @@ 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 {
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
private static final Duration SLA_TARGET = Duration.ofMillis(500);
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
private static final Duration SLA_TARGET = Duration.ofMillis(500);
private static final Duration[] BOUNDARIES = {
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
Duration.ofSeconds(10)
private static final Duration[] BOUNDARIES = {
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
Duration.ofSeconds(10)
};
@Bean
public MeterFilter histogramsForTimers() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(
Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) {
return config;
}
double[] boundaries = new double[BOUNDARIES.length];
for (int i = 0; i < BOUNDARIES.length; i++) {
boundaries[i] = BOUNDARIES[i].toNanos();
}
return DistributionStatisticConfig.builder()
.percentilesHistogram(false)
.serviceLevelObjectives(boundaries)
.build()
.merge(config);
}
};
}
@Bean
public MeterFilter histogramsForTimers() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) {
return config;
}
double[] boundaries = new double[BOUNDARIES.length];
for (int i = 0; i < BOUNDARIES.length; i++) {
boundaries[i] = BOUNDARIES[i].toNanos();
}
return DistributionStatisticConfig.builder()
.percentilesHistogram(false)
.serviceLevelObjectives(boundaries)
.build()
.merge(config);
}
};
}
private static boolean needsHistogram(String name) {
return "pdguard.process".equals(name)
|| "pdguard.ner.duration".equals(name)
|| "http.server.requests".equals(name);
}
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,87 +1,89 @@
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 {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12;
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
private final SecretKeySpec key;
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);
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
this.key =
hexKey == null || hexKey.isBlank()
? null
: new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
}
/** Выключенное шифрование — для тестов и сборки без ключа. */
public static PayloadCipher disabled() {
return new PayloadCipher("");
}
public boolean enabled() {
return key != null;
}
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */
public String encrypt(String plaintext) {
if (key == null) {
return plaintext;
}
/** Выключенное шифрование — для тестов и сборки без ключа. */
public static PayloadCipher disabled() {
return new PayloadCipher("");
try {
byte[] iv = new byte[IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
}
}
public boolean enabled() {
return key != null;
/** Дешифрует текст; при выключенном шифровании возвращает исходный текст. */
public String decrypt(String ciphertext) {
if (key == null) {
return ciphertext;
}
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */
public String encrypt(String plaintext) {
if (key == null) {
return plaintext;
}
try {
byte[] iv = new byte[IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + encrypted.length];
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) {
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
}
}
/** Дешифрует текст; при выключенном шифровании возвращает исходный текст. */
public String decrypt(String ciphertext) {
if (key == null) {
return ciphertext;
}
try {
byte[] combined = Base64.getDecoder().decode(ciphertext);
byte[] iv = new byte[IV_BYTES];
System.arraycopy(combined, 0, iv, 0, iv.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
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) {
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
}
try {
byte[] combined = Base64.getDecoder().decode(ciphertext);
byte[] iv = new byte[IV_BYTES];
System.arraycopy(combined, 0, iv, 0, iv.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
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 (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
}
}
}
+139 -161
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,191 +8,173 @@ 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 {
/** Сколько протухших записей просматривается за одну операцию записи. */
private static final int SWEEP_PER_PUT = 4;
/** Сколько протухших записей просматривается за одну операцию записи. */
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;
}
int weight() {
return original.length() + masked.length();
}
boolean alive(long now) {
return now < expiresAt;
}
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
private final AtomicLong charsHeld = new AtomicLong();
private final long maxChars;
private final long ttlMillis;
private final SharedIndex shared;
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;
int weight() {
return original.length() + masked.length();
}
}
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
public PayloadStore(long maxChars, int ttlMinutes) {
this(maxChars, ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
private final AtomicLong charsHeld = new AtomicLong();
private final long ttlMillis;
private final SharedIndex shared;
private final PayloadCipher cipher;
@Autowired
public PayloadStore(
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
SharedIndex shared,
PayloadCipher cipher) {
this.ttlMillis = ttlMinutes * 60_000L;
this.shared = shared;
this.cipher = cipher;
}
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */
public PayloadStore(int ttlMinutes) {
this(ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled());
}
public void put(String system, String payloadId, String original, String masked) {
long now = System.currentTimeMillis();
String encrypted = cipher.encrypt(original);
Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis);
String idKey = ScopedKey.of(system, payloadId);
Entry replaced = byId.put(idKey, entry);
byMaskFingerprint.put(ScopedKey.of(system, entry.fingerprint()), entry);
insertionOrder.add(idKey);
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
}
public Entry byId(String system, String payloadId) {
String idKey = ScopedKey.of(system, payloadId);
Entry entry = byId.get(idKey);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return decrypt(entry);
}
public void put(String system, String payloadId, String original, String masked) {
long now = System.currentTimeMillis();
String encrypted = cipher.encrypt(original);
Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis);
String idKey = ScopedKey.of(system, payloadId);
Entry replaced = byId.put(idKey, entry);
byMaskFingerprint.put(ScopedKey.of(system, entry.fingerprint()), entry);
insertionOrder.add(idKey);
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
evictWhileOverLimit();
shared.put(system, payloadId, encrypted, masked, entry.fingerprint());
if (entry != null) {
forget(idKey, entry);
}
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(system, payloadId, fromShared.original(), fromShared.masked());
return decrypt(byId.get(idKey));
}
public Entry byId(String system, String payloadId) {
String idKey = ScopedKey.of(system, payloadId);
Entry entry = byId.get(idKey);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return decrypt(entry);
}
if (entry != null) {
forget(idKey, entry);
}
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(system, payloadId, fromShared.original(), fromShared.masked());
return decrypt(byId.get(idKey));
/**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в
* пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя.
*/
public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(ScopedKey.of(system, fingerprint));
if (entry != null && entry.alive(System.currentTimeMillis())) {
return cipher.decrypt(entry.original());
}
return shared.originalForFingerprint(system, fingerprint);
}
/**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
* данные нельзя.
*/
public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(ScopedKey.of(system, fingerprint));
if (entry != null && entry.alive(System.currentTimeMillis())) {
return cipher.decrypt(entry.original());
}
return shared.originalForFingerprint(system, fingerprint);
}
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
public long charsHeld() {
return charsHeld.get();
}
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
public long charsHeld() {
return charsHeld.get();
}
/**
* Убирает протухшие записи с головы очереди, не более нескольких за раз.
*/
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry == null) {
insertionOrder.poll();
continue;
}
if (entry.alive(now)) {
return;
}
insertionOrder.poll();
forget(oldest, entry);
}
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry == null) {
insertionOrder.poll();
continue;
}
if (entry.alive(now)) {
return;
}
insertionOrder.poll();
forget(oldest, entry);
}
}
private void evictWhileOverLimit() {
while (charsHeld.get() > maxChars) {
String oldest = insertionOrder.poll();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry != null) {
// ponytail: если тот же payload_id записали повторно, в очереди остался
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
forget(oldest, entry);
}
}
private void forget(String idKey, Entry entry) {
if (byId.remove(idKey, entry)) {
byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
charsHeld.addAndGet(-entry.weight());
}
}
private void forget(String idKey, Entry entry) {
if (byId.remove(idKey, entry)) {
byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
charsHeld.addAndGet(-entry.weight());
}
/** Возвращает запись с расшифрованным исходным текстом. */
private Entry decrypt(Entry entry) {
if (entry == null) {
return null;
}
return new Entry(
entry.system(),
cipher.decrypt(entry.original()),
entry.masked(),
entry.fingerprint(),
entry.expiresAt());
}
/** Возвращает запись с расшифрованным исходным текстом. */
private Entry decrypt(Entry entry) {
if (entry == null) {
return null;
}
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(),
entry.fingerprint(), entry.expiresAt());
}
private static String fingerprint(String value) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
}
private static String fingerprint(String value) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
}
}
}
+294 -258
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,297 +24,325 @@ 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 {
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
private static final int CHARS_PER_TOKEN = 4;
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
private static final int CHARS_PER_TOKEN = 4;
private final RuleRegistry registry;
private final Masker masker;
private final PayloadStore store;
private final MeterRegistry meters;
private final NameCascade cascade;
private final Counter tokensProcessed;
private final Counter unresolvedDemask;
private final RuleRegistry registry;
private final Masker masker;
private final PayloadStore store;
private final MeterRegistry meters;
private final NameCascade cascade;
private final Counter tokensProcessed;
private final Counter unresolvedDemask;
@Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
this.store = store;
this.meters = meters;
this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved")
.description("Запрос на демаскирование, для которого соответствие не нашлось ни по "
+ "id, ни по отпечатку маски — обработан как новое маскирование")
.register(meters);
}
@Autowired
public Pipeline(
RuleRegistry registry,
Masker masker,
PayloadStore store,
MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
this.store = store;
this.meters = meters;
this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed =
Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
this.unresolvedDemask =
Counter.builder("pdguard.demask.unresolved")
.description(
"Запрос на демаскирование, для которого соответствие не нашлось ни по "
+ "id, ни по отпечатку маски — обработан как новое маскирование")
.register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
}
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
}
/** Конструктор для тестов второй ступени. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
}
/** Конструктор для тестов второй ступени. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
}
public String process(String payload, String payloadId, SystemPolicy policy) {
long started = System.nanoTime();
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
if (known != null) {
if (policy.demask() && payload.equals(known.masked())) {
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
recordLatency("unmask", policy.name(), started);
return known.original();
}
if (payload.equals(known.original())) {
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
recordLatency("mask", policy.name(), started);
return known.masked();
}
}
if (policy.demask()) {
String original = store.originalForMask(policy.name(), payload);
if (original != null) {
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
recordLatency("unmask", policy.name(), started);
return original;
}
// Соответствие не нашлось нигде — не отличить достоверно новый payload от
// демаскирования с утраченным состоянием (например, узел, где маскировали,
// не успел записать в общий слой). Ниже это обработается как маскирование
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
// и логируем каждый такой случай явно, чтобы не потерять его молча.
unresolvedDemask.increment();
LOG.warn("payload_id={} демаскирование не нашло соответствие ни по id, ни по "
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
payloadId);
}
return mask(payload, payloadId, policy, started);
}
/**
* Длительность обработки с разрезом по направлению и системе-потребителю.
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
*/
private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", direction)
.tag("system", system)
.register(meters)
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (policy.allows(PdTypes.FIO)) {
// Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
}
spans = dropOrganisationNames(text, spans);
spans = dropWellKnownNames(text, spans);
return dropLonelyCompanions(spans, policy);
}
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
List<Span> spans = findPersonalData(payload, policy);
String masked = apply(payload, spans, policy);
store.put(policy.name(), payloadId, payload, masked);
public String process(String payload, String payloadId, SystemPolicy policy) {
long started = System.nanoTime();
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
if (known != null) {
if (policy.demask() && payload.equals(known.masked())) {
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
recordLatency("unmask", policy.name(), started);
return known.original();
}
if (payload.equals(known.original())) {
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
recordLatency("mask", policy.name(), started);
logFindings(policy.name(), payloadId, payload.length(), spans);
// Только для отладки формата входных данных на DEBUG — на INFO не активен.
// На боевом прогоне (нагрузочное тестирование) уровень DEBUG должен быть выключен:
// сюда попадают сырые ПД, что напрямую нарушает требование ТЗ не логировать значения.
LOG.debug("payload_id={} raw={}", payloadId, payload);
return masked;
return known.masked();
}
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
* приоритетный, при равном приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
// фрагментов набираются тысячи.
NavigableMap<Integer, Span> accepted = new TreeMap<>();
for (Span candidate : candidates) {
if (overlapsAccepted(accepted, candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
if (policy.demask()) {
String original = store.originalForMask(policy.name(), payload);
if (original != null) {
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
recordLatency("unmask", policy.name(), started);
return original;
}
// Соответствие не нашлось нигде — не отличить достоверно новый payload от
// демаскирования с утраченным состоянием (например, узел, где маскировали,
// не успел записать в общий слой). Ниже это обработается как маскирование
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
// и логируем каждый такой случай явно, чтобы не потерять его молча.
unresolvedDemask.increment();
LOG.warn(
"payload_id={} демаскирование не нашло соответствие ни по id, ни по "
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
payloadId);
}
return mask(payload, payloadId, policy, started);
}
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
if (before != null && before.getValue().overlaps(candidate)) {
return true;
}
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
return after != null && after.getValue().overlaps(candidate);
}
/**
* Длительность обработки с разрезом по направлению и системе-потребителю. Метрики берутся из
* реестра по тегам: систем немного и они заданы настройками, поэтому разрастания рядов не будет,
* а разрез по потребителям виден сразу.
*/
private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", direction)
.tag("system", system)
.register(meters)
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
}
/**
* Убирает имена, стоящие в названиях организаций и объектов на карте:
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
* само по себе.
*/
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type())
|| !OrganisationDetector.precededByOrganisation(text, span.start()))
.toList();
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все
* отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая
* замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (cascade.coversAny(policy)) {
// Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
}
spans = dropOrganisationNames(text, spans);
spans = dropWellKnownNames(text, spans);
return dropLonelyCompanions(spans, policy);
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
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())))
.toList();
}
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
List<Span> spans = findPersonalData(payload, policy);
String masked = apply(payload, spans, policy);
store.put(policy.name(), payloadId, payload, masked);
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
*
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
* сравнивалось число различных типов, и два спутника заверяли друг друга:
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
* Сочетание двух несамостоятельных типов самостоятельным не становится.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) {
if (!policy.needsCompanion(span.type())) {
return spans;
}
}
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
return List.of();
}
recordLatency("mask", policy.name(), started);
logFindings(policy.name(), payloadId, payload.length(), spans);
return masked;
}
/** Замаскированный текст вместе с таблицей обратной замены. */
public record Masked(String text, java.util.Map<String, String> restorations) {
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более приоритетный, при равном
* приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(
Comparator.comparingInt(Span::priority)
.reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
/**
* Маскирует текст и отдаёт таблицу обратной замены.
*
* <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());
List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) {
return new Masked(text, java.util.Map.of());
}
MaskContext context = new MaskContext();
String masked = apply(text, spans, reversible, context);
logFindings(policy.name(), "proxy", text.length(), spans);
return new Masked(masked, context.restorations());
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
// фрагментов набираются тысячи.
NavigableMap<Integer, Span> accepted = new TreeMap<>();
for (Span candidate : candidates) {
if (overlapsAccepted(accepted, candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
}
private String apply(String text, List<Span> spans, SystemPolicy policy) {
return apply(text, spans, policy, new MaskContext());
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
if (before != null && before.getValue().overlaps(candidate)) {
return true;
}
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
return after != null && after.getValue().overlaps(candidate);
}
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
if (spans.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
int cursor = 0;
for (Span span : spans) {
sb.append(text, cursor, span.start());
String value = text.substring(span.start(), span.end());
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
cursor = span.end();
}
sb.append(text, cursor, text.length());
return sb.toString();
}
/**
* Убирает имена, стоящие в названиях организаций и объектов на карте: «Институт Склифосовского»,
* «Музей Тропинина», «улица Королёва». Проверка не зависит от того, есть ли в тексте другие ПД:
* слово перед именем решает само по себе.
*/
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream()
.filter(
span ->
!PdTypes.FIO.equals(span.type())
|| !OrganisationDetector.precededByOrganisation(text, span.start()))
.toList();
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
* На 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) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными данными не являются.
* Если же в тексте есть ПД другого типа, речь идёт о конкретном человеке, и имя остаётся
* замаскированным — однофамилец исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
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()) || !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»
* маскировалась целиком, хотя человека в тексте нет, а дата и ОГРН по отдельности персональными
* данными не являются. Сочетание двух несамостоятельных типов самостоятельным не становится.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) {
if (!policy.needsCompanion(span.type())) {
return spans;
}
}
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
return List.of();
}
/** Замаскированный текст вместе с таблицей обратной замены. */
public record Masked(String text, Map<String, String> restorations) {
public Masked {
restorations = Map.copyOf(restorations);
}
}
/**
* Маскирует текст и отдаёт таблицу обратной замены.
*
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить его целиком
* по идентификатору нельзя, замену приходится делать пофрагментно. Звёздочки для этого не годятся
* — одна и та же маска может отвечать разным значениям, — поэтому режим замены здесь всегда
* обратимый.
*/
public Masked maskWithRestorations(String text, SystemPolicy policy) {
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, Map.of());
}
MaskContext context = new MaskContext();
String masked = apply(text, spans, reversible, context);
logFindings(policy.name(), "proxy", text.length(), spans);
return new Masked(masked, context.restorations());
}
private String apply(String text, List<Span> spans, SystemPolicy policy) {
return apply(text, spans, policy, new MaskContext());
}
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
if (spans.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
int cursor = 0;
for (Span span : spans) {
sb.append(text, cursor, span.start());
String value = text.substring(span.start(), span.end());
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
cursor = span.end();
}
sb.append(text, cursor, text.length());
return sb.toString();
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество. На 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) ->
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,69 +12,76 @@ 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 {
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
private static final String[] SAMPLES = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
private static final String[] SAMPLES = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
private final RuleRegistry registry;
private final Masker masker;
private final int iterations;
private final RuleRegistry registry;
private final Masker masker;
private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry;
this.masker = masker;
this.iterations = iterations;
public PipelineWarmup(
RuleRegistry registry,
Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry;
this.masker = masker;
this.iterations = iterations;
}
@PostConstruct
void warmup() {
if (iterations <= 0) {
LOG.info("Прогрев обработки отключён");
return;
}
long started = System.nanoTime();
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);
@PostConstruct
void warmup() {
if (iterations <= 0) {
LOG.info("Прогрев обработки отключён");
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);
for (int i = 0; i < iterations; i++) {
String text = SAMPLES[i % SAMPLES.length];
String id = "warmup-" + i;
String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy);
}
LOG.info("Прогрев обработки: {} прогонов за {} мс",
iterations, (System.nanoTime() - started) / 1_000_000);
for (int i = 0; i < iterations; i++) {
String text = SAMPLES[i % SAMPLES.length];
String id = "warmup-" + i;
String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy);
}
LOG.info(
"Прогрев обработки: {} прогонов за {} мс",
iterations,
(System.nanoTime() - started) / 1_000_000);
}
}
+8 -10
View File
@@ -3,18 +3,16 @@ 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;
return owner.length() + ":" + owner + ":" + key;
}
static String of(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
}
+125 -126
View File
@@ -2,165 +2,164 @@ 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 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;
/** Сколько подряд неудач размыкает предохранитель. */
private static final int FAILURES_TO_OPEN = 3;
/** На сколько общий слой перестают опрашивать после размыкания. */
private static final long OPEN_MILLIS = 5_000;
/** На сколько общий слой перестают опрашивать после размыкания. */
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;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final PayloadCipher cipher;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(
StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper,
PayloadCipher cipher) {
this.redis = redis;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
this.mapper = mapper;
this.cipher = cipher;
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled());
}
public boolean enabled() {
return enabled;
}
public void put(
String system, String payloadId, String original, String masked, String maskFingerprint) {
if (unavailable()) {
return;
}
private final boolean enabled;
private final Duration ttl;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final PayloadCipher cipher;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper,
PayloadCipher cipher) {
this.redis = redis;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
this.mapper = mapper;
this.cipher = cipher;
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, maskFingerprint), encrypted, ttl);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
}
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled());
public SharedEntry byId(String system, String payloadId) {
if (unavailable()) {
return null;
}
try {
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());
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
public boolean enabled() {
return enabled;
public String originalForFingerprint(String system, String maskFingerprint) {
if (unavailable()) {
return null;
}
try {
String encrypted = redis.opsForValue().get(ScopedKey.of(system, maskFingerprint));
noteSuccess();
return encrypted == null ? null : cipher.decrypt(encrypted);
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
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, maskFingerprint), encrypted, ttl);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
}
private String toJson(SharedEntry entry) {
try {
return mapper.writeValueAsString(entry);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
}
}
public SharedEntry byId(String system, String payloadId) {
if (unavailable()) {
return null;
}
try {
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());
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
private SharedEntry fromJson(String json) {
try {
return mapper.readValue(json, SharedEntry.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
}
}
public String originalForFingerprint(String system, String maskFingerprint) {
if (unavailable()) {
return null;
}
try {
String encrypted = redis.opsForValue().get(ScopedKey.of(system, maskFingerprint));
noteSuccess();
return encrypted == null ? null : cipher.decrypt(encrypted);
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
/** Общий слой выключен или предохранитель разомкнут. */
private boolean unavailable() {
return !enabled || System.currentTimeMillis() < silentUntil;
}
private String toJson(SharedEntry entry) {
try {
return mapper.writeValueAsString(entry);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
}
private void noteSuccess() {
if (consecutiveFailures.getAndSet(0) != 0) {
reported = false;
LOG.info("Общий слой снова доступен");
}
}
private SharedEntry fromJson(String json) {
try {
return mapper.readValue(json, SharedEntry.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе
* каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ
* дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
}
/** Общий слой выключен или предохранитель разомкнут. */
private boolean unavailable() {
return !enabled || System.currentTimeMillis() < silentUntil;
}
private void noteSuccess() {
if (consecutiveFailures.getAndSet(0) != 0) {
reported = false;
LOG.info("Общий слой снова доступен");
}
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
* проверяющая система считает ответ дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
}
if (!reported) {
reported = true;
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
}
if (!reported) {
reported = true;
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));
}
+34 -38
View File
@@ -3,51 +3,47 @@ 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 static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
};
private Declension() {
}
private Declension() {}
/**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
* Слова короче четырёх букв не трогает — короткая основа и так шире
* большинства падежных форм.
*/
static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT);
for (String ending : ADJECTIVE_ENDINGS) {
if (lower.length() > ending.length() && lower.endsWith(ending)) {
return lower.substring(0, lower.length() - ending.length() + 2);
}
}
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
return lower.substring(0, lower.length() - 1);
}
return lower;
/**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком
* «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая
* основа и так шире большинства падежных форм.
*/
static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT);
for (String ending : ADJECTIVE_ENDINGS) {
if (lower.length() > ending.length() && lower.endsWith(ending)) {
return lower.substring(0, lower.length() - ending.length() + 2);
}
}
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
return lower.substring(0, lower.length() - 1);
}
return lower;
}
}
@@ -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));
}
+457 -236
View File
@@ -5,284 +5,505 @@ 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 {
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> NAME_TYPES = Map.of(
"PER", PdTypes.FIO);
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Map<String, String> ADDRESS_TYPES = Map.of(
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
"CITY", PdTypes.ADDRESS_CITY,
"STREET", PdTypes.ADDRESS_STREET,
"HOUSE", PdTypes.ADDRESS_HOUSE);
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> NAME_TYPES = Map.of("PER", PdTypes.FIO);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
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);
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Map<String, String> ADDRESS_TYPES =
Map.of(
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
"CITY", PdTypes.ADDRESS_CITY,
"STREET", PdTypes.ADDRESS_STREET,
"HOUSE", PdTypes.ADDRESS_HOUSE);
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
private static final int PRIORITY = 73;
/**
* Метки 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 int CONTEXT_CHARS = 60;
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE =
Pattern.compile(
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
private final RuBertRecogniser nameRecogniser;
private final RuBertRecogniser addressRecogniser;
private final Semaphore concurrent;
private final int maxCandidates;
private volatile boolean broken;
/**
* Кандидат для 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);
/**
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
* посильна ли тяжёлая модель на боевом трафике.
*/
private final Counter engaged;
private final Counter withoutCandidates;
private final Counter busy;
private final Counter candidates;
private final Timer duration;
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
private static final int PRIORITY = 73;
@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.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.concurrent = new Semaphore(Math.max(1, poolSize));
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.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)
.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);
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
private static final int CONTEXT_CHARS = 60;
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} ко
* всем обращениям и есть та доля, от которой зависит, посильна ли тяжёлая модель на боевом
* трафике.
*/
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)
.description(NER_REQUESTS_DESCRIPTION)
.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)
.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);
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;
}
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
this(engine, modelPath.orElse(""), "off", "", maxCandidates, poolSize, new SimpleMeterRegistry());
/** Таймеры времени работы каждой модели: тег {@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;
}
/** Конструктор для тестов двух моделей: метрики никуда не отдаются. */
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());
/** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */
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(
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(
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(
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");
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
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() {
return new NameCascade();
}
public boolean enabled() {
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 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 List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
return accepted;
}
/**
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
* что в итоге работает.
*/
private NameCascade() {
this.maxCandidates = 0;
this.nameRecogniser = null;
this.addressRecogniser = null;
this.concurrent = new Semaphore(1);
MeterRegistry meters = new SimpleMeterRegistry();
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
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);
if (!concurrent.tryAcquire()) {
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
busy.increment();
return accepted;
}
public static NameCascade disabled() {
return new NameCascade();
}
public boolean enabled() {
return (nameRecogniser != null || addressRecogniser != null) && !broken;
}
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
* трогаются: модели разбирают только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
return accepted;
long started = System.nanoTime();
try {
List<Span> found = new ArrayList<>(accepted);
int examined = 0;
Matcher m = CANDIDATE.matcher(text);
while (m.find() && examined < maxCandidates) {
if (fullyCovered(found, m.start(), m.end())) {
continue;
}
if (!concurrent.tryAcquire()) {
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
busy.increment();
return accepted;
}
long started = System.nanoTime();
try {
List<Span> found = new ArrayList<>(accepted);
int examined = 0;
Matcher m = CANDIDATE.matcher(text);
while (m.find() && examined < maxCandidates) {
if (fullyCovered(found, m.start(), m.end())) {
continue;
}
examined++;
collect(text, m.start(), m.end(), found, nameRecogniser);
collect(text, m.start(), m.end(), found, addressRecogniser);
}
candidates.increment(examined);
(examined > 0 ? engaged : withoutCandidates).increment();
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return found;
} catch (RuntimeException e) {
broken = true;
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
return accepted;
} finally {
concurrent.release();
examined++;
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();
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return found;
} catch (RuntimeException e) {
broken = true;
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
return accepted;
} finally {
concurrent.release();
}
}
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink,
RuBertRecogniser recogniser) {
if (recogniser == null) {
return;
}
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
boolean nameFound = false;
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
if (isAccepted(text, candidateStart, candidateEnd, span)) {
sink.add(span);
if (PdTypes.FIO.equals(span.type())) {
nameFound = true;
}
}
}
// Модель распознала имя в кандидате, но правила могли найти лишь его часть
// («Жан» вместо «Жан-Поль Дюваль») с более высоким приоритетом и заблокировать
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
if (nameFound) {
sink.removeIf(span -> PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd && candidateStart < span.end()
&& span.priority() > PRIORITY);
}
private void collect(
String text,
int candidateStart,
int candidateEnd,
List<Span> sink,
String modelName,
RuBertRecogniser recogniser) {
if (recogniser == null) {
return;
}
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);
if (PdTypes.FIO.equals(span.type())) {
nameFound = true;
}
}
}
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()
&& span.priority() > PRIORITY);
}
}
/** Покрыт ли фрагмент целиком уже принятыми находками. */
private static boolean fullyCovered(List<Span> spans, int start, int end) {
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
}
/** Покрыт ли фрагмент целиком уже принятыми находками. */
private static boolean fullyCovered(List<Span> spans, int start, int end) {
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
}
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит
* те же условия, что и находки правил.
*/
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради
// качества разбора, а не для расширения находки.
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
return false;
}
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
// Вернадского» попадали бы под маску наравне с адресом клиента.
return !RuleRegistry.isAddressType(span.type())
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
}
/**
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»).
* Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя.
*/
private static final Set<String> PD_MARKERS =
Set.of(
"инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство");
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)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null;
}
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath), 1, types);
if (created == null) {
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
}
return created;
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит те же условия, что и
* находки правил.
*/
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради
// качества разбора, а не для расширения находки.
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;
}
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
// Вернадского» попадали бы под маску наравне с адресом клиента.
return !RuleRegistry.isAddressType(span.type())
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
}
@PreDestroy
void shutdown() {
if (nameRecogniser != null) {
nameRecogniser.close();
}
if (addressRecogniser != null) {
addressRecogniser.close();
}
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)
&& !"ru-legal-ner".equals(chosen)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null;
}
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath), 1, types);
if (created == null) {
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
}
return created;
}
@PreDestroy
void shutdown() {
if (nameRecogniser != null) {
nameRecogniser.close();
}
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,203 +8,300 @@ 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 Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
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())
.toList();
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
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())
.toList();
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS =
ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS);
private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS);
private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS =
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
/** Разделитель слов: любая последовательность не-буквенных символов. */
private static final String WORD_SPLIT = "\\P{L}+";
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
private static final String REGNAL_EPITHETS =
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS =
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
/**
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
*/
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);
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
private static final String REGNAL_EPITHETS =
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/** Не более скольких падежных букв дописывается к основе имени. */
private static final int MAX_INFLECTION = 3;
/**
* Имя правителя: личное имя плюс порядковое числительное или прозвище — «Пётр Первый», «Иван
* Грозный», «Екатерина Вторая», «Ярослав Мудрый». Задано правилом, а не перечнем: правителей
* много, а форма записи одна.
*/
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);
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
/** Не более скольких падежных букв дописывается к основе имени. */
private static final int MAX_INFLECTION = 3;
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES =
Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна",
"евна", "овы", "евы", "ины");
private NameDictionary() {
private static final Set<String> GIVEN_NAMES =
GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
/**
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию совпадают с
* основами имён («ИНН» — основа имени «Инна») и потому ложно распознаются как ФИО. Это
* аббревиатуры, а не имена.
*/
private static final Set<String> PD_MARKERS =
Set.of(
"инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство",
"ву");
private NameDictionary() {}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() {
return new NameDictionary();
}
/**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения из конфигурации
* Spring-бина; статические методы словаря работают без экземпляра, поэтому путь хранится в
* статическом поле.
*/
public static void configure(String wellKnownFile) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Путь фиксирован в статическом поле; для тестов используется useExternalFile.
if (!"config/well-known.txt".equals(wellKnownFile)) {
useExternalFile(Path.of(wellKnownFile));
}
}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() {
return new NameDictionary();
}
/**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
* из конфигурации Spring-бина; статические методы словаря работают без
* экземпляра, поэтому путь хранится в статическом поле.
*/
public static void configure(String wellKnownFile) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Путь фиксирован в статическом поле; для тестов используется useExternalFile.
if (!"config/well-known.txt".equals(wellKnownFile)) {
useExternalFile(Path.of(wellKnownFile));
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось
* бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений
* к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split(WORD_SPLIT)) {
String lower = word.toLowerCase(Locale.ROOT);
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
// но это имя, а не фамилия.
if (GIVEN_NAMES.contains(lower)) {
return true;
}
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION);
length < lower.length();
length++) {
if (GIVEN_NAMES.contains(lower.substring(0, length))
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
return true;
}
}
}
return false;
}
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
* приходится не больше четырёх обращений к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
// но это имя, а не фамилия.
if (GIVEN_NAMES.contains(lower)) {
return true;
/**
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется для строчных имён
* после ролевого слова («клиент иван иванов»), где регистр не подсказывает, что перед нами имя.
*/
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}, тот же приём) перебор списка на каждое
* слово текста был бы заметен, а префиксов у слова — не больше, чем в нём букв.
*/
public static boolean isWellKnown(String value) {
if (REGNAL_NAME.matcher(value.strip()).matches()) {
return true;
}
Set<String> stems = currentWellKnownStems();
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))) {
return true;
}
}
}
return false;
}
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
static void useExternalFile(Path path) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Перечитываем немедленно, минуя секундный троттлинг.
reloadExternal(path);
}
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal(Path path) {
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!java.nio.file.Files.isReadable(path)) {
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info(
"Внешний файл денилиста {} исчез, остаётся только встроенный список",
path.toAbsolutePath());
}
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = 0;
return;
}
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)) {
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());
} catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
}
}
private static Set<String> currentWellKnownStems() {
return ResourceLoader.refreshIfChanged(
EXTERNAL_FILE,
WELL_KNOWN_STATE,
lines -> {
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : lines) {
String trimmed = Declension.withoutInflectedEnding(line);
if (!trimmed.isEmpty()) {
merged.add(trimmed);
}
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
if (GIVEN_NAMES.contains(lower.substring(0, length))
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
return true;
}
}
}
return false;
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху.
*
* <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}+")) {
String lower = word.toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (stems.contains(lower.substring(0, length))) {
return true;
}
}
}
return false;
}
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
static void useExternalFile(Path path) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Перечитываем немедленно, минуя секундный троттлинг.
reloadExternal(path);
}
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal(Path path) {
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!java.nio.file.Files.isReadable(path)) {
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
path.toAbsolutePath());
}
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = 0;
return;
}
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)) {
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());
} catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
}
}
private static Set<String> currentWellKnownStems() {
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
lines -> {
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : lines) {
String trimmed = Declension.withoutInflectedEnding(line);
if (!trimmed.isEmpty()) {
merged.add(trimmed);
}
}
return Set.copyOf(merged);
},
BUNDLED_WELL_KNOWN_STEMS);
}
}
return Set.copyOf(merged);
});
}
}
@@ -3,33 +3,34 @@ 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}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже, между ним и именем
* допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
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 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);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
}
+48 -47
View File
@@ -3,56 +3,57 @@ 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";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Регион и район размечает только модель второй ступени: правил под них нет. */
public static final String 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";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME";
public static final String OGRN = "OGRN";
public static final String OGRNIP = "OGRNIP";
public static final String KPP = "KPP";
public static final String BIOMETRIC = "BIOMETRIC";
/** Регион и район размечает только модель второй ступени: правил под них нет. */
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";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
/** Банковские реквизиты сверх платёжной карты. */
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";
public static final String OGRN = "OGRN";
public static final String OGRNIP = "OGRNIP";
public static final String KPP = "KPP";
public static final String BIOMETRIC = "BIOMETRIC";
}
@@ -12,105 +12,109 @@ 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);
/**
* Читает строки ресурса, отбрасывая пустые и комментарии.
*
* @param resource путь к ресурсу в classpath
* @param sortByLength сортировать ли от длинных к коротким (нужно для чередований)
*/
static List<String> lines(String resource, boolean sortByLength) {
try (InputStream in = ResourceLoader.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(sortByLength
? Comparator.comparingInt(String::length).reversed()
: Comparator.naturalOrder())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
private ResourceLoader() {}
/**
* Читает строки ресурса в множество, отбрасывая пустые и комментарии.
*/
static java.util.Set<String> set(String resource) {
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
/**
* Читает строки ресурса, отбрасывая пустые и комментарии.
*
* @param resource путь к ресурсу в classpath
* @param sortByLength сортировать ли от длинных к коротким (нужно для чередований)
*/
static List<String> lines(String resource, boolean sortByLength) {
try (InputStream in = ResourceLoader.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader
.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(
sortByLength
? Comparator.comparingInt(String::length).reversed()
: Comparator.naturalOrder())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
/**
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее.
*
* @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) {
long now = System.currentTimeMillis();
if (now - state.lastCheck < state.recheckMillis) {
return state.current;
}
state.lastCheck = now;
try {
if (!Files.isReadable(path)) {
return state.current;
}
long mtime = Files.getLastModifiedTime(path).toMillis();
if (mtime != state.mtime) {
state.mtime = mtime;
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8).stream()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList();
state.current = reader.apply(lines);
}
} catch (IOException e) {
// Битый файл не должен ронять работу: остаётся прежнее значение.
}
/** Читает строки ресурса в множество, отбрасывая пустые и комментарии. */
static java.util.Set<String> set(String resource) {
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
}
/**
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду.
* Возвращает текущее содержимое; при недоступности файла — прежнее.
*
* @param path путь к файлу
* @param state состояние проверки (время последней проверки и mtime файла)
* @param reader как превратить строки файла в итоговое значение
*/
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;
}
state.lastCheck = now;
try {
if (!Files.isReadable(path)) {
return state.current;
}
long mtime = Files.getLastModifiedTime(path).toMillis();
if (mtime != state.mtime) {
state.mtime = mtime;
List<String> lines =
Files.readAllLines(path, StandardCharsets.UTF_8).stream()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList();
state.current = reader.apply(lines);
}
} catch (IOException e) {
// Битый файл не должен ронять работу: остаётся прежнее значение.
LOG.warn("Не удалось перечитать файл {}", path, e);
}
return state.current;
}
/** Состояние проверки внешнего файла: время последней проверки и mtime. */
static final class FileWatchState<T> {
private static final long DEFAULT_RECHECK_MILLIS = 1000;
final long recheckMillis;
long lastCheck;
long mtime;
T current;
FileWatchState(T initial, long recheckMillis) {
this.current = initial;
this.recheckMillis = recheckMillis;
}
/** Состояние проверки внешнего файла: время последней проверки и mtime. */
static final class FileWatchState<T> {
private static final long DEFAULT_RECHECK_MILLIS = 1000;
final long recheckMillis;
long lastCheck;
long mtime;
T current;
FileWatchState(T initial, long recheckMillis) {
this.current = initial;
this.recheckMillis = recheckMillis;
}
FileWatchState(T initial) {
this(initial, DEFAULT_RECHECK_MILLIS);
}
FileWatchState(T initial) {
this(initial, DEFAULT_RECHECK_MILLIS);
}
}
}
@@ -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,197 +16,213 @@ 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 {
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
private static final int MAX_PIECES = 190;
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
private static final int MAX_PIECES = 190;
private final OrtEnvironment environment;
private final OrtSession session;
private final WordPiece tokenizer;
private final String[] labels;
private final Set<String> inputNames;
private final Map<String, String> types;
private final OrtEnvironment environment;
private final OrtSession session;
private final WordPiece tokenizer;
private final String[] labels;
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) {
this.environment = environment;
this.session = session;
this.tokenizer = tokenizer;
this.labels = labels;
this.inputNames = session.getInputNames();
this.types = types;
private RuBertRecogniser(
OrtEnvironment environment,
OrtSession session,
WordPiece tokenizer,
String[] labels,
Map<String, String> types) {
this.environment = environment;
this.session = session;
this.tokenizer = tokenizer;
this.labels = labels;
this.inputNames = session.getInputNames();
this.types = types;
}
/**
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} и {@code
* config.json}. Каталог недоступен или испорчен — вернётся {@code null}, и сервис продолжит
* работать на правилах.
*
* @param types соответствие меток модели типам ПД сервиса
*/
static RuBertRecogniser load(Path directory, int threadsPerCall, Map<String, String> types) {
Path model = directory.resolve("model.onnx");
Path tokenizer = directory.resolve("tokenizer.json");
Path config = directory.resolve("config.json");
if (!Files.isReadable(model) || !Files.isReadable(tokenizer) || !Files.isReadable(config)) {
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
return null;
}
/**
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json}
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
* и сервис продолжит работать на правилах.
*
* @param types соответствие меток модели типам ПД сервиса
*/
static RuBertRecogniser load(Path directory, int threadsPerCall, Map<String, String> types) {
Path model = directory.resolve("model.onnx");
Path tokenizer = directory.resolve("tokenizer.json");
Path config = directory.resolve("config.json");
if (!Files.isReadable(model) || !Files.isReadable(tokenizer) || !Files.isReadable(config)) {
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
return null;
}
OrtSession session = null;
try {
OrtEnvironment environment = OrtEnvironment.getEnvironment();
try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) {
options.setIntraOpNumThreads(threadsPerCall);
options.setInterOpNumThreads(1);
session = environment.createSession(model.toString(), options);
}
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
WordPiece.fromTokenizerJson(tokenizer), readLabels(config), types);
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser;
} catch (OrtException | IOException | RuntimeException e) {
closeQuietly(session);
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
return null;
}
OrtSession session = null;
try {
OrtEnvironment environment = OrtEnvironment.getEnvironment();
try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) {
options.setIntraOpNumThreads(threadsPerCall);
options.setInterOpNumThreads(1);
session = environment.createSession(model.toString(), options);
}
RuBertRecogniser recogniser =
new RuBertRecogniser(
environment,
session,
WordPiece.fromTokenizerJson(tokenizer),
readLabels(config),
types);
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser;
} catch (OrtException | IOException | RuntimeException e) {
closeQuietly(session);
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
return null;
}
}
private static void closeQuietly(OrtSession session) {
if (session == null) {
return;
}
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e);
}
private static void closeQuietly(OrtSession session) {
if (session == null) {
return;
}
List<Span> recognise(String text, int from, int to, int priority) {
String region = text.substring(from, to);
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
if (pieces.isEmpty()) {
return List.of();
}
try {
String[] tags = classify(pieces);
return toSpans(pieces, tags, from, priority, types);
} catch (OrtException e) {
throw new IllegalStateException("Сбой вычисления модели BERT", e);
}
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e);
}
}
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
int length = pieces.size() + 2;
long[] ids = new long[length];
long[] mask = new long[length];
long[] tokenTypes = new long[length];
ids[0] = tokenizer.classifyId();
List<Span> recognise(String text, int from, int to, int priority) {
String region = text.substring(from, to);
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
if (pieces.isEmpty()) {
return List.of();
}
try {
String[] tags = classify(pieces);
return toSpans(pieces, tags, from, priority, types);
} catch (OrtException e) {
throw new IllegalStateException("Сбой вычисления модели BERT", e);
}
}
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
int length = pieces.size() + 2;
long[] ids = new long[length];
long[] mask = new long[length];
long[] tokenTypes = new long[length];
ids[0] = tokenizer.classifyId();
for (int i = 0; i < pieces.size(); i++) {
ids[i + 1] = pieces.get(i).id();
}
ids[length - 1] = tokenizer.separatorId();
java.util.Arrays.fill(mask, 1L);
long[] shape = {1, length};
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));
if (inputNames.contains("token_type_ids")) {
inputs.put(
"token_type_ids",
OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
}
inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) {
float[][][] logits = (float[][][]) result.get(0).getValue();
String[] tags = new String[pieces.size()];
for (int i = 0; i < pieces.size(); i++) {
ids[i + 1] = pieces.get(i).id();
}
ids[length - 1] = tokenizer.separatorId();
java.util.Arrays.fill(mask, 1L);
long[] shape = {1, length};
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));
if (inputNames.contains("token_type_ids")) {
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
}
inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) {
float[][][] logits = (float[][][]) result.get(0).getValue();
String[] tags = new String[pieces.size()];
for (int i = 0; i < pieces.size(); i++) {
tags[i] = labels[argmax(logits[0][i + 1])];
}
return tags;
}
} finally {
inputs.values().forEach(OnnxTensor::close);
tags[i] = labels[argmax(logits[0][i + 1])];
}
return tags;
}
} finally {
inputs.values().forEach(OnnxTensor::close);
}
}
/**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
* Схема разметки различает начало, середину, конец и одиночный токен, но для
* сборки достаточно смены типа: границы участков и так проставлены по словам.
*/
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;
int start = 0;
int end = 0;
for (int i = 0; i < tags.length; i++) {
String type = types.get(entityOf(tags[i]));
if (type != null && type.equals(currentType)) {
end = pieces.get(i).end();
continue;
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
currentType = type;
start = pieces.get(i).start();
end = pieces.get(i).end();
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
return spans;
/**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки
* различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа:
* границы участков и так проставлены по словам.
*/
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;
int start = 0;
int end = 0;
for (int i = 0; i < tags.length; i++) {
String type = types.get(entityOf(tags[i]));
if (type != null && type.equals(currentType)) {
end = pieces.get(i).end();
continue;
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
currentType = type;
start = pieces.get(i).start();
end = pieces.get(i).end();
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
return spans;
}
private static String entityOf(String tag) {
int dash = tag.indexOf('-');
return dash < 0 ? tag : tag.substring(dash + 1);
}
private static String entityOf(String tag) {
int dash = tag.indexOf('-');
return dash < 0 ? tag : tag.substring(dash + 1);
}
private static int argmax(float[] scores) {
int best = 0;
for (int i = 1; i < scores.length; i++) {
if (scores[i] > scores[best]) {
best = i;
}
}
return best;
private static int argmax(float[] scores) {
int best = 0;
for (int i = 1; i < scores.length; i++) {
if (scores[i] > scores[best]) {
best = i;
}
}
return best;
}
private static String[] readLabels(Path config) throws IOException {
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
String[] labels = new String[node.size()];
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
}
return labels;
private static String[] readLabels(Path config) throws IOException {
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
String[] labels = new String[node.size()];
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
}
return labels;
}
void close() {
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели", e);
}
void close() {
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели", e);
}
}
}
+99 -80
View File
@@ -7,93 +7,112 @@ 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 type тип ПД, который распознаёт правило
* @param pattern регулярное выражение
* @param priority приоритет при разрешении перекрытий
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. Несколько
* групп нужны, когда значение разорвано словами: «серия 4509 номер 123456»
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); {@code null}
* — проверка не нужна
* @param veto шаблон окружения, при котором совпадение персональными данными не считается: адрес
* отделения банка не является ПД, хотя выглядит как адрес
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, где форма
* совпадения сама по себе слишком общая: «Невский проспект» это адрес рядом с домом и индексом
* и просто топоним в рассказе о городе
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. Проверка через
* {@code indexOf} на порядок дешевле запуска регулярного выражения и отсекает большинство
* правил на коротком запросе. Пустой список — правило запускается всегда
*/
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
public record Rule(
String type,
Pattern pattern,
int priority,
List<Integer> groups,
Predicate<String> validator,
Pattern veto,
Pattern context,
List<String> anchors) {
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
public Rule {
groups = List.copyOf(groups);
anchors = List.copyOf(anchors);
}
/**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
*
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
* вето не срабатывало на части из них.
*/
public static final int VETO_LOOKBEHIND = 150;
public static final int VETO_LOOKAHEAD = 40;
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} и {@code \b} в
* Java охватывают только латиницу, и якорные слова вроде «водительское удостоверение» не
* находятся. {@code UNICODE_CASE} делает {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/** Правило без проверок, маскируется всё совпадение. */
public static 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());
/**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
*
* <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());
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
public Rule groups(Integer... indexes) {
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
}
/** Принять совпадение, только если значение прошло проверку. */
public Rule validatedBy(Predicate<String> check) {
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
}
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
public Rule anchoredBy(String... required) {
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
}
/** Есть ли в тексте хоть один из якорей правила. */
public boolean mayMatch(String lowercasedText) {
if (anchors.isEmpty()) {
return true;
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
public Rule groups(Integer... indexes) {
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
for (String anchor : anchors) {
if (lowercasedText.contains(anchor)) {
return true;
}
}
return false;
}
/** Принять совпадение, только если значение прошло проверку. */
public Rule validatedBy(Predicate<String> check) {
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
}
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(
type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
public Rule anchoredBy(String... required) {
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
}
/** Есть ли в тексте хоть один из якорей правила. */
public boolean mayMatch(String lowercasedText) {
if (anchors.isEmpty()) {
return true;
}
for (String anchor : anchors) {
if (lowercasedText.contains(anchor)) {
return true;
}
}
return false;
}
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
}
/** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) {
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+адрес)";
}
+189 -439
View File
@@ -1,479 +1,229 @@
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}]+";
/**
* Слова, при которых адрес принадлежит организации, а не человеку: адрес отделения банка
* персональными данными не является. Части адреса рядом: улица, упомянутая в рассказе о городе,
* адресом клиента не является — ровно как адрес отделения банка из технического задания.
* Требование стояло только у постфиксной формы правила, префиксная его не имела.
*/
public static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
/**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
* «Тверская», «Малая Никитская», «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 Pattern ADDRESS_CONTEXT =
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/
private static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
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);
/**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
*/
private static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/**
* Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных
* правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных
* разделителей, и не должна перебивать находки с якорным словом.
*/
private static final int NORMALISED_PRIORITY = 63;
/** Серия и номер: «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}";
/**
* Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними (пробел, дефис,
* точка, слэш, скобки). Негативные просмотры не дают захватить часть более длинного числа.
* Разделители вычищаются, и чистая цифровая строка прогоняется через контрольную сумму — так
* находятся ИНН/СНИЛС/карта/ОГРН(ИП) в свободной форме, где жёсткий шаблон ломается на
* нестандартном разделителе.
*/
private static final Pattern DIGIT_CLUSTER =
Pattern.compile("(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
private static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
public static boolean isAddressType(String type) {
return ADDRESS_TYPES.contains(type);
}
/** «12 мая 1985 г.» */
private static final String DATE_MONTH_WORD =
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
/**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам второй ступени
* проверку нужно навязать снаружи: модель размечает «Москву» в названии клуба и «Вернадского» в
* названии проспекта наравне с настоящим адресом.
*/
public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
}
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
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 List<Rule> RULES =
Stream.of(
DocumentRules.RULES,
FinanceRules.RULES,
DateRules.RULES,
FioRules.RULES,
ContactRules.RULES,
AddressRules.RULES)
.flatMap(List::stream)
.toList();
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
/** Все типы ПД, которые умеет распознавать сервис. */
public List<String> knownTypes() {
return RULES.stream().map(Rule::type).distinct().toList();
}
/**
* Слова, при которых адрес принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является. Части адреса рядом:
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
* как адрес отделения банка из технического задания. Требование стояло только
* у постфиксной формы правила, префиксная его не имела.
*/
public static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
private static final Pattern ADDRESS_CONTEXT =
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);
public static boolean isAddressType(String type) {
return ADDRESS_TYPES.contains(type);
/**
* Находит все фрагменты ПД, разрешённые политикой системы. Перекрытия здесь не разрешаются — это
* делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
String lowercased = text.toLowerCase(Locale.ROOT);
for (Rule rule : RULES) {
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
continue;
}
collect(rule, text, found);
}
collectNormalisedDigits(text, policy, found);
return found;
}
/**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
*/
public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
/**
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями,
* которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется
* контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах.
*/
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;
}
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)
);
/** Все типы ПД, которые умеет распознавать сервис. */
public List<String> knownTypes() {
return RULES.stream().map(Rule::type).distinct().toList();
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));
}
}
}
/**
* Находит все фрагменты ПД, разрешённые политикой системы.
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
String lowercased = text.toLowerCase(Locale.ROOT);
for (Rule rule : RULES) {
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
continue;
}
collect(rule, text, found);
/**
* Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 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()) {
for (int group : rule.groups()) {
int start = m.start(group);
int end = m.end(group);
if (isValidGroup(rule, text, start, end)) {
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
return found;
}
}
}
private static void collect(Rule rule, String text, List<Span> sink) {
Matcher m = rule.pattern().matcher(text);
while (m.find()) {
for (int group : rule.groups()) {
int start = m.start(group);
int end = m.end(group);
if (isValidGroup(rule, text, start, end)) {
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
}
}
/**
* Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и
* контекст.
*/
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) {
return false;
}
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
return false;
}
String surroundings = surroundings(text, start, end);
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
return false;
}
return rule.context() == null || rule.context().matcher(surroundings).find();
}
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) {
return false;
}
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
return false;
}
String surroundings = surroundings(text, start, end);
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
return false;
}
return rule.context() == null || rule.context().matcher(surroundings).find();
}
static String surroundings(String text, int start, int end) {
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
return text.substring(from, to);
}
static String surroundings(String text, int start, int end) {
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
return text.substring(from, to);
}
}
+13 -13
View File
@@ -3,24 +3,24 @@ package ru.pdguard.detect;
/**
* Найденный фрагмент персональных данных в исходном тексте.
*
* @param start индекс первого символа (включительно)
* @param end индекс за последним символом (исключительно)
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
* @param start индекс первого символа (включительно)
* @param end индекс за последним символом (исключительно)
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
* @param priority приоритет при разрешении перекрытий: больше — важнее
*/
public record Span(int start, int end, String type, int priority) {
public Span {
if (start < 0 || end <= start) {
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
}
public Span {
if (start < 0 || end <= start) {
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
}
}
public int length() {
return end - start;
}
public int length() {
return end - start;
}
public boolean overlaps(Span other) {
return start < other.end && other.start < end;
}
public boolean overlaps(Span other) {
return start < other.end && other.start < end;
}
}
@@ -4,51 +4,47 @@ 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()
.map(Declension::withoutInflectedEnding)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
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>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
* основ по значению: перебор списка на каждое совпадение правила был бы
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
*/
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
return true;
}
}
return false;
/**
* Похоже ли значение на название населённого пункта из словаря в любом падеже.
*
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому
* начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву
* («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению:
* перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у
* слова не больше, чем в нём букв.
*/
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
return true;
}
}
return false;
}
}
+169 -162
View File
@@ -1,184 +1,191 @@
package ru.pdguard.detect;
/**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
* которые по форме похожи на ПД, но ими не являются.
* Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме
* похожи на ПД, но ими не являются.
*/
public final class Validators {
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private Validators() {
private Validators() {}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) {
int sum = 0;
int digits = 0;
boolean doubled = false;
for (int i = value.length() - 1; i >= 0; i--) {
char c = value.charAt(i);
if (!Character.isDigit(c)) {
continue;
}
int d = c - '0';
digits++;
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
return digits >= 13 && digits <= 19 && sum % 10 == 0;
}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) {
int sum = 0;
int digits = 0;
boolean doubled = false;
for (int i = value.length() - 1; i >= 0; i--) {
char c = value.charAt(i);
if (!Character.isDigit(c)) {
continue;
}
int d = c - '0';
digits++;
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
/**
* Контрольная цифра Луна для последовательности цифр: дописывается к телу номера, чтобы весь
* номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных
* номеров карт.
*/
public static int luhnCheckDigit(String body) {
int sum = 0;
boolean doubled = true;
for (int i = body.length() - 1; i >= 0; i--) {
int d = body.charAt(i) - '0';
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
return digits >= 13 && digits <= 19 && sum % 10 == 0;
}
sum += d;
doubled = !doubled;
}
return (10 - sum % 10) % 10;
}
/**
* Контрольная цифра Луна для последовательности цифр: дописывается к телу
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при
* генерации правдоподобных подставных номеров карт.
*/
public static int luhnCheckDigit(String body) {
int sum = 0;
boolean doubled = true;
for (int i = body.length() - 1; i >= 0; i--) {
int d = body.charAt(i) - '0';
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
return (10 - sum % 10) % 10;
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
public static boolean inn(String value) {
int[] d = digits(value);
if (d.length == 10) {
return d[9] == checksum(d, INN_10);
}
if (d.length == 12) {
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
}
return false;
}
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
public static boolean inn(String value) {
int[] d = digits(value);
if (d.length == 10) {
return d[9] == checksum(d, INN_10);
}
if (d.length == 12) {
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
}
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
public static boolean snils(String value) {
int[] d = digits(value);
if (d.length != 11) {
return false;
}
int sum = 0;
for (int i = 0; i < 9; i++) {
sum += d[i] * (9 - i);
}
int control = snilsControl(sum);
return control == d[9] * 10 + d[10];
}
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
private static int snilsControl(int sum) {
if (sum < 100) {
return sum;
}
if (sum == 100 || sum == 101) {
return 0;
}
return sum % 101 % 100;
}
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
public static boolean ogrn(String value) {
int[] d = digits(value);
return d.length == 13 && d[12] == modReduce(d, 12, 11);
}
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
public static boolean ogrnip(String value) {
int[] d = digits(value);
return d.length == 15 && d[14] == modReduce(d, 14, 13);
}
/**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor}, взятый по
* младшему разряду. Числовое накопление по цифрам, а не парсинг строки в {@code long}: у ОГРНИП
* 14 цифр — на грани переполнения {@code int}, и это тот же приём, что уже применяется к самой
* длинной последовательности в {@link #luhn}.
*/
private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0;
for (int i = 0; i < count; i++) {
remainder = (remainder * 10 + d[i]) % divisor;
}
return (int) (remainder % 10);
}
/**
* Дата в числовой записи при любом порядке частей: {@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) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
// «мая» само по себе однозначно указывает на дату.
for (int i = 0; i < value.length(); i++) {
if (Character.isLetter(value.charAt(i))) {
return true;
}
}
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) {
return false;
}
n[i] = Integer.parseInt(parts[i]);
}
for (int y = 0; y < 3; y++) {
if (parts[y].length() == 4) {
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
}
}
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
}
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
public static boolean snils(String value) {
int[] d = digits(value);
if (d.length != 11) {
return false;
}
int sum = 0;
for (int i = 0; i < 9; i++) {
sum += d[i] * (9 - i);
}
int control = snilsControl(sum);
return control == d[9] * 10 + d[10];
}
/** Пара чисел похожа на «день и месяц» в любом порядке. */
private static boolean dayAndMonth(int a, int b) {
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
}
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
private static int snilsControl(int sum) {
if (sum < 100) {
return sum;
}
if (sum == 100 || sum == 101) {
return 0;
}
return sum % 101 % 100;
private static int checksum(int[] d, int[] weights) {
int sum = 0;
for (int i = 0; i < weights.length; i++) {
sum += d[i] * weights[i];
}
return sum % 11 % 10;
}
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
public static boolean ogrn(String value) {
int[] d = digits(value);
return d.length == 13 && d[12] == modReduce(d, 12, 11);
}
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
public static boolean ogrnip(String value) {
int[] d = digits(value);
return d.length == 15 && d[14] == modReduce(d, 14, 13);
}
/**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
*/
private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0;
for (int i = 0; i < count; i++) {
remainder = (remainder * 10 + d[i]) % divisor;
}
return (int) (remainder % 10);
}
/**
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
* последовательности вроде {@code 192.168.1}.
*/
public static boolean date(String value) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
// «мая» само по себе однозначно указывает на дату.
for (int i = 0; i < value.length(); i++) {
if (Character.isLetter(value.charAt(i))) {
return true;
}
}
String[] parts = value.split("[.\\-/]");
if (parts.length != 3) {
return false;
}
int[] n = new int[3];
for (int i = 0; i < 3; i++) {
if (parts[i].isEmpty() || parts[i].length() > 4) {
return false;
}
n[i] = Integer.parseInt(parts[i]);
}
for (int y = 0; y < 3; y++) {
if (parts[y].length() == 4) {
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
}
}
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
}
/** Пара чисел похожа на «день и месяц» в любом порядке. */
private static boolean dayAndMonth(int a, int b) {
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
}
private static int checksum(int[] d, int[] weights) {
int sum = 0;
for (int i = 0; i < weights.length; i++) {
sum += d[i] * weights[i];
}
return sum % 11 % 10;
}
private static int[] digits(String value) {
int[] out = new int[value.length()];
int n = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isDigit(c)) {
out[n++] = c - '0';
}
}
int[] trimmed = new int[n];
System.arraycopy(out, 0, trimmed, 0, n);
return trimmed;
private static int[] digits(String value) {
int[] out = new int[value.length()];
int n = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isDigit(c)) {
out[n++] = c - '0';
}
}
int[] trimmed = new int[n];
System.arraycopy(out, 0, trimmed, 0, n);
return trimmed;
}
}
+137 -139
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,163 +17,162 @@ import java.util.Map;
/**
* Разбиение текста на подслова так, как это делает токенизатор BERT.
*
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает
* нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила
* здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем
* каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом
* «##».
*
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
* модели не перенести обратно на строку.
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не
* перенести обратно на строку.
*/
final class WordPiece {
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
private static final int MAX_WORD_CHARS = 100;
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
private static final int MAX_WORD_CHARS = 100;
private static final String CONTINUATION = "##";
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;
private final int classifyId;
private final int separatorId;
private WordPiece(Map<String, Integer> vocabulary) {
this.vocabulary = vocabulary;
this.unknownId = required(vocabulary, "[UNK]");
this.classifyId = required(vocabulary, "[CLS]");
this.separatorId = required(vocabulary, "[SEP]");
}
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line;
int index = 0;
while ((line = reader.readLine()) != null) {
vocabulary.putIfAbsent(line.strip(), index++);
}
}
return new WordPiece(vocabulary);
}
private final Map<String, Integer> vocabulary;
private final int unknownId;
private final int classifyId;
private final int separatorId;
private WordPiece(Map<String, Integer> vocabulary) {
this.vocabulary = vocabulary;
this.unknownId = required(vocabulary, "[UNK]");
this.classifyId = required(vocabulary, "[CLS]");
this.separatorId = required(vocabulary, "[SEP]");
/**
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal)
* не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора.
*/
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
JsonNode vocab = root.path("model").path("vocab");
Map<String, Integer> vocabulary = HashMap.newHashMap(vocab.size());
Iterator<Map.Entry<String, JsonNode>> fields = vocab.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> entry = fields.next();
vocabulary.putIfAbsent(entry.getKey(), entry.getValue().asInt());
}
return new WordPiece(vocabulary);
}
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line;
int index = 0;
while ((line = reader.readLine()) != null) {
vocabulary.putIfAbsent(line.strip(), index++);
}
}
return new WordPiece(vocabulary);
int classifyId() {
return classifyId;
}
int separatorId() {
return separatorId;
}
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
List<Piece> split(String text, int maxPieces) {
List<Piece> pieces = new ArrayList<>();
for (int[] word : words(text)) {
if (pieces.size() >= maxPieces) {
break;
}
splitWord(text, word[0], word[1], pieces, maxPieces);
}
return pieces;
}
/**
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели
* (например, WikiNEuRal) не кладут отдельный {@code vocab.txt}, а хранят
* словарь внутри токенизатора.
*/
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
JsonNode vocab = root.path("model").path("vocab");
Map<String, Integer> vocabulary = HashMap.newHashMap(vocab.size());
Iterator<Map.Entry<String, JsonNode>> fields = vocab.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> entry = fields.next();
vocabulary.putIfAbsent(entry.getKey(), entry.getValue().asInt());
}
return new WordPiece(vocabulary);
}
int classifyId() {
return classifyId;
}
int separatorId() {
return separatorId;
}
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
List<Piece> split(String text, int maxPieces) {
List<Piece> pieces = new ArrayList<>();
for (int[] word : words(text)) {
if (pieces.size() >= maxPieces) {
break;
}
splitWord(text, word[0], word[1], pieces, maxPieces);
}
return pieces;
}
/**
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
* причём знак препинания сам становится отдельным словом.
*/
private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>();
int start = -1;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
if (separator) {
if (start >= 0) {
result.add(new int[]{start, i});
start = -1;
}
if (isPunctuation(c)) {
result.add(new int[]{i, i + 1});
}
} else if (start < 0) {
start = i;
}
}
/**
* Границы слов: разделителями считаются пробельные символы и знаки препинания, причём знак
* препинания сам становится отдельным словом.
*/
private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>();
int start = -1;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
if (separator) {
if (start >= 0) {
result.add(new int[]{start, text.length()});
result.add(new int[] {start, i});
start = -1;
}
return result;
if (isPunctuation(c)) {
result.add(new int[] {i, i + 1});
}
} else if (start < 0) {
start = i;
}
}
if (start >= 0) {
result.add(new int[] {start, text.length()});
}
return result;
}
private static boolean isPunctuation(char c) {
if (Character.isLetterOrDigit(c)) {
return false;
}
return !Character.isWhitespace(c);
private static boolean isPunctuation(char c) {
if (Character.isLetterOrDigit(c)) {
return false;
}
return !Character.isWhitespace(c);
}
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
if (to - from > MAX_WORD_CHARS) {
sink.add(new Piece(unknownId, from, to));
return;
}
int cursor = from;
List<Piece> ofThisWord = new ArrayList<>();
while (cursor < to) {
int end = to;
Integer id = null;
while (end > cursor) {
String candidate = text.substring(cursor, end);
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
id = vocabulary.get(lookup);
if (id != null) {
break;
}
end--;
}
if (id == null) {
// Ни одна часть слова не нашлась — слово целиком неизвестно.
sink.add(new Piece(unknownId, from, to));
return;
}
ofThisWord.add(new Piece(id, cursor, end));
cursor = end;
}
for (Piece piece : ofThisWord) {
if (sink.size() >= maxPieces) {
return;
}
sink.add(piece);
}
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
if (to - from > MAX_WORD_CHARS) {
sink.add(new Piece(unknownId, from, to));
return;
}
int cursor = from;
List<Piece> ofThisWord = new ArrayList<>();
while (cursor < to) {
int end = to;
Integer id = null;
while (end > cursor) {
String candidate = text.substring(cursor, end);
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
id = vocabulary.get(lookup);
if (id != null) {
break;
}
end--;
}
if (id == null) {
// Ни одна часть слова не нашлась — слово целиком неизвестно.
sink.add(new Piece(unknownId, from, to));
return;
}
ofThisWord.add(new Piece(id, cursor, end));
cursor = end;
}
for (Piece piece : ofThisWord) {
if (sink.size() >= maxPieces) {
return;
}
sink.add(piece);
}
}
private static int required(Map<String, Integer> vocabulary, String token) {
Integer id = vocabulary.get(token);
if (id == null) {
throw new IllegalStateException("В словаре нет служебного токена " + token);
}
return id;
private static int required(Map<String, Integer> vocabulary, String token) {
Integer id = vocabulary.get(token);
if (id == null) {
throw new IllegalStateException("В словаре нет служебного токена " + token);
}
return id;
}
}
+27 -26
View File
@@ -8,39 +8,40 @@ import java.util.function.BiFunction;
/**
* Состояние одной операции маскирования.
*
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
* запроса для модели сохранится.
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут
* дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится.
*
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/
public final class MaskContext {
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
private static final char SEPARATOR = '#';
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
private static final char SEPARATOR = '#';
private final Map<String, String> assigned = new HashMap<>();
private final Map<String, Integer> counters = new HashMap<>();
private final Map<String, String> restorations = new LinkedHashMap<>();
private final Map<String, String> assigned = new HashMap<>();
private final Map<String, Integer> counters = new HashMap<>();
private final Map<String, String> restorations = new LinkedHashMap<>();
/**
* Замена для значения; при повторе возвращается ранее выданная.
*
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
/**
* Замена для значения; при повторе возвращается ранее выданная.
*
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(
type + SEPARATOR + value,
key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
});
}
}
/**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
*/
public Map<String, String> restorations() {
return Map.copyOf(restorations);
}
/**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не
* целиком, а изменённым — например, в ответе языковой модели.
*/
public Map<String, String> restorations() {
return Map.copyOf(restorations);
}
}
+14 -6
View File
@@ -3,12 +3,20 @@ package ru.pdguard.mask;
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
/**
* Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично
* открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code
* ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую
* букву каждого).
*/
STRICT,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
}
+64 -65
View File
@@ -1,82 +1,81 @@
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 {
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 UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
Map.entry(PdTypes.EMAIL, Strategies::email),
Map.entry(PdTypes.PHONE, EDGES),
Map.entry(PdTypes.CARD, EDGES),
Map.entry(PdTypes.INN, EDGES),
Map.entry(PdTypes.SNILS, EDGES),
Map.entry(PdTypes.PASSPORT, EDGES),
Map.entry(PdTypes.DRIVER_LICENSE, EDGES),
Map.entry(PdTypes.DEPT_CODE, EDGES),
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
// только последние. У паспорта РФ и водительского удостоверения серия
// из четырёх знаков, там открывается половина.
Map.entry(PdTypes.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES),
Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(PdTypes.MEDICAL_POLICY, EDGES),
Map.entry(PdTypes.CARDHOLDER, Strategies::initials),
Map.entry(PdTypes.FIO, Strategies::initials),
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),
Map.entry(PdTypes.INN, EDGES),
Map.entry(PdTypes.SNILS, EDGES),
Map.entry(PdTypes.PASSPORT, EDGES),
Map.entry(PdTypes.DRIVER_LICENSE, EDGES),
Map.entry(PdTypes.DEPT_CODE, EDGES),
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
// только последние. У паспорта РФ и водительского удостоверения серия
// из четырёх знаков, там открывается половина.
Map.entry(PdTypes.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES),
Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(PdTypes.MEDICAL_POLICY, EDGES),
Map.entry(PdTypes.CARDHOLDER, Strategies::initials),
Map.entry(PdTypes.FIO, Strategies::initials),
// Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один.
Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(PdTypes.PIN, Strategies::stars),
// Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один.
Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(PdTypes.PIN, Strategies::stars),
Map.entry(PdTypes.PASSPORT_ISSUER, 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),
Map.entry(PdTypes.ADDRESS_STREET, Strategies::stars),
Map.entry(PdTypes.ADDRESS_HOUSE, Strategies::stars),
Map.entry(PdTypes.ADDRESS_FLAT, Strategies::stars),
Map.entry(PdTypes.ADDRESS_REGION, Strategies::stars),
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),
Map.entry(PdTypes.KPP, EDGES),
// Срок действия карты — разделитель виден, сам месяц/год нет.
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.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),
Map.entry(PdTypes.ADDRESS_STREET, Strategies::stars),
Map.entry(PdTypes.ADDRESS_HOUSE, Strategies::stars),
Map.entry(PdTypes.ADDRESS_FLAT, Strategies::stars),
Map.entry(PdTypes.ADDRESS_REGION, Strategies::stars),
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),
Map.entry(PdTypes.KPP, EDGES),
// Срок действия карты — разделитель виден, сам месяц/год нет.
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)
);
public String mask(String type, String value, MaskMode mode, MaskContext context) {
return switch (mode) {
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
};
}
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));
};
}
}
+89 -92
View File
@@ -3,113 +3,110 @@ package ru.pdguard.mask;
/**
* Способы преобразования найденного значения в маску.
*
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
* замаскированный текст остаётся читаемым для LLM и минимально отличается
* от эталона при посимвольном сравнении.
* <p>Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст
* остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении.
*/
public final class Strategies {
private static final char MASK = '*';
private static final char MASK = '*';
private Strategies() {
}
private Strategies() {}
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isWhitespace(c) ? c : MASK);
}
return sb.toString();
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isWhitespace(c) ? c : MASK);
}
return sb.toString();
}
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
* само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
}
return sb.toString();
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code
* 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
}
return sb.toString();
}
/**
* Оставляет первые и последние значащие символы, остальные скрывает,
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
for (int i = 0; i < value.length(); i++) {
if (Character.isLetterOrDigit(value.charAt(i))) {
significant++;
}
}
if (significant <= head + tail) {
return stars(value);
}
StringBuilder sb = new StringBuilder(value.length());
int seen = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isLetterOrDigit(c)) {
sb.append(c);
continue;
}
boolean visible = seen < head || seen >= significant - tail;
sb.append(visible ? c : MASK);
seen++;
}
return sb.toString();
/**
* Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет:
* {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
for (int i = 0; i < value.length(); i++) {
if (Character.isLetterOrDigit(value.charAt(i))) {
significant++;
}
}
if (significant <= head + tail) {
return stars(value);
}
StringBuilder sb = new StringBuilder(value.length());
int seen = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isLetterOrDigit(c)) {
sb.append(c);
continue;
}
boolean visible = seen < head || seen >= significant - tail;
sb.append(visible ? c : MASK);
seen++;
}
return sb.toString();
}
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
public static String initials(String value) {
StringBuilder sb = new StringBuilder();
boolean wordStart = true;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isLetter(c)) {
if (wordStart) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(Character.toUpperCase(c)).append('.');
wordStart = false;
}
} else {
wordStart = true;
}
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
public static String initials(String value) {
StringBuilder sb = new StringBuilder();
boolean wordStart = true;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isLetter(c)) {
if (wordStart) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(Character.toUpperCase(c)).append('.');
wordStart = false;
}
return sb.isEmpty() ? stars(value) : sb.toString();
} else {
wordStart = true;
}
}
return sb.isEmpty() ? stars(value) : sb.toString();
}
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
if (at <= 0 || at == value.length() - 1) {
return stars(value);
}
String local = value.substring(0, at);
String domain = value.substring(at + 1);
int dot = domain.lastIndexOf('.');
if (dot <= 0) {
return hideTail(local) + '@' + hideTail(domain);
}
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code
* ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
if (at <= 0 || at == value.length() - 1) {
return stars(value);
}
String local = value.substring(0, at);
String domain = value.substring(at + 1);
int dot = domain.lastIndexOf('.');
if (dot <= 0) {
return hideTail(local) + '@' + hideTail(domain);
}
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
}
private static String hideTail(String part) {
if (part.length() <= 1) {
return part;
}
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
private static String hideTail(String part) {
if (part.length() <= 1) {
return part;
}
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
}
}
+88 -66
View File
@@ -6,80 +6,102 @@ import ru.pdguard.detect.Validators;
/**
* Правдоподобные подставные значения вместо настоящих.
*
* <p>Модель получает текст, который выглядит естественно, и качество ответа
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
* исходное значение всегда даёт одну и ту же подстановку.
* <p>Модель получает текст, который выглядит естественно, и качество ответа страдает меньше, чем от
* звёздочек. Значения детерминированы: одно и то же исходное значение всегда даёт одну и ту же
* подстановку.
*/
final class Synthetic {
private static final String[] SURNAMES =
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS =
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private static final String[] SURNAMES = {
"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"
};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS = {
"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"
};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() {
}
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.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.CARD -> luhnCard(seed);
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.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
case PdTypes.ADDRESS_CITY -> "Зареченск";
case PdTypes.ADDRESS_STREET -> "Сосновая";
case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6);
case PdTypes.ADDRESS_COUNTRY -> "Заречье";
case PdTypes.ADDRESS_REGION -> "Заречная область";
case PdTypes.ADDRESS_DISTRICT -> "Сосновый район";
case PdTypes.CVV -> digits(seed, 3);
case PdTypes.PIN -> digits(seed, 4);
// Для остальных типов правдоподобной замены нет — отдаём токен.
default -> "[" + type + "_" + ordinal + "]";
};
}
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.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.CARD -> luhnCard(seed);
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.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
case PdTypes.ADDRESS_CITY -> "Зареченск";
case PdTypes.ADDRESS_STREET -> "Сосновая";
case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6);
case PdTypes.ADDRESS_COUNTRY -> "Заречье";
case PdTypes.ADDRESS_REGION -> "Заречная область";
case PdTypes.ADDRESS_DISTRICT -> "Сосновый район";
case PdTypes.CVV -> digits(seed, 3);
case PdTypes.PIN -> digits(seed, 4);
// Для остальных типов правдоподобной замены нет — отдаём токен.
default -> "[" + type + "_" + ordinal + "]";
};
}
private static String pick(String[] options, int seed) {
return options[Math.floorMod(seed, options.length)];
}
private static String pick(String[] options, int seed) {
return options[Math.floorMod(seed, options.length)];
}
private static String syntheticDate(int seed) {
int day = 1 + Math.floorMod(seed, 28);
int month = 1 + Math.floorMod(seed >> 5, 12);
int year = 1960 + Math.floorMod(seed >> 9, 45);
return String.format("%02d.%02d.%d", day, month, year);
}
private static String syntheticDate(int seed) {
int day = 1 + Math.floorMod(seed, 28);
int month = 1 + Math.floorMod(seed >> 5, 12);
int year = 1960 + Math.floorMod(seed >> 9, 45);
return String.format("%02d.%02d.%d", day, month, year);
}
private static String digits(int seed, int count) {
StringBuilder sb = new StringBuilder(count);
int value = Math.abs(seed);
for (int i = 0; i < count; i++) {
sb.append((char) ('0' + Math.floorMod(value, 10)));
value = value / 10 + (i + 1) * 7;
}
return sb.toString();
private static String digits(int seed, int count) {
StringBuilder sb = new StringBuilder(count);
int value = Math.abs(seed);
for (int i = 0; i < count; i++) {
sb.append((char) ('0' + Math.floorMod(value, 10)));
value = value / 10 + (i + 1) * 7;
}
return sb.toString();
}
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
body.append(Validators.luhnCheckDigit(body.toString()));
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
+ body.substring(8, 12) + " " + body.substring(12);
}
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
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);
}
}
+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>
+144 -124
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,149 +11,165 @@ 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));
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 + "»");
}
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
/**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
*/
@Test
void masksAccountNumberNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
"40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
}
/**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
* организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
* переведены в {@code requireCompanion}, как пин-код и дата.
*/
@Test
void masksAccountNumberNextToPersonalData() {
assertHidden(
"Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
}
@Test
void masksBikNextToPersonalData() {
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
}
@Test
void masksBikNextToPersonalData() {
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
}
@Test
void keepsBankDetailsWithoutAnyPersonalData() {
for (String text : new String[]{
"Расчётный счёт 40702810500000001234 открыт вчера",
"БИК 044525593 банка-получателя",
"ОГРН 1027700132195 организации",
"КПП 770101001 указан в реквизитах"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются");
}
@Test
void keepsBankDetailsWithoutAnyPersonalData() {
for (String text :
new String[] {
"Расчётный счёт 40702810500000001234 открыт вчера",
"БИК 044525593 банка-получателя",
"ОГРН 1027700132195 организации",
"КПП 770101001 указан в реквизитах"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются");
}
}
@Test
void masksCardExpiryButNotCardNumber() {
String masked = pipeline.process(
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("09/27"), masked);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
}
@Test
void masksCardExpiryButNotCardNumber() {
String masked =
pipeline.process(
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("09/27"), masked);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
}
@Test
void masksOgrnAndOgrnipDifferently() {
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
}
@Test
void masksOgrnAndOgrnipDifferently() {
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
}
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test
void ogrnDoesNotSwallowOgrnip() {
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
"ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("304500116000157"), masked);
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
}
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test
void ogrnDoesNotSwallowOgrnip() {
String masked =
pipeline.process(
"ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("304500116000157"), masked);
assertFalse(
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
}
/**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
*/
@Test
void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН");
}
/**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
* так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
* карты, и тест ничего бы не показывал.
*/
@Test
void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации";
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН");
}
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
@Test
void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП");
}
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
@Test
void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП");
}
@Test
void masksKppNextToPersonalData() {
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
}
@Test
void masksKppNextToPersonalData() {
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
}
@Test
void masksIncomeNextToPersonalData() {
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
}
@Test
void masksIncomeNextToPersonalData() {
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
}
/**
* Сумма заработка без человека — статистика или описание продукта. Опознать по
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
*/
@Test
void keepsIncomeWithoutAnyPersonalData() {
for (String text : new String[]{
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является");
}
/**
* Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
* а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
* отрасли отвечать уже не на чем.
*/
@Test
void keepsIncomeWithoutAnyPersonalData() {
for (String text :
new String[] {
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
}) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является");
}
}
@Test
void masksBiometricMentionNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
}
@Test
void masksBiometricMentionNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
}
/**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
* ноль сведений и разрушает смысл фразы.
*/
@Test
void keepsBiometricMentionWithoutAnyPersonalData() {
for (String text : new String[]{
"Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"}) {
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"упоминание биометрии без человека персональными данными не является");
}
/**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
* не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
*/
@Test
void keepsBiometricMentionWithoutAnyPersonalData() {
for (String text :
new String[] {
"Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"
}) {
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),
"спутники заверили друг друга в отсутствие настоящих ПД");
}
/**
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным
* не становится, человека в таком тексте нет.
*/
@Test
void twoCompanionTypesDoNotVouchForEachOther() {
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"спутники заверили друг друга в отсутствие настоящих ПД");
}
}
+41 -42
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,56 +10,57 @@ 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_]+):([^}]*)}}");
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))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
samples.add(parse(trimmed));
}
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать " + resource, e);
/** Читает набор построчно, пропуская пустые строки и комментарии {@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))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
samples.add(parse(trimmed));
}
return samples;
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать " + resource, e);
}
return samples;
}
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
static Sample parse(String line) {
StringBuilder text = new StringBuilder(line.length());
List<Span> gold = new ArrayList<>();
Matcher m = MARKUP.matcher(line);
int cursor = 0;
while (m.find()) {
text.append(line, cursor, m.start());
int start = text.length();
text.append(m.group(2));
gold.add(new Span(start, text.length(), m.group(1), 0));
cursor = m.end();
}
text.append(line, cursor, line.length());
return new Sample(text.toString(), gold);
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
static Sample parse(String line) {
StringBuilder text = new StringBuilder(line.length());
List<Span> gold = new ArrayList<>();
Matcher m = MARKUP.matcher(line);
int cursor = 0;
while (m.find()) {
text.append(line, cursor, m.start());
int start = text.length();
text.append(m.group(2));
gold.add(new Span(start, text.length(), m.group(1), 0));
cursor = m.end();
}
text.append(line, cursor, line.length());
return new Sample(text.toString(), gold);
}
}
+374 -320
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,348 +11,409 @@ 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=...}.
*/
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/**
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
* отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
*/
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
/** Итог замера по одному набору. */
private record Result(double fioF1, double overallPrecision, double overallRecall,
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
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 static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
}
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private int gold() {
return truePositive + falseNegative;
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
}
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
/**
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение означает, что
* сломалось то, что раньше работало.
*/
@Test
void detectionQualityOnTuningSet() {
Result result = measure("/benchmark.txt", "набор отладки");
assertTrue(result.fioF1() >= 0.95, String.format("F1 по ФИО упал до %.3f", result.fioF1()));
assertTrue(
result.overallRecall() >= 0.95,
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
assertTrue(
result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
}
/**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они отражают измеренное на нём
* качество, а не желаемое.
*/
@Test
void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(
result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(
result.falsePositiveRate() <= 0.15,
String.format(
"ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
}
/**
* Второй контрольный набор, составленный после того, как первый дважды повлиял на правила. На нём
* не настраивалось ничего — он и показывает настоящее качество. Пороги низкие намеренно: тест
* ловит обвал, а не сторожит значение.
*/
@Test
void detectionQualityOnThirdHoldoutSet() {
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,
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
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(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
}
/**
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации написания, не
* встречавшиеся ни в одном из остальных наборов. Правила под него не настраивались; пороги низкие
* по той же причине, что и у второго отложенного набора — тест ловит обвал, а не сторожит
* достигнутое значение.
*/
@Test
void detectionQualityOnGeneratedSet() {
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,
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
}
/**
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет — проверка пропускается:
* в сборке без модели сервис работает на одних правилах.
*/
@Test
void detectionQualityWithNameCascade() {
Path model = Path.of(MODEL_PATH);
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
Pipeline withCascade =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
Result result =
measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
}
private Result measure(String resource, String title) {
return measure(pipeline, resource, title);
}
private Result measure(Pipeline stage, String resource, String title) {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
Map<String, Score> byType = new LinkedHashMap<>();
Score anyType = new Score();
int cleanTexts = 0;
int cleanTextsWithFalseHit = 0;
int goldFioSpans = 0;
int foundFioSpans = 0;
List<String> falseHits = new ArrayList<>();
List<String> missedFio = new ArrayList<>();
List<String> overMasked = new ArrayList<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
}
if (sample.gold().isEmpty()) {
cleanTexts++;
if (!found.isEmpty()) {
cleanTextsWithFalseHit++;
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
}
} else {
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
for (Span gold : sample.gold()) {
if (!PdTypes.FIO.equals(gold.type())) {
continue;
}
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
goldFioSpans++;
if (overlappedByFio(gold, found)) {
foundFioSpans++;
} else {
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
}
}
}
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
report(
title,
samples.size(),
byType,
anyType,
goldFioSpans,
foundFioSpans,
cleanTexts,
cleanTextsWithFalseHit,
missedFio,
falseHits,
overMasked);
/**
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
* означает, что сломалось то, что раньше работало.
*/
@Test
void detectionQualityOnTuningSet() {
Result result = measure("/benchmark.txt", "набор отладки");
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);
}
assertTrue(result.fioF1() >= 0.95,
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.95,
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
/**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
* отражают измеренное на нём качество, а не желаемое.
*/
@Test
void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.15,
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
/**
* Второй контрольный набор, составленный после того, как первый дважды повлиял
* на правила. На нём не настраивалось ничего — он и показывает настоящее
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
*/
@Test
void detectionQualityOnThirdHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
/**
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
* показывать качество на данных, которых разработка не видела. Пороги здесь
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
*/
@Test
void detectionQualityOnSecondHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
private static void accountAnyType(Score score, boolean gold, boolean found) {
if (gold && found) {
score.truePositive++;
} else if (gold) {
score.falseNegative++;
} else if (found) {
score.falsePositive++;
}
}
/**
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
* расширенный денилист, обобщённое companion-правило (место рождения,
* страна) и новые банковские типы. Собран специально под соответствующие
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
* что было доработано, а не общее качество остального пайплайна.
*/
@Test
void detectionQualityOnBankContextSet() {
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
}
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()));
private static String fragment(String text, Span span) {
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
}
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(
String text, String[] gold, String[] found, List<String> sink) {
int from = -1;
for (int i = 0; i <= text.length(); i++) {
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
if (extra && from < 0) {
from = i;
} else if (!extra && from >= 0) {
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
from = -1;
}
}
}
/**
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
* него не настраивались; пороги низкие по той же причине, что и у второго
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
*/
@Test
void detectionQualityOnGeneratedSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
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"));
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
byType.entrySet().stream()
.sorted(
Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(
e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
"ЛЮБОЙ ТИП", anyType.gold(), anyType.precision(), anyType.recall(), anyType.f1()));
out.append(
String.format(
"%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
out.append(
String.format(
"Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio);
appendList(out, "\nЛожные срабатывания:", falseHits);
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
System.out.println(out);
}
private static void appendList(StringBuilder out, String title, List<String> lines) {
if (lines.isEmpty()) {
return;
}
/**
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
*/
@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", "отложенный набор, вторая ступень включена");
assertTrue(result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
}
private Result measure(String resource, String title) {
return measure(pipeline, resource, title);
}
private Result measure(Pipeline stage, String resource, String title) {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
Map<String, Score> byType = new LinkedHashMap<>();
Score anyType = new Score();
int cleanTexts = 0;
int cleanTextsWithFalseHit = 0;
int goldFioSpans = 0;
int foundFioSpans = 0;
List<String> falseHits = new ArrayList<>();
List<String> missedFio = new ArrayList<>();
List<String> overMasked = new ArrayList<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
}
if (sample.gold().isEmpty()) {
cleanTexts++;
if (!found.isEmpty()) {
cleanTextsWithFalseHit++;
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
}
} else {
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
}
for (Span gold : sample.gold()) {
if (!PdTypes.FIO.equals(gold.type())) {
continue;
}
goldFioSpans++;
if (overlappedByFio(gold, found)) {
foundFioSpans++;
} else {
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
}
}
}
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
Score fio = byType.getOrDefault(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);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private static void accountAnyType(Score score, boolean gold, boolean found) {
if (gold && found) {
score.truePositive++;
} else if (gold) {
score.falseNegative++;
} else if (found) {
score.falsePositive++;
}
}
private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream()
.anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
}
private static String fragment(String text, Span span) {
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
}
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
int from = -1;
for (int i = 0; i <= text.length(); i++) {
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
if (extra && from < 0) {
from = i;
} else if (!extra && from >= 0) {
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
from = -1;
}
}
}
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
StringBuilder out = new StringBuilder(4096);
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
anyType.precision(), anyType.recall(), anyType.f1()));
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio);
appendList(out, "\nЛожные срабатывания:", falseHits);
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
System.out.println(out);
}
private static void appendList(StringBuilder out, String title, List<String> lines) {
if (lines.isEmpty()) {
return;
}
out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n'));
}
out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n'));
}
}
@@ -1,18 +1,18 @@
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;
@Autowired PayloadCipher cipher;
@Test
void cipherIsEnabled() {
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
}
@Test
void cipherIsEnabled() {
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
}
}
+15 -12
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)),
"round-trip с ключом из application.yml должен работать");
}
@Test
void keyIsValidAes256() {
PayloadCipher cipher = new PayloadCipher(KEY);
assertTrue(cipher.enabled(), "ключ должен включать шифрование");
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
assertEquals(
original,
cipher.decrypt(cipher.encrypt(original)),
"round-trip с ключом из application.yml должен работать");
}
}
+128 -127
View File
@@ -1,162 +1,163 @@
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));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksPassportInEveryNotation() {
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
assertHidden("ПАСПОРТ 4509123456", "4509123456");
assertHidden("Серия 4509 номер 123456", "4509");
assertHidden("серии 45 09 № 123456", "123456");
}
@Test
void masksPassportInEveryNotation() {
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
assertHidden("ПАСПОРТ 4509123456", "4509123456");
assertHidden("Серия 4509 номер 123456", "4509");
assertHidden("серии 45 09 № 123456", "123456");
}
@Test
void masksPassportSeriesAndNumberSplitByWords() {
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
assertTrue(masked.contains("серия "), masked);
assertTrue(masked.contains("номер "), masked);
assertFalse(masked.contains("4509"), masked);
assertFalse(masked.contains("123456"), masked);
}
@Test
void masksPassportSeriesAndNumberSplitByWords() {
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
assertTrue(masked.contains("серия "), masked);
assertTrue(masked.contains("номер "), masked);
assertFalse(masked.contains("4509"), masked);
assertFalse(masked.contains("123456"), masked);
}
@Test
void masksDepartmentCode() {
assertHidden("Код подразделения 770-001", "770-001");
assertHidden("к/п 770001", "770001");
}
@Test
void masksDepartmentCode() {
assertHidden("Код подразделения 770-001", "770-001");
assertHidden("к/п 770001", "770001");
}
@Test
void masksIssuingAuthorityButNotTheDateAfterIt() {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test
void masksIssuingAuthorityButNotTheDateAfterIt() {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(
masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test
void masksDriverLicense() {
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
assertHidden("в/у 99 02 123456", "99 02 123456");
}
@Test
void masksDriverLicense() {
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
assertHidden("в/у 99 02 123456", "99 02 123456");
}
@Test
void masksCitizenship() {
assertHidden("Гражданство: РФ", "РФ");
assertHidden("гражданство Республики Беларусь", "Беларусь");
assertHidden("Гражданин России обратился", "России");
}
@Test
void masksCitizenship() {
assertHidden("гражданство Республики Беларусь", "Беларусь");
}
@Test
void masksBirthPlace() {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон.
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
}
@Test
void masksBirthPlace() {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон.
assertHidden(
"Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
}
@Test
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
String text = "Экскурсия в Нижний Новгород перенесена на май";
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
}
@Test
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
String text = "Экскурсия в Нижний Новгород перенесена на май";
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
}
@Test
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
}
@Test
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
}
@Test
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
String text = "Цены на нефть выросли в Казахстане в этом квартале";
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
}
@Test
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
String text = "Цены на нефть выросли в Казахстане в этом квартале";
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
}
@Test
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
}
@Test
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
}
@Test
void masksCardholderName() {
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
}
@Test
void masksCardholderName() {
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
}
@Test
void masksSecurityCodeAndPinCompletely() {
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
assertFalse(masked.contains("123,"), masked);
assertFalse(masked.contains("4321"), masked);
assertTrue(masked.contains("***"), masked);
}
@Test
void masksSecurityCodeAndPinCompletely() {
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
assertFalse(masked.contains("123,"), masked);
assertFalse(masked.contains("4321"), masked);
assertTrue(masked.contains("***"), masked);
}
@Test
void doesNotMaskPinWithoutAnyOtherPersonalData() {
String text = "Пин-код 1234 введён неверно";
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
}
@Test
void doesNotMaskPinWithoutAnyOtherPersonalData() {
String text = "Пин-код 1234 введён неверно";
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
}
@Test
void masksPinWhenCardNumberIsAlsoPresent() {
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
}
@Test
void masksPinWhenCardNumberIsAlsoPresent() {
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
}
@Test
void anchorWordsAreCaseInsensitive() {
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
}
@Test
void anchorWordsAreCaseInsensitive() {
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
}
@Test
void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
@Test
void complexSentenceKeepsSurroundingWords() {
String original =
"Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
assertTrue(masked.contains("код подразделения"), masked);
assertTrue(masked.contains("телефон"), masked);
assertFalse(masked.contains("4509 123456"), masked);
assertFalse(masked.contains("770301234550"), masked);
}
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
assertTrue(masked.contains("код подразделения"), masked);
assertTrue(masked.contains("телефон"), masked);
assertFalse(masked.contains("4509 123456"), masked);
assertFalse(masked.contains("770301234550"), masked);
}
@Test
void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
@Test
void unmaskingRestoresComplexSentence() {
String original =
"Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("4509 123456"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("4509 123456"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
}
+48 -47
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,60 +15,55 @@ 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 RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-200.txt");
@TestFactory
Stream<DynamicTest> datasetOf200Cases() {
List<DynamicTest> cases = new java.util.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("#%03d: %s", index, preview(sample.text())),
() -> runCase(sample, index)));
}
return cases.stream();
@TestFactory
Stream<DynamicTest> datasetOf200Cases() {
List<DynamicTest> cases = new java.util.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("#%03d: %s", index, preview(sample.text())),
() -> runCase(sample, index)));
}
return cases.stream();
}
private void runCase(BenchmarkFixtures.Sample sample, int index) {
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),
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
}
private void runCase(BenchmarkFixtures.Sample sample, int index) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30));
String payloadId = "dataset200-" + index;
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
}
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),
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
}
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
}
private static String preview(String text) {
return text.length() <= 40 ? text : text.substring(0, 40) + "...";
}
private static String preview(String text) {
return text.length() <= 40 ? text : text.substring(0, 40) + "...";
}
}
+108 -111
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;
@@ -8,137 +13,129 @@ 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));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksBirthDateInAnyPartOrder() {
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
assertHidden("Родился 12-05-1985", "12-05-1985");
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
}
@Test
void masksBirthDateInAnyPartOrder() {
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
assertHidden("Родился 12-05-1985", "12-05-1985");
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
}
@Test
void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая");
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
}
@Test
void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden(
"Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая");
}
@Test
void keepsSeparatorsInMaskedDate() {
String masked = mask("Дата рождения 12.05.1985");
assertTrue(masked.endsWith("**.**.****"), masked);
}
@Test
void keepsSeparatorsInMaskedDate() {
String masked = mask("Дата рождения 12.05.1985");
assertTrue(masked.endsWith("**.**.****"), masked);
}
@Test
void masksPassportIssueDate() {
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
}
@Test
void masksPassportIssueDate() {
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
}
@Test
void doesNotMaskDateWithoutAnyOtherPersonalData() {
String text = "Встреча перенесена на 12.05.2025, подтвердите";
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
}
@Test
void doesNotMaskDateWithoutAnyOtherPersonalData() {
String text = "Встреча перенесена на 12.05.2025, подтвердите";
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
}
@Test
void masksBareDateWhenOtherPersonalDataIsPresent() {
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
}
@Test
void masksBareDateWhenOtherPersonalDataIsPresent() {
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
}
@Test
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
assertEquals(text, mask(text));
}
@Test
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
assertEquals(text, mask(text));
}
@Test
void masksAddressComponentsSeparately() {
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
assertFalse(masked.contains("125009"), masked);
assertFalse(masked.contains("Москва"), masked);
assertFalse(masked.contains("Тверская"), masked);
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
assertTrue(masked.contains("ул. "), masked);
}
@Test
void masksAddressComponentsSeparately() {
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
assertFalse(masked.contains("125009"), masked);
assertFalse(masked.contains("Москва"), masked);
assertFalse(masked.contains("Тверская"), masked);
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
assertTrue(masked.contains("ул. "), masked);
}
@Test
void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked);
}
@Test
void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(
masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked);
}
@Test
void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника"));
}
@Test
void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals(
"Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника"));
}
@Test
void masksMultiWordStreetName() {
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
assertFalse(masked.contains("Малая Никитская"), masked);
}
@Test
void masksMultiWordStreetName() {
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
assertFalse(masked.contains("Малая Никитская"), masked);
}
@Test
void masksIndexByAnchorWord() {
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
}
@Test
void masksIndexByAnchorWord() {
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
}
@Test
void doesNotMaskBankBranchAddress() {
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
}
@Test
void doesNotMaskOfficeAddress() {
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
assertEquals(text, mask(text));
}
@Test
void doesNotMaskOfficeAddress() {
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
assertEquals(text, mask(text));
}
@Test
void addressTypesAreConfigurableSeparately() {
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
@Test
void addressTypesAreConfigurableSeparately() {
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
assertFalse(masked.contains("Москва"), masked);
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
}
assertFalse(masked.contains("Москва"), masked);
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
}
@Test
void unmaskingRestoresTextWithDateAndAddress() {
String original =
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
@Test
void unmaskingRestoresTextWithDateAndAddress() {
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("12.05.1985"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("12.05.1985"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
}
+99 -98
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,125 +12,121 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** ФИО и защита от ложных срабатываний. */
class FioTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertUnchanged(String text) {
assertEquals(text, mask(text), "ложное срабатывание");
}
private void assertUnchanged(String text) {
assertEquals(text, mask(text), "ложное срабатывание");
}
@Test
void masksFullNameAsInitials() {
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
}
@Test
void masksFullNameAsInitials() {
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
}
@Test
void masksNameAndPatronymicWithoutSurname() {
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
}
@Test
void masksNameAndPatronymicWithoutSurname() {
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
}
@Test
void masksFemalePatronymic() {
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
}
@Test
void masksFemalePatronymic() {
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
}
@Test
void masksSurnameWithInitialsInBothOrders() {
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
}
@Test
void masksSurnameWithInitialsInBothOrders() {
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
}
@Test
void masksSurnameNextToKnownGivenName() {
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
}
@Test
void masksSurnameNextToKnownGivenName() {
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
}
@Test
void masksLowercaseNameAfterExplicitAnchor() {
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
}
@Test
void masksLowercaseNameAfterExplicitAnchor() {
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
}
@Test
void masksNameAfterRoleAnchor() {
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
}
@Test
void masksNameAfterRoleAnchor() {
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
}
@Test
void doesNotMaskWellKnownPerson() {
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
assertUnchanged("Когда Гагарин полетел в космос");
}
@Test
void doesNotMaskWellKnownPerson() {
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
assertUnchanged("Когда Гагарин полетел в космос");
}
@Test
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
}
@Test
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
}
@Test
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
assertUnchanged("Московский Кремль открыт для посещения");
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
assertUnchanged("Смоленская площадь закрыта на ремонт");
}
@Test
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
assertUnchanged("Московский Кремль открыт для посещения");
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
assertUnchanged("Смоленская площадь закрыта на ремонт");
}
@Test
void doesNotMaskOrdinaryCapitalisedWords() {
assertUnchanged("Банк Открытие подтвердил лимит");
assertUnchanged("В Понедельник Отдел Согласует Договор");
}
@Test
void doesNotMaskOrdinaryCapitalisedWords() {
assertUnchanged("Банк Открытие подтвердил лимит");
assertUnchanged("В Понедельник Отдел Согласует Договор");
}
@Test
void identificationIgnoresCase() {
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
}
@Test
void identificationIgnoresCase() {
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
}
@Test
void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
@Test
void unmaskingRestoresNames() {
String original =
"Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
assertTrue(masked.contains("И. И. И."), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
assertTrue(masked.contains("И. И. И."), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
@Test
void namesStayFastOnLargeText() {
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
String large = block.repeat(4000);
@Test
void namesStayFastOnLargeText() {
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
String large = block.repeat(4000);
long started = System.nanoTime();
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
long started = System.nanoTime();
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("Иванов Иван Иванович"));
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
}
assertFalse(masked.contains("Иванов Иван Иванович"));
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
}
}
+146 -128
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,149 +18,158 @@ 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>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
*/
class HugeDatasetTest {
private static final int TOTAL_CASES = 1000;
private static final int HUGE_CASES = 50;
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, номер дома и т.п.) чаще случайно совпадают с посторонним
* текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */
private static final int LEAK_CHECK_MIN_LENGTH = 6;
/** Допустимая доля утечек на прогон. Пул включает настоящие 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 int TOTAL_CASES = 1000;
private static final int HUGE_CASES = 50;
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;
private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> POOL = loadPool();
/**
* Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
* их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
*/
private static final int LEAK_CHECK_MIN_LENGTH = 6;
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")) {
pool.addAll(BenchmarkFixtures.load(resource));
/**
* Допустимая доля утечек на прогон. Пул включает настоящие 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();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> POOL = loadPool();
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")) {
pool.addAll(BenchmarkFixtures.load(resource));
}
return pool;
}
@TestFactory
Stream<DynamicTest> datasetOfThousandCases() {
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
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),
() -> runCase(targetChars, index)));
}
return cases.stream();
}
/**
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — так тесты
* покрывают все порядки величины, а не только маленькие и не только большие. Последние {@link
* #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
*/
private static int targetChars(int index) {
int regular = TOTAL_CASES - HUGE_CASES;
if (index >= regular) {
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1);
return HUGE_MIN_CHARS + (index - regular) * step;
}
double minChars = 80;
double maxChars = HUGE_MIN_CHARS - 1;
double ratio = (double) index / Math.max(1, regular - 1);
return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio));
}
private void runCase(int targetChars, int seed) {
Pipeline pipeline =
new Pipeline(REGISTRY, MASKER, new PayloadStore(30));
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
String payloadId = "dataset-" + seed;
long maskStarted = System.nanoTime();
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
int checked = 0;
int leaked = 0;
for (Span gold : sample.gold()) {
String value = sample.text().substring(gold.start(), gold.end());
if (value.length() >= LEAK_CHECK_MIN_LENGTH) {
checked++;
if (masked.contains(value)) {
leaked++;
}
return pool;
}
}
if (checked > 0) {
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
assertTrue(
leaked <= allowed,
String.format(
"утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
leaked, checked, allowed));
}
@TestFactory
Stream<DynamicTest> datasetOfThousandCases() {
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
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),
() -> runCase(targetChars, index)));
}
return cases.stream();
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
if (targetChars >= HUGE_MIN_CHARS) {
assertTrue(
maskMillis < 5000,
"маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
}
}
/**
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста —
* так тесты покрывают все порядки величины, а не только маленькие и не только большие.
* Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
*/
private static int targetChars(int index) {
int regular = TOTAL_CASES - HUGE_CASES;
if (index >= regular) {
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1);
return HUGE_MIN_CHARS + (index - regular) * step;
/** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
Random random = new Random(seed);
StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>();
while (text.length() < targetChars) {
Collections.shuffle(shuffled, random);
for (BenchmarkFixtures.Sample sample : shuffled) {
int offset = text.length();
text.append(sample.text()).append('\n');
for (Span span : sample.gold()) {
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
}
double minChars = 80;
double maxChars = HUGE_MIN_CHARS - 1;
double ratio = (double) index / Math.max(1, regular - 1);
return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio));
}
private void runCase(int targetChars, int seed) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
String payloadId = "dataset-" + seed;
long maskStarted = System.nanoTime();
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
int checked = 0;
int leaked = 0;
for (Span gold : sample.gold()) {
String value = sample.text().substring(gold.start(), gold.end());
if (value.length() >= LEAK_CHECK_MIN_LENGTH) {
checked++;
if (masked.contains(value)) {
leaked++;
}
}
if (text.length() >= targetChars) {
break;
}
if (checked > 0) {
// На малых текстах пара пропусков — статистический шум, не деградация детектора:
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
assertTrue(leaked <= allowed,
String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
leaked, checked, allowed));
}
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
if (targetChars >= HUGE_MIN_CHARS) {
assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
}
}
/** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
Random random = new Random(seed);
StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>();
while (text.length() < targetChars) {
Collections.shuffle(shuffled, random);
for (BenchmarkFixtures.Sample sample : shuffled) {
int offset = text.length();
text.append(sample.text()).append('\n');
for (Span span : sample.gold()) {
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
}
if (text.length() >= targetChars) {
break;
}
}
}
return new BenchmarkFixtures.Sample(text.toString(), gold);
}
}
return new BenchmarkFixtures.Sample(text.toString(), gold);
}
}
@@ -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,64 +11,62 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
class IdentityDocumentTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private 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 + "»");
}
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertMasked(String text, String payloadId, String expected) {
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
}
private void assertMasked(String text, String payloadId, String expected) {
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
}
@Test
void masksForeignPassport() {
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
}
@Test
void masksForeignPassport() {
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
}
@Test
void masksMilitaryId() {
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
}
@Test
void masksMilitaryId() {
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
}
@Test
void masksBirthCertificate() {
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
}
@Test
void masksBirthCertificate() {
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
}
@Test
void masksMedicalPolicy() {
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
}
@Test
void masksMedicalPolicy() {
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
}
/**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
* целиком, поэтому у этих документов открыты только последние знаки номера.
*/
@Test
void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
"Свидетельство о рождении **-** № ****56");
}
/**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
* буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
* открыты только последние знаки номера.
*/
@Test
void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked(
"Свидетельство о рождении II-МЮ № 123456",
"bc-1",
"Свидетельство о рождении **-** № ****56");
}
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
@Test
void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
"Водительское удостоверение 99** ****56");
}
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
@Test
void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked(
"Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
}
}
+125 -120
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,131 +10,143 @@ 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 {
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
private static final int TARGET_CHARS = 400_000;
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
private static final int TARGET_CHARS = 400_000;
/**
* Перемешивает исходные строки (фиксированный seed — детерминированный
* тест) и склеивает их через перенос строки, пока не наберётся целевой
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
*/
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
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<>();
/**
* Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
* перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
* под общий текст.
*/
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
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<>();
while (text.length() < targetChars) {
Collections.shuffle(pool, random);
for (BenchmarkFixtures.Sample sample : pool) {
int offset = text.length();
text.append(sample.text()).append('\n');
for (Span span : sample.gold()) {
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
}
if (text.length() >= targetChars) {
break;
}
}
while (text.length() < targetChars) {
Collections.shuffle(pool, random);
for (BenchmarkFixtures.Sample sample : pool) {
int offset = text.length();
text.append(sample.text()).append('\n');
for (Span span : sample.gold()) {
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
}
return new BenchmarkFixtures.Sample(text.toString(), gold);
}
/**
* Маскирование и обратное преобразование на большом тексте дают
* побайтово тот же результат, что и исходный текст — при объёме на
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
* а не одним повторяющимся предложением.
*/
@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));
long maskStarted = System.nanoTime();
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
long unmaskStarted = System.nanoTime();
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
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));
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
int hit = 0;
for (Span gold : large.gold()) {
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
hit++;
}
if (text.length() >= targetChars) {
break;
}
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);
assertTrue(recall >= 0.85,
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
}
}
return new BenchmarkFixtures.Sample(text.toString(), gold);
}
/**
* Вторая ступень ограничена числом кандидатов на запрос
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
* превращать её в квадратичную нагрузку — проверяем на том же большом
* тексте, что и остальные тесты, а не на маленьком образце.
*/
@Test
void nameCascadeStaysBoundedOnLargeText() {
Path model = Path.of(MODEL_PATH);
if (!Files.isReadable(model)) {
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
return;
}
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(large.text().length() * 2L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
/**
* Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и
* исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным
* содержимым, а не одним повторяющимся предложением.
*/
@Test
void roundTripOnLargeMixedText() {
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
Pipeline pipeline =
new Pipeline(
new RuleRegistry(), new Masker(), new PayloadStore(30));
long started = System.nanoTime();
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
long maskStarted = System.nanoTime();
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n",
large.text().length(), millis);
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
long unmaskStarted = System.nanoTime();
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
assertTrue(
maskMillis < 5000,
"маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
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(30));
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
int hit = 0;
for (Span gold : large.gold()) {
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
hit++;
}
}
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);
assertTrue(
recall >= 0.85,
String.format(
"полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
}
/**
* Вторая ступень ограничена числом кандидатов на запрос ({@code pdguard.ner.max-candidates}),
* поэтому объём текста не должен превращать её в квадратичную нагрузку — проверяем на том же
* большом тексте, что и остальные тесты, а не на маленьком образце.
*/
@Test
void nameCascadeStaysBoundedOnLargeText() {
Path model = Path.of(MODEL_PATH);
if (!Files.isReadable(model)) {
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
return;
}
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
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);
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), "должно найти паспорт");
}
}
+89 -67
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,78 +17,92 @@ import ru.pdguard.detect.Validators;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Виды замены: звёздочки, токены, правдоподобные значения. */
class MaskModeTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private final Pipeline pipeline =
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);
private SystemPolicy policy(MaskMode mode) {
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");
assertTrue(masked.contains("[FIO_1]"), masked);
assertTrue(masked.contains("[EMAIL_1]"), masked);
}
@Test
void sameValueGetsSameTokenWithinRequest() {
String masked =
mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
}
@Test
void syntheticModeProducesPlausibleValues() {
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked);
assertTrue(
Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
}
@Test
void syntheticValuesAreStable() {
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
}
@Test
void unmaskingWorksInEveryMode() {
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
for (MaskMode mode : MaskMode.values()) {
String id = "mode-" + mode;
String masked = pipeline.process(original, id, policy(mode));
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
}
}
private String mask(MaskMode mode, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
}
@Test
void tokenModeNumbersEachType() {
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
assertTrue(masked.contains("[FIO_1]"), masked);
assertTrue(masked.contains("[EMAIL_1]"), masked);
}
@Test
void sameValueGetsSameTokenWithinRequest() {
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
}
@Test
void syntheticModeProducesPlausibleValues() {
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked);
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
}
@Test
void syntheticValuesAreStable() {
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
}
@Test
void unmaskingWorksInEveryMode() {
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
for (MaskMode mode : MaskMode.values()) {
String id = "mode-" + mode;
String masked = pipeline.process(original, id, policy(mode));
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
}
}
private static int count(String text, String fragment) {
int n = 0;
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
n++;
}
return n;
private static int count(String text, String fragment) {
int n = 0;
for (int i = text.indexOf(fragment);
i >= 0;
i = text.indexOf(fragment, i + fragment.length())) {
n++;
}
return n;
}
}
+44 -41
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,51 +17,46 @@ 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 static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
private String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(1_000_000L, 30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
private String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
}
@Test
void withoutModelTheStageIsOff() {
NameCascade cascade = NameCascade.disabled();
assertFalse(cascade.enabled());
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
}
@Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade =
new NameCascade(
"rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
}
@Test
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
Path broken = dir.resolve("испорченная-модель");
Files.createDirectories(broken);
for (String name : new String[] {"model_int8.onnx", "vocab.txt", "config.json"}) {
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
}
@Test
void withoutModelTheStageIsOff() {
NameCascade cascade = NameCascade.disabled();
assertFalse(cascade.enabled());
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
}
@Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
}
@Test
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
Path broken = dir.resolve("испорченная-модель");
Files.createDirectories(broken);
for (String name : new String[]{"model_int8.onnx", "vocab.txt", "config.json"}) {
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
}
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
"маскирование по правилам обязано работать и без второй ступени");
}
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
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,86 +11,91 @@ 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));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
@Test
void keepsNamesInsideInstitutionNames() {
for (String text : new String[]{
"Институт Мечникова принимает по записи",
"Музей Верещагина работает по будням",
"Театр Станиславского открыл сезон",
"Библиотека Некрасова закрыта на ремонт",
"Премия имени Ломоносова вручена в декабре",
"Больница Боткина приняла пациентов",
"Стадион Яшина отремонтирован"}) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
}
@Test
void keepsNamesInsideInstitutionNames() {
for (String text :
new String[] {
"Институт Мечникова принимает по записи",
"Музей Верещагина работает по будням",
"Театр Станиславского открыл сезон",
"Библиотека Некрасова закрыта на ремонт",
"Премия имени Ломоносова вручена в декабре",
"Больница Боткина приняла пациентов",
"Стадион Яшина отремонтирован"
}) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
}
}
@Test
void keepsNamesInsidePlaceNames() {
for (String text : new String[]{
"Улица Королёва названа в честь конструктора",
"Проспект Вернадского перекрыт до вечера",
"Площадь Гагарина находится на юго-западе",
"Набережная Макарова уходит к заливу",
"Мост Кадырова разведут ночью"}) {
assertEquals(text, mask(text), "топоним маскировать не нужно");
}
@Test
void keepsNamesInsidePlaceNames() {
for (String text :
new String[] {
"Улица Королёва названа в честь конструктора",
"Проспект Вернадского перекрыт до вечера",
"Площадь Гагарина находится на юго-западе",
"Набережная Макарова уходит к заливу",
"Мост Кадырова разведут ночью"
}) {
assertEquals(text, mask(text), "топоним маскировать не нужно");
}
}
@Test
void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(masked.contains("Королёв Сергей Павлович"),
"однофамилец объекта на карте остаётся под защитой: " + masked);
}
@Test
void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(
masked.contains("Королёв Сергей Павлович"),
"однофамилец объекта на карте остаётся под защитой: " + masked);
}
@Test
void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(masked.contains("Иванова Ивана Ивановича"),
"слово-маркер действует только вплотную перед именем: " + masked);
}
@Test
void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(
masked.contains("Иванова Ивана Ивановича"),
"слово-маркер действует только вплотную перед именем: " + masked);
}
@Test
void keepsRulerNames() {
for (String text : new String[]{
"Василий Тёмный правил недолго",
"Ярослав Мудрый составил свод законов",
"Екатерина Вторая издала указ",
"Алексей Тишайший принимал послов"}) {
assertEquals(text, mask(text), "имя правителя персональными данными не является");
}
@Test
void keepsRulerNames() {
for (String text :
new String[] {
"Василий Тёмный правил недолго",
"Ярослав Мудрый составил свод законов",
"Екатерина Вторая издала указ",
"Алексей Тишайший принимал послов"
}) {
assertEquals(text, mask(text), "имя правителя персональными данными не является");
}
}
@Test
void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(masked.contains("Василий Тёмный"),
"рядом с паспортными данными это конкретный человек: " + masked);
}
@Test
void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(
masked.contains("Василий Тёмный"),
"рядом с паспортными данными это конкретный человек: " + masked);
}
@Test
void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
"имя предпринимателя — это персональные данные: " + masked);
}
@Test
void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(
masked.contains("Пахомов Вениамин Николаевич"),
"имя предпринимателя — это персональные данные: " + masked);
}
}
+38 -34
View File
@@ -1,50 +1,54 @@
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";
/** 32 байта в hex — валидный AES-256 ключ. */
private static final String KEY =
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test
void encryptDecryptRoundTrip() {
PayloadCipher cipher = new PayloadCipher(KEY);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String encrypted = cipher.encrypt(original);
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
}
@Test
void encryptDecryptRoundTrip() {
PayloadCipher cipher = new PayloadCipher(KEY);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String encrypted = cipher.encrypt(original);
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
}
@Test
void disabledCipherPassesThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
String original = "Клиент Иванов";
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
}
@Test
void disabledCipherPassesThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
String original = "Клиент Иванов";
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
}
@Test
void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
@Test
void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store =
new PayloadStore(30, ru.pdguard.core.SharedIndex.disabled(), cipher);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56";
store.put("test", "id-1", original, masked);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56";
store.put("test", "id-1", original, masked);
// Чтение по id возвращает исходный текст.
PayloadStore.Entry entry = store.byId("test", "id-1");
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
// Чтение по id возвращает исходный текст.
PayloadStore.Entry entry = store.byId("test", "id-1");
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
// Чтение по маске возвращает исходный текст.
assertEquals(original, store.originalForMask("test", masked),
"чтение по маске должно вернуть исходный текст");
}
// Чтение по маске возвращает исходный текст.
assertEquals(
original,
store.originalForMask("test", masked),
"чтение по маске должно вернуть исходный текст");
}
}
+48 -57
View File
@@ -1,73 +1,64 @@
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";
private static final String SYSTEM = "crm";
@Test
void returnsWhatWasStored() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put(SYSTEM, "id", "исходный текст", "маска");
@Test
void returnsWhatWasStored() {
PayloadStore store = new PayloadStore(30);
store.put(SYSTEM, "id", "исходный текст", "маска");
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
assertNotNull(entry);
assertEquals("исходный текст", entry.original());
assertEquals("маска", entry.masked());
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
}
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
assertNotNull(entry);
assertEquals("исходный текст", entry.original());
assertEquals("маска", entry.masked());
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
}
@Test
void forgetsEntriesAfterTheirLifetime() {
PayloadStore store = new PayloadStore(1_000_000L, 0);
store.put(SYSTEM, "id", "исходный текст", "маска");
@Test
void forgetsEntriesAfterTheirLifetime() {
PayloadStore store = new PayloadStore(0);
store.put(SYSTEM, "id", "исходный текст", "маска");
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
assertNull(store.originalForMask(SYSTEM, "маска"));
}
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);
}
@Test
void unknownKeysReturnNothing() {
PayloadStore store = new PayloadStore(30);
assertNull(store.byId(SYSTEM, "нет такого"));
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
}
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
}
/**
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без
* разделения чужую маску можно было бы подобрать и обменять на исходные данные другого
* потребителя.
*/
@Test
void oneSystemCannotReadAnotherSystemData() {
PayloadStore store = new PayloadStore(30);
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
@Test
void unknownKeysReturnNothing() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
assertNull(store.byId(SYSTEM, "нет такого"));
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
}
/**
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
* на исходные данные другого потребителя.
*/
@Test
void oneSystemCannotReadAnotherSystemData() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
assertNull(store.originalForMask("analytics", "И. И. И."),
"чужую маску нельзя обменять на исходный текст");
assertNull(store.byId("analytics", "общий-id"),
"совпадение идентификатора у другой системы не даёт доступа");
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
"своя система свои данные по-прежнему получает");
}
assertNull(
store.originalForMask("analytics", "И. И. И."),
"чужую маску нельзя обменять на исходный текст");
assertNull(
store.byId("analytics", "общий-id"),
"совпадение идентификатора у другой системы не даёт доступа");
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,142 +15,146 @@ 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));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
private int gold() {
return truePositive + falseNegative;
}
@Test
void efficiencyByPdnType() {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
Map<String, Score> byType = new LinkedHashMap<>();
Map<String, List<String>> missed = new LinkedHashMap<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
}
report(byType);
reportMissed(missed);
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
for (Map.Entry<String, Score> e : byType.entrySet()) {
if (isCompanion(e.getKey())) {
continue;
}
assertTrue(e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private void reportMissed(Map<String, List<String>> missed) {
if (missed.isEmpty()) {
return;
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
}
@Test
void efficiencyByPdnType() {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
Map<String, Score> byType = new LinkedHashMap<>();
Map<String, List<String>> missed = new LinkedHashMap<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> {
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
}
}
report(byType);
reportMissed(missed);
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
for (Map.Entry<String, Score> e : byType.entrySet()) {
if (isCompanion(e.getKey())) {
continue;
}
assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
private void reportMissed(Map<String, List<String>> missed) {
if (missed.isEmpty()) {
return;
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach(
(type, values) -> {
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
});
System.out.println(out);
System.out.println(out);
}
private static boolean isCompanion(String type) {
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
private static boolean isCompanion(String type) {
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private 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"));
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"));
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())));
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())));
System.out.println(out);
}
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,191 +16,214 @@ 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 {
/** Типовой текст с ПД — как в реальном обращении. */
private static final String[] PAYLOADS = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
/** Типовой текст с ПД — как в реальном обращении. */
private static final String[] PAYLOADS = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
/**
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
* а не правила, которые в типовых текстах уже всё покрыли.
*/
private static final String[] CASCADE_PAYLOADS = {
"Готье и Руссо пришли на встречу в офис",
"Дюма написал роман за несколько месяцев",
"Виктор Гюго был известным писателем",
"Оноре де Бальзак писал романы о жизни",
"Жан-Поль Сартр философ и писатель",
};
/**
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает вторая ступень (модель).
* Нужны, чтобы честно измерить стоимость модели, а не правила, которые в типовых текстах уже всё
* покрыли.
*/
private static final String[] CASCADE_PAYLOADS = {
"Готье и Руссо пришли на встречу в офис",
"Дюма написал роман за несколько месяцев",
"Виктор Гюго был известным писателем",
"Оноре де Бальзак писал романы о жизни",
"Жан-Поль Сартр философ и писатель",
};
private static final int WARMUP = 20_000;
private static final int MEASURE = 50_000;
private static final int WARMUP = 20_000;
private static final int MEASURE = 50_000;
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));
private void warmup() {
for (int i = 0; i < WARMUP; i++) {
String text = PAYLOADS[i % PAYLOADS.length];
String id = "warmup-" + i;
pipeline.process(text, id, SystemPolicy.DEFAULT);
}
private void warmup() {
for (int i = 0; i < WARMUP; i++) {
String text = PAYLOADS[i % PAYLOADS.length];
String id = "warmup-" + i;
pipeline.process(text, id, SystemPolicy.DEFAULT);
}
}
/** Задержка маскирования и демаскирования типового обращения. */
@Test
void singleRequestLatency() {
warmup();
long[] maskNanos = new long[MEASURE];
long[] unmaskNanos = new long[MEASURE];
for (int i = 0; i < MEASURE; i++) {
String text = PAYLOADS[i % PAYLOADS.length];
String id = "lat-" + i;
long t0 = System.nanoTime();
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
long t1 = System.nanoTime();
pipeline.process(masked, id, SystemPolicy.DEFAULT);
unmaskNanos[i] = System.nanoTime() - t1;
}
/** Задержка маскирования и демаскирования типового обращения. */
@Test
void singleRequestLatency() {
warmup();
Arrays.sort(maskNanos);
Arrays.sort(unmaskNanos);
long[] maskNanos = new long[MEASURE];
long[] unmaskNanos = new long[MEASURE];
double maskUs = nanosToMicros(maskNanos);
double unmaskUs = nanosToMicros(unmaskNanos);
for (int i = 0; i < MEASURE; i++) {
String text = PAYLOADS[i % PAYLOADS.length];
String id = "lat-" + i;
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);
long t0 = System.nanoTime();
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
assertTrue(
maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
}
long t1 = System.nanoTime();
pipeline.process(masked, id, SystemPolicy.DEFAULT);
unmaskNanos[i] = System.nanoTime() - t1;
}
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
@Test
void throughputUnderLoad() throws Exception {
warmup();
Arrays.sort(maskNanos);
Arrays.sort(unmaskNanos);
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
int perThread = 10_000;
ExecutorService pool = Executors.newFixedThreadPool(threads);
double maskUs = nanosToMicros(maskNanos);
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);
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
long started = System.nanoTime();
List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int threadId = t;
futures.add(
pool.submit(
(Callable<Long>)
() -> {
long local = 0;
for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
String id = "load-" + threadId + "-" + i;
long t0 = System.nanoTime();
pipeline.process(text, id, SystemPolicy.DEFAULT);
local += System.nanoTime() - t0;
}
return local;
}));
}
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
@Test
void throughputUnderLoad() throws Exception {
warmup();
long totalNanos = 0;
for (Future<Long> f : futures) {
totalNanos += f.get();
}
long wallNanos = System.nanoTime() - started;
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
int perThread = 10_000;
ExecutorService pool = Executors.newFixedThreadPool(threads);
int requests = threads * perThread;
double rps = requests / (wallNanos / 1e9);
double avgUs = totalNanos / (double) requests / 1000.0;
long started = System.nanoTime();
List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int threadId = t;
futures.add(pool.submit((Callable<Long>) () -> {
long local = 0;
for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
String id = "load-" + threadId + "-" + i;
long t0 = System.nanoTime();
pipeline.process(text, id, SystemPolicy.DEFAULT);
local += System.nanoTime() - t0;
}
return local;
}));
}
System.out.printf(
"%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
long totalNanos = 0;
for (Future<Long> f : futures) {
totalNanos += f.get();
}
long wallNanos = System.nanoTime() - started;
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
}
int requests = threads * perThread;
double rps = requests / (wallNanos / 1e9);
double avgUs = totalNanos / (double) requests / 1000.0;
private static double nanosToMicros(long[] nanos) {
long sum = 0;
for (long n : nanos) {
sum += n;
}
return sum / (double) nanos.length / 1000.0;
}
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
@Test
void singleRequestLatencyWithNameCascade() {
Path model = Path.of("models/rubert-ner");
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
MeterRegistry meters = new SimpleMeterRegistry();
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++) {
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
}
private static double nanosToMicros(long[] nanos) {
long sum = 0;
for (long n : nanos) {
sum += n;
}
return sum / (double) nanos.length / 1000.0;
long[] maskNanos = new long[2000];
for (int i = 0; i < 2000; i++) {
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
String id = "cascade-lat-" + i;
long t0 = System.nanoTime();
withCascade.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
}
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
@Test
void singleRequestLatencyWithNameCascade() {
Path model = Path.of("models/rubert-ner");
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
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));
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));
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);
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
for (int i = 0; i < 200; i++) {
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
}
long[] maskNanos = new long[2000];
for (int i = 0; i < 2000; i++) {
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
String id = "cascade-lat-" + i;
long t0 = System.nanoTime();
withCascade.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
}
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));
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);
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
// должно укладываться в десятки миллисекунд.
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
"p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
}
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
// должно укладываться в десятки миллисекунд.
assertTrue(
maskNanos[(int) (n * 0.99)] < 200_000_000,
"p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000
+ " мс");
}
}
+109 -108
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,132 +14,127 @@ 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 {
private static final String VALID_CARD = "4111 1111 1111 1111";
private static final String VALID_INN_12 = "770301234550";
private static final String VALID_SNILS = "112-233-445 95";
private static final String VALID_CARD = "4111 1111 1111 1111";
private static final String VALID_INN_12 = "770301234550";
private static final String VALID_SNILS = "112-233-445 95";
private Pipeline pipeline() {
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private Pipeline pipeline() {
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
}
private String mask(Pipeline pipeline, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
@Test
void masksCardNumber() {
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
assertTrue(masked.contains("41** **** **** **11"), masked);
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
}
@Test
void keepsNumberThatFailsLuhn() {
String text = "Заказ 1234 5678 9012 3456 отгружен";
assertEquals(text, mask(pipeline(), text));
}
@Test
void masksEmailKeepingTopLevelDomain() {
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
assertEquals("Почта i**********@m***.ru для связи", masked);
}
@Test
void masksPhoneInAnyNotation() {
Pipeline pipeline = pipeline();
for (String phone : new String[] {"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
String masked = mask(pipeline, "Телефон " + phone);
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
assertTrue(masked.endsWith("67"), masked);
}
}
private String mask(Pipeline pipeline, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
@Test
void masksInnByContextAndByChecksum() {
Pipeline pipeline = pipeline();
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
}
@Test
void masksCardNumber() {
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
assertTrue(masked.contains("41** **** **** **11"), masked);
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
}
@Test
void masksSnils() {
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
assertFalse(masked.contains(VALID_SNILS), masked);
}
@Test
void keepsNumberThatFailsLuhn() {
String text = "Заказ 1234 5678 9012 3456 отгружен";
assertEquals(text, mask(pipeline(), text));
}
@Test
void unmaskingRestoresOriginalText() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
String id = "pair-1";
@Test
void masksEmailKeepingTopLevelDomain() {
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
assertEquals("Почта i**********@m***.ru для связи", masked);
}
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertNotEquals(original, masked);
@Test
void masksPhoneInAnyNotation() {
Pipeline pipeline = pipeline();
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
String masked = mask(pipeline, "Телефон " + phone);
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
assertTrue(masked.endsWith("67"), masked);
}
}
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
assertEquals(original, restored);
}
@Test
void masksInnByContextAndByChecksum() {
Pipeline pipeline = pipeline();
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
}
@Test
void retryReturnsSameMask() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD;
String id = "retry-1";
@Test
void masksSnils() {
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
assertFalse(masked.contains(VALID_SNILS), masked);
}
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertEquals(first, second);
}
@Test
void unmaskingRestoresOriginalText() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
String id = "pair-1";
@Test
void unmasksWhenPayloadIdIsUnknown() {
Pipeline pipeline = pipeline();
String original = "Почта ivan@mail.ru";
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertNotEquals(original, masked);
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
}
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
assertEquals(original, restored);
}
@Test
void textWithoutPersonalDataIsUnchanged() {
String text = "Расскажи о погоде в Москве завтра";
assertEquals(text, mask(pipeline(), text));
}
@Test
void retryReturnsSameMask() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD;
String id = "retry-1";
@Test
void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String masked =
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertEquals(first, second);
}
assertTrue(
masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked);
}
@Test
void unmasksWhenPayloadIdIsUnknown() {
Pipeline pipeline = pipeline();
String original = "Почта ivan@mail.ru";
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
@Test
void handlesLargeText() {
Pipeline pipeline = pipeline();
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
String large = block.repeat(4000);
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
}
long started = System.nanoTime();
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
@Test
void textWithoutPersonalDataIsUnchanged() {
String text = "Расскажи о погоде в Москве завтра";
assertEquals(text, mask(pipeline(), text));
}
@Test
void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked);
}
@Test
void handlesLargeText() {
Pipeline pipeline = pipeline();
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
String large = block.repeat(4000);
long started = System.nanoTime();
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("ivan@mail.ru"));
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
}
assertFalse(masked.contains("ivan@mail.ru"));
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
}
}
@@ -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) + "...";
}
}
+270 -190
View File
@@ -1,214 +1,294 @@
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");
}
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");
}
@Test
void maskAndUnmaskPair() {
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
String id = "8a77d363c7c044b49b41d7b8a448243a";
@Test
void maskAndUnmaskPair() {
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
String id = "8a77d363c7c044b49b41d7b8a448243a";
String masked = post(original, id);
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
}
String masked = post(original, id);
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
}
@Test
void rejectsRequestWithoutRequiredFields() {
@Test
void rejectsRequestWithoutRequiredFields() {
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "текст"))
.when()
.post("/process")
.then()
.statusCode(400);
}
@Test
void healthProbeResponds() {
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
}
@Test
void disabledSystemIsRefused() {
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
.when()
.post("/process")
.then()
.statusCode(403);
}
@Test
void systemPolicySelectsMaskMode() {
String masked =
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "текст"))
.when().post("/process")
.then().statusCode(400);
}
.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");
@Test
void healthProbeResponds() {
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
}
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
}
@Test
void disabledSystemIsRefused() {
@Test
void unknownSystemFallsBackToDefaultPolicy() {
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when()
.post("/process")
.then()
.statusCode(200)
.body("result", equalTo("почта i***@m***.ru"));
}
@Test
void metricsExposeLatencyAndTokenCounters() {
post("Клиент Иванов Иван Иванович", "metrics-1");
String body =
given()
.port(port)
.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);
}
.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 systemPolicySelectsMaskMode() {
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");
/**
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны, поэтому без
* разделения, прислав чужую маску, можно было бы получить исходные данные другого потребителя.
*/
@Test
void anotherSystemCannotExchangeMaskForOriginal() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String id = "cross-system-1";
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
}
@Test
void unknownSystemFallsBackToDefaultPolicy() {
String masked =
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when().post("/process")
.then().statusCode(200)
.body("result", equalTo("почта i***@m***.ru"));
}
.port(port)
.contentType("application/json")
.body(Map.of("payload", original, "payload_id", id))
.when()
.post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
@Test
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"), "нет метрики типов ПД");
}
/**
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны,
* поэтому без разделения, прислав чужую маску, можно было бы получить исходные
* данные другого потребителя.
*/
@Test
void anotherSystemCannotExchangeMaskForOriginal() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String id = "cross-system-1";
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");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
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,
"чужая система не должна получать исходный текст по маске");
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,
"своя система по своему идентификатору исходный текст получает");
}
@Test
void systemWithKeyRequiresIt() {
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);
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);
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);
}
@Test
void systemWithoutKeyWorksWithoutIt() {
given().port(port).contentType("application/json")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
.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"),
"нет счётчика участков, отданных модели");
}
@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 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"), "ПД попали в метрики");
}
@Test
void adminShowsSystemsAndTypes() {
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);
}
@Test
void textWithoutPersonalDataIsReturnedAsIs() {
String byOther =
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when().post("/process")
.then().statusCode(200)
.body("result", equalTo("тестовая строка"))
.body("result", not(equalTo("")));
}
.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, "чужая система не должна получать исходный текст по маске");
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, "своя система по своему идентификатору исходный текст получает");
}
@Test
void systemWithKeyRequiresIt() {
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);
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);
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);
}
@Test
void systemWithoutKeyWorksWithoutIt() {
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
.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"), "нет счётчика участков, отданных модели");
}
@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 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"), "ПД попали в метрики");
}
@Test
void adminShowsSystemsAndTypes() {
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);
}
@Test
void textWithoutPersonalDataIsReturnedAsIs() {
given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when()
.post("/process")
.then()
.statusCode(200)
.body("result", equalTo("тестовая строка"))
.body("result", not(equalTo("")));
}
}
+79 -65
View File
@@ -1,88 +1,102 @@
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";
private static final String PROMPT =
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
private JsonPath proxy(String prompt) {
return given()
.port(port)
.contentType("application/json")
.body(Map.of("prompt", prompt))
.when().post("/proxy")
.then().statusCode(200)
.extract().jsonPath();
}
private JsonPath proxy(String prompt) {
return given()
.port(port)
.contentType("application/json")
.body(Map.of("prompt", prompt))
.when()
.post("/proxy")
.then()
.statusCode(200)
.extract()
.jsonPath();
}
@Test
void personalDataDoesNotReachTheModel() {
JsonPath json = proxy(PROMPT);
String toModel = json.getString("prompt_masked");
@Test
void personalDataDoesNotReachTheModel() {
JsonPath json = proxy(PROMPT);
String toModel = json.getString("prompt_masked");
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
assertFalse(toModel.contains("4509 123456"), toModel);
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
}
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
assertFalse(toModel.contains("4509 123456"), toModel);
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
}
@Test
void consumerGetsTheAnswerWithOriginalValues() {
JsonPath json = proxy(PROMPT);
String answer = json.getString("response");
@Test
void consumerGetsTheAnswerWithOriginalValues() {
JsonPath json = proxy(PROMPT);
String answer = json.getString("response");
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
assertTrue(answer.contains("4509 123456"), answer);
assertTrue(answer.contains("ivan@mail.ru"), answer);
}
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
assertTrue(answer.contains("4509 123456"), answer);
assertTrue(answer.contains("ivan@mail.ru"), answer);
}
@Test
void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT);
@Test
void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT);
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
"ответ модели ещё содержит подстановки");
assertFalse(json.getString("response").contains("[FIO_1]"),
"потребителю подстановки не видны");
assertEquals("заглушка", json.getString("llm"));
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
}
assertTrue(
json.getString("prompt_masked").contains("[FIO_1]"),
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
assertTrue(
json.getString("llm_response_masked").contains("[FIO_1]"),
"ответ модели ещё содержит подстановки");
assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
assertEquals("заглушка", json.getString("llm"));
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
}
@Test
void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
}
@Test
void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals(
"Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
}
@Test
void rejectsEmptyPrompt() {
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
.when().post("/proxy").then().statusCode(400);
}
@Test
void rejectsEmptyPrompt() {
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")
.header("X-System-Id", "disabled")
.body(Map.of("prompt", PROMPT))
.when().post("/proxy").then().statusCode(403);
}
@Test
void disabledSystemIsRefused() {
given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("prompt", PROMPT))
.when()
.post("/proxy")
.then()
.statusCode(403);
}
}
+87 -89
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,113 +11,107 @@ 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));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksVillage() {
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
}
@Test
void masksVillage() {
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
}
@Test
void masksWorkersSettlement() {
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
}
@Test
void masksWorkersSettlement() {
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
}
@Test
void masksUrbanTypeSettlement() {
assertHidden("Доставка курьером в пгт. Энем", "Энем");
}
@Test
void masksUrbanTypeSettlement() {
assertHidden("Доставка курьером в пгт. Энем", "Энем");
}
@Test
void masksHamlet() {
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
}
@Test
void masksHamlet() {
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
}
@Test
void masksFarmstead() {
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
}
@Test
void masksFarmstead() {
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
}
@Test
void masksCossackStanitsa() {
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
}
@Test
void masksCossackStanitsa() {
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
}
@Test
void masksAul() {
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
}
@Test
void masksAul() {
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
}
@Test
void masksSloboda() {
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
}
@Test
void masksSloboda() {
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
}
@Test
void masksAal() {
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
}
@Test
void masksAal() {
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
}
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
@Test
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
String text = "Клиент проживает в с. Мнимогорск";
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
}
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
@Test
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
String text = "Клиент проживает в с. Мнимогорск";
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
}
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
@Test
void doesNotMaskOrganisationAddressInVillage() {
String text = "Ближайшее отделение банка находится в с. Кукуево";
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
}
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
@Test
void doesNotMaskOrganisationAddressInVillage() {
String text = "Ближайшее отделение банка находится в с. Кукуево";
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
}
@Test
void anchorsAreCaseInsensitive() {
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
}
@Test
void anchorsAreCaseInsensitive() {
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
}
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
@Test
void masksSettlementStreetAndHouseTogether() {
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
assertFalse(masked.contains("Аксеновка"), masked);
assertFalse(masked.contains("Садовая"), masked);
assertFalse(masked.contains("д. 7"), masked);
}
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
@Test
void masksSettlementStreetAndHouseTogether() {
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
assertFalse(masked.contains("Аксеновка"), masked);
assertFalse(masked.contains("Садовая"), masked);
assertFalse(masked.contains("д. 7"), masked);
}
/**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
* букву сразу после якоря, а не цифру.
*/
@Test
void pageReferenceIsNotMistakenForSettlement() {
String text = "См. с. 25 договора";
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
}
/**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
* на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
*/
@Test
void pageReferenceIsNotMistakenForSettlement() {
String text = "См. с. 25 договора";
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
}
}
@@ -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,87 +11,81 @@ 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));
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertUnmasked(String text) {
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
}
private void assertUnmasked(String text) {
assertEquals(
text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
}
@Test
void doesNotMaskNikolaiOstrovskyStreet() {
assertUnmasked("Живу на улице Николая Островского уже десять лет");
}
@Test
void doesNotMaskNikolaiOstrovskyStreet() {
assertUnmasked("Живу на улице Николая Островского уже десять лет");
}
@Test
void doesNotMaskAlexanderMatrosovStreet() {
assertUnmasked("Магазин находится на улице Александра Матросова");
}
@Test
void doesNotMaskAlexanderMatrosovStreet() {
assertUnmasked("Магазин находится на улице Александра Матросова");
}
@Test
void doesNotMaskValeryChkalovStreet() {
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
}
@Test
void doesNotMaskValeryChkalovStreet() {
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
}
@Test
void doesNotMaskVeraVoloshinaStreet() {
assertUnmasked("Новый дом построили на улице Веры Волошиной");
}
@Test
void doesNotMaskVeraVoloshinaStreet() {
assertUnmasked("Новый дом построили на улице Веры Волошиной");
}
@Test
void doesNotMaskIvanSusaninStreet() {
assertUnmasked("Школа расположена на улице Ивана Сусанина");
}
@Test
void doesNotMaskIvanSusaninStreet() {
assertUnmasked("Школа расположена на улице Ивана Сусанина");
}
@Test
void doesNotMaskSergeyKirovStreet() {
assertUnmasked("Парковка есть на улице Сергея Кирова");
}
@Test
void doesNotMaskSergeyKirovStreet() {
assertUnmasked("Парковка есть на улице Сергея Кирова");
}
@Test
void doesNotMaskGeorgiDimitrovStreet() {
assertUnmasked("Остановка на улице Георгия Димитрова");
}
@Test
void doesNotMaskGeorgiDimitrovStreet() {
assertUnmasked("Остановка на улице Георгия Димитрова");
}
/**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
* применяется — если в тексте всё-таки есть настоящие персональные данные,
* совпадение с историческим именем их не прикрывает.
*/
@Test
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
assertFalse(masked.contains("Николая Островского"), masked);
}
/**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
* всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
*/
@Test
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
assertFalse(masked.contains("Николая Островского"), masked);
}
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
@Test
void stillMasksRealClientNameWithSimilarPattern() {
String masked = mask("Живёт на улице, зовут Николая Смирнова");
assertFalse(masked.contains("Николая Смирнова"), masked);
}
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
@Test
void stillMasksRealClientNameWithSimilarPattern() {
String masked = mask("Живёт на улице, зовут Николая Смирнова");
assertFalse(masked.contains("Николая Смирнова"), masked);
}
@Test
void doesNotMaskDmitryDonskoyStreet() {
assertUnmasked("Дом стоит на улице Дмитрия Донского");
}
@Test
void doesNotMaskDmitryDonskoyStreet() {
assertUnmasked("Дом стоит на улице Дмитрия Донского");
}
}
+66 -64
View File
@@ -1,90 +1,92 @@
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 = """
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
"old": { "enabled": false }
}
""";
private static final String CONTENT =
"""
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
"old": { "enabled": false }
}
""";
private SystemsConfig configAt(Path file) {
return new SystemsConfig(file.toString(), new ObjectMapper());
}
private SystemsConfig configAt(Path file) {
return new SystemsConfig(file.toString(), new ObjectMapper());
}
@Test
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
@Test
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
SystemPolicy crm = config.policyFor("crm");
SystemsConfig config = configAt(file);
SystemPolicy crm = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, crm.maskMode());
assertFalse(crm.demask());
assertTrue(crm.allows("FIO"));
assertFalse(crm.allows("CARD"));
assertFalse(config.policyFor("old").enabled());
}
assertEquals(MaskMode.TOKEN, crm.maskMode());
assertFalse(crm.demask());
assertTrue(crm.allows("FIO"));
assertFalse(crm.allows("CARD"));
assertFalse(config.policyFor("old").enabled());
}
@Test
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
@Test
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
assertTrue(policy.enabled());
assertTrue(policy.allows("CARD"));
}
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
assertTrue(policy.enabled());
assertTrue(policy.allows("CARD"));
}
@Test
void worksWithoutConfigFile(@TempDir Path dir) {
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
}
@Test
void worksWithoutConfigFile(@TempDir Path dir) {
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
}
@Test
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
@Test
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
config.reload();
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
}
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
}
@Test
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
@Test
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload();
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,132 +17,141 @@ 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),
new NameCascade(
"wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"),
16, 4));
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"),
"off", Optional.empty()),
16,
4));
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
private int gold() {
return truePositive + falseNegative;
}
@Test
void twoModelEfficiency() {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
Map<String, Score> byType = new LinkedHashMap<>();
Map<String, List<String>> missed = new LinkedHashMap<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
}
report(byType);
reportMissed(missed);
// Каждый тип должен быть найден с F1 не ниже 0.8.
for (Map.Entry<String, Score> e : byType.entrySet()) {
assertTrue(e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
}
@Test
void twoModelEfficiency() {
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
Map<String, Score> byType = new LinkedHashMap<>();
Map<String, List<String>> missed = new LinkedHashMap<>();
for (BenchmarkFixtures.Sample sample : samples) {
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
}
for (Span gold : sample.gold()) {
boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) {
missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end()));
}
}
}
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"));
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())));
System.out.println(out);
}
report(byType);
reportMissed(missed);
private void reportMissed(Map<String, List<String>> missed) {
if (missed.isEmpty()) {
return;
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> out.append(type).append(": ")
.append(String.join(" | ", values)).append('\n'));
System.out.println(out);
// Каждый тип должен быть найден с F1 не ниже 0.8.
for (Map.Entry<String, Score> e : byType.entrySet()) {
assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
}
}
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private 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"));
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())));
System.out.println(out);
}
private void reportMissed(Map<String, List<String>> missed) {
if (missed.isEmpty()) {
return;
}
StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\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,43 +14,44 @@ 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(
private List<Span> find(String text) {
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);
return p.findPersonalData(text, SystemPolicy.DEFAULT);
}
"off", Optional.empty()),
16,
4);
Pipeline p =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return p.findPersonalData(text, SystemPolicy.DEFAULT);
}
@Test
void recognisesNamesAndAddresses() {
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
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(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
@Test
void recognisesNamesAndAddresses() {
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
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(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
}
@Test
void wellKnownNamesNotMasked() {
String text = "Напиши стихотворение в духе Александра Пушкина про осень";
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(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться");
@Test
void wellKnownNamesNotMasked() {
String text = "Напиши стихотворение в духе Александра Пушкина про осень";
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(
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,87 +1,88 @@
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 {
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
@AfterEach
void resetToBundledList() {
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
}
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
@AfterEach
void resetToBundledList() {
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
}
@Test
void bundledListCoversClassicFigures() {
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
// ограничение самого приёма, не завязанное на список имён.
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
}
@Test
void bundledListCoversClassicFigures() {
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
// ограничение самого приёма, не завязанное на список имён.
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
}
@Test
void bundledListCoversCurrentPublicFigures() {
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
}
@Test
void bundledListCoversCurrentPublicFigures() {
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
}
@Test
void unknownSurnameIsNotWellKnown() {
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
}
@Test
void unknownSurnameIsNotWellKnown() {
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
}
/**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
* правила нужны два слова, — но денилист должен покрывать и составные
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* «Буденный»).
*/
@Test
void bundledListCoversCommemorativeStreetNames() {
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
// основа обрезается сразу до «ск» — см. Declension.
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
assertTrue(NameDictionary.isWellKnown("улица Донского"));
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// известная граница приёма, родительный падеж («Будённого») им не ловится.
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского"));
}
/**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица
* Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен
* покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* «Буденный»).
*/
@Test
void bundledListCoversCommemorativeStreetNames() {
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
// основа обрезается сразу до «ск» — см. Declension.
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
assertTrue(NameDictionary.isWellKnown("улица Донского"));
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// известная граница приёма, родительный падеж («Будённого») им не ловится.
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(
NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского"));
}
@Test
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
Path file = dir.resolve("well-known.txt");
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
@Test
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
Path file = dir.resolve("well-known.txt");
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
NameDictionary.useExternalFile(file);
NameDictionary.useExternalFile(file);
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
"дописанное сверху имя должно распознаваться наравне со встроенными");
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
"встроенный список не должен теряться при дозагрузке");
}
assertTrue(
NameDictionary.isWellKnown("Интервью Кастомова"),
"дописанное сверху имя должно распознаваться наравне со встроенными");
assertTrue(
NameDictionary.isWellKnown("Стихи Пушкина"),
"встроенный список не должен теряться при дозагрузке");
}
@Test
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
@Test
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
}
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
}
}
@@ -1,62 +1,61 @@
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 {
@Test
void recognisesNominativeCase() {
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
}
@Test
void recognisesNominativeCase() {
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
}
@Test
void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
}
@Test
void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(
ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
}
@Test
void recognisesCisCapitals() {
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
}
@Test
void recognisesCisCapitals() {
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
}
@Test
void rejectsMadeUpWord() {
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
}
@Test
void rejectsMadeUpWord() {
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
}
@Test
void isCaseInsensitive() {
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
}
@Test
void isCaseInsensitive() {
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
}
/**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
* населённого пункта.
*/
@Test
void recognisesSettlementsFromCensusNotJustOfficialCities() {
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
}
/**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы,
* аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры
* из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта.
*/
@Test
void recognisesSettlementsFromCensusNotJustOfficialCities() {
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
}
}
@@ -1,69 +1,70 @@
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 {
@Test
void validOgrnPassesChecksum() {
assertTrue(Validators.ogrn("1027700123450"));
assertTrue(Validators.ogrn("1025000678900"));
assertTrue(Validators.ogrn("1045002233440"));
}
@Test
void validOgrnPassesChecksum() {
assertTrue(Validators.ogrn("1027700123450"));
assertTrue(Validators.ogrn("1025000678900"));
assertTrue(Validators.ogrn("1045002233440"));
}
@Test
void invalidOgrnChecksumFails() {
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
}
@Test
void invalidOgrnChecksumFails() {
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
}
@Test
void ogrnWrongLengthFails() {
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
}
@Test
void ogrnWrongLengthFails() {
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
}
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test
void ogrnRemainderTenMapsToZero() {
assertTrue(Validators.ogrn("1000000000000"));
}
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test
void ogrnRemainderTenMapsToZero() {
assertTrue(Validators.ogrn("1000000000000"));
}
@Test
void validOgrnipPassesChecksum() {
assertTrue(Validators.ogrnip("304500116000157"));
assertTrue(Validators.ogrnip("312500000000013"));
assertTrue(Validators.ogrnip("305500112233041"));
}
@Test
void validOgrnipPassesChecksum() {
assertTrue(Validators.ogrnip("304500116000157"));
assertTrue(Validators.ogrnip("312500000000013"));
assertTrue(Validators.ogrnip("305500112233041"));
}
@Test
void invalidOgrnipChecksumFails() {
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
}
@Test
void invalidOgrnipChecksumFails() {
assertFalse(
Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
}
@Test
void ogrnipWrongLengthFails() {
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
}
@Test
void ogrnipWrongLengthFails() {
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
}
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test
void ogrnipRemainderTenMapsToZero() {
assertTrue(Validators.ogrnip("100000000000000"));
}
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test
void ogrnipRemainderTenMapsToZero() {
assertTrue(Validators.ogrnip("100000000000000"));
}
@Test
void nonDigitCharactersAreIgnored() {
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
}
@Test
void nonDigitCharactersAreIgnored() {
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
}
@Test
void emptyValueIsNotValid() {
assertFalse(Validators.ogrn(""));
assertFalse(Validators.ogrnip(""));
}
@Test
void emptyValueIsNotValid() {
assertFalse(Validators.ogrn(""));
assertFalse(Validators.ogrnip(""));
}
}
+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"