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`), - Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`),
сбрасывается при перезапуске. Для кластера используется Redis. сбрасывается при перезапуске. Для кластера используется Redis.
- NER-модель второй ступени (`models/rubert-ner`) не входит в репозиторий и - NER-модели второй ступени (`models/rubert-ner`, `models/wikineural-ner`,
скачивается скриптом `tools/fetch-ner-model.sh`; без неё сервис работает на `models/ru-legal-ner`) не входят в репозиторий и скачиваются скриптом
правилах. `tools/fetch-ner-model.sh`; без них сервис работает на правилах.
- Демаскирование доступно только системам с `demask: true` и корректным ключом. - Демаскирование доступно только системам с `demask: true` и корректным ключом.
--- ---
+41 -90
View File
@@ -1,114 +1,65 @@
# `docker compose up` поднимает кластер целиком: redis, node-a, node-b за # Один узел, состояние маскирования/демаскирования — в памяти самого процесса
# nginx-балансировщиком на 8080, плюс Prometheus на 9090 и Grafana на 3000. # (pdguard.store.backend=memory, дефолт). Redis был нужен только чтобы разделить
# # состояние между несколькими репликами; кластерная схема на 4-vCPU хосте под
# Маскирование детерминировано и работает на любом узле, а вот обратный шаг # нагрузкой давала конкуренцию за CPU (см. историю в git) — один узел проще и
# требует общего состояния — отсюда общий Redis между node-a и node-b. # получает весь хост целиком.
#
# Лимиты CPU/RAM: сервер — 4 vCPU. Без лимитов node-a/node-b/prometheus/grafana
# под нагрузкой отжимали CPU у Redis, тот не укладывался в таймаут команд, узлы
# теряли общее состояние и демаскирование съезжало на резервный путь. Лимиты —
# это потолок (docker compose без swarm не умеет в гарантированные reservations),
# но они не дают соседям выесть Redis подчистую.
services: services:
prometheus: pd-guard:
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
image: pd-guard-spring:jvm image: pd-guard-spring:jvm
cpus: 1.0 ports:
mem_limit: 2200m - "8080:8080"
deploy:
resources:
limits:
cpus: "4"
memory: 6g
environment: environment:
PDGUARD_STORE_BACKEND: redis # Дефолт JVM — 25% контейнерного лимита на heap.
SPRING_DATA_REDIS_HOST: redis JAVA_OPTS: >-
-Dspring.config.additional-location=optional:file:/deployments/config/
-XX:MaxRAMPercentage=75.0
PDGUARD_MAX_CONCURRENT: "2000" PDGUARD_MAX_CONCURRENT: "2000"
# На 1 vCPU дефолт 200мс держал concurrency у пола; на 4 vCPU запас есть,
# но 800мс оставлено с той же осторожностью — целевая latency контракта 1с.
PDGUARD_TARGET_LATENCY_MS: "800"
PDGUARD_WARMUP_ITERATIONS: "2000" PDGUARD_WARMUP_ITERATIONS: "2000"
# Вторая ступень распознавания — две модели под разные задачи (см. # Вторая ступень распознавания — две модели под разные задачи (см.
# NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие # NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие
# адреса. Модели в репозиторий не входят: ./tools/fetch-ner-model.sh. # адреса. Модели в репозиторий не входят: ./tools/fetch-ner-model.sh.
# Пока движок не задан, соответствующая часть ступени выключена и
# сервис работает на одних правилах.
PDGUARD_NER_NAME_ENGINE: wikineural PDGUARD_NER_NAME_ENGINE: wikineural
PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner
PDGUARD_NER_ADDRESS_ENGINE: rubert PDGUARD_NER_ADDRESS_ENGINE: rubert
PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner
# ВРЕМЕННО для разового разбора формата тестовых payload'ов — пишет сырые ПД # Третья ступень (юридические реквизиты, LLAIM Legal NER) временно выключена
# в логи узла. LOGGING_LEVEL_* не подходит: relaxed binding из env приводит # для нагрузочного теста — проверяем, она ли основной источник CPU-затрат
# имя логгера к нижнему регистру и не совпадает с ru.pdguard.core.Pipeline # на 1-vCPU лимите. Включить: PDGUARD_NER_LEGAL_ENGINE=ru-legal-ner.
# (заглавная P), поэтому уровень задан через -D, где регистр сохраняется. PDGUARD_NER_LEGAL_ENGINE: "off"
# Выключить (убрать переменную) перед официальным нагрузочным прогоном.
JAVA_OPTS: "-Dspring.config.additional-location=optional:file:/deployments/config/ -Dlogging.level.ru.pdguard.core.Pipeline=DEBUG"
volumes: volumes:
- ./config:/deployments/config:ro - ./config:/deployments/config:ro
- ./models:/deployments/models:ro - ./models:/deployments/models:ro
depends_on:
redis:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 10s interval: 10s
timeout: 2s timeout: 2s
retries: 3 retries: 3
node-b: prometheus:
<<: *node image: prom/prometheus:v2.55.1
lb:
image: nginx:1.27-alpine
cpus: 0.3
mem_limit: 128m
ports:
- "8080:80"
volumes: volumes:
- ./nginx/lb.conf:/etc/nginx/nginx.conf:ro - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
depends_on: ports:
node-a: - "9090:9090"
condition: service_healthy
node-b:
condition: service_healthy
volumes: grafana:
prometheus-data: image: grafana/grafana:11.3.0
grafana-data: 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, "enabled": true,
"demask": false, "demask": false,
"maskMode": "TOKEN", "maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"] "types": ["*"]
}, },
"analytics": { "analytics": {
"enabled": true, "enabled": true,
@@ -27,5 +27,16 @@
"demask": false, "demask": false,
"maskMode": "MASK", "maskMode": "MASK",
"types": ["*"] "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` под капотом.
+140 -10
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", "type": "row",
"title": "Ресурсы узла", "title": "Ресурсы узла",
@@ -2157,7 +2287,7 @@
"h": 1, "h": 1,
"w": 24, "w": 24,
"x": 0, "x": 0,
"y": 74 "y": 90
}, },
"collapsed": false "collapsed": false
}, },
@@ -2173,7 +2303,7 @@
"h": 5, "h": 5,
"w": 4, "w": 4,
"x": 0, "x": 0,
"y": 75 "y": 91
}, },
"targets": [ "targets": [
{ {
@@ -2236,7 +2366,7 @@
"h": 5, "h": 5,
"w": 4, "w": 4,
"x": 4, "x": 4,
"y": 75 "y": 91
}, },
"targets": [ "targets": [
{ {
@@ -2291,7 +2421,7 @@
"h": 5, "h": 5,
"w": 4, "w": 4,
"x": 8, "x": 8,
"y": 75 "y": 91
}, },
"targets": [ "targets": [
{ {
@@ -2346,7 +2476,7 @@
"h": 5, "h": 5,
"w": 4, "w": 4,
"x": 12, "x": 12,
"y": 75 "y": 91
}, },
"targets": [ "targets": [
{ {
@@ -2409,7 +2539,7 @@
"h": 5, "h": 5,
"w": 4, "w": 4,
"x": 16, "x": 16,
"y": 75 "y": 91
}, },
"targets": [ "targets": [
{ {
@@ -2464,7 +2594,7 @@
"h": 5, "h": 5,
"w": 4, "w": 4,
"x": 20, "x": 20,
"y": 75 "y": 91
}, },
"targets": [ "targets": [
{ {
@@ -2523,7 +2653,7 @@
"h": 8, "h": 8,
"w": 12, "w": 12,
"x": 0, "x": 0,
"y": 80 "y": 96
}, },
"targets": [ "targets": [
{ {
@@ -2588,7 +2718,7 @@
"h": 8, "h": 8,
"w": 12, "w": 12,
"x": 12, "x": 12,
"y": 80 "y": 96
}, },
"targets": [ "targets": [
{ {
@@ -2643,4 +2773,4 @@
} }
} }
] ]
} }
+2 -9
View File
@@ -8,13 +8,6 @@ scrape_configs:
- job_name: pd-guard - job_name: pd-guard
metrics_path: /actuator/prometheus metrics_path: /actuator/prometheus
static_configs: static_configs:
- targets: ["pd-guard:8080"] - targets: ["node-a:8080", "node-b:8080"]
labels: labels:
instance: "один узел" instance: "кластер из двух узлов"
# Узлы кластерного профиля. Пока он не поднят, цели просто числятся
# недоступными и на остальной сбор это не влияет.
- job_name: pd-guard-cluster
metrics_path: /actuator/prometheus
static_configs:
- targets: ["node-a:8080", "node-b:8080"]
+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 приложения. * Точка входа Spring Boot приложения.
* *
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем * <p>Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный * персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге.
* текст на обратном шаге.
*/ */
@SpringBootApplication @SpringBootApplication
public class PdGuardApplication { public class PdGuardApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(PdGuardApplication.class, args); SpringApplication.run(PdGuardApplication.class, args);
} }
} }
+22 -23
View File
@@ -1,5 +1,7 @@
package ru.pdguard.api; 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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -7,34 +9,31 @@ import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig; import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */ /** Просмотр действующих настроек и принудительное их перечитывание. */
@RestController @RestController
public class AdminResource { public class AdminResource {
private final SystemsConfig systems; private final SystemsConfig systems;
private final RuleRegistry registry; private final RuleRegistry registry;
public AdminResource(SystemsConfig systems, RuleRegistry registry) { public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems; this.systems = systems;
this.registry = registry; this.registry = registry;
} }
@GetMapping("/admin/config") @GetMapping("/admin/config")
public Map<String, SystemPolicy> config() { public Map<String, SystemPolicy> config() {
return systems.current(); return systems.current();
} }
@GetMapping("/admin/types") @GetMapping("/admin/types")
public List<String> types() { public List<String> types() {
return registry.knownTypes(); return registry.knownTypes();
} }
@PostMapping("/admin/reload") @PostMapping("/admin/reload")
public Map<String, SystemPolicy> reload() { public Map<String, SystemPolicy> reload() {
systems.reload(); systems.reload();
return systems.current(); return systems.current();
} }
} }
@@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
public class HealthResource { public class HealthResource {
@GetMapping("/health") @GetMapping("/health")
public String health() { public String health() {
return "OK"; return "OK";
} }
} }
@@ -18,116 +18,119 @@ import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
/** /**
* Единственная точка входа контракта: маскирование и демаскирование по * Единственная точка входа контракта: маскирование и демаскирование по {@code payload_id}.
* {@code payload_id}.
* *
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка * <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка нет или система
* нет или система неизвестна — применяются настройки {@code default}, поэтому * неизвестна — применяются настройки {@code default}, поэтому контракт работает и без него.
* контракт работает и без него. Система, выключенная в настройках, получает * Система, выключенная в настройках, получает {@code 403}.
* {@code 403}.
* *
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — * <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — не фиксированное
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} * число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} сам находит потолок
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру, * конкурентности под то, сколько CPU реально досталось контейнеру, вместо того чтобы копить запросы
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны. * и упереться в таймаут вызывающей стороны.
*/ */
@RestController @RestController
public class ProcessResource { 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 REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/** private static final String REASON_TAG = "reason";
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
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; if (!limiter.tryAcquire()) {
private final SystemsConfig systems; rejected.increment();
private final AdaptiveConcurrencyLimiter limiter; return ResponseEntity.status(429).header("Retry-After", "1").build();
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);
} }
long started = System.nanoTime();
@PostMapping("/process") try {
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request, String result = pipeline.process(request.payload(), request.payloadId(), policy);
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId, return ResponseEntity.ok(new ProcessResponse(result));
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) { } catch (RuntimeException e) {
if (request == null || request.payload() == null // Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
|| request.payloadId() == null || request.payloadId().isBlank()) { // прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
malformed.increment(); // нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
return ResponseEntity.badRequest() // ради чего сервис и существует. Ответ фиксированный: он ничего не
.body(new ProcessResponse("payload и payload_id обязательны")); // раскрывает и не выглядит порчей данных.
} failed.increment();
LOG.error(
SystemPolicy policy = systems.policyFor(systemId); "payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e);
if (!policy.accepts(systemKey)) { return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
forbidden.increment(); } finally {
LOG.warn("Системе {} отказано: неверный ключ", systemId); limiter.release(System.nanoTime() - started);
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);
}
} }
} }
}
+79 -67
View File
@@ -1,6 +1,7 @@
package ru.pdguard.api; package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -14,8 +15,6 @@ import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.LlmClient; import ru.pdguard.core.LlmClient;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import java.util.Map;
/** /**
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком. * Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
* *
@@ -23,81 +22,94 @@ import java.util.Map;
* потребитель → маскирование → LLM → демаскирование → потребитель * потребитель → маскирование → LLM → демаскирование → потребитель
* </pre> * </pre>
* *
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что * <p>В ответе видны все три текста — что ушло в модель, что она вернула и что получил потребитель.
* получил потребитель. Это и есть доказательство, что в модель не попало ничего * Это и есть доказательство, что в модель не попало ничего незамаскированного, а ответ вернулся с
* незамаскированного, а ответ вернулся с восстановленными значениями. * восстановленными значениями.
* *
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по * <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по идентификатору
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не * целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не годятся — одна и та же маска
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда * отвечала бы разным значениям, — поэтому здесь всегда применяется обратимая подстановка,
* применяется обратимая подстановка, независимо от режима маскирования системы. * независимо от режима маскирования системы.
* *
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в * <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в {@link ProcessResource}.
* {@link ProcessResource}.
*/ */
@RestController @RestController
public class ProxyResource { 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( SystemPolicy policy = systems.policyFor(systemId);
@JsonProperty("prompt_masked") String promptMasked, if (!policy.accepts(systemKey)) {
@JsonProperty("llm_response_masked") String llmResponseMasked, return ResponseEntity.status(HttpStatus.FORBIDDEN)
@JsonProperty("response") String response, .body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
@JsonProperty("replaced") Map<String, String> replaced, }
@JsonProperty("llm") String llm, if (!policy.enabled()) {
@JsonProperty("error") String error) { return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
new ProxyResponse(
null,
null,
null,
null,
null,
"Системе " + systemId + " обращение в модуль запрещено"));
} }
private final Pipeline pipeline; Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
private final SystemsConfig systems; LlmClient.Answer answer = llm.ask(masked.text());
private final LlmClient llm; String restored =
policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) { LOG.info(
this.pipeline = pipeline; "proxy: система={} заменено={} модель={}",
this.systems = systems; policy.name(),
this.llm = llm; 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;
@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;
}
}
@@ -8,18 +8,17 @@ import ru.pdguard.detect.NameDictionary;
/** /**
* Конфигурация словарей распознавания ФИО. * Конфигурация словарей распознавания ФИО.
* *
* <p>{@link NameDictionary} работает через статические методы и не требует * <p>{@link NameDictionary} работает через статические методы и не требует экземпляра, но путь к
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при * внешнему файлу денилиста задаётся из настроек при старте. Бин здесь нужен только для того, чтобы
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение * Spring подставил значение {@code pdguard.well-known-file} и передал его словарю.
* {@code pdguard.well-known-file} и передал его словарю.
*/ */
@Configuration @Configuration
public class DictionaryConfiguration { public class DictionaryConfiguration {
@Bean @Bean
public NameDictionary nameDictionary( public NameDictionary nameDictionary(
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) { @Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
NameDictionary.configure(wellKnownFile); NameDictionary.configure(wellKnownFile);
return NameDictionary.create(); return NameDictionary.create();
} }
} }
@@ -1,81 +1,96 @@
package ru.pdguard.config; package ru.pdguard.config;
import java.util.Set;
import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.PdTypes;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import java.util.Set;
/** /**
* Правила обработки для одной системы-потребителя. * Правила обработки для одной системы-потребителя.
* *
* @param name имя системы; им же разделяется хранилище соответствий, * @param name имя системы; им же разделяется хранилище соответствий, чтобы одна система не могла
* чтобы одна система не могла достать данные другой * достать данные другой
* @param enabled разрешено ли системе обращаться в модуль * @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование * @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение * @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные * @param types типы ПД к маскированию; {@code "*"} — все известные
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан * @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан совпасть, иначе
* совпасть, иначе имя системы можно было бы просто назвать. * имя системы можно было бы просто назвать. Только знаки ASCII: заголовки HTTP передаются в
* Только знаки ASCII: заголовки HTTP передаются в Latin-1, * Latin-1, и кириллица в ключе до сервиса доедет искажённой
* и кириллица в ключе до сервиса доедет искажённой * @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: пин-код сам по
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: * себе безвреден, пин-код рядом с номером карты — уже нет; то же для даты без якорного слова,
* пин-код сам по себе безвреден, пин-код рядом с номером * места рождения («Нижний Новгород» в рассказе о городе — не адрес клиента) и страны («цены
* карты — уже нет; то же для даты без якорного слова, места * выросли в Казахстане» — не гражданство). Сюда же банковские реквизиты — счёт, БИК, ОГРН,
* рождения («Нижний Новгород» в рассказе о городе — не адрес * ОГРНИП, КПП: сами по себе они опознают организацию или счёт, а не человека, и в перечне типов
* клиента) и страны («цены выросли в Казахстане» — не гражданство). * из задания их нет. Рядом с именем клиента они становятся его данными и маскируются. Сюда же
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП: * доход и биометрия. Сумма заработка без человека — статистика («доход домохозяйств вырос до 74
* сами по себе они опознают организацию или счёт, а не человека, * 500 руб»), а не персональные данные. Биометрия же в тексте не встречается вовсе: это шаблон в
* и в перечне типов из задания их нет. Рядом с именем клиента * базе, и правило маскирует лишь само упоминание, то есть слово, а не данные. Чувствителен
* они становятся его данными и маскируются. * здесь факт, что биометрию сдал названный человек, — а он и существует только при имени рядом
* Сюда же доход и биометрия. Сумма заработка без человека —
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
* персональные данные. Биометрия же в тексте не встречается
* вовсе: это шаблон в базе, и правило маскирует лишь само
* упоминание, то есть слово, а не данные. Чувствителен здесь
* факт, что биометрию сдал названный человек, — а он и
* существует только при имени рядом
*/ */
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode, public record SystemPolicy(
Set<String> types, Set<String> requireCompanion, String key) { 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( public static final SystemPolicy DEFAULT =
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL), new SystemPolicy(
Set.of(PdTypes.CVV, PdTypes.PIN, PdTypes.DATE, PdTypes.BIRTH_PLACE, PdTypes.ADDRESS_COUNTRY, DEFAULT_NAME,
PdTypes.ACCOUNT_NUMBER, PdTypes.BIK, PdTypes.OGRN, PdTypes.OGRNIP, PdTypes.KPP, true,
PdTypes.INCOME, PdTypes.BIOMETRIC), null); 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 { public SystemPolicy {
types = Set.copyOf(types); types = Set.copyOf(types);
requireCompanion = Set.copyOf(requireCompanion); 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 boolean allows(String type) {
public static SystemPolicy forTypes(String... types) { return types.contains(ALL) || types.contains(type);
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK, }
Set.of(types), DEFAULT.requireCompanion(), null);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */ public boolean needsCompanion(String type) {
public boolean accepts(String presentedKey) { return requireCompanion.contains(type);
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);
}
} }
+129 -107
View File
@@ -1,12 +1,6 @@
package ru.pdguard.config; package ru.pdguard.config;
import com.fasterxml.jackson.databind.ObjectMapper; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -14,123 +8,151 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Set; import java.util.Set;
import java.util.TreeMap; 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 @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, public record SystemEntry(
List<String> types, List<String> requireCompanion, String key) { 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 Path file;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final AtomicReference<Map<String, SystemPolicy>> policies = private final AtomicReference<Map<String, SystemPolicy>> policies =
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT)); new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
private volatile long fileTimestamp; private volatile long fileTimestamp;
private volatile long lastCheck; private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path, public SystemsConfig(
ObjectMapper mapper) { @Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) {
this.file = Path.of(path); this.file = Path.of(path);
this.mapper = mapper; 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(); reload();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", file, e);
} }
}
/** Правила для системы; неизвестная система получает настройки по умолчанию. */ private static SystemPolicy toPolicy(String name, SystemEntry entry) {
public SystemPolicy policyFor(String systemId) { SystemPolicy base = SystemPolicy.DEFAULT;
refreshIfChanged(); Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
Map<String, SystemPolicy> current = policies.get(); Set<String> companions =
SystemPolicy policy = systemId == null ? null : current.get(systemId); entry.requireCompanion() == null
if (policy != null) { ? base.requireCompanion()
return policy; : new HashSet<>(entry.requireCompanion());
} MaskMode mode =
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT); entry.maskMode() == null
} ? base.maskMode()
: MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
/** Известна ли система по имени. */ return new SystemPolicy(
public boolean isKnown(String systemId) { name,
refreshIfChanged(); entry.enabled() == null || entry.enabled(),
return systemId != null && policies.get().containsKey(systemId); entry.demask() == null || entry.demask(),
} mode,
types,
/** Текущие настройки — для отдачи в административном интерфейсе. */ companions,
public Map<String, SystemPolicy> current() { entry.key());
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());
}
}
@@ -1,129 +1,121 @@
package ru.pdguard.core; package ru.pdguard.core;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.TimeUnit;
/** /**
* Предел одновременных запросов, который сам подстраивается под задержку, * Предел одновременных запросов, который сам подстраивается под задержку, а не задан фиксированным
* а не задан фиксированным числом. Растёт, пока обработка укладывается в * числом. Растёт, пока обработка укладывается в целевое время, и сжимается, как только перестаёт —
* целевое время, и сжимается, как только перестаёт — вместо того чтобы * вместо того чтобы копить очередь и подходить к таймауту вызывающей стороны.
* копить очередь и подходить к таймауту вызывающей стороны.
* *
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. * <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. availableProcessors()} под
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется * квотой {@code --cpus} в cgroups не меняется (это не affinity, а квота), поэтому в контейнере с
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно * долей ядра оно показывает все ядра хоста и как источник предела не годится. Задержка —
* показывает все ядра хоста и как источник предела не годится. Задержка —
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере. * наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
* *
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой * <p>Шаг регулировки привязан к времени, не к числу запросов: при первой версии предел менялся на
* версии предел менялся на каждый завершённый запрос, и на высоком RPS * каждый завершённый запрос, и на высоком RPS тысячи «быстрых» замеров прилетали за миллисекунды —
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал * предел успевал разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, и то же
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, * самое повторялось после каждого восстановления. Проверено нагрузочным тестом: без привязки к
* и то же самое повторялось после каждого восстановления. Проверено * времени p95 на перегрузке доходил до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил * сжаться. Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним шагом на основе
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться. * среднего за окно — так скорость регулировки не зависит от того, насколько высок входящий RPS.
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
* шагом на основе среднего за окно — так скорость регулировки не зависит
* от того, насколько высок входящий RPS.
* *
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start * <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start из TCP) здесь не
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и * подходит: там обратная связь — RTT, миллисекунды, и лишний виток роста стоит дёшево. Здесь
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа * обратная связь — время ответа заявки, и под перегрузкой оно само составляет секунды: предел
* заявки, и под перегрузкой оно само составляет секунды: предел успевает * успевает удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый сигнал о деградации,
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый * и уже принятые заявки не исчезают из очереди, даже если следующим окном предел тут же обрушить.
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже * Проверено нагрузочным тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* если следующим окном предел тут же обрушить. Проверено нагрузочным * Линейный рост копит риск медленно, и первый плохой сигнал останавливает его на порядок раньше.
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с. * Сжатие — вдвое, а не на единицу: на перегрузке дешевле один раз отрезать с запасом, чем несколько
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает * окон подряд плавно подходить к безопасному уровню, пока заявки продолжают копиться.
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
* подходить к безопасному уровню, пока заявки продолжают копиться.
* *
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе * <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе окна может добавить
* окна может добавить образец в уже подводимый итог или отбросить один, * образец в уже подводимый итог или отбросить один, не больше; при масштабах в десятки-сотни
* не больше; при масштабах в десятки-сотни образцов на окно это не видно. * образцов на окно это не видно. Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix * {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из осторожности к GraalVM
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из * native-image: незнакомая рефлексия в чужой библиотеке — это ровно тот класс проблем, из-за
* осторожности к 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 AtomicInteger inFlight = new AtomicInteger();
private final AtomicLong windowSumNanos = new AtomicLong(); private final AtomicLong windowSumNanos = new AtomicLong();
private final AtomicInteger windowSamples = new AtomicInteger(); private final AtomicInteger windowSamples = new AtomicInteger();
private final AtomicLong lastAdjustNanos; private final AtomicLong lastAdjustNanos;
private final int minLimit; private final int minLimit;
private final int maxLimit; private final int maxLimit;
private final long targetLatencyNanos; private final long targetLatencyNanos;
private final long adjustWindowNanos; private final long adjustWindowNanos;
private final AtomicInteger limit; private final AtomicInteger limit;
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) { public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS); 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мс на шаг не подходят. */ /** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) { public boolean tryAcquire() {
if (minLimit < 1 || maxLimit < minLimit) { if (inFlight.incrementAndGet() > limit.get()) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit); inFlight.decrementAndGet();
} return false;
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());
} }
return true;
}
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */ /** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
public boolean tryAcquire() { public void release(long elapsedNanos) {
if (inFlight.incrementAndGet() > limit.get()) { inFlight.decrementAndGet();
inFlight.decrementAndGet(); windowSumNanos.addAndGet(elapsedNanos);
return false; windowSamples.incrementAndGet();
}
return true; long now = System.nanoTime();
long last = lastAdjustNanos.get();
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
adjust();
} }
}
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */ private void adjust() {
public void release(long elapsedNanos) { int samples = windowSamples.getAndSet(0);
inFlight.decrementAndGet(); long sum = windowSumNanos.getAndSet(0);
windowSumNanos.addAndGet(elapsedNanos); if (samples == 0) {
windowSamples.incrementAndGet(); return;
long now = System.nanoTime();
long last = lastAdjustNanos.get();
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
adjust();
}
} }
long avg = sum / samples;
private void adjust() { if (avg < targetLatencyNanos) {
int samples = windowSamples.getAndSet(0); limit.set(Math.min(maxLimit, limit.get() + 1));
long sum = windowSumNanos.getAndSet(0); } else {
if (samples == 0) { limit.set(Math.max(minLimit, limit.get() / 2));
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));
}
} }
}
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */ /** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
public int inFlight() { public int inFlight() {
return inFlight.get(); return inFlight.get();
} }
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */ /** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
public int limit() { public int limit() {
return limit.get(); return limit.get();
} }
} }
+83 -79
View File
@@ -2,11 +2,6 @@ package ru.pdguard.core;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; 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.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -15,93 +10,102 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Optional; 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 @Component
public class LlmClient { 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; public Answer ask(String maskedPrompt) {
private final Optional<String> apiKey; if (url.isEmpty()) {
private final String model; return new Answer(stub(maskedPrompt), "заглушка");
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("Адрес языковой модели не задан, плечо работает на заглушке");
}
} }
try {
public Answer ask(String maskedPrompt) { return new Answer(call(maskedPrompt), model);
if (url.isEmpty()) { } catch (InterruptedException e) {
return new Answer(stub(maskedPrompt), "заглушка"); Thread.currentThread().interrupt();
} LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e);
try { return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
return new Answer(call(maskedPrompt), model); } catch (IOException e) {
} catch (InterruptedException e) { LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
Thread.currentThread().interrupt(); return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
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) { private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + 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);
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
private String call(String maskedPrompt) throws IOException, InterruptedException { .readTree(response.body())
ObjectNode body = mapper.createObjectNode(); .path("choices")
body.put("model", model); .path(0)
ObjectNode message = body.putArray("messages").addObject(); .path("message")
message.put("role", "user"); .path("content")
message.put("content", maskedPrompt); .asText();
}
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();
}
}
@@ -3,63 +3,62 @@ package ru.pdguard.core;
import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import java.time.Duration;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/** /**
* Настройка распределений для метрик времени. * Настройка распределений для метрик времени.
* *
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. * <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. Этого хватает
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно * на среднее, но не на перцентили, а именно они описывают SLA: важно не среднее время ответа, а то,
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма * сколько запросов уложилось в срок. Гистограмма добавляет ряды по корзинам, и {@code
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по * histogram_quantile} в Prometheus считает по ним p50, p95 и p99.
* ним p50, p95 и p99.
* *
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти * <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти миллисекунды до десяти
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и * секунд. Без явных границ Micrometer создаёт их сам и заметно больше, а каждая корзина — это
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое * отдельный временной ряд на каждое сочетание меток.
* сочетание меток.
*/ */
@Configuration @Configuration
public class MetricsConfiguration { 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 = { private static final Duration[] BOUNDARIES = {
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5), Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50), Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET, Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5), Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
Duration.ofSeconds(10) 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 private static boolean needsHistogram(String name) {
public MeterFilter histogramsForTimers() { return "pdguard.process".equals(name)
return new MeterFilter() { || "pdguard.ner.duration".equals(name)
@Override || "pdguard.ner.model.duration".equals(name)
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) { || "http.server.requests".equals(name);
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);
}
}
@@ -1,87 +1,89 @@
package ru.pdguard.core; 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.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
import java.util.HexFormat; 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>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если * <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если процесс или Redis
* процесс или Redis скомпрометированы, исходные значения остаются недоступными * скомпрометированы, исходные значения остаются недоступными без ключа. Используется AES-GCM —
* без ключа. Используется AES-GCM — аутентифицированное шифрование, которое * аутентифицированное шифрование, которое защищает и от подмены шифротекста.
* защищает и от подмены шифротекста.
* *
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в * <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в hex). Пока ключ не
* hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для * задан, шифрование выключено — это нужно для тестов и для сборки, где хранилище не содержит
* сборки, где хранилище не содержит чувствительных данных. * чувствительных данных.
*/ */
@Component @Component
public class PayloadCipher { public class PayloadCipher {
private static final String ALGORITHM = "AES"; private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128; private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12; private static final int IV_BYTES = 12;
private final SecretKeySpec key; private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom(); private final SecureRandom random = new SecureRandom();
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) { public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
this.key = hexKey == null || hexKey.isBlank() ? null : new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM); this.key =
hexKey == null || hexKey.isBlank()
? null
: new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
}
/** Выключенное шифрование — для тестов и сборки без ключа. */
public static PayloadCipher disabled() {
return new PayloadCipher("");
}
public boolean enabled() {
return key != null;
}
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */
public String encrypt(String plaintext) {
if (key == null) {
return plaintext;
} }
try {
/** Выключенное шифрование — для тестов и сборки без ключа. */ byte[] iv = new byte[IV_BYTES];
public static PayloadCipher disabled() { random.nextBytes(iv);
return new PayloadCipher(""); 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;
} }
try {
/** Шифрует текст; при выключенном шифровании возвращает исходный текст. */ byte[] combined = Base64.getDecoder().decode(ciphertext);
public String encrypt(String plaintext) { byte[] iv = new byte[IV_BYTES];
if (key == null) { System.arraycopy(combined, 0, iv, 0, iv.length);
return plaintext; Cipher cipher = Cipher.getInstance(TRANSFORMATION);
} cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
try { byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
byte[] iv = new byte[IV_BYTES]; return new String(decrypted, StandardCharsets.UTF_8);
random.nextBytes(iv); } catch (GeneralSecurityException | IllegalArgumentException e) {
Cipher cipher = Cipher.getInstance(TRANSFORMATION); throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
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);
}
}
}
+140 -162
View File
@@ -1,9 +1,5 @@
package ru.pdguard.core; 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.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
@@ -12,191 +8,173 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong; 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>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет * <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет совпадение по самому
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в * тексту запроса, и без такого разделения он превращался бы в способ достать чужие данные: маски
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому, * детерминированы и низкоэнтропийны, поэтому, прислав «Клиент И. И. И., паспорт 45** ****56», можно
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ * было бы получить в ответ исходные значения из запроса другого потребителя. Разделение
* исходные значения из запроса другого потребителя. Разделение ограничивает это * ограничивает это пределами одной системы, которая и так видит свои данные.
* пределами одной системы, которая и так видит свои данные.
* *
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное * <p>Записи живут ограниченное время: персональные данные не должны залёживаться в памяти.
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не * Протухшие записи убираются в порядке добавления прямо на записи — отдельного потока и внешней
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо * библиотеки кеширования не требуется.
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
* *
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, * <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, а чтение при
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на * промахе по локальной памяти идёт в него. Это нужно при работе на нескольких узлах: обратный
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял * запрос легко попадает не на тот узел, который выполнял прямой. Локальная память при этом остаётся
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь * первым уровнем, и обычный путь обходится без обращения по сети.
* обходится без обращения по сети.
*/ */
@Component @Component
public class PayloadStore { 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, public record Entry(
String fingerprint, long expiresAt) { String system, String original, String masked, String fingerprint, long expiresAt) {
boolean alive(long now) { boolean alive(long now) {
return now < expiresAt; return now < expiresAt;
}
int weight() {
return original.length() + masked.length();
}
} }
private final Map<String, Entry> byId = new ConcurrentHashMap<>(); int weight() {
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>(); return original.length() + masked.length();
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;
} }
}
/** Конструктор для тестов: только локальная память, общий слой и шифрование выключены. */ private final Map<String, Entry> byId = new ConcurrentHashMap<>();
public PayloadStore(long maxChars, int ttlMinutes) { private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
this(maxChars, ttlMinutes, SharedIndex.disabled(), PayloadCipher.disabled()); 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);
} }
if (entry != null) {
public void put(String system, String payloadId, String original, String masked) { forget(idKey, entry);
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());
} }
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); * Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в
Entry entry = byId.get(idKey); * пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя.
if (entry != null && entry.alive(System.currentTimeMillis())) { */
return decrypt(entry); public String originalForMask(String system, String masked) {
} String fingerprint = fingerprint(masked);
if (entry != null) { Entry entry = byMaskFingerprint.get(ScopedKey.of(system, fingerprint));
forget(idKey, entry); if (entry != null && entry.alive(System.currentTimeMillis())) {
} return cipher.decrypt(entry.original());
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(system, payloadId, fromShared.original(), fromShared.masked());
return decrypt(byId.get(idKey));
} }
return shared.originalForFingerprint(system, fingerprint);
}
/** /** Сколько символов сейчас удерживается — для диагностики и тестов. */
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт public long charsHeld() {
* только в пределах той же системы: чужую маску подобрать и обменять на исходные return charsHeld.get();
* данные нельзя. }
*/
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() { private void sweepExpired(long now) {
return charsHeld.get(); for (int i = 0; i < SWEEP_PER_PUT; i++) {
} String oldest = insertionOrder.peek();
/** if (oldest == null) {
* Убирает протухшие записи с головы очереди, не более нескольких за раз. return;
*/ }
private void sweepExpired(long now) { Entry entry = byId.get(oldest);
for (int i = 0; i < SWEEP_PER_PUT; i++) { if (entry == null) {
String oldest = insertionOrder.peek(); insertionOrder.poll();
if (oldest == null) { continue;
return; }
} if (entry.alive(now)) {
Entry entry = byId.get(oldest); return;
if (entry == null) { }
insertionOrder.poll(); insertionOrder.poll();
continue; forget(oldest, entry);
}
if (entry.alive(now)) {
return;
}
insertionOrder.poll();
forget(oldest, entry);
}
} }
}
private void evictWhileOverLimit() { private void forget(String idKey, Entry entry) {
while (charsHeld.get() > maxChars) { if (byId.remove(idKey, entry)) {
String oldest = insertionOrder.poll(); byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
if (oldest == null) { charsHeld.addAndGet(-entry.weight());
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)) { private Entry decrypt(Entry entry) {
byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry); if (entry == null) {
charsHeld.addAndGet(-entry.weight()); return null;
}
} }
return new Entry(
entry.system(),
cipher.decrypt(entry.original()),
entry.masked(),
entry.fingerprint(),
entry.expiresAt());
}
/** Возвращает запись с расшифрованным исходным текстом. */ private static String fingerprint(String value) {
private Entry decrypt(Entry entry) { try {
if (entry == null) { MessageDigest sha = MessageDigest.getInstance("SHA-256");
return null; return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
} } catch (NoSuchAlgorithmException e) {
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(), throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
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);
}
}
}
+295 -259
View File
@@ -4,6 +4,14 @@ import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; 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.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.MaskContext; import ru.pdguard.mask.MaskContext;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker; 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}, а не по содержимому запроса: * <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
*
* <ul> * <ul>
* <li>идентификатор неизвестен — маскируем;</li> * <li>идентификатор неизвестен — маскируем;
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li> * <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li> * <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.
* </ul> * </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан *
* совпасть с первым, иначе демаскирование по этому элементу развалится. * Последний случай — повторная попытка проверяющей системы: ответ обязан совпасть с первым, иначе
* демаскирование по этому элементу развалится.
*/ */
@Component @Component
public class Pipeline { public class Pipeline {
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class); private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */ /** Грубая оценка числа токенов по числу символов — для метрики TPS. */
private static final int CHARS_PER_TOKEN = 4; private static final int CHARS_PER_TOKEN = 4;
private final RuleRegistry registry; private final RuleRegistry registry;
private final Masker masker; private final Masker masker;
private final PayloadStore store; private final PayloadStore store;
private final MeterRegistry meters; private final MeterRegistry meters;
private final NameCascade cascade; private final NameCascade cascade;
private final Counter tokensProcessed; private final Counter tokensProcessed;
private final Counter unresolvedDemask; private final Counter unresolvedDemask;
@Autowired @Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters, public Pipeline(
NameCascade cascade) { RuleRegistry registry,
this.registry = registry; Masker masker,
this.masker = masker; PayloadStore store,
this.store = store; MeterRegistry meters,
this.meters = meters; NameCascade cascade) {
this.cascade = cascade; this.registry = registry;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld); this.masker = masker;
this.tokensProcessed = Counter.builder("pdguard.tokens.processed") this.store = store;
.description("Оценка числа обработанных токенов, для расчёта TPS") this.meters = meters;
.register(meters); this.cascade = cascade;
this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved") meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
.description("Запрос на демаскирование, для которого соответствие не нашлось ни по " this.tokensProcessed =
+ "id, ни по отпечатку маски — обработан как новое маскирование") Counter.builder("pdguard.tokens.processed")
.register(meters); .description("Оценка числа обработанных токенов, для расчёта TPS")
} .register(meters);
this.unresolvedDemask =
Counter.builder("pdguard.demask.unresolved")
.description(
"Запрос на демаскирование, для которого соответствие не нашлось ни по "
+ "id, ни по отпечатку маски — обработан как новое маскирование")
.register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */ /** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) { public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled()); this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
} }
/** Конструктор для тестов второй ступени. */ /** Конструктор для тестов второй ступени. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) { public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
this(registry, masker, store, new SimpleMeterRegistry(), cascade); this(registry, masker, store, new SimpleMeterRegistry(), cascade);
} }
public String process(String payload, String payloadId, SystemPolicy policy) { public String process(String payload, String payloadId, SystemPolicy policy) {
long started = System.nanoTime(); long started = System.nanoTime();
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN); 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);
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); recordLatency("mask", policy.name(), started);
logFindings(policy.name(), payloadId, payload.length(), spans); return known.masked();
// Только для отладки формата входных данных на DEBUG — на INFO не активен. }
// На боевом прогоне (нагрузочное тестирование) уровень DEBUG должен быть выключен:
// сюда попадают сырые ПД, что напрямую нарушает требование ТЗ не логировать значения.
LOG.debug("payload_id={} raw={}", payloadId, payload);
return masked;
} }
if (policy.demask()) {
/** String original = store.originalForMask(policy.name(), payload);
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более if (original != null) {
* приоритетный, при равном приоритете — более длинный. LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
*/ recordLatency("unmask", policy.name(), started);
static List<Span> resolveOverlaps(List<Span> spans) { return original;
List<Span> candidates = new ArrayList<>(spans); }
candidates.sort(Comparator.comparingInt(Span::priority).reversed() // Соответствие не нашлось нигде — не отличить достоверно новый payload от
.thenComparing(Comparator.comparingInt(Span::length).reversed()) // демаскирования с утраченным состоянием (например, узел, где маскировали,
.thenComparingInt(Span::start)); // не успел записать в общий слой). Ниже это обработается как маскирование
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому // и логируем каждый такой случай явно, чтобы не потерять его молча.
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа. unresolvedDemask.increment();
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов LOG.warn(
// фрагментов набираются тысячи. "payload_id={} демаскирование не нашло соответствие ни по id, ни по "
NavigableMap<Integer, Span> accepted = new TreeMap<>(); + "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
for (Span candidate : candidates) { payloadId);
if (overlapsAccepted(accepted, candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
} }
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; */
} private void recordLatency(String direction, String system, long startedNanos) {
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start()); Timer.builder("pdguard.process")
return after != null && after.getValue().overlaps(candidate); .description("Длительность обработки обращения")
} .tag("direction", direction)
.tag("system", system)
.register(meters)
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
}
/** /**
* Убирает имена, стоящие в названиях организаций и объектов на карте: * Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка * отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает * замаскированный текст обратно.
* само по себе. */
*/ public List<Span> findPersonalData(String text, SystemPolicy policy) {
static List<Span> dropOrganisationNames(String text, List<Span> spans) { List<Span> spans = resolveOverlaps(registry.detect(text, policy));
return spans.stream() if (cascade.coversAny(policy)) {
.filter(span -> !PdTypes.FIO.equals(span.type()) // Вторая ступень разбирает только то, что не покрыла первая.
|| !OrganisationDetector.precededByOrganisation(text, span.start())) spans = resolveOverlaps(cascade.addMissedNames(text, spans));
.toList();
} }
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);
* исторической фигуры защиту не теряет.
*/
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();
}
/** recordLatency("mask", policy.name(), started);
* Убирает типы, которые опасны только в сочетании с другими ПД. logFindings(policy.name(), payloadId, payload.length(), spans);
* Пин-код в отрыве от номера карты не является персональными данными, return 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();
}
/** Замаскированный текст вместе с таблицей обратной замены. */ /**
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>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить // фрагментов набираются тысячи.
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно. NavigableMap<Integer, Span> accepted = new TreeMap<>();
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным for (Span candidate : candidates) {
* значениям, — поэтому режим замены здесь всегда обратимый. if (overlapsAccepted(accepted, candidate)) {
*/ continue;
public Masked maskWithRestorations(String text, SystemPolicy policy) { }
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(), accepted.put(candidate.start(), candidate);
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());
} }
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; static List<Span> dropOrganisationNames(String text, List<Span> spans) {
for (Span span : spans) { return spans.stream()
sb.append(text, cursor, span.start()); .filter(
String value = text.substring(span.start(), span.end()); span ->
sb.append(masker.mask(span.type(), value, policy.maskMode(), context)); !PdTypes.FIO.equals(span.type())
cursor = span.end(); || !OrganisationDetector.precededByOrganisation(text, span.start()))
} .toList();
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) { static List<Span> dropWellKnownNames(String text, List<Span> spans) {
Map<String, Integer> counts = new LinkedHashMap<>(); boolean otherPersonalDataPresent =
for (Span span : spans) { spans.stream().anyMatch(span -> !PdTypes.FIO.equals(span.type()));
counts.merge(span.type(), 1, Integer::sum); if (otherPersonalDataPresent) {
} return spans;
counts.forEach((type, count) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
} }
} 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; package ru.pdguard.core;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.util.Set;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; 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.MaskMode;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.Set;
/** /**
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код. * Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
* *
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по * <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по интерпретируемому
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно * и наспех скомпилированному коду, и p95 оказывается примерно вдесятеро хуже установившегося.
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли * Несколько тысяч прогонов на старте занимают доли секунды и переводят горячий путь на
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут * оптимизирующий компилятор до того, как придут настоящие запросы.
* настоящие запросы.
* *
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим * <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим хранилищем: настоящие
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя. * соответствия «текст ↔ маска» замусорить нельзя.
* *
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики * <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики показывают долю
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту * запросов, дошедших до модели, а тысячи служебных прогонов эту долю исказили бы до неузнаваемости.
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при * Сама модель прогревается отдельно, при создании своего пула.
* создании своего пула.
*/ */
@Component @Component
public class PipelineWarmup { 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 = { private static final String[] SAMPLES = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67", "Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru", "Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111", "Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ", "Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка", "Напиши краткое описание продукта для рассылки клиентам банка",
}; };
private final RuleRegistry registry; private final RuleRegistry registry;
private final Masker masker; private final Masker masker;
private final int iterations; private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker, public PipelineWarmup(
@Value("${pdguard.warmup-iterations:2000}") int iterations) { RuleRegistry registry,
this.registry = registry; Masker masker,
this.masker = masker; @Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.iterations = 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 for (int i = 0; i < iterations; i++) {
void warmup() { String text = SAMPLES[i % SAMPLES.length];
if (iterations <= 0) { String id = "warmup-" + i;
LOG.info("Прогрев обработки отключён"); String masked = scratch.process(text, id, policy);
return; scratch.process(masked, id, policy);
}
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);
} }
} LOG.info(
"Прогрев обработки: {} прогонов за {} мс",
iterations,
(System.nanoTime() - started) / 1_000_000);
}
}
+9 -11
View File
@@ -3,18 +3,16 @@ package ru.pdguard.core;
/** /**
* Ключ, однозначно разделяющий системы-потребители. * Ключ, однозначно разделяющий системы-потребители.
* *
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может * <p>Длина имени в начале снимает вопрос о разделителе: имя системы может содержать любые знаки, и
* содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы. * без длины «a:b» и «ab:» были бы неразличимы. Используется и в локальном хранилище, и в общем слое
* Используется и в локальном хранилище, и в общем слое — единая реализация * — единая реализация вместо двух копий.
* вместо двух копий.
*/ */
final class ScopedKey { final class ScopedKey {
private ScopedKey() { private ScopedKey() {}
}
static String of(String system, String key) { static String of(String system, String key) {
String owner = system == null ? "" : system; String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key; return owner.length() + ":" + owner + ":" + key;
} }
} }
+126 -127
View File
@@ -2,165 +2,164 @@ package ru.pdguard.core;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах. * Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
* *
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. * <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. Обратное же
* Обратное же преобразование требует состояния: если прямой запрос обработал * преобразование требует состояния: если прямой запрос обработал один узел, а обратный попал на
* один узел, а обратный попал на другой, соответствие должно быть общим. * другой, соответствие должно быть общим.
* *
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не * <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не выставлена, к Redis не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной. * обращаются вовсе и зависимость остаётся неактивной.
* *
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до * <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до локальной памяти
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не * узла, а ошибка попадает в журнал. Чтобы простой Redis не съедал время ответа, команды ограничены
* съедал время ответа, команды ограничены по времени настройкой * по времени настройкой {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой * слой временно перестают опрашивать вовсе.
* временно перестают опрашивать вовсе.
*/ */
@Component @Component
public class SharedIndex { 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) {}
/** Пара «исходный текст — маска», как она хранится в общем слое. */ private final boolean enabled;
public record SharedEntry(String original, String masked) { 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;
} }
try {
private final boolean enabled; String encrypted = cipher.encrypt(original);
private final Duration ttl; redis
private final StringRedisTemplate redis; .opsForValue()
private final ObjectMapper mapper; .set(ScopedKey.of(system, payloadId), toJson(new SharedEntry(encrypted, masked)), ttl);
private final PayloadCipher cipher; redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
noteSuccess();
private final AtomicInteger consecutiveFailures = new AtomicInteger(); } catch (RuntimeException e) {
private volatile long silentUntil; noteFailure("записать", e);
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 SharedEntry byId(String system, String payloadId) {
public static SharedIndex disabled() { if (unavailable()) {
return new SharedIndex(null, "memory", 30, new ObjectMapper(), PayloadCipher.disabled()); 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() { public String originalForFingerprint(String system, String maskFingerprint) {
return enabled; 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, private String toJson(SharedEntry entry) {
String maskFingerprint) { try {
if (unavailable()) { return mapper.writeValueAsString(entry);
return; } catch (JsonProcessingException e) {
} throw new IllegalStateException("Не удалось сериализовать соответствие", e);
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);
}
} }
}
public SharedEntry byId(String system, String payloadId) { private SharedEntry fromJson(String json) {
if (unavailable()) { try {
return null; return mapper.readValue(json, SharedEntry.class);
} } catch (JsonProcessingException e) {
try { throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
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 String originalForFingerprint(String system, String maskFingerprint) { /** Общий слой выключен или предохранитель разомкнут. */
if (unavailable()) { private boolean unavailable() {
return null; return !enabled || System.currentTimeMillis() < silentUntil;
} }
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 String toJson(SharedEntry entry) { private void noteSuccess() {
try { if (consecutiveFailures.getAndSet(0) != 0) {
return mapper.writeValueAsString(entry); reported = false;
} catch (JsonProcessingException e) { LOG.info("Общий слой снова доступен");
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
}
} }
}
private SharedEntry fromJson(String json) { /**
try { * После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе
return mapper.readValue(json, SharedEntry.class); * каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ
} catch (JsonProcessingException e) { * дольше десяти секунд неответом.
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e); */
} private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
} }
if (!reported) {
/** Общий слой выключен или предохранитель разомкнут. */ reported = true;
private boolean unavailable() { LOG.error(
return !enabled || System.currentTimeMillis() < silentUntil; "Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
} }
}
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);
}
}
}
@@ -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; import java.util.Locale;
/** /**
* Общий приём для словарей, сравнивающих слово из текста с основой из списка: * Общий приём для словарей, сравнивающих слово из текста с основой из списка: личные имена ({@link
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}). * NameDictionary}) и города ({@link ToponymDictionary}).
* *
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», * <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», «Пушкин» →
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на * «Пушкина») — там основы из списка достаточно как есть. Слова на гласную меняют последнюю букву
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») — * («Москва» → «Москве», «Ольга» → «Ольге») — для них сравнение идёт по основе без неё.
* для них сравнение идёт по основе без неё.
* *
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется * <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется целиком («Дзержинский» →
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается), * «Дзержинского», «-ий» на «-ого», а не дописывается), поэтому для них отсечения одной буквы
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу * недостаточно — основа обрезается сразу до «ск». Для улиц в честь людей это не редкий случай, а
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица * основной: «улица Дзержинского», «улица Островского» пишутся только в родительном падеже,
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще. * именительный там не встречается вообще.
*/ */
final class Declension { final class Declension {
/** /**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский * Падежные окончания прилагательного склонения на «-ск-»: мужской, женский и средний род, все
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского» * падежи. Проверяются от длинных к коротким — «-ского» не должно потеряться из-за более короткого
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п. * совпадения на «-ким» и т.п.
*/ */
private static final String[] ADJECTIVE_ENDINGS = { private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский" "ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
}; };
private Declension() { private Declension() {}
}
/** /**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную — * Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий. * «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая
* Слова короче четырёх букв не трогает — короткая основа и так шире * основа и так шире большинства падежных форм.
* большинства падежных форм. */
*/ static String withoutInflectedEnding(String word) {
static String withoutInflectedEnding(String word) { String lower = word.toLowerCase(Locale.ROOT);
String lower = word.toLowerCase(Locale.ROOT); for (String ending : ADJECTIVE_ENDINGS) {
for (String ending : ADJECTIVE_ENDINGS) { if (lower.length() > ending.length() && lower.endsWith(ending)) {
if (lower.length() > ending.length() && lower.endsWith(ending)) { return lower.substring(0, lower.length() - ending.length() + 2);
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;
} }
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));
}
+458 -237
View File
@@ -5,284 +5,505 @@ import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.annotation.PreDestroy; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
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;
/** /**
* Вторая ступень распознавания. * Вторая ступень распознавания.
* *
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки * <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки микросекунд.
* микросекунд. Модель нужна там, где они бессильны: имена без русского * Модель нужна там, где они бессильны: имена без русского словообразования и нестандартные
* словообразования и нестандартные топонимы. * топонимы.
* *
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из * <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном * заглавной буквы, которые первая ступень не покрыла. Их в обычном запросе единицы, и на задержку
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее * это почти не влияет. Дороже модель — тем важнее такая экономия: у BERT вызов стоит десятки
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый * миллисекунд, и звать его на каждый запрос было бы невозможно.
* запрос было бы невозможно.
* *
* <p>Используются две модели под разные задачи: одна размечает имена (например, * <p>Используются две модели под разные задачи: одна размечает имена (например, WikiNEuRal, который
* WikiNEuRal, который не распознаёт известных личностей), другая — составляющие * не распознаёт известных личностей), другая — составляющие адреса (например, ruBERT с детальными
* адреса (например, ruBERT с детальными метками страны, региона, района, города, * метками страны, региона, района, города, улицы и дома). Каждая модель зовётся только на
* улицы и дома). Каждая модель зовётся только на непокрытые кандидаты. * непокрытые кандидаты.
* *
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: * <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: ошибка
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают * перехватывается здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
* правила. Иначе одно исключение обнуляло бы маскирование целиком. * исключение обнуляло бы маскирование целиком.
*/ */
@Component @Component
public class NameCascade { 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_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */ private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final Map<String, String> NAME_TYPES = Map.of( private static final String OUTCOME_TAG = "outcome";
"PER", PdTypes.FIO);
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */ /** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> ADDRESS_TYPES = Map.of( private static final Map<String, String> NAME_TYPES = Map.of("PER", PdTypes.FIO);
"COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", PdTypes.ADDRESS_DISTRICT,
"CITY", PdTypes.ADDRESS_CITY,
"STREET", PdTypes.ADDRESS_STREET,
"HOUSE", PdTypes.ADDRESS_HOUSE);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */ /** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Pattern CANDIDATE = Pattern.compile( private static final Map<String, String> ADDRESS_TYPES =
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}", Map.of(
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); "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; * Кандидат для LLAIM Legal NER: цифровой кластер (10–19 цифр с разделителями) или
private final Semaphore concurrent; * адрес электронной почты. Юридические реквизиты (ИНН, СНИЛС, паспорт, телефон,
private final int maxCandidates; * банковский счёт) — это цифры, а не слова с заглавной буквы, поэтому отдельный
private volatile boolean broken; * проход не влияет на кандидатов моделей имён и адресов. Имена (PER) размечает
* модель имён, email — правила, так что слова сюда не включаются: иначе модель
* звалась бы на каждое слово текста.
*/
private static final Pattern LEGAL_CANDIDATE =
Pattern.compile(
"(?:\\d(?:[\\s.\\-/()]?\\d){9,18}|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** /** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение private static final int PRIORITY = 73;
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
* посильна ли тяжёлая модель на боевом трафике.
*/
private final Counter engaged;
private final Counter withoutCandidates;
private final Counter busy;
private final Counter candidates;
private final Timer duration;
@Autowired /** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
public NameCascade( private static final int CONTEXT_CHARS = 60;
@Value("${pdguard.ner.name-engine:off}") String nameEngine,
@Value("${pdguard.ner.name-model:}") String nameModel, private final RuBertRecogniser nameRecogniser;
@Value("${pdguard.ner.address-engine:off}") String addressEngine, private final RuBertRecogniser addressRecogniser;
@Value("${pdguard.ner.address-model:}") String addressModel, private final RuBertRecogniser legalRecogniser;
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates, private final Semaphore concurrent;
@Value("${pdguard.ner.pool-size:16}") int poolSize, private final int maxCandidates;
MeterRegistry meters) { private volatile boolean broken;
this.maxCandidates = maxCandidates;
this.nameRecogniser = create(nameEngine, nameModel, NAME_TYPES); /**
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES); * Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение {@code engaged} ко
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); private final Counter engaged;
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION) private final Counter withoutCandidates;
.tag(OUTCOME_TAG, "no_candidates").register(meters); private final Counter busy;
this.busy = Counter.builder(NER_REQUESTS_METRIC) private final Counter candidates;
.description(NER_REQUESTS_DESCRIPTION) private final Timer duration;
.tag(OUTCOME_TAG, "busy").register(meters);
this.candidates = Counter.builder("pdguard.ner.candidates") /** Счётчики обращений к каждой модели второй ступени: тег {@code model} — имя движка. */
.description("Участки текста, отданные модели").register(meters); private final Map<String, Counter> modelRequests;
this.duration = Timer.builder("pdguard.ner.duration")
.description("Время работы второй ступени").register(meters); /** Время работы каждой модели: тег {@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;
}
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */ /** Таймеры времени работы каждой модели: тег {@code model} — имя движка. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) { private static Map<String, Timer> modelTimers(MeterRegistry meters) {
this(engine, modelPath.orElse(""), "off", "", maxCandidates, poolSize, new SimpleMeterRegistry()); 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, private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) {
String addressEngine, Optional<String> addressModel, this(
int maxCandidates, int poolSize) { config.nameEngine(),
this(nameEngine, nameModel.orElse(""), addressEngine, addressModel.orElse(""), config.nameModel().orElse(""),
maxCandidates, poolSize, new SimpleMeterRegistry()); 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(""), public List<Span> addMissedNames(String text, List<Span> accepted) {
maxCandidates, poolSize, meters); if (!enabled()) {
return accepted;
} }
if (!concurrent.tryAcquire()) {
/** // Модель занята целиком: отвечаем по правилам, а не копим очередь.
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный busy.increment();
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности 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);
} }
long started = System.nanoTime();
public static NameCascade disabled() { try {
return new NameCascade(); List<Span> found = new ArrayList<>(accepted);
} int examined = 0;
Matcher m = CANDIDATE.matcher(text);
public boolean enabled() { while (m.find() && examined < maxCandidates) {
return (nameRecogniser != null || addressRecogniser != null) && !broken; if (fullyCovered(found, m.start(), m.end())) {
} continue;
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
* трогаются: модели разбирают только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
return accepted;
} }
if (!concurrent.tryAcquire()) { examined++;
// Модель занята целиком: отвечаем по правилам, а не копим очередь. collect(text, m.start(), m.end(), found, "name", nameRecogniser);
busy.increment(); collect(text, m.start(), m.end(), found, "address", addressRecogniser);
return accepted; }
} // LLAIM Legal NER ищет реквизиты (ИНН, СНИЛС, паспорт), которые не
long started = System.nanoTime(); // являются словами с заглавной буквы, — отдельный проход по своим
try { // кандидатам, чтобы не вытеснять кандидатов моделей имён и адресов.
List<Span> found = new ArrayList<>(accepted); if (legalRecogniser != null) {
int examined = 0; Matcher lm = LEGAL_CANDIDATE.matcher(text);
Matcher m = CANDIDATE.matcher(text); while (lm.find() && examined < maxCandidates) {
while (m.find() && examined < maxCandidates) { if (fullyCovered(found, lm.start(), lm.end())) {
if (fullyCovered(found, m.start(), m.end())) { continue;
continue; }
} examined++;
examined++; collect(text, lm.start(), lm.end(), found, "legal", legalRecogniser);
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();
} }
}
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, private void collect(
RuBertRecogniser recogniser) { String text,
if (recogniser == null) { int candidateStart,
return; int candidateEnd,
} List<Span> sink,
int from = Math.max(0, candidateStart - CONTEXT_CHARS); String modelName,
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS); RuBertRecogniser recogniser) {
boolean nameFound = false; if (recogniser == null) {
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) { return;
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);
}
} }
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) { private static boolean fullyCovered(List<Span> spans, int start, int end) {
return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end()); return spans.stream().anyMatch(span -> span.start() <= start && end <= span.end());
} }
/** /**
* Принимает находку модели, если она пересекается с кандидатом и проходит * Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»).
* те же условия, что и находки правил. * Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя.
*/ */
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) { private static final Set<String> PD_MARKERS =
// Берём только пересекающееся с кандидатом: контекст добавлен ради Set.of(
// качества разбора, а не для расширения находки. "инн",
if (span.start() >= candidateEnd || candidateStart >= span.end()) { "снилс",
return false; "огрн",
} "огрнип",
// Адресные типы принимаются на тех же условиях, что и от правил: рядом "кпп",
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект "бик",
// Вернадского» попадали бы под маску наравне с адресом клиента. "паспорт",
return !RuleRegistry.isAddressType(span.type()) "счёт",
|| RuleRegistry.hasAddressContext(text, span.start(), span.end()); "счет",
} "телефон",
"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; private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
} // Берём только пересекающееся с кандидатом: контекст добавлен ради
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen)) { // качества разбора, а не для расширения находки.
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen); if (span.start() >= candidateEnd || candidateStart >= span.end()) {
return null; return false;
}
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath), 1, types);
if (created == null) {
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
}
return created;
} }
// Модель с приоритетом 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 private static RuBertRecogniser create(
void shutdown() { String engine, String modelPath, Map<String, String> types) {
if (nameRecogniser != null) { String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
nameRecogniser.close(); if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) {
} LOG.info("Вторая ступень распознавания выключена");
if (addressRecogniser != null) { return null;
addressRecogniser.close();
}
} }
} 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; package ru.pdguard.detect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
@@ -11,203 +8,300 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* Словари для распознавания ФИО. * Словари для распознавания ФИО.
* *
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: * <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: «Тверская» по
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени. * окончанию похожа на фамилию, но рядом с ней нет личного имени.
* *
* <p>Список известных людей решает обратную задачу — упоминание Пушкина * <p>Список известных людей решает обратную задачу — упоминание Пушкина персональными данными не
* персональными данными не является. Ограничение осознанное: клиент по фамилии * является. Ограничение осознанное: клиент по фамилии Пушкин в тексте без других ПД замаскирован не
* Пушкин в тексте без других ПД замаскирован не будет. * будет.
* *
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх * <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх него можно дописать
* него можно дописать своих публичных лиц без пересборки — файл по пути * своих публичных лиц без пересборки — файл по пути {@code pdguard.well-known-file} (по умолчанию
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt}) * {@code config/well-known.txt}) перечитывается сам при изменении, тем же приёмом, что {@code
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json} * systems.json} в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время изменения,
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время * содержимое читается заново только когда оно другое.
* изменения, содержимое читается заново только когда оно другое.
*/ */
public final class NameDictionary { 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() private static final List<String> GIVEN_NAME_STEMS =
.map(Declension::withoutInflectedEnding) ResourceLoader.lines("/names/given-names.txt", true).stream()
.distinct() .map(Declension::withoutInflectedEnding)
.sorted(Comparator.comparingInt(String::length).reversed()) .distinct()
.toList(); .sorted(Comparator.comparingInt(String::length).reversed())
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/ .toList();
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// что и для личных имён. // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream() // что и для личных имён.
.map(Declension::withoutInflectedEnding) private static final Set<String> BUNDLED_WELL_KNOWN_STEMS =
.collect(Collectors.toUnmodifiableSet()); ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE = private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS); 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 String REGNAL_EPITHETS =
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый». "велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
*/
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 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)) private static final Set<String> SURNAME_SUFFIXES =
.collect(Collectors.toUnmodifiableSet()); 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(); *
} * <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось
* бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений
/** * к хеш-таблице.
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения */
* из конфигурации Spring-бина; статические методы словаря работают без public static boolean containsGivenName(String value) {
* экземпляра, поэтому путь хранится в статическом поле. for (String word : value.split(WORD_SPLIT)) {
*/ String lower = word.toLowerCase(Locale.ROOT);
public static void configure(String wellKnownFile) { // Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; // но это имя, а не фамилия.
WELL_KNOWN_STATE.mtime = -1; if (GIVEN_NAMES.contains(lower)) {
WELL_KNOWN_STATE.lastCheck = 0; return true;
// Путь фиксирован в статическом поле; для тестов используется useExternalFile. }
if (!"config/well-known.txt".equals(wellKnownFile)) { // По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
useExternalFile(Path.of(wellKnownFile)); // «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
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 containsNamePart(String value) {
* приходится не больше четырёх обращений к хеш-таблице. for (String word : value.split(WORD_SPLIT)) {
*/ String lower = word.toLowerCase(Locale.ROOT);
public static boolean containsGivenName(String value) { if (GIVEN_NAMES.contains(lower)) {
for (String word : value.split("\\P{L}+")) { return true;
String lower = word.toLowerCase(Locale.ROOT); }
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов», if (isPatronymic(lower) || isSurname(lower)) {
// но это имя, а не фамилия. return true;
if (GIVEN_NAMES.contains(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);
} }
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа }
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное return Set.copyOf(merged);
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за });
// человека, а «Марина Шевченко» переставала бы им быть. }
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);
}
}
@@ -3,33 +3,34 @@ package ru.pdguard.detect;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* Проверка, стоит ли перед именем слово, относящее его к организации или * Проверка, стоит ли перед именем слово, относящее его к организации или объекту на карте.
* объекту на карте.
* *
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена * <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена в названиях, а не
* в названиях, а не персональные данные. Отличие от списка известных людей в том, * персональные данные. Отличие от списка известных людей в том, что здесь решает не само имя, а
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв * слово перед ним: клиент по фамилии Королёв защиту не теряет, а улица Королёва под маску не
* защиту не теряет, а улица Королёва под маску не попадает. * попадает.
*/ */
public final class OrganisationDetector { public final class OrganisationDetector {
/** /**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже, * Маркер организации вплотную перед именем. Слово может стоять в любом падеже, между ним и именем
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова». * допускается «имени» или «им.» — «Премия имени Ломоносова».
*/ */
private static final Pattern ORGANISATION_BEFORE = Pattern.compile( private static final Pattern ORGANISATION_BEFORE =
"(?iu:" + String.join("|", ResourceLoader.lines("/names/organisations.txt", false)) + ")\\p{L}*" Pattern.compile(
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$", "(?iu:"
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); + 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) { public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND); int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find(); return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
} }
} }
+49 -48
View File
@@ -3,56 +3,57 @@ package ru.pdguard.detect;
/** /**
* Имена типов персональных данных, которые умеет распознавать сервис. * Имена типов персональных данных, которые умеет распознавать сервис.
* *
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в * <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в правилах, и в
* правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках * маскировании ({@link ru.pdguard.mask.Masker}), и в политиках ({@link
* ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках * ru.pdguard.config.SystemPolicy}), и в синтетических подстановках ({@link
* ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не * ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не расходилось между слоями.
* расходилось между слоями.
*/ */
public final class PdTypes { public final class PdTypes {
private PdTypes() { private PdTypes() {}
}
public static final String EMAIL = "EMAIL"; public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE"; public static final String PHONE = "PHONE";
public static final String CARD = "CARD"; public static final String CARD = "CARD";
public static final String INN = "INN"; public static final String INN = "INN";
public static final String SNILS = "SNILS"; public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT"; public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER"; public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE"; public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE"; public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE"; public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP"; public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE"; public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE"; public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE"; public static final String DATE = "DATE";
public static final String CVV = "CVV"; public static final String CVV = "CVV";
public static final String PIN = "PIN"; public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER"; public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY"; public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE"; public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY"; public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET"; public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE"; public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT"; 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 ACCOUNT_NUMBER = "ACCOUNT_NUMBER"; public static final String ADDRESS_REGION = "ADDRESS_REGION";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY"; public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String INCOME = "INCOME"; public static final String FIO = "FIO";
public static final String OGRN = "OGRN"; public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
public static final String OGRNIP = "OGRNIP"; public static final String MILITARY_ID = "MILITARY_ID";
public static final String KPP = "KPP"; public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String BIOMETRIC = "BIOMETRIC"; 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.List;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* Общие приёмы чтения словарей и внешних файлов. * Общие приёмы чтения словарей и внешних файлов.
* *
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, * <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, пустые и
* пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем) * комментарии отбрасываются. Внешние файлы (денилист, настройки систем) перечитываются, когда
* перечитываются, когда меняется время их изменения, и не чаще раза в секунду — * меняется время их изменения, и не чаще раза в секунду — чтобы не ходить в файловую систему на
* чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда, * каждом запросе. Обе задачи вынесены сюда, чтобы не дублировать их в каждом словаре.
* чтобы не дублировать их в каждом словаре.
*/ */
final class ResourceLoader { final class ResourceLoader {
private ResourceLoader() { private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class);
}
/** private ResourceLoader() {}
* Читает строки ресурса, отбрасывая пустые и комментарии.
*
* @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);
}
}
/** /**
* Читает строки ресурса в множество, отбрасывая пустые и комментарии. * Читает строки ресурса, отбрасывая пустые и комментарии.
*/ *
static java.util.Set<String> set(String resource) { * @param resource путь к ресурсу в classpath
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet()); * @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);
} }
}
/** /** Читает строки ресурса в множество, отбрасывая пустые и комментарии. */
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза static java.util.Set<String> set(String resource) {
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее. return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
* }
* @param path путь к файлу
* @param state состояние проверки (время последней проверки и mtime файла) /**
* @param reader как превратить строки файла в итоговое значение * Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду.
* @param fallback значение, если файл недоступен * Возвращает текущее содержимое; при недоступности файла — прежнее.
*/ *
static <T> T refreshIfChanged(Path path, FileWatchState<T> state, * @param path путь к файлу
Function<List<String>, T> reader, T fallback) { * @param state состояние проверки (время последней проверки и mtime файла)
long now = System.currentTimeMillis(); * @param reader как превратить строки файла в итоговое значение
if (now - state.lastCheck < state.recheckMillis) { */
return state.current; static <T> T refreshIfChanged(
} Path path, FileWatchState<T> state, Function<List<String>, T> reader) {
state.lastCheck = now; long now = System.currentTimeMillis();
try { if (now - state.lastCheck < state.recheckMillis) {
if (!Files.isReadable(path)) { return state.current;
return state.current; }
} state.lastCheck = now;
long mtime = Files.getLastModifiedTime(path).toMillis(); try {
if (mtime != state.mtime) { if (!Files.isReadable(path)) {
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) {
// Битый файл не должен ронять работу: остаётся прежнее значение.
}
return state.current; 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. */ FileWatchState(T initial) {
static final class FileWatchState<T> { this(initial, DEFAULT_RECHECK_MILLIS);
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);
}
} }
} }
}
@@ -6,9 +6,6 @@ import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession; import ai.onnxruntime.OrtSession;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.nio.LongBuffer; import java.nio.LongBuffer;
import java.nio.file.Files; import java.nio.file.Files;
@@ -19,197 +16,213 @@ import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход. * Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
* *
* <p>В отличие от правил, он опознаёт имена без русского словообразования и * <p>В отличие от правил, он опознаёт имена без русского словообразования и нестандартные топонимы.
* нестандартные топонимы. Метки модели ложатся почти один в один * Метки модели ложатся почти один в один на типы из технического задания: имя, отчество, фамилия,
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район, * страна, регион, район, город, улица, дом.
* город, улица, дом.
* *
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на * <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на вызов, — поэтому её
* вызов, — поэтому её зовут только на участках, которые не разобрала первая * зовут только на участках, которые не разобрала первая ступень. Одновременных вызовов не больше,
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с * чем задано: иначе один запрос с десятком кандидатов занял бы все ядра.
* десятком кандидатов занял бы все ядра.
*/ */
final class RuBertRecogniser { final class RuBertRecogniser {
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class); private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */ /** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
private static final int MAX_PIECES = 190; private static final int MAX_PIECES = 190;
private final OrtEnvironment environment; private final OrtEnvironment environment;
private final OrtSession session; private final OrtSession session;
private final WordPiece tokenizer; private final WordPiece tokenizer;
private final String[] labels; private final String[] labels;
private final Set<String> inputNames; private final Set<String> inputNames;
private final Map<String, String> types; private final Map<String, String> types;
private RuBertRecogniser(OrtEnvironment environment, OrtSession session, private RuBertRecogniser(
WordPiece tokenizer, String[] labels, Map<String, String> types) { OrtEnvironment environment,
this.environment = environment; OrtSession session,
this.session = session; WordPiece tokenizer,
this.tokenizer = tokenizer; String[] labels,
this.labels = labels; Map<String, String> types) {
this.inputNames = session.getInputNames(); this.environment = environment;
this.types = types; 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;
} }
OrtSession session = null;
/** try {
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} OrtEnvironment environment = OrtEnvironment.getEnvironment();
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null}, try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) {
* и сервис продолжит работать на правилах. options.setIntraOpNumThreads(threadsPerCall);
* options.setInterOpNumThreads(1);
* @param types соответствие меток модели типам ПД сервиса session = environment.createSession(model.toString(), options);
*/ }
static RuBertRecogniser load(Path directory, int threadsPerCall, Map<String, String> types) { RuBertRecogniser recogniser =
Path model = directory.resolve("model.onnx"); new RuBertRecogniser(
Path tokenizer = directory.resolve("tokenizer.json"); environment,
Path config = directory.resolve("config.json"); session,
if (!Files.isReadable(model) || !Files.isReadable(tokenizer) || !Files.isReadable(config)) { WordPiece.fromTokenizerJson(tokenizer),
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath()); readLabels(config),
return null; types);
} LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
OrtSession session = null; return recogniser;
try { } catch (OrtException | IOException | RuntimeException e) {
OrtEnvironment environment = OrtEnvironment.getEnvironment(); closeQuietly(session);
try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) { LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
options.setIntraOpNumThreads(threadsPerCall); return null;
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) { private static void closeQuietly(OrtSession session) {
if (session == null) { if (session == null) {
return; return;
}
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e);
}
} }
try {
List<Span> recognise(String text, int from, int to, int priority) { session.close();
String region = text.substring(from, to); } catch (OrtException e) {
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES); LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e);
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 { List<Span> recognise(String text, int from, int to, int priority) {
int length = pieces.size() + 2; String region = text.substring(from, to);
long[] ids = new long[length]; List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
long[] mask = new long[length]; if (pieces.isEmpty()) {
long[] tokenTypes = new long[length]; return List.of();
ids[0] = tokenizer.classifyId(); }
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++) { for (int i = 0; i < pieces.size(); i++) {
ids[i + 1] = pieces.get(i).id(); tags[i] = labels[argmax(logits[0][i + 1])];
}
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);
} }
return tags;
}
} finally {
inputs.values().forEach(OnnxTensor::close);
} }
}
/** /**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. * Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки
* Схема разметки различает начало, середину, конец и одиночный токен, но для * различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа:
* сборки достаточно смены типа: границы участков и так проставлены по словам. * границы участков и так проставлены по словам.
*/ */
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority, private static List<Span> toSpans(
Map<String, String> types) { List<WordPiece.Piece> pieces,
List<Span> spans = new ArrayList<>(); String[] tags,
String currentType = null; int offset,
int start = 0; int priority,
int end = 0; Map<String, String> types) {
for (int i = 0; i < tags.length; i++) { List<Span> spans = new ArrayList<>();
String type = types.get(entityOf(tags[i])); String currentType = null;
if (type != null && type.equals(currentType)) { int start = 0;
end = pieces.get(i).end(); int end = 0;
continue; for (int i = 0; i < tags.length; i++) {
} String type = types.get(entityOf(tags[i]));
if (currentType != null) { if (type != null && type.equals(currentType)) {
spans.add(new Span(offset + start, offset + end, currentType, priority)); end = pieces.get(i).end();
} continue;
currentType = type; }
start = pieces.get(i).start(); if (currentType != null) {
end = pieces.get(i).end(); spans.add(new Span(offset + start, offset + end, currentType, priority));
} }
if (currentType != null) { currentType = type;
spans.add(new Span(offset + start, offset + end, currentType, priority)); start = pieces.get(i).start();
} end = pieces.get(i).end();
return spans;
} }
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
return spans;
}
private static String entityOf(String tag) { private static String entityOf(String tag) {
int dash = tag.indexOf('-'); int dash = tag.indexOf('-');
return dash < 0 ? tag : tag.substring(dash + 1); return dash < 0 ? tag : tag.substring(dash + 1);
} }
private static int argmax(float[] scores) { private static int argmax(float[] scores) {
int best = 0; int best = 0;
for (int i = 1; i < scores.length; i++) { for (int i = 1; i < scores.length; i++) {
if (scores[i] > scores[best]) { if (scores[i] > scores[best]) {
best = i; best = i;
} }
}
return best;
} }
return best;
}
private static String[] readLabels(Path config) throws IOException { private static String[] readLabels(Path config) throws IOException {
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label"); JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
String[] labels = new String[node.size()]; String[] labels = new String[node.size()];
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) { for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next(); Map.Entry<String, JsonNode> entry = it.next();
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText(); labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
}
return labels;
} }
return labels;
}
void close() { void close() {
try { try {
session.close(); session.close();
} catch (OrtException e) { } catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели", e); LOG.debug("Не удалось закрыть сессию модели", e);
}
} }
} }
}
+99 -80
View File
@@ -7,93 +7,112 @@ import java.util.regex.Pattern;
/** /**
* Одно правило детекции персональных данных. * Одно правило детекции персональных данных.
* *
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в * <p>Добавление нового типа ПД — это добавление одного {@code Rule} в {@link RuleRegistry}; менять
* {@link RuleRegistry}; менять остальной код не требуется. * остальной код не требуется.
* *
* @param type тип ПД, который распознаёт правило * @param type тип ПД, который распознаёт правило
* @param pattern регулярное выражение * @param pattern регулярное выражение
* @param priority приоритет при разрешении перекрытий * @param priority приоритет при разрешении перекрытий
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. * @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. Несколько
* Несколько групп нужны, когда значение разорвано словами: * групп нужны, когда значение разорвано словами: «серия 4509 номер 123456»
* «серия 4509 номер 123456» * @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); {@code null}
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); * — проверка не нужна
* {@code null} — проверка не нужна * @param veto шаблон окружения, при котором совпадение персональными данными не считается: адрес
* @param veto шаблон окружения, при котором совпадение персональными данными не считается: * отделения банка не является ПД, хотя выглядит как адрес
* адрес отделения банка не является ПД, хотя выглядит как адрес * @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, где форма
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, * совпадения сама по себе слишком общая: «Невский проспект» это адрес рядом с домом и индексом
* где форма совпадения сама по себе слишком общая: «Невский проспект» * и просто топоним в рассказе о городе
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе * @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. Проверка через
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. * {@code indexOf} на порядок дешевле запуска регулярного выражения и отсекает большинство
* Проверка через {@code indexOf} на порядок дешевле запуска * правил на коротком запросе. Пустой список — правило запускается всегда
* регулярного выражения и отсекает большинство правил на коротком
* запросе. Пустой список — правило запускается всегда
*/ */
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups, public record Rule(
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) { String type,
Pattern pattern,
int priority,
List<Integer> groups,
Predicate<String> validator,
Pattern veto,
Pattern context,
List<String> anchors) {
/** public Rule {
* Флаги компиляции для всех правил. groups = List.copyOf(groups);
* anchors = List.copyOf(anchors);
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} }
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/** /**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон. * Флаги компиляции для всех правил.
* *
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город, * <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} и {@code \b} в
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома * Java охватывают только латиницу, и якорные слова вроде «водительское удостоверение» не
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский * находятся. {@code UNICODE_CASE} делает {@code (?i)} корректным для кириллицы.
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным */
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
* вето не срабатывало на части из них.
*/
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()); *
* <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;
} }
for (String anchor : anchors) {
/** Маскировать только перечисленные группы, а не всё совпадение. */ if (lowercasedText.contains(anchor)) {
public Rule groups(Integer... indexes) { return true;
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors); }
} }
return false;
}
/** Принять совпадение, только если значение прошло проверку. */ /** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule validatedBy(Predicate<String> check) { public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, check, veto, context, anchors); return new Rule(
} type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */ /** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule anchoredBy(String... required) { public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required)); return new Rule(
} type,
pattern,
/** Есть ли в тексте хоть один из якорей правила. */ priority,
public boolean mayMatch(String lowercasedText) { groups,
if (anchors.isEmpty()) { validator,
return true; Pattern.compile(regex, FLAGS),
} context,
for (String anchor : anchors) { anchors);
if (lowercasedText.contains(anchor)) { }
return true;
}
}
return false;
}
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
}
} }
@@ -0,0 +1,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; package ru.pdguard.detect;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
/** /**
* Реестр правил детекции и сам поиск ПД в тексте. * Реестр правил детекции и сам поиск ПД в тексте.
* *
* <p>Правила разбиты на три уровня доверия: * <p>Правила разбиты на три уровня доверия:
*
* <ol> * <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li> * <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
* <li>однозначные по формату — email, телефон;</li> * <li>однозначные по формату — email, телефон;
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, * <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, где
* где сама по себе последовательность знаков ни о чём не говорит.</li> * сама по себе последовательность знаков ни о чём не говорит.
* </ol> * </ol>
* *
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен * <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен именно на них.
* именно на них. На захватываемое значение регистронезависимость не распространяется: * На захватываемое значение регистронезависимость не распространяется: там, где значение опознаётся
* там, где значение опознаётся по заглавной букве, это существенно. * по заглавной букве, это существенно.
*
* <p>Сами правила сгруппированы по категориям в отдельных классах пакета — {@link DocumentRules},
* {@link FinanceRules}, {@link DateRules}, {@link FioRules}, {@link ContactRules}, {@link
* AddressRules} — чтобы каждая категория читалась отдельно от остальных. Здесь их списки только
* объединяются и используются.
*/ */
@Component @Component
public class RuleRegistry { 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город|регистрац|прожива)";
/** private static final Pattern ADDRESS_CONTEXT =
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел — Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
* «Тверская», «Малая Никитская», «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 Set<String> ADDRESS_TYPES =
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а. Set.of(
*/ PdTypes.ADDRESS_COUNTRY,
private static final String SURNAME = PdTypes.ADDRESS_REGION,
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)" PdTypes.ADDRESS_DISTRICT,
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}"; PdTypes.ADDRESS_CITY,
PdTypes.ADDRESS_STREET,
PdTypes.ADDRESS_HOUSE,
PdTypes.ADDRESS_FLAT,
PdTypes.ADDRESS_POSTCODE);
/** /**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается. * Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне. * правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных
*/ * разделителей, и не должна перебивать находки с якорным словом.
private static final String PATRONYMIC = */
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич" private static final int NORMALISED_PRIORITY = 63;
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */ /**
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}"; * Цифровой кластер: от 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]");
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */ public static boolean isAddressType(String type) {
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b"; 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 List<Rule> RULES =
private static final String DATE_WORDS = Stream.of(
"\\b(?:(?iu:двадцать|тридцать)\\s+)?" DocumentRules.RULES,
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат" FinanceRules.RULES,
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)" DateRules.RULES,
+ "(?iu:ьего|ого|его)\\s+" + MONTH FioRules.RULES,
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)"; 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 List<Span> detect(String text, SystemPolicy policy) {
* у постфиксной формы правила, префиксная его не имела. List<Span> found = new ArrayList<>();
*/ String lowercased = text.toLowerCase(Locale.ROOT);
public static final String ADDRESS_NEARBY = for (Rule rule : RULES) {
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)"; if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
continue;
private static final Pattern ADDRESS_CONTEXT = }
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); collect(rule, text, found);
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
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);
} }
collectNormalisedDigits(text, policy, found);
return found;
}
/** /**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам * Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями,
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в * которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом. * контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах.
*/ */
public static boolean hasAddressContext(String text, int start, int end) { private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> sink) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find(); if (!policy.allows(PdTypes.CARD)
&& !policy.allows(PdTypes.INN)
&& !policy.allows(PdTypes.SNILS)
&& !policy.allows(PdTypes.OGRN)
&& !policy.allows(PdTypes.OGRNIP)) {
return;
} }
Matcher m = DIGIT_CLUSTER.matcher(text);
private static final String ORGANISATION_NEARBY = while (m.find()) {
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)"; String digits = NON_DIGIT.matcher(m.group()).replaceAll("");
String type = typeFor(digits);
private static final List<Rule> RULES = List.of( if (type != null && policy.allows(type)) {
sink.add(new Span(m.start(), m.end(), type, NORMALISED_PRIORITY));
// --- Уровень 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();
} }
}
/** /**
* Находит все фрагменты ПД, разрешённые политикой системы. * Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 цифр сначала
* Перекрытия здесь не разрешаются — это делает вызывающая сторона. * пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный ОГРН не должен случайно стать
*/ * номером карты (карта самостоятельна, ОГРН — только спутник, и одинокий ОГРН убирается в {@code
public List<Span> detect(String text, SystemPolicy policy) { * Pipeline}).
List<Span> found = new ArrayList<>(); */
String lowercased = text.toLowerCase(Locale.ROOT); private static String typeFor(String digits) {
for (Rule rule : RULES) { int length = digits.length();
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) { switch (length) {
continue; case 10, 12:
} return Validators.inn(digits) ? PdTypes.INN : null;
collect(rule, text, found); 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); * Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и
while (m.find()) { * контекст.
for (int group : rule.groups()) { */
int start = m.start(group); private static boolean isValidGroup(Rule rule, String text, int start, int end) {
int end = m.end(group); if (start < 0 || end <= start) {
if (isValidGroup(rule, text, start, end)) { return false;
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
}
}
} }
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 и контекст. */ static String surroundings(String text, int start, int end) {
private static boolean isValidGroup(Rule rule, String text, int start, int end) { int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
if (start < 0 || end <= start) { int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
return false; return text.substring(from, to);
} }
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);
}
} }
+13 -13
View File
@@ -3,24 +3,24 @@ package ru.pdguard.detect;
/** /**
* Найденный фрагмент персональных данных в исходном тексте. * Найденный фрагмент персональных данных в исходном тексте.
* *
* @param start индекс первого символа (включительно) * @param start индекс первого символа (включительно)
* @param end индекс за последним символом (исключительно) * @param end индекс за последним символом (исключительно)
* @param type тип ПД, например {@code CARD} или {@code EMAIL} * @param type тип ПД, например {@code CARD} или {@code EMAIL}
* @param priority приоритет при разрешении перекрытий: больше — важнее * @param priority приоритет при разрешении перекрытий: больше — важнее
*/ */
public record Span(int start, int end, String type, int priority) { public record Span(int start, int end, String type, int priority) {
public Span { public Span {
if (start < 0 || end <= start) { if (start < 0 || end <= start) {
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end); throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
}
} }
}
public int length() { public int length() {
return end - start; return end - start;
} }
public boolean overlaps(Span other) { public boolean overlaps(Span other) {
return start < other.end && other.start < end; return start < other.end && other.start < end;
} }
} }
@@ -4,51 +4,47 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
/** /**
* Словарь населённых пунктов России — проверка того, что значение, пойманное * Словарь населённых пунктов России — проверка того, что значение, пойманное правилом {@code
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город, * ADDRESS_CITY}, действительно похоже на существующий город, село, посёлок или другой населённый
* село, посёлок или другой населённый пункт, а не на произвольное слово с * пункт, а не на произвольное слово с заглавной буквы после якоря.
* заглавной буквы после якоря.
* *
* <p>Не только официальные города (~1100 по классификатору): перепись * <p>Не только официальные города (~1100 по классификатору): перепись добавляет сёла, деревни,
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево» * хутора, станицы — «рп. Ильинское», «с. Кукуево» из ТЗ находятся ровно за счёт неё. Какой
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта * конкретно тип населённого пункта стоит перед названием, определяет якорь самого правила в {@link
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry}, * RuleRegistry}, а не этот словарь — он только подтверждает, что название реальное.
* а не этот словарь — он только подтверждает, что название реальное.
* *
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания * <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в Москве», «из
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора * Казани») тем самым покрываются без отдельного разбора морфологии, как и у известных людей в
* морфологии, как и у известных людей в {@link NameDictionary}. * {@link NameDictionary}.
*/ */
public final class ToponymDictionary { public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream() private static final Set<String> SETTLEMENT_STEMS =
.map(Declension::withoutInflectedEnding) ResourceLoader.set("/names/settlements.txt").stream()
.collect(java.util.stream.Collectors.toUnmodifiableSet()); .map(Declension::withoutInflectedEnding)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private ToponymDictionary() { private ToponymDictionary() {}
}
/** /**
* Похоже ли значение на название населённого пункта из словаря в любом * Похоже ли значение на название населённого пункта из словаря в любом падеже.
* падеже. *
* * <p>Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» → * начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак. * («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для * в {@link NameDictionary}.
* них сравнение идёт по основе без неё — так же, как с личными именами *
* в {@link NameDictionary}. * <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению:
* * перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 * слова не больше, чем в нём букв.
* основ по значению: перебор списка на каждое совпадение правила был бы */
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв. public static boolean isKnownSettlement(String value) {
*/ String lower = value.strip().toLowerCase(Locale.ROOT);
public static boolean isKnownSettlement(String value) { for (int length = lower.length(); length > 0; length--) {
String lower = value.strip().toLowerCase(Locale.ROOT); if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
for (int length = lower.length(); length > 0; length--) { return true;
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) { }
return true;
}
}
return false;
} }
} return false;
}
}
+169 -162
View File
@@ -1,184 +1,191 @@
package ru.pdguard.detect; package ru.pdguard.detect;
/** /**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности, * Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме
* которые по форме похожи на ПД, но ими не являются. * похожи на ПД, но ими не являются.
*/ */
public final class Validators { public final class Validators {
private static final int[] INN_10 = {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_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_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; * номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных
int digits = 0; * номеров карт.
boolean doubled = false; */
for (int i = value.length() - 1; i >= 0; i--) { public static int luhnCheckDigit(String body) {
char c = value.charAt(i); int sum = 0;
if (!Character.isDigit(c)) { boolean doubled = true;
continue; for (int i = body.length() - 1; i >= 0; i--) {
} int d = body.charAt(i) - '0';
int d = c - '0'; if (doubled) {
digits++; d *= 2;
if (doubled) { if (d > 9) {
d *= 2; d -= 9;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
} }
return digits >= 13 && digits <= 19 && sum % 10 == 0; }
sum += d;
doubled = !doubled;
} }
return (10 - sum % 10) % 10;
}
/** /** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
* Контрольная цифра Луна для последовательности цифр: дописывается к телу public static boolean inn(String value) {
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при int[] d = digits(value);
* генерации правдоподобных подставных номеров карт. if (d.length == 10) {
*/ return d[9] == checksum(d, INN_10);
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;
} }
if (d.length == 12) {
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
}
return false;
}
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */ /** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
public static boolean inn(String value) { public static boolean snils(String value) {
int[] d = digits(value); int[] d = digits(value);
if (d.length == 10) { if (d.length != 11) {
return d[9] == checksum(d, INN_10); return false;
} }
if (d.length == 12) { int sum = 0;
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B); 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; 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) { private static boolean dayAndMonth(int a, int b) {
int[] d = digits(value); return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
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 checksum(int[] d, int[] weights) {
private static int snilsControl(int sum) { int sum = 0;
if (sum < 100) { for (int i = 0; i < weights.length; i++) {
return sum; sum += d[i] * weights[i];
}
if (sum == 100 || sum == 101) {
return 0;
}
return sum % 101 % 100;
} }
return sum % 11 % 10;
}
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */ private static int[] digits(String value) {
public static boolean ogrn(String value) { int[] out = new int[value.length()];
int[] d = digits(value); int n = 0;
return d.length == 13 && d[12] == modReduce(d, 12, 11); for (int i = 0; i < value.length(); i++) {
} char c = value.charAt(i);
if (Character.isDigit(c)) {
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */ out[n++] = c - '0';
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;
} }
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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@@ -18,163 +17,162 @@ import java.util.Map;
/** /**
* Разбиение текста на подслова так, как это делает токенизатор BERT. * Разбиение текста на подслова так, как это делает токенизатор BERT.
* *
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java * <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься * нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря: * здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой * каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом
* длинной подходящей записи словаря, продолжения помечаются префиксом «##». * «##».
* *
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку * <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не
* модели не перенести обратно на строку. * перенести обратно на строку.
*/ */
final class WordPiece { final class WordPiece {
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */ /** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
private static final int MAX_WORD_CHARS = 100; 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; * Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal)
private final int classifyId; * не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора.
private final int separatorId; */
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
private WordPiece(Map<String, Integer> vocabulary) { JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
this.vocabulary = vocabulary; JsonNode vocab = root.path("model").path("vocab");
this.unknownId = required(vocabulary, "[UNK]"); Map<String, Integer> vocabulary = HashMap.newHashMap(vocab.size());
this.classifyId = required(vocabulary, "[CLS]"); Iterator<Map.Entry<String, JsonNode>> fields = vocab.fields();
this.separatorId = required(vocabulary, "[SEP]"); 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 { int classifyId() {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000); return classifyId;
try (BufferedReader reader = new BufferedReader( }
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line; int separatorId() {
int index = 0; return separatorId;
while ((line = reader.readLine()) != null) { }
vocabulary.putIfAbsent(line.strip(), index++);
} /** Подслова текста в порядке следования; служебные токены сюда не входят. */
} List<Piece> split(String text, int maxPieces) {
return new WordPiece(vocabulary); 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}, а хранят * препинания сам становится отдельным словом.
* словарь внутри токенизатора. */
*/ private static List<int[]> words(String text) {
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException { List<int[]> result = new ArrayList<>();
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile)); int start = -1;
JsonNode vocab = root.path("model").path("vocab"); for (int i = 0; i < text.length(); i++) {
Map<String, Integer> vocabulary = HashMap.newHashMap(vocab.size()); char c = text.charAt(i);
Iterator<Map.Entry<String, JsonNode>> fields = vocab.fields(); boolean separator = Character.isWhitespace(c) || isPunctuation(c);
while (fields.hasNext()) { if (separator) {
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;
}
}
if (start >= 0) { 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) { private static boolean isPunctuation(char c) {
if (Character.isLetterOrDigit(c)) { if (Character.isLetterOrDigit(c)) {
return false; return false;
}
return !Character.isWhitespace(c);
} }
return !Character.isWhitespace(c);
}
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) { private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
if (to - from > MAX_WORD_CHARS) { if (to - from > MAX_WORD_CHARS) {
sink.add(new Piece(unknownId, from, to)); sink.add(new Piece(unknownId, from, to));
return; 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);
}
} }
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) { private static int required(Map<String, Integer> vocabulary, String token) {
Integer id = vocabulary.get(token); Integer id = vocabulary.get(token);
if (id == null) { if (id == null) {
throw new IllegalStateException("В словаре нет служебного токена " + token); throw new IllegalStateException("В словаре нет служебного токена " + token);
}
return id;
} }
return id;
}
} }
+27 -26
View File
@@ -8,39 +8,40 @@ import java.util.function.BiFunction;
/** /**
* Состояние одной операции маскирования. * Состояние одной операции маскирования.
* *
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если * <p>Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл * дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится.
* запроса для модели сохранится.
* *
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется. * <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/ */
public final class MaskContext { 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, String> assigned = new HashMap<>();
private final Map<String, Integer> counters = new HashMap<>(); private final Map<String, Integer> counters = new HashMap<>();
private final Map<String, String> restorations = new LinkedHashMap<>(); private final Map<String, String> restorations = new LinkedHashMap<>();
/** /**
* Замена для значения; при повторе возвращается ранее выданная. * Замена для значения; при повторе возвращается ранее выданная.
* *
* @param factory получает тип ПД и порядковый номер значения этого типа * @param factory получает тип ПД и порядковый номер значения этого типа
*/ */
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) { public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> { return assigned.computeIfAbsent(
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum)); type + SEPARATOR + value,
restorations.put(replacement, value); key -> {
return replacement; String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
}); });
} }
/** /**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст * Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не
* возвращается не целиком, а изменённым — например, в ответе языковой модели. * целиком, а изменённым — например, в ответе языковой модели.
*/ */
public Map<String, String> restorations() { public Map<String, String> restorations() {
return Map.copyOf(restorations); return Map.copyOf(restorations);
} }
} }
+14 -6
View File
@@ -3,12 +3,20 @@ package ru.pdguard.mask;
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */ /** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
public enum MaskMode { public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */ /** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK, MASK,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */ /**
TOKEN, * Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично
* открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code
* ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую
* букву каждого).
*/
STRICT,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */ /** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
SYNTHETIC TOKEN,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
} }
+64 -65
View File
@@ -1,82 +1,81 @@
package ru.pdguard.mask; package ru.pdguard.mask;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.PdTypes;
import java.util.Map; import java.util.Map;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.PdTypes;
/** /**
* Превращает найденное значение в замену согласно настройкам системы. * Превращает найденное значение в замену согласно настройкам системы.
* *
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — * <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — безопасное поведение по
* безопасное поведение по умолчанию для вновь добавленных правил. * умолчанию для вновь добавленных правил.
*/ */
@Component @Component
public class Masker { public class Masker {
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 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 UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries( private static final Map<String, UnaryOperator<String>> BY_TYPE =
Map.entry(PdTypes.EMAIL, Strategies::email), Map.ofEntries(
Map.entry(PdTypes.PHONE, EDGES), Map.entry(PdTypes.EMAIL, Strategies::email),
Map.entry(PdTypes.CARD, EDGES), Map.entry(PdTypes.PHONE, EDGES),
Map.entry(PdTypes.INN, EDGES), Map.entry(PdTypes.CARD, EDGES),
Map.entry(PdTypes.SNILS, EDGES), Map.entry(PdTypes.INN, EDGES),
Map.entry(PdTypes.PASSPORT, EDGES), Map.entry(PdTypes.SNILS, EDGES),
Map.entry(PdTypes.DRIVER_LICENSE, EDGES), Map.entry(PdTypes.PASSPORT, EDGES),
Map.entry(PdTypes.DEPT_CODE, 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.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES), Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES),
Map.entry(PdTypes.MEDICAL_POLICY, EDGES), Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(PdTypes.CARDHOLDER, Strategies::initials), Map.entry(PdTypes.MEDICAL_POLICY, EDGES),
Map.entry(PdTypes.FIO, Strategies::initials), Map.entry(PdTypes.CARDHOLDER, Strategies::initials),
Map.entry(PdTypes.FIO, Strategies::initials),
// Код проверки и пин-код не показываем даже частично: у них слишком // Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один. // мало знаков, чтобы открывать хотя бы один.
Map.entry(PdTypes.CVV, Strategies::stars), Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(PdTypes.PIN, 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));
// У дат сохраняем разделители: модель видит, что это дата, но не какая. public String mask(String type, String value, MaskMode mode, MaskContext context) {
Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation), return switch (mode) {
Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation), case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation), // STRICT игнорирует BY_TYPE целиком — ни один тип не открывает края
// и ФИО не превращается в инициалы, только сплошные звёздочки.
Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars), case STRICT -> Strategies.stars(value);
Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars), case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars), case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
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));
};
}
} }
+89 -92
View File
@@ -3,113 +3,110 @@ package ru.pdguard.mask;
/** /**
* Способы преобразования найденного значения в маску. * Способы преобразования найденного значения в маску.
* *
* <p>Все стратегии сохраняют длину и разделители исходного значения: так * <p>Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст
* замаскированный текст остаётся читаемым для LLM и минимально отличается * остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении.
* от эталона при посимвольном сравнении.
*/ */
public final class Strategies { public final class Strategies {
private static final char MASK = '*'; private static final char MASK = '*';
private Strategies() { private Strategies() {}
}
/** Каждый непробельный символ заменяется на «*». */ /** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) { public static String stars(String value) {
StringBuilder sb = new StringBuilder(value.length()); StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) { for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i); char c = value.charAt(i);
sb.append(Character.isWhitespace(c) ? c : MASK); sb.append(Character.isWhitespace(c) ? c : MASK);
}
return sb.toString();
} }
return sb.toString();
}
/** /**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, * Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, * 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет.
* само значение — нет. */
*/ public static String starsKeepingPunctuation(String value) {
public static String starsKeepingPunctuation(String value) { StringBuilder sb = new StringBuilder(value.length());
StringBuilder sb = new StringBuilder(value.length()); for (int i = 0; i < value.length(); i++) {
for (int i = 0; i < value.length(); i++) { char c = value.charAt(i);
char c = value.charAt(i); sb.append(Character.isLetterOrDigit(c) ? MASK : c);
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
}
return sb.toString();
} }
return sb.toString();
}
/** /**
* Оставляет первые и последние значащие символы, остальные скрывает, * Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет:
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}. * {@code 4509 123456} → {@code 45** ****56}.
*/ */
public static String keepEdges(String value, int head, int tail) { public static String keepEdges(String value, int head, int tail) {
int significant = 0; int significant = 0;
for (int i = 0; i < value.length(); i++) { for (int i = 0; i < value.length(); i++) {
if (Character.isLetterOrDigit(value.charAt(i))) { if (Character.isLetterOrDigit(value.charAt(i))) {
significant++; 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();
} }
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 И. И. И.} */ /** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
public static String initials(String value) { public static String initials(String value) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
boolean wordStart = true; boolean wordStart = true;
for (int i = 0; i < value.length(); i++) { for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i); char c = value.charAt(i);
if (Character.isLetter(c)) { if (Character.isLetter(c)) {
if (wordStart) { if (wordStart) {
if (!sb.isEmpty()) { if (!sb.isEmpty()) {
sb.append(' '); sb.append(' ');
} }
sb.append(Character.toUpperCase(c)).append('.'); sb.append(Character.toUpperCase(c)).append('.');
wordStart = false; wordStart = false;
}
} else {
wordStart = true;
}
} }
return sb.isEmpty() ? stars(value) : sb.toString(); } else {
wordStart = true;
}
} }
return sb.isEmpty() ? stars(value) : sb.toString();
}
/** /**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона. * Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru} * ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/ */
public static String email(String value) { public static String email(String value) {
int at = value.lastIndexOf('@'); int at = value.lastIndexOf('@');
if (at <= 0 || at == value.length() - 1) { if (at <= 0 || at == value.length() - 1) {
return stars(value); 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);
} }
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) { private static String hideTail(String part) {
if (part.length() <= 1) { if (part.length() <= 1) {
return part; return part;
}
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
} }
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 { final class Synthetic {
private static final String[] SURNAMES = private static final String[] SURNAMES = {
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"}; "Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"}; };
private static final String[] PATRONYMICS = private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"}; private static final String[] PATRONYMICS = {
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"}; "Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"
};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() { private Synthetic() {}
}
static String forType(String type, String value, int ordinal) { static String forType(String type, String value, int ordinal) {
int seed = value.hashCode() & Integer.MAX_VALUE; int seed = value.hashCode() & Integer.MAX_VALUE;
return switch (type) { return switch (type) {
case PdTypes.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) case PdTypes.FIO ->
+ " " + pick(PATRONYMICS, seed >> 6); pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) + " " + pick(PATRONYMICS, seed >> 6);
case PdTypes.CARDHOLDER -> "IVAN PETROV"; case PdTypes.CARDHOLDER -> "IVAN PETROV";
case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) case PdTypes.PHONE ->
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2); "+7 9"
case PdTypes.CARD -> luhnCard(seed); + digits(seed, 2)
case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT, + " "
PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); + digits(seed >> 4, 3)
case PdTypes.INN -> digits(seed, 12); + "-"
case PdTypes.MEDICAL_POLICY -> digits(seed, 16); + digits(seed >> 8, 2)
case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) + "-"
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2); + digits(seed >> 12, 2);
case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed); case PdTypes.CARD -> luhnCard(seed);
case PdTypes.ADDRESS_CITY -> "Зареченск"; case PdTypes.PASSPORT,
case PdTypes.ADDRESS_STREET -> "Сосновая"; PdTypes.DRIVER_LICENSE,
case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90)); PdTypes.FOREIGN_PASSPORT,
case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200)); PdTypes.MILITARY_ID ->
case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6); digits(seed, 4) + " " + digits(seed >> 6, 6);
case PdTypes.ADDRESS_COUNTRY -> "Заречье"; case PdTypes.INN -> digits(seed, 12);
case PdTypes.ADDRESS_REGION -> "Заречная область"; case PdTypes.MEDICAL_POLICY -> digits(seed, 16);
case PdTypes.ADDRESS_DISTRICT -> "Сосновый район"; case PdTypes.SNILS ->
case PdTypes.CVV -> digits(seed, 3); digits(seed, 3)
case PdTypes.PIN -> digits(seed, 4); + "-"
// Для остальных типов правдоподобной замены нет — отдаём токен. + digits(seed >> 4, 3)
default -> "[" + type + "_" + ordinal + "]"; + "-"
}; + 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) { private static String pick(String[] options, int seed) {
return options[Math.floorMod(seed, options.length)]; return options[Math.floorMod(seed, options.length)];
} }
private static String syntheticDate(int seed) { private static String syntheticDate(int seed) {
int day = 1 + Math.floorMod(seed, 28); int day = 1 + Math.floorMod(seed, 28);
int month = 1 + Math.floorMod(seed >> 5, 12); int month = 1 + Math.floorMod(seed >> 5, 12);
int year = 1960 + Math.floorMod(seed >> 9, 45); int year = 1960 + Math.floorMod(seed >> 9, 45);
return String.format("%02d.%02d.%d", day, month, year); return String.format("%02d.%02d.%d", day, month, year);
} }
private static String digits(int seed, int count) { private static String digits(int seed, int count) {
StringBuilder sb = new StringBuilder(count); StringBuilder sb = new StringBuilder(count);
int value = Math.abs(seed); int value = Math.abs(seed);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
sb.append((char) ('0' + Math.floorMod(value, 10))); sb.append((char) ('0' + Math.floorMod(value, 10)));
value = value / 10 + (i + 1) * 7; value = value / 10 + (i + 1) * 7;
}
return sb.toString();
} }
return sb.toString();
}
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */ /** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
private static String luhnCard(int seed) { private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
body.append(Validators.luhnCheckDigit(body.toString())); body.append(Validators.luhnCheckDigit(body.toString()));
return body.substring(0, 4) + " " + body.substring(4, 8) + " " return body.substring(0, 4)
+ body.substring(8, 12) + " " + body.substring(12); + " "
} + 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 systems-file: config/systems.json
store: store:
backend: memory backend: memory
max-chars: 134217728
ttl-minutes: 30 ttl-minutes: 30
# 32 байта в hex; AES-256 ключ шифрования хранилища # 32 байта в hex; AES-256 ключ шифрования хранилища
encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573" encryption-key: "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"
min-concurrent: 8 min-concurrent: 100
max-concurrent: 2000 max-concurrent: 2000
target-latency-ms: 200 target-latency-ms: 900
warmup-iterations: 2000 warmup-iterations: 2000
ner: ner:
name-engine: off name-engine: off
name-model: models/wikineural-ner name-model: models/wikineural-ner
address-engine: off address-engine: off
address-model: models/rubert-ner address-model: models/rubert-ner
legal-engine: off
legal-model: models/ru-legal-ner
max-candidates: 16 max-candidates: 16
pool-size: 16 pool-size: 16
llm: 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; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,149 +11,165 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class BankTypesTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
} masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
/** /**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они * Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их * организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата. * переведены в {@code requireCompanion}, как пин-код и дата.
*/ */
@Test @Test
void masksAccountNumberNextToPersonalData() { void masksAccountNumberNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", assertHidden(
"40702810500000001234"); "Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234"); assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
} }
@Test @Test
void masksBikNextToPersonalData() { void masksBikNextToPersonalData() {
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593"); assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
} }
@Test @Test
void keepsBankDetailsWithoutAnyPersonalData() { void keepsBankDetailsWithoutAnyPersonalData() {
for (String text : new String[]{ for (String text :
"Расчётный счёт 40702810500000001234 открыт вчера", new String[] {
"БИК 044525593 банка-получателя", "Расчётный счёт 40702810500000001234 открыт вчера",
"ОГРН 1027700132195 организации", "БИК 044525593 банка-получателя",
"КПП 770101001 указан в реквизитах"}) { "ОГРН 1027700132195 организации",
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), "КПП 770101001 указан в реквизитах"
"реквизиты без человека персональными данными не являются"); }) {
} assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются");
} }
}
@Test @Test
void masksCardExpiryButNotCardNumber() { void masksCardExpiryButNotCardNumber() {
String masked = pipeline.process( String masked =
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT); pipeline.process(
assertFalse(masked.contains("09/27"), masked); "Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked); assertFalse(masked.contains("09/27"), masked);
} assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
}
@Test @Test
void masksOgrnAndOgrnipDifferently() { void masksOgrnAndOgrnipDifferently() {
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195"); assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157"); assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
} }
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */ /** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test @Test
void ogrnDoesNotSwallowOgrnip() { void ogrnDoesNotSwallowOgrnip() {
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", String masked =
"ogrnip-1", SystemPolicy.DEFAULT); pipeline.process(
assertFalse(masked.contains("304500116000157"), masked); "ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked); assertFalse(masked.contains("304500116000157"), masked);
} assertFalse(
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
}
/** /**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». * Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно * так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал. * карты, и тест ничего бы не показывал.
*/ */
@Test @Test
void doesNotMaskOgrnWithBrokenChecksum() { void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации"; String text = "ОГРН 1027700132190 организации";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), assertEquals(
"число с неверной контрольной суммой не является настоящим ОГРН"); text,
} pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН");
}
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */ /** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
@Test @Test
void doesNotMaskOgrnipWithBrokenChecksum() { void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя"; String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), assertEquals(
"число с неверной контрольной суммой не является настоящим ОГРНИП"); text,
} pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП");
}
@Test @Test
void masksKppNextToPersonalData() { void masksKppNextToPersonalData() {
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001"); assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
} }
@Test @Test
void masksIncomeNextToPersonalData() { void masksIncomeNextToPersonalData() {
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000"); assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000"); assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
} }
/** /**
* Сумма заработка без человека — статистика или описание продукта. Опознать по * Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает, * а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем. * отрасли отвечать уже не на чем.
*/ */
@Test @Test
void keepsIncomeWithoutAnyPersonalData() { void keepsIncomeWithoutAnyPersonalData() {
for (String text : new String[]{ for (String text :
"По данным Росстата доход домохозяйств вырос до 74 500 руб", new String[] {
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) { "По данным Росстата доход домохозяйств вырос до 74 500 руб",
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), "Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
"сумма заработка без человека персональными данными не является"); }) {
} assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является");
} }
}
@Test @Test
void masksBiometricMentionNextToPersonalData() { void masksBiometricMentionNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные"); assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса"); assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
} }
/** /**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само * Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает * не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
* ноль сведений и разрушает смысл фразы. */
*/ @Test
@Test void keepsBiometricMentionWithoutAnyPersonalData() {
void keepsBiometricMentionWithoutAnyPersonalData() { for (String text :
for (String text : new String[]{ new String[] {
"Банк внедрил биометрические данные в обслуживание клиентов", "Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"}) { "Сдать биометрию можно через ЕБС в любом отделении"
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), }) {
"упоминание биометрии без человека персональными данными не является"); assertEquals(
} text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"упоминание биометрии без человека персональными данными не является");
} }
}
/** /**
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и * Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным
* ОГРН самостоятельным не становится, человека в таком тексте нет. * не становится, человека в таком тексте нет.
*/ */
@Test @Test
void twoCompanionTypesDoNotVouchForEachOther() { void twoCompanionTypesDoNotVouchForEachOther() {
String text = "Оплата 01.02.2025, ОГРН 1027700132195"; String text = "Оплата 01.02.2025, ОГРН 1027700132195";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), assertEquals(
"спутники заверили друг друга в отсутствие настоящих ПД"); text,
} pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"спутники заверили друг друга в отсутствие настоящих ПД");
}
} }
+41 -42
View File
@@ -1,7 +1,5 @@
package ru.pdguard; package ru.pdguard;
import ru.pdguard.detect.Span;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -12,56 +10,57 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import ru.pdguard.detect.Span;
/** /**
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется * Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется и {@link BenchmarkTest}
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest} * (замер качества по строкам), и {@link LargeTextTest} (те же строки, перемешанные и склеенные в
* (те же строки, перемешанные и склеенные в большой текст). * большой текст).
*/ */
final class BenchmarkFixtures { 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 #}. */ /** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
static List<Sample> load(String resource) { static List<Sample> load(String resource) {
List<Sample> samples = new ArrayList<>(); List<Sample> samples = new ArrayList<>();
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource); try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
BufferedReader reader = new BufferedReader( BufferedReader reader =
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { new BufferedReader(
String line; new InputStreamReader(
while ((line = reader.readLine()) != null) { Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
String trimmed = line.trim(); String line;
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { while ((line = reader.readLine()) != null) {
samples.add(parse(trimmed)); String trimmed = line.trim();
} if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
} samples.add(parse(trimmed));
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать " + resource, e);
} }
return samples; }
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать " + resource, e);
} }
return samples;
}
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */ /** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
static Sample parse(String line) { static Sample parse(String line) {
StringBuilder text = new StringBuilder(line.length()); StringBuilder text = new StringBuilder(line.length());
List<Span> gold = new ArrayList<>(); List<Span> gold = new ArrayList<>();
Matcher m = MARKUP.matcher(line); Matcher m = MARKUP.matcher(line);
int cursor = 0; int cursor = 0;
while (m.find()) { while (m.find()) {
text.append(line, cursor, m.start()); text.append(line, cursor, m.start());
int start = text.length(); int start = text.length();
text.append(m.group(2)); text.append(m.group(2));
gold.add(new Span(start, text.length(), m.group(1), 0)); gold.add(new Span(start, text.length(), m.group(1), 0));
cursor = m.end(); cursor = m.end();
}
text.append(line, cursor, line.length());
return new Sample(text.toString(), gold);
} }
text.append(line, cursor, line.length());
return new Sample(text.toString(), gold);
}
} }
+374 -320
View File
@@ -1,14 +1,7 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue;
import ru.pdguard.config.SystemPolicy; import static org.junit.jupiter.api.Assumptions.assumeTrue;
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 java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -18,348 +11,409 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue; import ru.pdguard.config.SystemPolicy;
import static org.junit.jupiter.api.Assumptions.assumeTrue; 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} использовался при отладке правил, поэтому * <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка
* его оценка завышена и годится только как защита от ухудшений. * завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не * независимо и на нём правила не настраивались — именно он показывает настоящее качество.
* настраивались — именно он показывает настоящее качество.
* *
* <p>Метрики посимвольные: так они не зависят от того, где именно правило * <p>Метрики посимвольные: так они не зависят от того, где именно правило поставило границу
* поставило границу совпадения, и напрямую соотносятся с посимвольным * совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном.
* сравнением замаскированного текста с эталоном.
* *
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, * <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в
* а расхождение в названии типа (скажем, место рождения против города) на * названии типа (скажем, место рождения против города) на качество маскирования не влияет.
* качество маскирования не влияет.
*/ */
class BenchmarkTest { class BenchmarkTest {
/** /**
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это * Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}. * отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
*/ */
private static final String ENGINE = System.getProperty("bench.engine", "rubert"); 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 MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
private record Result(double fioF1, double overallPrecision, double overallRecall,
double falsePositiveRate, int foundFioSpans, int goldFioSpans) { /** Итог замера по одному набору. */
private record Result(
double fioF1,
double overallPrecision,
double overallRecall,
double falsePositiveRate,
int foundFioSpans,
int goldFioSpans) {}
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
} }
/** Накопитель посимвольных совпадений по одному типу. */ private double precision() {
private static final class Score { int found = truePositive + falsePositive;
private int truePositive; return found == 0 ? 1.0 : (double) truePositive / found;
private int falsePositive; }
private int falseNegative;
private int gold() { private double recall() {
return truePositive + falseNegative; 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() { for (Span gold : sample.gold()) {
int found = truePositive + falsePositive; if (!PdTypes.FIO.equals(gold.type())) {
return found == 0 ? 1.0 : (double) truePositive / found; continue;
} }
goldFioSpans++;
private double recall() { if (overlappedByFio(gold, found)) {
return gold() == 0 ? 1.0 : (double) truePositive / gold(); foundFioSpans++;
} } else {
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
} }
}
} }
private final Pipeline pipeline = report(
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); 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(),
@Test anyType.precision(),
void detectionQualityOnTuningSet() { anyType.recall(),
Result result = measure("/benchmark.txt", "набор отладки"); falsePositiveRate,
foundFioSpans,
goldFioSpans);
}
assertTrue(result.fioF1() >= 0.95, /** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
String.format("F1 по ФИО упал до %.3f", result.fioF1())); private static String[] paint(int length, List<Span> spans) {
assertTrue(result.overallRecall() >= 0.95, String[] painted = new String[length];
String.format("полнота по всем типам упала до %.3f", result.overallRecall())); for (Span span : spans) {
assertTrue(result.falsePositiveRate() <= 0.05, for (int i = span.start(); i < Math.min(span.end(), length); i++) {
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate())); 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)) {
@Test score.truePositive++;
void detectionQualityOnHoldoutSet() { } else {
Result result = measure("/benchmark-holdout.txt", "отложенный набор"); score.falseNegative++;
}
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()));
} }
if (found != null && !found.equals(gold)) {
/** byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
* Второй контрольный набор, составленный после того, как первый дважды повлиял
* на правила. На нём не настраивалось ничего — он и показывает настоящее
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
*/
@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()));
} }
}
/** private static void accountAnyType(Score score, boolean gold, boolean found) {
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы if (gold && found) {
* показывать качество на данных, которых разработка не видела. Пороги здесь score.truePositive++;
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение. } else if (gold) {
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах. score.falseNegative++;
*/ } else if (found) {
@Test score.falsePositive++;
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 boolean overlappedByFio(Span gold, List<Span> found) {
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
* расширенный денилист, обобщённое companion-правило (место рождения, }
* страна) и новые банковские типы. Собран специально под соответствующие
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
* что было доработано, а не общее качество остального пайплайна.
*/
@Test
void detectionQualityOnBankContextSet() {
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
assertTrue(result.fioF1() >= 0.70, private static String fragment(String text, Span span) {
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1())); return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
assertTrue(result.overallRecall() >= 0.70, }
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.10, /** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate())); 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,
@Test int foundFio,
void detectionQualityOnGeneratedSet() { int cleanTexts,
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH)) int falseHitTexts,
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), List<String> missedFio,
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)) List<String> falseHits,
: pipeline; List<String> overMasked) {
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор"); 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, byType.entrySet().stream()
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1())); .sorted(
assertTrue(result.overallRecall() >= 0.70, Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall())); .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'));
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет — }
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
*/
@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'));
}
} }
@@ -1,18 +1,18 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import ru.pdguard.core.PayloadCipher; import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest @SpringBootTest
class CipherEnabledTest { class CipherEnabledTest {
@Autowired PayloadCipher cipher; @Autowired PayloadCipher cipher;
@Test @Test
void cipherIsEnabled() { void cipherIsEnabled() {
assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации"); assertTrue(cipher.enabled(), "шифрование должно быть включено ключом из конфигурации");
} }
} }
+15 -12
View File
@@ -1,22 +1,25 @@
package ru.pdguard; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
/** Проверка ключа шифрования из application.yml. */ /** Проверка ключа шифрования из application.yml. */
class CipherKeyTest { class CipherKeyTest {
private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"; private static final String KEY =
"46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
@Test @Test
void keyIsValidAes256() { void keyIsValidAes256() {
PayloadCipher cipher = new PayloadCipher(KEY); PayloadCipher cipher = new PayloadCipher(KEY);
assertTrue(cipher.enabled(), "ключ должен включать шифрование"); assertTrue(cipher.enabled(), "ключ должен включать шифрование");
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
assertEquals(original, cipher.decrypt(cipher.encrypt(original)), assertEquals(
"round-trip с ключом из application.yml должен работать"); original,
} cipher.decrypt(cipher.encrypt(original)),
"round-trip с ключом из application.yml должен работать");
}
} }
+128 -127
View File
@@ -1,162 +1,163 @@
package ru.pdguard; 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.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class ContextDetectionTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) { private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
} }
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
} masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test @Test
void masksPassportInEveryNotation() { void masksPassportInEveryNotation() {
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456"); assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456"); assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
assertHidden("ПАСПОРТ 4509123456", "4509123456"); assertHidden("ПАСПОРТ 4509123456", "4509123456");
assertHidden("Серия 4509 номер 123456", "4509"); assertHidden("Серия 4509 номер 123456", "4509");
assertHidden("серии 45 09 № 123456", "123456"); assertHidden("серии 45 09 № 123456", "123456");
} }
@Test @Test
void masksPassportSeriesAndNumberSplitByWords() { void masksPassportSeriesAndNumberSplitByWords() {
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом"); String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
assertTrue(masked.contains("серия "), masked); assertTrue(masked.contains("серия "), masked);
assertTrue(masked.contains("номер "), masked); assertTrue(masked.contains("номер "), masked);
assertFalse(masked.contains("4509"), masked); assertFalse(masked.contains("4509"), masked);
assertFalse(masked.contains("123456"), masked); assertFalse(masked.contains("123456"), masked);
} }
@Test @Test
void masksDepartmentCode() { void masksDepartmentCode() {
assertHidden("Код подразделения 770-001", "770-001"); assertHidden("Код подразделения 770-001", "770-001");
assertHidden("к/п 770001", "770001"); assertHidden("к/п 770001", "770001");
} }
@Test @Test
void masksIssuingAuthorityButNotTheDateAfterIt() { void masksIssuingAuthorityButNotTheDateAfterIt() {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015"); String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked); assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked); assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked); assertTrue(
} masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test @Test
void masksDriverLicense() { void masksDriverLicense() {
assertHidden("Водительское удостоверение 9902 123456", "9902 123456"); assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
assertHidden("в/у 99 02 123456", "99 02 123456"); assertHidden("в/у 99 02 123456", "99 02 123456");
} }
@Test @Test
void masksCitizenship() { void masksCitizenship() {
assertHidden("Гражданство: РФ", "РФ"); assertHidden("гражданство Республики Беларусь", "Беларусь");
assertHidden("гражданство Республики Беларусь", "Беларусь"); }
assertHidden("Гражданин России обратился", "России");
}
@Test @Test
void masksBirthPlace() { void masksBirthPlace() {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется // Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте // («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон. // на распознавание якоря рядом добавлен телефон.
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь"); assertHidden(
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде"); "Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
} assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
}
@Test @Test
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() { void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
String text = "Экскурсия в Нижний Новгород перенесена на май"; String text = "Экскурсия в Нижний Новгород перенесена на май";
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется"); assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
} }
@Test @Test
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() { void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь"); assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
} }
@Test @Test
void doesNotMaskCountryWithoutAnyOtherPersonalData() { void doesNotMaskCountryWithoutAnyOtherPersonalData() {
String text = "Цены на нефть выросли в Казахстане в этом квартале"; String text = "Цены на нефть выросли в Казахстане в этом квартале";
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется"); assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
} }
@Test @Test
void masksCountryWhenOtherPersonalDataIsAlsoPresent() { void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан"); assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
} }
@Test @Test
void masksCardholderName() { void masksCardholderName() {
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV"); assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV"); assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
} }
@Test @Test
void masksSecurityCodeAndPinCompletely() { void masksSecurityCodeAndPinCompletely() {
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321"); String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
assertFalse(masked.contains("123,"), masked); assertFalse(masked.contains("123,"), masked);
assertFalse(masked.contains("4321"), masked); assertFalse(masked.contains("4321"), masked);
assertTrue(masked.contains("***"), masked); assertTrue(masked.contains("***"), masked);
} }
@Test @Test
void doesNotMaskPinWithoutAnyOtherPersonalData() { void doesNotMaskPinWithoutAnyOtherPersonalData() {
String text = "Пин-код 1234 введён неверно"; String text = "Пин-код 1234 введён неверно";
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является"); assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
} }
@Test @Test
void masksPinWhenCardNumberIsAlsoPresent() { void masksPinWhenCardNumberIsAlsoPresent() {
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от"); assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
} }
@Test @Test
void anchorWordsAreCaseInsensitive() { void anchorWordsAreCaseInsensitive() {
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456"); assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456"); assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
} }
@Test @Test
void complexSentenceKeepsSurroundingWords() { void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, " String original =
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67"; "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
String masked = mask(original); + "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
assertTrue(masked.startsWith("Клиент, паспорт "), masked); assertTrue(masked.startsWith("Клиент, паспорт "), masked);
assertTrue(masked.contains("код подразделения"), masked); assertTrue(masked.contains("код подразделения"), masked);
assertTrue(masked.contains("телефон"), masked); assertTrue(masked.contains("телефон"), masked);
assertFalse(masked.contains("4509 123456"), masked); assertFalse(masked.contains("4509 123456"), masked);
assertFalse(masked.contains("770301234550"), masked); assertFalse(masked.contains("770301234550"), masked);
} }
@Test @Test
void unmaskingRestoresComplexSentence() { void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, " String original =
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111"; "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
String id = "complex-1"; + "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("4509 123456"), masked); assertFalse(masked.contains("4509 123456"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
} }
} }
+48 -47
View File
@@ -1,5 +1,11 @@
package ru.pdguard; 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.DynamicTest;
import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
@@ -9,60 +15,55 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; 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} — по одному * 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному предложению на
* предложению на строку, каждое своя отдельная проверка (не сборка одного большого * строку, каждое своя отдельная проверка (не сборка одного большого текста, как в {@link
* текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из * HugeDatasetTest}). Набор покрывает все типы ПДН из {@link RuleRegistry} (кроме
* {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, * ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, только модель второй ступени), варианты
* только модель второй ступени), варианты написания (регистр, формат даты, разделяющие * написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки
* слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения, * (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе.
* дата без якоря) — они не должны маскироваться вовсе.
* *
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом * <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а
* виде, а демаскирование побайтово восстанавливает исходный текст. * демаскирование побайтово восстанавливает исходный текст.
*/ */
class Dataset200Test { class Dataset200Test {
private static final RuleRegistry REGISTRY = new RuleRegistry(); private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker(); private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-200.txt"); private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-200.txt");
@TestFactory @TestFactory
Stream<DynamicTest> datasetOf200Cases() { Stream<DynamicTest> datasetOf200Cases() {
List<DynamicTest> cases = new java.util.ArrayList<>(DATASET.size()); List<DynamicTest> cases = new java.util.ArrayList<>(DATASET.size());
for (int i = 0; i < DATASET.size(); i++) { for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i); BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i; int index = i;
cases.add(dynamicTest( cases.add(
String.format("#%03d: %s", index, preview(sample.text())), dynamicTest(
() -> runCase(sample, index))); String.format("#%03d: %s", index, preview(sample.text())),
} () -> runCase(sample, index)));
return cases.stream(); }
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) { String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30)); assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
String payloadId = "dataset200-" + index; }
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); private static String preview(String text) {
for (Span gold : sample.gold()) { return text.length() <= 40 ? text : text.substring(0, 40) + "...";
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) + "...";
}
} }
+108 -111
View File
@@ -1,5 +1,10 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -8,137 +13,129 @@ import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class DateAndAddressTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) { private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
} }
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
} masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test @Test
void masksBirthDateInAnyPartOrder() { void masksBirthDateInAnyPartOrder() {
assertHidden("Дата рождения 12.05.1985", "12.05.1985"); assertHidden("Дата рождения 12.05.1985", "12.05.1985");
assertHidden("дата рождения: 05/12/1985", "05/12/1985"); assertHidden("дата рождения: 05/12/1985", "05/12/1985");
assertHidden("Дата рождения 1985-12-05", "1985-12-05"); assertHidden("Дата рождения 1985-12-05", "1985-12-05");
assertHidden("Родился 12-05-1985", "12-05-1985"); assertHidden("Родился 12-05-1985", "12-05-1985");
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985"); assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
} }
@Test @Test
void masksBirthDateWrittenWithWords() { void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985"); assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года", assertHidden(
"двенадцатого мая"); "Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа"); "двенадцатого мая");
} }
@Test @Test
void keepsSeparatorsInMaskedDate() { void keepsSeparatorsInMaskedDate() {
String masked = mask("Дата рождения 12.05.1985"); String masked = mask("Дата рождения 12.05.1985");
assertTrue(masked.endsWith("**.**.****"), masked); assertTrue(masked.endsWith("**.**.****"), masked);
} }
@Test @Test
void masksPassportIssueDate() { void masksPassportIssueDate() {
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015"); assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
} }
@Test @Test
void doesNotMaskDateWithoutAnyOtherPersonalData() { void doesNotMaskDateWithoutAnyOtherPersonalData() {
String text = "Встреча перенесена на 12.05.2025, подтвердите"; String text = "Встреча перенесена на 12.05.2025, подтвердите";
assertEquals(text, mask(text), "дата сама по себе персональными данными не является"); assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
} }
@Test @Test
void masksBareDateWhenOtherPersonalDataIsPresent() { void masksBareDateWhenOtherPersonalDataIsPresent() {
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015"); assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
} }
@Test @Test
void doesNotTreatVersionOrAddressLikeNumbersAsDate() { void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута"; String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
assertEquals(text, mask(text)); assertEquals(text, mask(text));
} }
@Test @Test
void masksAddressComponentsSeparately() { void masksAddressComponentsSeparately() {
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15"); String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
assertFalse(masked.contains("125009"), masked); assertFalse(masked.contains("125009"), masked);
assertFalse(masked.contains("Москва"), masked); assertFalse(masked.contains("Москва"), masked);
assertFalse(masked.contains("Тверская"), masked); assertFalse(masked.contains("Тверская"), masked);
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked); assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
assertTrue(masked.contains("ул. "), masked); assertTrue(masked.contains("ул. "), masked);
} }
@Test @Test
void streetNameDoesNotSwallowTheRestOfTheSentence() { void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта"); String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"), assertTrue(
"название улицы это одно-три слова, а не остаток предложения: " + masked); masked.contains("перекрыта из-за ремонта"),
assertFalse(masked.contains("Сосновая"), masked); "название улицы это одно-три слова, а не остаток предложения: " + masked);
} assertFalse(masked.contains("Сосновая"), masked);
}
@Test @Test
void doesNotMaskStreetMentionedOutsideAnAddress() { void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера")); assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника", assertEquals(
mask("Улица Весенняя названа в честь праздника")); "Улица Весенняя названа в честь праздника",
} mask("Улица Весенняя названа в честь праздника"));
}
@Test @Test
void masksMultiWordStreetName() { void masksMultiWordStreetName() {
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4"); String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
assertFalse(masked.contains("Малая Никитская"), masked); assertFalse(masked.contains("Малая Никитская"), masked);
} }
@Test @Test
void masksIndexByAnchorWord() { void masksIndexByAnchorWord() {
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009"); assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
} }
@Test @Test
void doesNotMaskBankBranchAddress() { void doesNotMaskOfficeAddress() {
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00"; String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является"); assertEquals(text, mask(text));
} }
@Test @Test
void doesNotMaskOfficeAddress() { void addressTypesAreConfigurableSeparately() {
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1"; SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
assertEquals(text, mask(text)); String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
}
@Test assertFalse(masked.contains("Москва"), masked);
void addressTypesAreConfigurableSeparately() { assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY); }
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
assertFalse(masked.contains("Москва"), masked); @Test
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked); void unmaskingRestoresTextWithDateAndAddress() {
} String original =
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
@Test String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
void unmaskingRestoresTextWithDateAndAddress() { assertFalse(masked.contains("12.05.1985"), masked);
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, " assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
+ "ул. Тверская, д. 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));
}
} }
+99 -98
View File
@@ -1,5 +1,10 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,125 +12,121 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class FioTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) { private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
} }
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
} masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertUnchanged(String text) { private void assertUnchanged(String text) {
assertEquals(text, mask(text), "ложное срабатывание"); assertEquals(text, mask(text), "ложное срабатывание");
} }
@Test @Test
void masksFullNameAsInitials() { void masksFullNameAsInitials() {
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился")); assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
} }
@Test @Test
void masksNameAndPatronymicWithoutSurname() { void masksNameAndPatronymicWithoutSurname() {
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича"); assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
} }
@Test @Test
void masksFemalePatronymic() { void masksFemalePatronymic() {
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна"); assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична"); assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
} }
@Test @Test
void masksSurnameWithInitialsInBothOrders() { void masksSurnameWithInitialsInBothOrders() {
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И."); assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
assertHidden("Подписал И.И. Иванов", "И.И. Иванов"); assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
} }
@Test @Test
void masksSurnameNextToKnownGivenName() { void masksSurnameNextToKnownGivenName() {
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей"); assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров"); assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой"); assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
} }
@Test @Test
void masksLowercaseNameAfterExplicitAnchor() { void masksLowercaseNameAfterExplicitAnchor() {
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович"); assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
assertHidden("Карта оформлена на имя петров сергей", "петров сергей"); assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
} }
@Test @Test
void masksNameAfterRoleAnchor() { void masksNameAfterRoleAnchor() {
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей"); assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва"); assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
} }
@Test @Test
void doesNotMaskWellKnownPerson() { void doesNotMaskWellKnownPerson() {
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень"); assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
assertUnchanged("Сравни Толстого и Достоевского как прозаиков"); assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
assertUnchanged("Когда Гагарин полетел в космос"); assertUnchanged("Когда Гагарин полетел в космос");
} }
@Test @Test
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() { void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин"); assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
} }
@Test @Test
void doesNotMaskPlaceNamesThatLookLikeSurnames() { void doesNotMaskPlaceNamesThatLookLikeSurnames() {
assertUnchanged("Московский Кремль открыт для посещения"); assertUnchanged("Московский Кремль открыт для посещения");
assertUnchanged("Экскурсия в Нижний Новгород перенесена"); assertUnchanged("Экскурсия в Нижний Новгород перенесена");
assertUnchanged("Смоленская площадь закрыта на ремонт"); assertUnchanged("Смоленская площадь закрыта на ремонт");
} }
@Test @Test
void doesNotMaskOrdinaryCapitalisedWords() { void doesNotMaskOrdinaryCapitalisedWords() {
assertUnchanged("Банк Открытие подтвердил лимит"); assertUnchanged("Банк Открытие подтвердил лимит");
assertUnchanged("В Понедельник Отдел Согласует Договор"); assertUnchanged("В Понедельник Отдел Согласует Договор");
} }
@Test @Test
void identificationIgnoresCase() { void identificationIgnoresCase() {
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ"); assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна"); assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
} }
@Test @Test
void unmaskingRestoresNames() { void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, " String original =
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67"; "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
String id = "fio-1"; + "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("Иванов Иван Иванович"), masked); assertFalse(masked.contains("Иванов Иван Иванович"), masked);
assertTrue(masked.contains("И. И. И."), masked); assertTrue(masked.contains("И. И. И."), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT)); assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
} }
@Test @Test
void namesStayFastOnLargeText() { void namesStayFastOnLargeText() {
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. "; String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
String large = block.repeat(4000); String large = block.repeat(4000);
long started = System.nanoTime(); long started = System.nanoTime();
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT); String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000; long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("Иванов Иван Иванович")); assertFalse(masked.contains("Иванов Иван Иванович"));
assertTrue(millis < 1000, "обработка заняла " + millis + " мс"); assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
} }
} }
+146 -128
View File
@@ -1,5 +1,14 @@
package ru.pdguard; 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.DynamicTest;
import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
@@ -9,149 +18,158 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; 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 из "Критерии_оценивания_альфа" * Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" (обработка
* (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы". * текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
* *
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов * <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code
* {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, * benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных
* пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения * примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000
* до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам * токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link
* {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 * #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются:
* токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном * отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после
* тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов * демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование
* дополнительно проверяется, что маскирование укладывается в 5 секунд. * укладывается в 5 секунд.
* *
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ. * <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
*/ */
class HugeDatasetTest { class HugeDatasetTest {
private static final int TOTAL_CASES = 1000; private static final int TOTAL_CASES = 1000;
private static final int HUGE_CASES = 50; private static final int HUGE_CASES = 50;
private static final int CHARS_PER_TOKEN = 4; 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_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
private static final int HUGE_MAX_CHARS = 100_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 RuleRegistry REGISTRY = new RuleRegistry(); /**
private static final Masker MASKER = new Masker(); * Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
private static final List<BenchmarkFixtures.Sample> POOL = loadPool(); * их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
*/
private static final int LEAK_CHECK_MIN_LENGTH = 6;
private static List<BenchmarkFixtures.Sample> loadPool() { /**
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(); * Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
for (String resource : List.of( * (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту от 0.70 — это и есть
"/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt", * отправная точка, а не 0.85 из LargeTextTest, где участвует только benchmark-generated.txt,
"/benchmark-bank-context.txt", "/benchmark-holdout.txt", * подстроенный под правила.
"/benchmark-holdout2.txt", "/benchmark-holdout3.txt")) { */
pool.addAll(BenchmarkFixtures.load(resource)); 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 String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
Stream<DynamicTest> datasetOfThousandCases() { assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
List<DynamicTest> cases = new ArrayList<>(TOTAL_CASES);
for (int i = 0; i < TOTAL_CASES; i++) { if (targetChars >= HUGE_MIN_CHARS) {
int targetChars = targetChars(i); assertTrue(
int index = i; maskMillis < 5000,
cases.add(dynamicTest( "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
String.format("#%04d, %d знаков (~%d токенов)", index, targetChars, targetChars / CHARS_PER_TOKEN),
() -> runCase(targetChars, index)));
}
return cases.stream();
} }
}
/** /** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) {
* так тесты покрывают все порядки величины, а не только маленькие и не только большие. List<BenchmarkFixtures.Sample> shuffled = new ArrayList<>(POOL);
* Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ. Random random = new Random(seed);
*/ StringBuilder text = new StringBuilder(targetChars + 1024);
private static int targetChars(int index) { List<Span> gold = new ArrayList<>();
int regular = TOTAL_CASES - HUGE_CASES;
if (index >= regular) { while (text.length() < targetChars) {
int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1); Collections.shuffle(shuffled, random);
return HUGE_MIN_CHARS + (index - regular) * step; 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; if (text.length() >= targetChars) {
double maxChars = HUGE_MIN_CHARS - 1; break;
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 (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; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,64 +11,62 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class IdentityDocumentTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
} masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertMasked(String text, String payloadId, String expected) { private void assertMasked(String text, String payloadId, String expected) {
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT)); assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
} }
@Test @Test
void masksForeignPassport() { void masksForeignPassport() {
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567"); assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
} }
@Test @Test
void masksMilitaryId() { void masksMilitaryId() {
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567"); assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
} }
@Test @Test
void masksBirthCertificate() { void masksBirthCertificate() {
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456"); assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
} }
@Test @Test
void masksMedicalPolicy() { void masksMedicalPolicy() {
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456"); assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
} }
/** /**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — * У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна * буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
* целиком, поэтому у этих документов открыты только последние знаки номера. * открыты только последние знаки номера.
*/ */
@Test @Test
void hidesShortDocumentSeriesCompletely() { void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67"); assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67"); assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1", assertMasked(
"Свидетельство о рождении **-** № ****56"); "Свидетельство о рождении II-МЮ № 123456",
} "bc-1",
"Свидетельство о рождении **-** № ****56");
}
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */ /** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
@Test @Test
void keepsHalfOfFourCharacterSeries() { void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56"); assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1", assertMasked(
"Водительское удостоверение 99** ****56"); "Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
} }
} }
+125 -120
View File
@@ -1,14 +1,7 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals;
import ru.pdguard.config.SystemPolicy; import static org.junit.jupiter.api.Assertions.assertTrue;
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 java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -17,131 +10,143 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Random; import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals; import ru.pdguard.config.SystemPolicy;
import static org.junit.jupiter.api.Assertions.assertTrue; 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} * строках из {@code benchmark-generated.txt} (все типы ПД вперемешку с чистым текстом), растянутых
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ * до объёма из ТЗ (около 100 000 токенов, ~400 КБ по оценке из README).
* (около 100 000 токенов, ~400 КБ по оценке из README).
* *
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не * <p>Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под
* падает на объёме: под маской всегда один и тот же тип, а остальные правила * маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе. * разнообразие проверяются вместе.
*/ */
class LargeTextTest { class LargeTextTest {
private static final String ENGINE = System.getProperty("bench.engine", "rubert"); 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 MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */ /** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
private static final int TARGET_CHARS = 400_000; private static final int TARGET_CHARS = 400_000;
/** /**
* Перемешивает исходные строки (фиксированный seed — детерминированный * Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
* тест) и склеивает их через перенос строки, пока не наберётся целевой * перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
* объём. Смещения золотых фрагментов пересчитываются под общий текст. * под общий текст.
*/ */
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) { private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt")); List<BenchmarkFixtures.Sample> pool =
Random random = new Random(seed); new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
StringBuilder text = new StringBuilder(targetChars + 1024); Random random = new Random(seed);
List<Span> gold = new ArrayList<>(); StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>();
while (text.length() < targetChars) { while (text.length() < targetChars) {
Collections.shuffle(pool, random); Collections.shuffle(pool, random);
for (BenchmarkFixtures.Sample sample : pool) { for (BenchmarkFixtures.Sample sample : pool) {
int offset = text.length(); int offset = text.length();
text.append(sample.text()).append('\n'); text.append(sample.text()).append('\n');
for (Span span : sample.gold()) { for (Span span : sample.gold()) {
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0)); 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); if (text.length() >= targetChars) {
} break;
/**
* Маскирование и обратное преобразование на большом тексте дают
* побайтово тот же результат, что и исходный текст — при объёме на
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
* а не одним повторяющимся предложением.
*/
@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++;
}
} }
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
@Test void roundTripOnLargeMixedText() {
void nameCascadeStaysBoundedOnLargeText() { BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
Path model = Path.of(MODEL_PATH); Pipeline pipeline =
if (!Files.isReadable(model)) { new Pipeline(
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю"); new RuleRegistry(), new Masker(), new PayloadStore(30));
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));
long started = System.nanoTime(); long maskStarted = System.nanoTime();
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT); String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000; long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n", long unmaskStarted = System.nanoTime();
large.text().length(), millis); String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс"); 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; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -9,78 +17,92 @@ import ru.pdguard.detect.Validators;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker; 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 { class MaskModeTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private SystemPolicy policy(MaskMode mode) { private SystemPolicy policy(MaskMode mode) {
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode, return new SystemPolicy(
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null); 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) { private static int count(String text, String fragment) {
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode)); int n = 0;
} for (int i = text.indexOf(fragment);
i >= 0;
@Test i = text.indexOf(fragment, i + fragment.length())) {
void tokenModeNumbersEachType() { n++;
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;
} }
return n;
}
} }
+44 -41
View File
@@ -1,5 +1,13 @@
package ru.pdguard; 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
@@ -9,51 +17,46 @@ import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class NameCascadeTest {
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
private String mask(NameCascade cascade, String payloadId) { private String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), Pipeline pipeline =
new PayloadStore(1_000_000L, 30), cascade); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT); 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 NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
void withoutModelTheStageIsOff() { assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
NameCascade cascade = NameCascade.disabled(); assertEquals(
assertFalse(cascade.enabled()); "Клиент И. И. И., паспорт 45** ****56",
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1")); mask(cascade, "broken-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"),
"маскирование по правилам обязано работать и без второй ступени");
}
} }
@@ -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; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,86 +11,91 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class OrganisationNamesTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) { private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
} }
@Test @Test
void keepsNamesInsideInstitutionNames() { void keepsNamesInsideInstitutionNames() {
for (String text : new String[]{ for (String text :
"Институт Мечникова принимает по записи", new String[] {
"Музей Верещагина работает по будням", "Институт Мечникова принимает по записи",
"Театр Станиславского открыл сезон", "Музей Верещагина работает по будням",
"Библиотека Некрасова закрыта на ремонт", "Театр Станиславского открыл сезон",
"Премия имени Ломоносова вручена в декабре", "Библиотека Некрасова закрыта на ремонт",
"Больница Боткина приняла пациентов", "Премия имени Ломоносова вручена в декабре",
"Стадион Яшина отремонтирован"}) { "Больница Боткина приняла пациентов",
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно"); "Стадион Яшина отремонтирован"
} }) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
} }
}
@Test @Test
void keepsNamesInsidePlaceNames() { void keepsNamesInsidePlaceNames() {
for (String text : new String[]{ for (String text :
"Улица Королёва названа в честь конструктора", new String[] {
"Проспект Вернадского перекрыт до вечера", "Улица Королёва названа в честь конструктора",
"Площадь Гагарина находится на юго-западе", "Проспект Вернадского перекрыт до вечера",
"Набережная Макарова уходит к заливу", "Площадь Гагарина находится на юго-западе",
"Мост Кадырова разведут ночью"}) { "Набережная Макарова уходит к заливу",
assertEquals(text, mask(text), "топоним маскировать не нужно"); "Мост Кадырова разведут ночью"
} }) {
assertEquals(text, mask(text), "топоним маскировать не нужно");
} }
}
@Test @Test
void masksRealClientWithTheSameSurname() { void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456"); String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(masked.contains("Королёв Сергей Павлович"), assertFalse(
"однофамилец объекта на карте остаётся под защитой: " + masked); masked.contains("Королёв Сергей Павлович"),
} "однофамилец объекта на карте остаётся под защитой: " + masked);
}
@Test @Test
void markerOnlyCountsRightBeforeTheName() { void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой"); String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(masked.contains("Иванова Ивана Ивановича"), assertFalse(
"слово-маркер действует только вплотную перед именем: " + masked); masked.contains("Иванова Ивана Ивановича"),
} "слово-маркер действует только вплотную перед именем: " + masked);
}
@Test @Test
void keepsRulerNames() { void keepsRulerNames() {
for (String text : new String[]{ for (String text :
"Василий Тёмный правил недолго", new String[] {
"Ярослав Мудрый составил свод законов", "Василий Тёмный правил недолго",
"Екатерина Вторая издала указ", "Ярослав Мудрый составил свод законов",
"Алексей Тишайший принимал послов"}) { "Екатерина Вторая издала указ",
assertEquals(text, mask(text), "имя правителя персональными данными не является"); "Алексей Тишайший принимал послов"
} }) {
assertEquals(text, mask(text), "имя правителя персональными данными не является");
} }
}
@Test @Test
void masksClientEvenIfNameLooksRegnal() { void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456"); String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(masked.contains("Василий Тёмный"), assertFalse(
"рядом с паспортными данными это конкретный человек: " + masked); masked.contains("Василий Тёмный"),
} "рядом с паспортными данными это конкретный человек: " + masked);
}
@Test @Test
void doesNotSuppressSoleTraderName() { void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547"); String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(masked.contains("Пахомов Вениамин Николаевич"), assertFalse(
"имя предпринимателя — это персональные данные: " + masked); masked.contains("Пахомов Вениамин Николаевич"),
} "имя предпринимателя — это персональные данные: " + masked);
}
} }
+39 -35
View File
@@ -1,50 +1,54 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher; import ru.pdguard.core.PayloadCipher;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/** Шифрование персональных данных в хранилище. */ /** Шифрование персональных данных в хранилище. */
class PayloadCipherTest { class PayloadCipherTest {
/** 32 байта в hex — валидный AES-256 ключ. */ /** 32 байта в hex — валидный AES-256 ключ. */
private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; private static final String KEY =
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test @Test
void encryptDecryptRoundTrip() { void encryptDecryptRoundTrip() {
PayloadCipher cipher = new PayloadCipher(KEY); PayloadCipher cipher = new PayloadCipher(KEY);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String encrypted = cipher.encrypt(original); String encrypted = cipher.encrypt(original);
assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником"); assertNotEquals(original, encrypted, "шифротекст не должен совпадать с исходником");
assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно"); assertEquals(original, cipher.decrypt(encrypted), "должно расшифроваться обратно");
} }
@Test @Test
void disabledCipherPassesThrough() { void disabledCipherPassesThrough() {
PayloadCipher cipher = PayloadCipher.disabled(); PayloadCipher cipher = PayloadCipher.disabled();
String original = "Клиент Иванов"; String original = "Клиент Иванов";
assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено"); assertEquals(original, cipher.encrypt(original), "без ключа шифрование выключено");
assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено"); assertEquals(original, cipher.decrypt(original), "без ключа дешифрование выключено");
} }
@Test @Test
void storeStoresEncryptedButReturnsPlaintext() { void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY); PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher); PayloadStore store =
new PayloadStore(30, ru.pdguard.core.SharedIndex.disabled(), cipher);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56"; String masked = "Клиент И. И. И., паспорт 45** ****56";
store.put("test", "id-1", original, masked); store.put("test", "id-1", original, masked);
// Чтение по id возвращает исходный текст. // Чтение по id возвращает исходный текст.
PayloadStore.Entry entry = store.byId("test", "id-1"); PayloadStore.Entry entry = store.byId("test", "id-1");
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст"); 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; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; 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 { class PayloadStoreTest {
private static final String SYSTEM = "crm"; private static final String SYSTEM = "crm";
@Test @Test
void returnsWhatWasStored() { void returnsWhatWasStored() {
PayloadStore store = new PayloadStore(1_000_000L, 30); PayloadStore store = new PayloadStore(30);
store.put(SYSTEM, "id", "исходный текст", "маска"); store.put(SYSTEM, "id", "исходный текст", "маска");
PayloadStore.Entry entry = store.byId(SYSTEM, "id"); PayloadStore.Entry entry = store.byId(SYSTEM, "id");
assertNotNull(entry); assertNotNull(entry);
assertEquals("исходный текст", entry.original()); assertEquals("исходный текст", entry.original());
assertEquals("маска", entry.masked()); assertEquals("маска", entry.masked());
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска")); assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
} }
@Test @Test
void forgetsEntriesAfterTheirLifetime() { void forgetsEntriesAfterTheirLifetime() {
PayloadStore store = new PayloadStore(1_000_000L, 0); PayloadStore store = new PayloadStore(0);
store.put(SYSTEM, "id", "исходный текст", "маска"); store.put(SYSTEM, "id", "исходный текст", "маска");
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться"); assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
assertNull(store.originalForMask(SYSTEM, "маска")); assertNull(store.originalForMask(SYSTEM, "маска"));
} }
@Test @Test
void evictsOldestWhenOverSizeLimit() { void unknownKeysReturnNothing() {
PayloadStore store = new PayloadStore(100L, 30); PayloadStore store = new PayloadStore(30);
for (int i = 0; i < 50; i++) { assertNull(store.byId(SYSTEM, "нет такого"));
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i); 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 assertNull(
void unknownKeysReturnNothing() { store.originalForMask("analytics", "И. И. И."),
PayloadStore store = new PayloadStore(1_000_000L, 30); "чужую маску нельзя обменять на исходный текст");
assertNull(store.byId(SYSTEM, "нет такого")); assertNull(
assertNull(store.originalForMask(SYSTEM, "нет такой маски")); store.byId("analytics", "общий-id"),
} "совпадение идентификатора у другой системы не даёт доступа");
assertEquals(
/** "Иванов Иван Иванович",
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и store.originalForMask("crm", "И. И. И."),
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять "своя система свои данные по-прежнему получает");
* на исходные данные другого потребителя. }
*/
@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", "И. И. И."),
"своя система свои данные по-прежнему получает");
}
} }
@@ -1,5 +1,12 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -8,142 +15,146 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; 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} содержит по несколько примеров каждого * <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 — * каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор
* так видно, какие типы детектор находит надёжно, а какие пропускает или * находит надёжно, а какие пропускает или маскирует сверх меры.
* маскирует сверх меры.
*/ */
class PdnTypeEfficiencyTest { class PdnTypeEfficiencyTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
/** Накопитель посимвольных совпадений по одному типу. */ /** Накопитель посимвольных совпадений по одному типу. */
private static final class Score { private static final class Score {
private int truePositive; private int truePositive;
private int falsePositive; private int falsePositive;
private int falseNegative; private int falseNegative;
private int gold() { private int gold() {
return truePositive + falseNegative; 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);
}
} }
@Test private double precision() {
void efficiencyByPdnType() { int found = truePositive + falsePositive;
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt"); return found == 0 ? 1.0 : (double) truePositive / found;
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 void reportMissed(Map<String, List<String>> missed) { private double recall() {
if (missed.isEmpty()) { return gold() == 0 ? 1.0 : (double) truePositive / gold();
return; }
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) { private static void account(Map<String, Score> byType, String gold, String found) {
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type); 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 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) { private void report(Map<String, Score> byType) {
if (gold != null) { StringBuilder out = new StringBuilder(2048);
Score score = byType.computeIfAbsent(gold, t -> new Score()); out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
if (gold.equals(found)) { out.append(
score.truePositive++; String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private void report(Map<String, Score> byType) { byType.entrySet().stream()
StringBuilder out = new StringBuilder(2048); .sorted(
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n"); Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1")); .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() System.out.println(out);
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed()) }
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", }
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
System.out.println(out);
}
}
@@ -1,15 +1,10 @@
package ru.pdguard; 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.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
@@ -21,191 +16,214 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.assumeTrue; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertTrue; 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>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой * <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели.
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел * Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый * замеры покажут интерпретируемый код и занизят результат в разы.
* код и занизят результат в разы.
*/ */
class PerformanceBenchmarkTest { class PerformanceBenchmarkTest {
/** Типовой текст с ПД — как в реальном обращении. */ /** Типовой текст с ПД — как в реальном обращении. */
private static final String[] PAYLOADS = { private static final String[] PAYLOADS = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67", "Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru", "Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111", "Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ", "Дата рождения 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 WARMUP = 20_000;
private static final int MEASURE = 50_000; private static final int MEASURE = 50_000;
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private void warmup() { private void warmup() {
for (int i = 0; i < WARMUP; i++) { for (int i = 0; i < WARMUP; i++) {
String text = PAYLOADS[i % PAYLOADS.length]; String text = PAYLOADS[i % PAYLOADS.length];
String id = "warmup-" + i; String id = "warmup-" + i;
pipeline.process(text, id, SystemPolicy.DEFAULT); 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;
} }
/** Задержка маскирования и демаскирования типового обращения. */ Arrays.sort(maskNanos);
@Test Arrays.sort(unmaskNanos);
void singleRequestLatency() {
warmup();
long[] maskNanos = new long[MEASURE]; double maskUs = nanosToMicros(maskNanos);
long[] unmaskNanos = new long[MEASURE]; double unmaskUs = nanosToMicros(unmaskNanos);
for (int i = 0; i < MEASURE; i++) { System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
String text = PAYLOADS[i % PAYLOADS.length]; System.out.printf(
String id = "lat-" + i; "Маскирование: 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(); // Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT); assertTrue(
maskNanos[i] = System.nanoTime() - t0; 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); @Test
unmaskNanos[i] = System.nanoTime() - t1; void throughputUnderLoad() throws Exception {
} warmup();
Arrays.sort(maskNanos); int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
Arrays.sort(unmaskNanos); int perThread = 10_000;
ExecutorService pool = Executors.newFixedThreadPool(threads);
double maskUs = nanosToMicros(maskNanos); long started = System.nanoTime();
double unmaskUs = nanosToMicros(unmaskNanos); List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n"); final int threadId = t;
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n", futures.add(
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0, pool.submit(
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs); (Callable<Long>)
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n", () -> {
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0, long local = 0;
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs); for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды. String id = "load-" + threadId + "-" + i;
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000, long t0 = System.nanoTime();
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс"); pipeline.process(text, id, SystemPolicy.DEFAULT);
local += System.nanoTime() - t0;
}
return local;
}));
} }
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */ long totalNanos = 0;
@Test for (Future<Long> f : futures) {
void throughputUnderLoad() throws Exception { totalNanos += f.get();
warmup(); }
long wallNanos = System.nanoTime() - started;
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
int threads = Math.max(4, Runtime.getRuntime().availableProcessors()); int requests = threads * perThread;
int perThread = 10_000; double rps = requests / (wallNanos / 1e9);
ExecutorService pool = Executors.newFixedThreadPool(threads); double avgUs = totalNanos / (double) requests / 1000.0;
long started = System.nanoTime(); System.out.printf(
List<Future<Long>> futures = new ArrayList<>(); "%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
for (int t = 0; t < threads; t++) { System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
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;
}));
}
long totalNanos = 0; assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
for (Future<Long> f : futures) { }
totalNanos += f.get();
}
long wallNanos = System.nanoTime() - started;
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
int requests = threads * perThread; private static double nanosToMicros(long[] nanos) {
double rps = requests / (wallNanos / 1e9); long sum = 0;
double avgUs = totalNanos / (double) requests / 1000.0; for (long n : nanos) {
sum += n;
}
return sum / (double) nanos.length / 1000.0;
}
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests); /** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs); @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[] maskNanos = new long[2000];
long sum = 0; for (int i = 0; i < 2000; i++) {
for (long n : nanos) { String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
sum += n; String id = "cascade-lat-" + i;
} long t0 = System.nanoTime();
return sum / (double) nanos.length / 1000.0; withCascade.process(text, id, SystemPolicy.DEFAULT);
maskNanos[i] = System.nanoTime() - t0;
} }
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */ Arrays.sort(maskNanos);
@Test int n = maskNanos.length;
void singleRequestLatencyWithNameCascade() { System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
Path model = Path.of("models/rubert-ner"); System.out.printf(
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана"); "Маскирование: 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(); double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(), double candidates = meters.counter("pdguard.ner.candidates").count();
new PayloadStore(10_000_000L, 30), System.out.printf(
new NameCascade("rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters)); "Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные. // Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
for (int i = 0; i < 200; i++) { // должно укладываться в десятки миллисекунд.
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length]; assertTrue(
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT); maskNanos[(int) (n * 0.99)] < 200_000_000,
} "p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000
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 + " мс");
}
}
+109 -108
View File
@@ -1,5 +1,11 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -8,132 +14,127 @@ import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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-слоя. */ /** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
class PipelineTest { class PipelineTest {
private static final String VALID_CARD = "4111 1111 1111 1111"; private static final String VALID_CARD = "4111 1111 1111 1111";
private static final String VALID_INN_12 = "770301234550"; private static final String VALID_INN_12 = "770301234550";
private static final String VALID_SNILS = "112-233-445 95"; private static final String VALID_SNILS = "112-233-445 95";
private Pipeline pipeline() { private Pipeline pipeline() {
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
}
private String mask(Pipeline pipeline, String text) {
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) { @Test
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); 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 @Test
void masksCardNumber() { void masksSnils() {
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла"); String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked); assertFalse(masked.contains(VALID_SNILS), masked);
assertTrue(masked.contains("41** **** **** **11"), masked); }
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
}
@Test @Test
void keepsNumberThatFailsLuhn() { void unmaskingRestoresOriginalText() {
String text = "Заказ 1234 5678 9012 3456 отгружен"; Pipeline pipeline = pipeline();
assertEquals(text, mask(pipeline(), text)); String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
} String id = "pair-1";
@Test String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
void masksEmailKeepingTopLevelDomain() { assertNotEquals(original, masked);
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
assertEquals("Почта i**********@m***.ru для связи", masked);
}
@Test String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
void masksPhoneInAnyNotation() { assertEquals(original, restored);
Pipeline pipeline = pipeline(); }
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
String masked = mask(pipeline, "Телефон " + phone);
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
assertTrue(masked.endsWith("67"), masked);
}
}
@Test @Test
void masksInnByContextAndByChecksum() { void retryReturnsSameMask() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12)); String original = "Карта " + VALID_CARD;
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12)); String id = "retry-1";
}
@Test String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
void masksSnils() { String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS); assertEquals(first, second);
assertFalse(masked.contains(VALID_SNILS), masked); }
}
@Test @Test
void unmaskingRestoresOriginalText() { void unmasksWhenPayloadIdIsUnknown() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67"; String original = "Почта ivan@mail.ru";
String id = "pair-1"; String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT); assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
assertNotEquals(original, masked); }
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT); @Test
assertEquals(original, restored); void textWithoutPersonalDataIsUnchanged() {
} String text = "Расскажи о погоде в Москве завтра";
assertEquals(text, mask(pipeline(), text));
}
@Test @Test
void retryReturnsSameMask() { void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD; SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String id = "retry-1"; String masked =
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
String first = pipeline.process(original, id, SystemPolicy.DEFAULT); assertTrue(
String second = pipeline.process(original, id, SystemPolicy.DEFAULT); masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertEquals(first, second); assertFalse(masked.contains("ivan@mail.ru"), masked);
} }
@Test @Test
void unmasksWhenPayloadIdIsUnknown() { void handlesLargeText() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
String original = "Почта ivan@mail.ru"; String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT); 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 assertFalse(masked.contains("ivan@mail.ru"));
void textWithoutPersonalDataIsUnchanged() { assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
String text = "Расскажи о погоде в Москве завтра"; assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
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 + " мс");
}
} }
@@ -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) + "...";
}
}
+271 -191
View File
@@ -1,214 +1,294 @@
package ru.pdguard; 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 io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not; 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) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProcessResourceTest { class ProcessResourceTest {
@LocalServerPort @LocalServerPort int port;
int port;
private String post(String payload, String payloadId) { private String post(String payload, String payloadId) {
return given() return given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", payload, "payload_id", payloadId)) .body(Map.of("payload", payload, "payload_id", payloadId))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.extract().path("result"); .then()
} .statusCode(200)
.extract()
.path("result");
}
@Test @Test
void maskAndUnmaskPair() { void maskAndUnmaskPair() {
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67"; String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
String id = "8a77d363c7c044b49b41d7b8a448243a"; String id = "8a77d363c7c044b49b41d7b8a448243a";
String masked = post(original, id); String masked = post(original, id);
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked); org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id)); org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
} }
@Test @Test
void rejectsRequestWithoutRequiredFields() { 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() given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", "текст")) .header("X-System-Id", "crm")
.when().post("/process") .body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
.then().statusCode(400); .when()
} .post("/process")
.then()
.statusCode(200)
.extract()
.path("result");
@Test org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
void healthProbeResponds() { }
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
}
@Test @Test
void disabledSystemIsRefused() { 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() given()
.port(port) .port(port)
.contentType("application/json") .when()
.header("X-System-Id", "disabled") .get("/actuator/prometheus")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1")) .then()
.when().post("/process") .statusCode(200)
.then().statusCode(403); .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") @Test
.header("X-System-Id", "crm") void anotherSystemCannotExchangeMaskForOriginal() {
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2")) String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
.when().post("/process") String id = "cross-system-1";
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked); String masked =
}
@Test
void unknownSystemFallsBackToDefaultPolicy() {
given() given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.header("X-System-Id", "неизвестная-система") .body(Map.of("payload", original, "payload_id", id))
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3")) .when()
.when().post("/process") .post("/process")
.then().statusCode(200) .then()
.body("result", equalTo("почта i***@m***.ru")); .statusCode(200)
} .extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
@Test String byOther =
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() {
given() given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1")) .header("X-System-Id", "other")
.when().post("/process") .body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
.then().statusCode(200) .when()
.body("result", equalTo("тестовая строка")) .post("/process")
.body("result", not(equalTo(""))); .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("")));
}
}
+80 -66
View File
@@ -1,88 +1,102 @@
package ru.pdguard; 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 io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; 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 → демаскирование. */ /** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProxyResourceTest { class ProxyResourceTest {
@LocalServerPort @LocalServerPort int port;
int port;
private static final String PROMPT = private static final String PROMPT =
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru"; "Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
private JsonPath proxy(String prompt) { private JsonPath proxy(String prompt) {
return given() return given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("prompt", prompt)) .body(Map.of("prompt", prompt))
.when().post("/proxy") .when()
.then().statusCode(200) .post("/proxy")
.extract().jsonPath(); .then()
} .statusCode(200)
.extract()
.jsonPath();
}
@Test @Test
void personalDataDoesNotReachTheModel() { void personalDataDoesNotReachTheModel() {
JsonPath json = proxy(PROMPT); JsonPath json = proxy(PROMPT);
String toModel = json.getString("prompt_masked"); String toModel = json.getString("prompt_masked");
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel); assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
assertFalse(toModel.contains("4509 123456"), toModel); assertFalse(toModel.contains("4509 123456"), toModel);
assertFalse(toModel.contains("ivan@mail.ru"), toModel); assertFalse(toModel.contains("ivan@mail.ru"), toModel);
} }
@Test @Test
void consumerGetsTheAnswerWithOriginalValues() { void consumerGetsTheAnswerWithOriginalValues() {
JsonPath json = proxy(PROMPT); JsonPath json = proxy(PROMPT);
String answer = json.getString("response"); String answer = json.getString("response");
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer); assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
assertTrue(answer.contains("4509 123456"), answer); assertTrue(answer.contains("4509 123456"), answer);
assertTrue(answer.contains("ivan@mail.ru"), answer); assertTrue(answer.contains("ivan@mail.ru"), answer);
} }
@Test @Test
void responseShowsTheWholeChain() { void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT); JsonPath json = proxy(PROMPT);
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"), assertTrue(
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked")); json.getString("prompt_masked").contains("[FIO_1]"),
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"), "в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
"ответ модели ещё содержит подстановки"); assertTrue(
assertFalse(json.getString("response").contains("[FIO_1]"), json.getString("llm_response_masked").contains("[FIO_1]"),
"потребителю подстановки не видны"); "ответ модели ещё содержит подстановки");
assertEquals("заглушка", json.getString("llm")); assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой"); assertEquals("заглушка", json.getString("llm"));
} assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
}
@Test @Test
void textWithoutPersonalDataPassesThrough() { void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом"); JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked")); assertEquals(
} "Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
}
@Test @Test
void rejectsEmptyPrompt() { void rejectsEmptyPrompt() {
given().port(port).contentType("application/json").body(Map.of("prompt", " ")) given()
.when().post("/proxy").then().statusCode(400); .port(port)
} .contentType("application/json")
.body(Map.of("prompt", " "))
.when()
.post("/proxy")
.then()
.statusCode(400);
}
@Test @Test
void disabledSystemIsRefused() { void disabledSystemIsRefused() {
given().port(port).contentType("application/json") given()
.header("X-System-Id", "disabled") .port(port)
.body(Map.of("prompt", PROMPT)) .contentType("application/json")
.when().post("/proxy").then().statusCode(403); .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; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,113 +11,107 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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} расширено якорями * Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями на сёла, посёлки,
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь * деревни, хутора, станицы, аулы и аалы — раньше словарь ограничивался официальными городами
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево» * (~1100), и «рп. Ильинское»/«с. Кукуево» из ТЗ не находились вообще, дело было не в качестве
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что * детекции, а в том, что искать было негде.
* искать было негде.
*/ */
class SettlementTest { class SettlementTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) { private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
} }
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
} masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test @Test
void masksVillage() { void masksVillage() {
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево"); assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
} }
@Test @Test
void masksWorkersSettlement() { void masksWorkersSettlement() {
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское"); assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
} }
@Test @Test
void masksUrbanTypeSettlement() { void masksUrbanTypeSettlement() {
assertHidden("Доставка курьером в пгт. Энем", "Энем"); assertHidden("Доставка курьером в пгт. Энем", "Энем");
} }
@Test @Test
void masksHamlet() { void masksHamlet() {
assertHidden("Дом находится в д. Аксеновка", "Аксеновка"); assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
} }
@Test @Test
void masksFarmstead() { void masksFarmstead() {
assertHidden("Клиент родом из х. Прогресс", "Прогресс"); assertHidden("Клиент родом из х. Прогресс", "Прогресс");
} }
@Test @Test
void masksCossackStanitsa() { void masksCossackStanitsa() {
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская"); assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
} }
@Test @Test
void masksAul() { void masksAul() {
assertHidden("Живёт в аул Блечепсин", "Блечепсин"); assertHidden("Живёт в аул Блечепсин", "Блечепсин");
} }
@Test @Test
void masksSloboda() { void masksSloboda() {
assertHidden("Проживает в сл. Екатериновка", "Екатериновка"); assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
} }
@Test @Test
void masksAal() { void masksAal() {
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков"); assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
} }
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */ /** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
@Test @Test
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() { void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
String text = "Клиент проживает в с. Мнимогорск"; String text = "Клиент проживает в с. Мнимогорск";
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь"); assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
} }
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */ /** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
@Test @Test
void doesNotMaskOrganisationAddressInVillage() { void doesNotMaskOrganisationAddressInVillage() {
String text = "Ближайшее отделение банка находится в с. Кукуево"; String text = "Ближайшее отделение банка находится в с. Кукуево";
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента"); assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
} }
@Test @Test
void anchorsAreCaseInsensitive() { void anchorsAreCaseInsensitive() {
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО"); assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
} }
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */ /** Реалистичное предложение: населённый пункт, улица и дом вместе. */
@Test @Test
void masksSettlementStreetAndHouseTogether() { void masksSettlementStreetAndHouseTogether() {
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7"); String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
assertFalse(masked.contains("Аксеновка"), masked); assertFalse(masked.contains("Аксеновка"), masked);
assertFalse(masked.contains("Садовая"), masked); assertFalse(masked.contains("Садовая"), masked);
assertFalse(masked.contains("д. 7"), masked); assertFalse(masked.contains("д. 7"), masked);
} }
/** /**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого * «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную * на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
* букву сразу после якоря, а не цифру. */
*/ @Test
@Test void pageReferenceIsNotMistakenForSettlement() {
void pageReferenceIsNotMistakenForSettlement() { String text = "См. с. 25 договора";
String text = "См. с. 25 договора"; assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт"); }
}
} }
@@ -1,5 +1,9 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,87 +11,81 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; 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 { class StreetDenylistTest {
private final Pipeline pipeline = private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30));
private String mask(String text) { private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
} }
private void assertUnmasked(String text) { private void assertUnmasked(String text) {
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text); assertEquals(
} text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
}
@Test @Test
void doesNotMaskNikolaiOstrovskyStreet() { void doesNotMaskNikolaiOstrovskyStreet() {
assertUnmasked("Живу на улице Николая Островского уже десять лет"); assertUnmasked("Живу на улице Николая Островского уже десять лет");
} }
@Test @Test
void doesNotMaskAlexanderMatrosovStreet() { void doesNotMaskAlexanderMatrosovStreet() {
assertUnmasked("Магазин находится на улице Александра Матросова"); assertUnmasked("Магазин находится на улице Александра Матросова");
} }
@Test @Test
void doesNotMaskValeryChkalovStreet() { void doesNotMaskValeryChkalovStreet() {
assertUnmasked("Заезжайте на улицу Валерия Чкалова"); assertUnmasked("Заезжайте на улицу Валерия Чкалова");
} }
@Test @Test
void doesNotMaskVeraVoloshinaStreet() { void doesNotMaskVeraVoloshinaStreet() {
assertUnmasked("Новый дом построили на улице Веры Волошиной"); assertUnmasked("Новый дом построили на улице Веры Волошиной");
} }
@Test @Test
void doesNotMaskIvanSusaninStreet() { void doesNotMaskIvanSusaninStreet() {
assertUnmasked("Школа расположена на улице Ивана Сусанина"); assertUnmasked("Школа расположена на улице Ивана Сусанина");
} }
@Test @Test
void doesNotMaskSergeyKirovStreet() { void doesNotMaskSergeyKirovStreet() {
assertUnmasked("Парковка есть на улице Сергея Кирова"); assertUnmasked("Парковка есть на улице Сергея Кирова");
} }
@Test @Test
void doesNotMaskGeorgiDimitrovStreet() { void doesNotMaskGeorgiDimitrovStreet() {
assertUnmasked("Остановка на улице Георгия Димитрова"); assertUnmasked("Остановка на улице Георгия Димитрова");
} }
/** /**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не * Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
* применяется — если в тексте всё-таки есть настоящие персональные данные, * всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
* совпадение с историческим именем их не прикрывает. */
*/ @Test
@Test void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() { String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67"); assertFalse(masked.contains("Николая Островского"), masked);
assertFalse(masked.contains("Николая Островского"), masked); }
}
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */ /** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
@Test @Test
void stillMasksRealClientNameWithSimilarPattern() { void stillMasksRealClientNameWithSimilarPattern() {
String masked = mask("Живёт на улице, зовут Николая Смирнова"); String masked = mask("Живёт на улице, зовут Николая Смирнова");
assertFalse(masked.contains("Николая Смирнова"), masked); assertFalse(masked.contains("Николая Смирнова"), masked);
} }
@Test @Test
void doesNotMaskDmitryDonskoyStreet() { void doesNotMaskDmitryDonskoyStreet() {
assertUnmasked("Дом стоит на улице Дмитрия Донского"); assertUnmasked("Дом стоит на улице Дмитрия Донского");
} }
} }
+66 -64
View File
@@ -1,90 +1,92 @@
package ru.pdguard; 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 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig; import ru.pdguard.config.SystemsConfig;
import ru.pdguard.mask.MaskMode; 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 { class SystemsConfigTest {
private static final String CONTENT = """ private static final String CONTENT =
{ """
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] }, {
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] }, "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"old": { "enabled": false } "crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
} "old": { "enabled": false }
"""; }
""";
private SystemsConfig configAt(Path file) { private SystemsConfig configAt(Path file) {
return new SystemsConfig(file.toString(), new ObjectMapper()); return new SystemsConfig(file.toString(), new ObjectMapper());
} }
@Test @Test
void readsPoliciesFromFile(@TempDir Path dir) throws IOException { void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json"); Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8); Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file); SystemsConfig config = configAt(file);
SystemPolicy crm = config.policyFor("crm"); SystemPolicy crm = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, crm.maskMode()); assertEquals(MaskMode.TOKEN, crm.maskMode());
assertFalse(crm.demask()); assertFalse(crm.demask());
assertTrue(crm.allows("FIO")); assertTrue(crm.allows("FIO"));
assertFalse(crm.allows("CARD")); assertFalse(crm.allows("CARD"));
assertFalse(config.policyFor("old").enabled()); assertFalse(config.policyFor("old").enabled());
} }
@Test @Test
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException { void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json"); Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8); Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemPolicy policy = configAt(file).policyFor("никому-не-известная"); SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
assertTrue(policy.enabled()); assertTrue(policy.enabled());
assertTrue(policy.allows("CARD")); assertTrue(policy.allows("CARD"));
} }
@Test @Test
void worksWithoutConfigFile(@TempDir Path dir) { void worksWithoutConfigFile(@TempDir Path dir) {
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json")); SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая")); assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
} }
@Test @Test
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException { void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json"); Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8); Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file); SystemsConfig config = configAt(file);
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode()); assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8); Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
config.reload(); config.reload();
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode()); assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
} }
@Test @Test
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException { void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json"); Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8); Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file); SystemsConfig config = configAt(file);
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8); Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload(); config.reload();
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(), assertEquals(
"сломанный файл не должен ронять работающий сервис"); MaskMode.TOKEN,
} config.policyFor("crm").maskMode(),
"сломанный файл не должен ронять работающий сервис");
}
} }
@@ -1,5 +1,13 @@
package ru.pdguard; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -9,132 +17,141 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; 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 для адресов. * Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов.
* *
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса * <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а
* маскируются, а известные личности — нет. Для каждого типа считается посимвольная * известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1.
* точность, полнота и F1.
*/ */
class TwoModelBenchmarkTest { class TwoModelBenchmarkTest {
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), private final Pipeline pipeline =
new PayloadStore(10_000_000L, 30), new Pipeline(
new NameCascade( new RuleRegistry(),
"wikineural", Optional.of("models/wikineural-ner"), new Masker(),
"rubert", Optional.of("models/rubert-ner"), new PayloadStore(30),
16, 4)); 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 static final class Score {
private int truePositive; private int truePositive;
private int falsePositive; private int falsePositive;
private int falseNegative; private int falseNegative;
private int gold() { private int gold() {
return truePositive + falseNegative; 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);
}
} }
@Test private double precision() {
void twoModelEfficiency() { int found = truePositive + falsePositive;
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt"); return found == 0 ? 1.0 : (double) truePositive / found;
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 static String[] paint(int length, List<Span> spans) { private double recall() {
String[] painted = new String[length]; return gold() == 0 ? 1.0 : (double) truePositive / gold();
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) { private double f1() {
if (gold != null) { double p = precision();
Score score = byType.computeIfAbsent(gold, t -> new Score()); double r = recall();
if (gold.equals(found)) { return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
score.truePositive++; }
} else { }
score.falseNegative++;
} @Test
} void twoModelEfficiency() {
if (found != null && !found.equals(gold)) { List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-two-model.txt");
byType.computeIfAbsent(found, t -> new Score()).falsePositive++; 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) { report(byType);
StringBuilder out = new StringBuilder(2048); reportMissed(missed);
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) { // Каждый тип должен быть найден с F1 не ниже 0.8.
if (missed.isEmpty()) { for (Map.Entry<String, Score> e : byType.entrySet()) {
return; assertTrue(
} e.getValue().f1() >= 0.8,
StringBuilder out = new StringBuilder(); String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> out.append(type).append(": ")
.append(String.join(" | ", values)).append('\n'));
System.out.println(out);
} }
} }
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; 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 org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -10,43 +14,44 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Две модели: WikiNEuRal для имён, ruBERT для адресов. */ /** Две модели: WikiNEuRal для имён, ruBERT для адресов. */
class TwoModelCascadeTest { class TwoModelCascadeTest {
private List<Span> find(String text) { private List<Span> find(String text) {
NameCascade cascade = new NameCascade( NameCascade cascade =
new NameCascade(
new NameCascade.EngineConfig(
"wikineural", Optional.of("models/wikineural-ner"), "wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"), "rubert", Optional.of("models/rubert-ner"),
16, 4); "off", Optional.empty()),
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade); 16,
return p.findPersonalData(text, SystemPolicy.DEFAULT); 4);
} Pipeline p =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(30), cascade);
return p.findPersonalData(text, SystemPolicy.DEFAULT);
}
@Test @Test
void recognisesNamesAndAddresses() { void recognisesNamesAndAddresses() {
String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская"; String text = "Клиент Иванов Иван Иванович, проживает в городе Москва, на улице Тверская";
List<Span> spans = find(text); List<Span> spans = find(text);
System.out.println("TEXT: " + text); System.out.println("TEXT: " + text);
for (Span s : spans) { for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
}
assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
} }
assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
}
@Test @Test
void wellKnownNamesNotMasked() { void wellKnownNamesNotMasked() {
String text = "Напиши стихотворение в духе Александра Пушкина про осень"; String text = "Напиши стихотворение в духе Александра Пушкина про осень";
List<Span> spans = find(text); List<Span> spans = find(text);
System.out.println("TEXT: " + text); System.out.println("TEXT: " + text);
for (Span s : spans) { for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
}
assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться");
} }
assertTrue(
spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться");
}
} }
@@ -0,0 +1,132 @@
package ru.pdguard.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.mask.MaskMode;
class SystemsConfigTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void missingFileFallsBackToDefaultPolicy(@TempDir Path dir) {
SystemsConfig config = new SystemsConfig(dir.resolve("absent.json").toString(), mapper);
SystemPolicy policy = config.policyFor("anything");
assertEquals(SystemPolicy.DEFAULT_NAME, policy.name());
assertEquals(MaskMode.MASK, policy.maskMode());
}
@Test
void readsKnownSystemFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE"]
}
}
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
SystemPolicy policy = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, policy.maskMode());
assertFalse(policy.demask());
assertTrue(policy.allows("FIO"));
assertFalse(policy.allows("EMAIL"));
}
@Test
void unknownSystemFallsBackToDefault(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertFalse(config.isKnown("ghost"));
assertEquals(SystemPolicy.DEFAULT_NAME, config.policyFor("ghost").name());
}
@Test
void malformedJsonKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
Files.writeString(file, "{ not valid json", StandardCharsets.UTF_8);
config.reload();
assertTrue(config.isKnown("crm"), "битый файл не должен затирать рабочие настройки");
}
@Test
void unknownMaskModeKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
Files.writeString(
file,
"""
{ "crm": { "maskMode": "NOT_A_MODE" } }
""",
StandardCharsets.UTF_8);
config.reload();
assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"неизвестный maskMode не должен принять частично разобранные настройки");
}
@Test
void currentReturnsSortedSnapshot(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "zzz": { "maskMode": "TOKEN" }, "aaa": { "maskMode": "MASK" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertEquals(
java.util.List.of("aaa", "default", "zzz"),
config.current().keySet().stream().sorted().toList());
}
}
@@ -0,0 +1,93 @@
package ru.pdguard.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
@SuppressWarnings("java:S2925") // Thread.sleep ждёт прохождения окна регулировки лимитера
class AdaptiveConcurrencyLimiterTest {
@Test
void rejectsInvalidBounds() {
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(0, 10, 100));
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(10, 5, 100));
}
@Test
void acceptsUpToLimitThenRejects() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 2, 1000);
assertTrue(limiter.tryAcquire());
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire(), "предел исчерпан — третий запрос должен быть отклонён");
assertEquals(2, limiter.inFlight());
}
@Test
void releaseFreesSlotForNextRequest() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 1, 1000);
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire());
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertTrue(limiter.tryAcquire(), "после release слот должен освободиться");
}
@Test
void growsLimitWhenLatencyBelowTarget() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertEquals(2, limiter.limit(), "задержка ниже целевой — предел должен вырасти на единицу");
}
@Test
void shrinksLimitWhenLatencyAboveTarget() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 8, 10, window);
// Разгоняем предел до 4, чтобы было куда сжиматься.
for (int i = 0; i < 3; i++) {
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
}
assertEquals(4, limiter.limit());
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
assertEquals(2, limiter.limit(), "задержка выше целевой — предел должен сжаться вдвое");
}
@Test
void limitNeverDropsBelowMin() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(3, 10, 5, window);
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
assertEquals(3, limiter.limit(), "предел не должен опускаться ниже minLimit");
}
@Test
void doesNotAdjustBeforeWindowElapses() {
long window = TimeUnit.SECONDS.toNanos(10);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
limiter.tryAcquire();
limiter.release(1);
assertEquals(1, limiter.limit(), "окно ещё не прошло — предел не должен меняться");
}
}
@@ -0,0 +1,65 @@
package ru.pdguard.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.SecureRandom;
import org.junit.jupiter.api.Test;
class PayloadCipherTest {
private static String randomHexKey() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return java.util.HexFormat.of().formatHex(bytes);
}
@Test
void disabledCipherPassesTextThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
assertFalse(cipher.enabled());
assertEquals("Иванов Иван Иванович", cipher.encrypt("Иванов Иван Иванович"));
assertEquals("Иванов Иван Иванович", cipher.decrypt("Иванов Иван Иванович"));
}
@Test
void enabledCipherRoundTrips() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertTrue(cipher.enabled());
String encrypted = cipher.encrypt("Иванов Иван Иванович, паспорт 4509 123456");
assertNotEquals("Иванов Иван Иванович, паспорт 4509 123456", encrypted);
assertEquals("Иванов Иван Иванович, паспорт 4509 123456", cipher.decrypt(encrypted));
}
@Test
void ciphertextDiffersEachTimeDueToRandomIv() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
String first = cipher.encrypt("тот же текст");
String second = cipher.encrypt("тот же текст");
assertNotEquals(first, second, "разный IV должен давать разный шифротекст на одинаковом входе");
}
@Test
void decryptingGarbageThrows() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertThrows(IllegalStateException.class, () -> cipher.decrypt("не base64!!!"));
}
@Test
void decryptingWithDifferentKeyThrows() {
PayloadCipher first = new PayloadCipher(randomHexKey());
PayloadCipher second = new PayloadCipher(randomHexKey());
String encrypted = first.encrypt("секрет");
assertThrows(IllegalStateException.class, () -> second.decrypt(encrypted));
}
}
@@ -1,87 +1,88 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import org.junit.jupiter.api.AfterEach; import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import static org.junit.jupiter.api.Assertions.assertFalse; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.io.TempDir;
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */ /** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
class NameDictionaryTest { class NameDictionaryTest {
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */ /** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
@AfterEach @AfterEach
void resetToBundledList() { void resetToBundledList() {
NameDictionary.useExternalFile(Path.of("config/well-known.txt")); NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
} }
@Test @Test
void bundledListCoversClassicFigures() { void bundledListCoversClassicFigures() {
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» → // Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где // «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное // «-ой» меняется на «-ого» целиком, а не дописывается) — известное
// ограничение самого приёма, не завязанное на список имён. // ограничение самого приёма, не завязанное на список имён.
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertTrue(NameDictionary.isWellKnown("Портрет Толстой")); assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
} }
@Test @Test
void bundledListCoversCurrentPublicFigures() { void bundledListCoversCurrentPublicFigures() {
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной")); assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
assertTrue(NameDictionary.isWellKnown("Интервью Путина")); assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
} }
@Test @Test
void unknownSurnameIsNotWellKnown() { void unknownSurnameIsNotWellKnown() {
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
} }
/** /**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). * Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого * Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен
* правила нужны два слова, — но денилист должен покрывать и составные * покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/ * «Буденный»).
* «Буденный»). */
*/ @Test
@Test void bundledListCoversCommemorativeStreetNames() {
void bundledListCoversCommemorativeStreetNames() { assertTrue(NameDictionary.isWellKnown("улица Кирова"));
assertTrue(NameDictionary.isWellKnown("улица Кирова")); // «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких // основа обрезается сразу до «ск» — см. Declension.
// основа обрезается сразу до «ск» — см. Declension. assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского")); assertTrue(NameDictionary.isWellKnown("улица Донского"));
assertTrue(NameDictionary.isWellKnown("улица Донского")); // «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же // известная граница приёма, родительный падеж («Будённого») им не ловится.
// известная граница приёма, родительный падеж («Будённого») им не ловится. assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(NameDictionary.isWellKnown("улица Будённый")); assertTrue(
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться"); NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова")); assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского")); assertTrue(NameDictionary.isWellKnown("улица Островского"));
} }
@Test @Test
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException { void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
Path file = dir.resolve("well-known.txt"); Path file = dir.resolve("well-known.txt");
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8); 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 @Test
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) { void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt")); NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина")); assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова")); assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
} }
} }
@@ -1,62 +1,61 @@
package ru.pdguard.detect; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */ /** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
class ToponymDictionaryTest { class ToponymDictionaryTest {
@Test @Test
void recognisesNominativeCase() { void recognisesNominativeCase() {
assertTrue(ToponymDictionary.isKnownSettlement("Москва")); assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
assertTrue(ToponymDictionary.isKnownSettlement("Казань")); assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург")); assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
} }
@Test @Test
void recognisesInflectedForms() { void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную"); assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную"); assertTrue(
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж"); ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
} assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
}
@Test @Test
void recognisesCisCapitals() { void recognisesCisCapitals() {
assertTrue(ToponymDictionary.isKnownSettlement("Минск")); assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
assertTrue(ToponymDictionary.isKnownSettlement("Алматы")); assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
} }
@Test @Test
void rejectsMadeUpWord() { void rejectsMadeUpWord() {
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда")); assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла")); assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
} }
@Test @Test
void isCaseInsensitive() { void isCaseInsensitive() {
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА")); assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
assertTrue(ToponymDictionary.isKnownSettlement("москва")); assertTrue(ToponymDictionary.isKnownSettlement("москва"));
} }
/** /**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, * Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы,
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока * аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры
* словарь ограничивался официальными городами. Примеры из ТЗ («рп. * из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта.
* Ильинское», «с. Кукуево») и по одному реальному названию на тип */
* населённого пункта. @Test
*/ void recognisesSettlementsFromCensusNotJustOfficialCities() {
@Test assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
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("Энем"), "посёлок городского типа");
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; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */ /** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
class ValidatorsTest { class ValidatorsTest {
@Test @Test
void validOgrnPassesChecksum() { void validOgrnPassesChecksum() {
assertTrue(Validators.ogrn("1027700123450")); assertTrue(Validators.ogrn("1027700123450"));
assertTrue(Validators.ogrn("1025000678900")); assertTrue(Validators.ogrn("1025000678900"));
assertTrue(Validators.ogrn("1045002233440")); assertTrue(Validators.ogrn("1045002233440"));
} }
@Test @Test
void invalidOgrnChecksumFails() { void invalidOgrnChecksumFails() {
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится"); assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
} }
@Test @Test
void ogrnWrongLengthFails() { void ogrnWrongLengthFails() {
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной"); assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя"); assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
} }
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */ /** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test @Test
void ogrnRemainderTenMapsToZero() { void ogrnRemainderTenMapsToZero() {
assertTrue(Validators.ogrn("1000000000000")); assertTrue(Validators.ogrn("1000000000000"));
} }
@Test @Test
void validOgrnipPassesChecksum() { void validOgrnipPassesChecksum() {
assertTrue(Validators.ogrnip("304500116000157")); assertTrue(Validators.ogrnip("304500116000157"));
assertTrue(Validators.ogrnip("312500000000013")); assertTrue(Validators.ogrnip("312500000000013"));
assertTrue(Validators.ogrnip("305500112233041")); assertTrue(Validators.ogrnip("305500112233041"));
} }
@Test @Test
void invalidOgrnipChecksumFails() { void invalidOgrnipChecksumFails() {
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится"); assertFalse(
} Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
}
@Test @Test
void ogrnipWrongLengthFails() { void ogrnipWrongLengthFails() {
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной"); assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние"); assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
} }
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */ /** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
@Test @Test
void ogrnipRemainderTenMapsToZero() { void ogrnipRemainderTenMapsToZero() {
assertTrue(Validators.ogrnip("100000000000000")); assertTrue(Validators.ogrnip("100000000000000"));
} }
@Test @Test
void nonDigitCharactersAreIgnored() { void nonDigitCharactersAreIgnored() {
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр"); assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
} }
@Test @Test
void emptyValueIsNotValid() { void emptyValueIsNotValid() {
assertFalse(Validators.ogrn("")); assertFalse(Validators.ogrn(""));
assertFalse(Validators.ogrnip("")); assertFalse(Validators.ogrnip(""));
} }
} }
+1 -3
View File
@@ -172,9 +172,7 @@ cardholder {{CARDHOLDER:ELENA KUZNETSOVA}} указан латиницей
Клиент {{FIO:Юдина Кристина}}, место рождения {{BIRTH_PLACE:город Тула}} Клиент {{FIO:Юдина Кристина}}, место рождения {{BIRTH_PLACE:город Тула}}
Клиент {{FIO:Литвинов А.С.}}, родился в {{BIRTH_PLACE:Владивостоке}} Клиент {{FIO:Литвинов А.С.}}, родился в {{BIRTH_PLACE:Владивостоке}}
# --- Гражданство, спутник ФИО (6) --- # --- Гражданство, спутник ФИО (4) ---
Клиент {{FIO:Соколова Дарья}}, гражданство {{CITIZENSHIP:РФ}}
Клиент {{FIO:Петров Виктор}}, гражданин {{CITIZENSHIP:России}}
Клиент {{FIO:Алиев Тимур}}, гражданство {{CITIZENSHIP:Республики Казахстан}} Клиент {{FIO:Алиев Тимур}}, гражданство {{CITIZENSHIP:Республики Казахстан}}
Клиент {{FIO:Ким Артур}}, гражданство {{CITIZENSHIP:Республики Узбекистан}} Клиент {{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")
+16 -1
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 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 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 "Готово. Включить:"
echo " pdguard.ner.name-engine=wikineural, pdguard.ner.name-model=models/wikineural-ner" 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.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"