This commit is contained in:
dakocha3
2026-09-21 17:40:25 +03:00
commit 309188d191
52 changed files with 5124 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
target/*
!target/*-runner
!target/quarkus-app
.git
.idea
*.iml
models/*
+4
View File
@@ -0,0 +1,4 @@
target/
.idea/
*.iml
models/
+314
View File
@@ -0,0 +1,314 @@
# Модуль безопасности персональных данных
Прокси между системой-потребителем и LLM: находит персональные данные в запросе,
маскирует их и восстанавливает исходный текст на обратном шаге.
## Контракт
```
POST /process
{ "payload": "<строка>", "payload_id": "<идентификатор>" }
→ 200 { "result": "<строка>" }
```
Направление определяется по `payload_id`: первый запрос с новым идентификатором
маскирует, второй с тем же идентификатором — восстанавливает. Повторная попытка
с тем же исходным текстом возвращает ту же маску, поэтому эндпоинт идемпотентен.
| Ручка | Назначение |
|---|---|
| `POST /process` | маскирование и демаскирование |
| `GET /health` | проба готовности |
| `GET /metrics` | метрики Prometheus |
| `GET /admin/config` | действующие настройки систем |
| `GET /admin/types` | список распознаваемых типов ПД |
| `POST /admin/reload` | перечитать настройки немедленно |
## Настройка
Скопируйте `config/systems.json` рядом с приложением и перечислите в нём системы-потребители;
путь к файлу задаётся свойством `pdguard.systems-file`. Для каждой системы укажите `enabled`
(разрешено ли обращаться в модуль), `demask` (нужно ли обратное преобразование), `maskMode`
(`MASK` — звёздочки, `TOKEN` — `[FIO_1]`, `SYNTHETIC` — правдоподобная подстановка) и `types`
(список типов ПД или `"*"`). Поле `requireCompanion` перечисляет типы, которые маскируются
только вместе с ПД другого типа: одиночный пин-код персональными данными не является.
Система называет себя заголовком `X-System-Id`; без заголовка и для неизвестных имён
применяется политика `default`. Файл перечитывается автоматически при изменении —
перезапуск не нужен.
```json
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"],
"requireCompanion": ["CVV", "PIN", "DATE"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL"] }
}
```
## Типы персональных данных
ФИО, дата рождения, место рождения, гражданство, паспорт РФ (серия и номер, орган выдачи,
код подразделения, дата выдачи), водительское удостоверение, загранпаспорт, военный билет,
свидетельство о рождении, полис ОМС, СНИЛС, ИНН, адрес (страна, индекс, город, улица, дом,
квартира — каждый отдельно), email, телефон, номер карты, CVV, пин-код, имя держателя карты.
Новый тип добавляется одной строкой в `RuleRegistry` — остальной код не меняется.
Вид маски подобран под длину серии документа: у паспорта РФ и водительского
удостоверения серия из четырёх знаков, поэтому открыта половина (`45** ****56`);
у загранпаспорта, военного билета и свидетельства о рождении серия короткая —
две цифры или две буквы, — и открыты только последние знаки номера (`** *****67`).
## Качество детекции
Наборов два. `src/test/resources/benchmark.txt` использовался при отладке правил —
его оценка завышена и годится только как защита от ухудшений.
`src/test/resources/benchmark-holdout.txt` составлен независимо, правила на нём не
настраивались: именно он показывает настоящее качество. Персональные данные размечены
как `{{ТИП:значение}}`, строка без разметки — текст, где ПД нет и любое срабатывание
считается ложным. Метрики посимвольные.
```bash
mvn test -Dtest=BenchmarkTest
```
Наборов три. Первый использовался при отладке, второй вскрыл дефекты и после их
исправления перестал быть отложенным, третий составлен последним и на нём ничего не
настраивалось — **его числа и следует считать настоящими**.
| | набор отладки | отложенный №1 | **контрольный** |
|---|---|---|---|
| ФИО, точность | 1,000 | 1,000 | **0,967** |
| ФИО, полнота | 0,985 | 0,986 | **0,895** |
| ФИО, F1 | 0,993 | 0,993 | **0,930** |
| ФИО пофрагментно | 58 из 58 | 41 из 41 | **28 из 29** |
| Любой тип, F1 | 0,995 | 0,996 | **0,965** |
| Ложные на чистых текстах | 0 из 35 | 0 из 35 | **2 из 38** |
Разрыв между вторым и третьим набором — цена того, что второй использовался для
доработки правил. Ожидать на новых данных следует примерно третьего столбца.
**Порядок работы с контрольным набором.** По нему правила не настраиваются, иначе он
повторит судьбу второго. Дефекты, которые он вскрывает, либо чинятся по первым двум
наборам и собственным примерам, либо остаются записанными. Пороги в тесте по нему
низкие намеренно: он ловит обвал, а не сторожит достигнутое значение.
Известные и осознанно не исправленные дефекты, которые он показывает: одиночная
фамилия без ролевого слова («Свяжитесь с Зотовой») не находится; исторические
правители («Иван Грозный») и устойчивые выражения («Третий Рим») дают ложные
срабатывания.
История первого отложенного набора — 85 строк, 41 фрагмент ФИО, 35 текстов без ПД:
| | первый замер | после правок | + вторая ступень |
|---|---|---|---|
| ФИО, точность | 0,968 | 1,000 | 1,000 |
| ФИО, полнота | 0,791 | 0,958 | **0,986** |
| ФИО, F1 | 0,871 | 0,978 | **0,993** |
| ФИО пофрагментно | 36 из 41 | 39 из 41 | **41 из 41** |
| Любой тип, F1 | 0,898 | 0,986 | **0,996** |
| Ложные на чистых текстах | 1 из 35 | 0 из 35 | 0 из 35 |
Столбец «первый замер» — честная оценка до того, как набор был использован для
отладки. Дальнейшие столбцы измерены уже после исправлений по его разбору, поэтому
для следующей итерации нужен третий набор.
## Вторая ступень распознавания имён
Правила и словарь разбирают подавляющее большинство случаев за десятки микросекунд.
Модель нужна там, где они бессильны: имена без русского словообразования и без
отчества — «Нгуен Ван Ань», «Ким Сон Хо». Поэтому модель зовут не на весь текст, а
только на кандидатов — цепочки из двух-трёх слов с заглавной буквы, которые первая
ступень не покрыла.
Цена такого каскада:
| | только правила | + вторая ступень |
|---|---|---|
| строка, где правила всё разобрали | 83 мкс | 85 мкс |
| строка с неразобранным кандидатом | 1 мкс | 239 мкс |
| 400 КБ текста | 557 мс | 848 мс |
За обычный запрос не платим почти ничего — платим только за неопределённость.
Число кандидатов на запрос ограничено `pdguard.ner.max-candidates`.
Распознаватели создаются и прогоняют текст на старте приложения, по одному на
`pdguard.ner.pool-size`. Без прогрева первый запрос каждого рабочего потока платил
за создание распознавателя сотни миллисекунд; сейчас первый запрос после подъёма
занимает 26 мс, дальше медиана 2,9 мс. Ценой стал старт: с моделью он занимает
около 2,7 с вместо 0,02 с — почти всё это чтение файла модели. Если все
распознаватели заняты, запрос ждёт свободного 50 мс и дальше обходится правилами,
а не копит очередь.
Модель в репозиторий не кладётся: она весит около 15 МБ и собирается из открытых
корпусов (factRuEval-2016 и префикс Nerus, 402 тысячи предложений) за несколько минут.
```bash
./tools/train-ner.sh full
```
Дальше включается свойством `pdguard.ner.model=models/ru-ner-person.bin`. Свойство не
задано — ступень выключена и сервис работает на одних правилах, как и без модели.
В native-образе модель монтируется томом:
```bash
docker run -p 8080:8080 -v "$PWD/config:/work/config:ro" -v "$PWD/models:/work/models:ro" -e PDGUARD_NER_MODEL=/work/models/ru-ner-person.bin pd-guard
```
Классы, которые OpenNLP создаёт по имени из описания признаков внутри модели,
перечислены в `OpenNlpReflection`. Без этой регистрации модель загружается, но
создание распознавателя падает на первом запросе.
Сбой второй ступени на первую не влияет: ошибка перехватывается, ступень
выключается насовсем, маскирование продолжается по правилам. Отсутствующая и
испорченная модель покрыты тестами.
Объём обучающего набора решает всё. Модель на 62 тысячах предложений размечала
«Обратился Ким Сон Хо» как «Обратился Ким Сон» — слог оставался открытым, а глагол
попадал под маску. На 402 тысячах границы стали точными, и пере-маскирование на
отложенном наборе исчезло совсем.
## Как устроено распознавание
Три уровня доверия:
1. **Контрольная сумма** — карта (алгоритм Луна), ИНН, СНИЛС. Ложные срабатывания исключены.
2. **Однозначный формат** — email, телефон.
3. **Якорное слово** — паспорт, CVV, адрес и прочее, где сама по себе последовательность
знаков ни о чём не говорит. Якоря распознаются без учёта регистра.
Ложные срабатывания гасятся тремя механизмами: вето по окружению (адрес отделения банка
адресом клиента не является), денилист известных людей (упоминание Пушкина — не ПД, но
клиент с той же фамилией рядом с паспортными данными защиту не теряет) и правило
companion (дата или пин-код в отрыве от других ПД не маскируются).
Перекрытия разрешаются по приоритету правила, при равенстве — по длине совпадения.
## Сборка и запуск
Разработка с горячей перезагрузкой:
```bash
mvn quarkus:dev
```
Тесты:
```bash
mvn -q test
```
Native-сборка и образ (GraalVM локально не нужен, сборка идёт в контейнере):
```bash
mvn package -Dnative -Dquarkus.native.container-build=true
```
```bash
docker build -f src/main/docker/Dockerfile.native -t pd-guard .
```
```bash
docker run --rm -p 8080:8080 -v "$PWD/config:/work/config:ro" pd-guard
```
Запасной вариант на JVM — `src/main/docker/Dockerfile.jvm`; прогрев там обязателен,
иначе первые секунды нагрузки идут по интерпретируемому коду.
Проверка:
```bash
curl -s -X POST localhost:8080/process -H 'Content-Type: application/json' -d '{"payload":"Клиент Иванов Иван Иванович, паспорт 4509 123456","payload_id":"p1"}'
```
## Работа на нескольких узлах
Маскирование — чистая функция от текста: ни случайности, ни времени, ни состояния,
живущего дольше запроса. Один и тот же payload на любом узле даёт байт-в-байт
одинаковую маску, поэтому повтор прямого шага можно отправлять куда угодно.
Обратный шаг состояние требует: маскирование необратимо, восстановить исходный текст
можно только из сохранённого соответствия. На одном узле оно лежит в памяти процесса.
На нескольких узлах обратный запрос попадёт на «свой» узел лишь с вероятностью 1/N,
поэтому нужен общий слой:
```bash
docker compose --profile cluster up
```
`pdguard.store.backend=redis` включает второй уровень хранения. Запись идёт и в память
узла, и в Redis; чтение сначала локальное, при промахе — из Redis с подтягиванием
соответствия к себе. Обычный путь по сети не ходит.
Недоступность Redis не приводит к отказу. Команды ограничены `quarkus.redis.timeout`
(200 мс), а после трёх неудач подряд общий слой не опрашивается пять секунд: простой
Redis стоит не больше ~600 мс на каждые пять секунд, дальше ноль. Маскирование при
этом работает полностью, деградирует только межузловое демаскирование.
Redis запускается без персистентности (`--save ""`, без AOF) — персональные данные
на диск не попадают — и с `maxmemory-policy allkeys-lru`.
## Безопасность
В журнал и метрики попадают только `payload_id`, типы ПД и счётчики — сами значения
не логируются ни на одном уровне. Соответствия «текст ↔ маска» живут в памяти процесса,
ограничены по объёму и удаляются по истечении `pdguard.store.ttl-minutes` (по умолчанию 30).
Внутренний сбой обработки не приводит к `5xx`: возвращается исходный текст, а ошибка
попадает в журнал — пять подряд невалидных ответов останавливают проверку.
При перегрузке сервис отвечает `429` с `Retry-After` вместо накопления очереди.
## Производительность
Нагрузка подаётся парами «маскирование → демаскирование» с уникальным `payload_id` —
так же, как это делает проверяющая система:
```bash
k6 run -e RPS=1000 loadtest.js
```
Native-образ в Docker Desktop, Apple M-серия. Один узел, состояние в памяти:
| Целевой RPS | p95 | Отказы | Пары восстановлены |
|---|---|---|---|
| 1000 | 1,78 мс | 0 | 100 % |
| 2000 | 1,03 мс | 0 | 100 % |
| 6000 | 4,49 мс | 0 | 100 % |
Со включённой второй ступенью, один узел:
| Целевой RPS | p95 | Отказы | Пары восстановлены |
|---|---|---|---|
| 1000 | 1,57 мс | 0 | 100 % |
| 2000 | 0,98 мс | 0 | 100 % |
Два узла с общим слоем в Redis, обратный шаг **всегда** попадает на другой узел —
худший возможный случай:
| Целевой RPS | p95 | Отказы | Пары восстановлены |
|---|---|---|---|
| 1000 | 3,25 мс | 0 | 100 % |
| 2000 | 2,81 мс | 0 | 100 % |
Потолок выше 6000 — на этой машине упирается уже генератор нагрузки, не сервис.
Старт native-образа — 0,018 с, поэтому прогрева нет и первые секунды прогона идут
с той же задержкой, что и остальные.
Крупные тексты, JVM, один поток: 240 КБ — 272 мс, 400 КБ (~100 000 токенов) — 447 мс.
Обработка идёт на рабочих потоках, поэтому крупный текст не блокирует цикл событий.
Порог одновременных запросов — `pdguard.max-concurrent`.
## Настройки
| Свойство | По умолчанию | Смысл |
|---|---|---|
| `pdguard.systems-file` | `config/systems.json` | файл со списком систем |
| `pdguard.store.backend` | `memory` | `redis` включает общий слой для нескольких узлов |
| `quarkus.redis.hosts` | `redis://localhost:6379` | адрес общего слоя |
| `quarkus.redis.timeout` | `200ms` | после чего узел уходит на свою память |
| `pdguard.ner.model` | не задано | модель второй ступени; без неё работают только правила |
| `pdguard.ner.max-candidates` | `16` | предел кандидатов на запрос для второй ступени |
| `pdguard.ner.pool-size` | `16` | сколько распознавателей создаётся и прогревается на старте |
| `pdguard.max-concurrent` | `2000` | порог, после которого отдаётся `429` |
| `pdguard.store.ttl-minutes` | `30` | срок жизни соответствий |
| `pdguard.store.max-chars` | `134217728` | предел объёма хранилища |
+61
View File
@@ -0,0 +1,61 @@
# Один узел: памяти процесса достаточно, общий слой не нужен.
# docker compose up pd-guard
#
# Несколько узлов: маскирование детерминировано и работает на любом узле, а вот
# обратный шаг требует общего состояния — иначе запрос попадёт не на тот узел.
# docker compose --profile cluster up
services:
pd-guard:
build:
context: .
dockerfile: src/main/docker/Dockerfile.native
ports:
- "8080:8080"
environment:
PDGUARD_MAX_CONCURRENT: "2000"
PDGUARD_STORE_TTL_MINUTES: "30"
# Переменная не задана — вторая ступень выключена. Чтобы включить:
# PDGUARD_NER_MODEL: /work/models/ru-ner-person.bin
volumes:
- ./config:/work/config:ro
# Модель второй ступени монтируется томом: в образ она не входит.
# Собрать: ./tools/train-ner.sh full
- ./models:/work/models:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 10s
timeout: 2s
retries: 3
redis:
profiles: ["cluster"]
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 2s
retries: 5
node-a: &node
profiles: ["cluster"]
build:
context: .
dockerfile: src/main/docker/Dockerfile.native
ports:
- "8081:8080"
environment:
PDGUARD_STORE_BACKEND: redis
QUARKUS_REDIS_HOSTS: redis://redis:6379
PDGUARD_MAX_CONCURRENT: "2000"
volumes:
- ./config:/work/config:ro
depends_on:
redis:
condition: service_healthy
node-b:
<<: *node
ports:
- "8082:8080"
+27
View File
@@ -0,0 +1,27 @@
{
"default": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["*"],
"requireCompanion": ["CVV", "PIN", "DATE"]
},
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
},
"analytics": {
"enabled": true,
"demask": false,
"maskMode": "SYNTHETIC",
"types": ["*"]
},
"legacy-billing": {
"enabled": false,
"demask": false,
"maskMode": "MASK",
"types": ["*"]
}
}
+58
View File
@@ -0,0 +1,58 @@
// Нагрузка парами «маскирование → демаскирование», как её подаёт проверяющая
// система. Одна итерация — два запроса, поэтому rate задаётся вдвое меньше
// целевого RPS.
// k6 run -e RPS=1000 loadtest.js
import http from 'k6/http';
import { check } from 'k6';
const URL = __ENV.URL || 'http://localhost:8080/process';
// Второй узел для обратного шага: так проверяется худший случай на кластере —
// демаскирование всегда попадает не на тот узел, который маскировал.
const URL_BACK = __ENV.URL_BACK || URL;
const TARGET_RPS = Number(__ENV.RPS || 1000);
const HEADERS = { headers: { 'Content-Type': 'application/json' } };
const PAYLOADS = [
'Клиент Иванов Иван Иванович, паспорт 4509 123456 выдан ОУФМС России по г. Москве, дата рождения 12.05.1985',
'Заявление от И.И. Петрова, ИНН 770301234550, адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15',
'Карта 4111 1111 1111 1111, держатель IVAN PETROV, CVV 123, пин-код 4321',
'Свяжитесь: ivan.petrov@mail.ru или +7 (916) 123-45-67, гражданство РФ',
'Загранпаспорт 75 1234567, водительское удостоверение 9902 123456, СНИЛС 112-233-445 95',
'Напиши стихотворение в духе Александра Пушкина про осень',
];
export const options = {
scenarios: {
pairs: {
executor: 'constant-arrival-rate',
rate: TARGET_RPS / 2,
timeUnit: '1s',
duration: __ENV.DURATION || '30s',
preAllocatedVUs: 300,
maxVUs: 2000,
},
},
thresholds: {
http_req_duration: ['p(95)<1000'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
},
};
export default function () {
const id = `${__VU}-${__ITER}`;
const original = PAYLOADS[__ITER % PAYLOADS.length];
const masked = http.post(URL, JSON.stringify({ payload: original, payload_id: id }), HEADERS);
check(masked, { 'маскирование 200': (r) => r.status === 200 });
if (masked.status !== 200) {
return;
}
const restored = http.post(URL_BACK,
JSON.stringify({ payload: masked.json('result'), payload_id: id }), HEADERS);
check(restored, {
'демаскирование 200': (r) => r.status === 200,
'исходный текст восстановлен': (r) => r.json('result') === original,
});
}
+109
View File
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.pdguard</groupId>
<artifactId>pd-guard</artifactId>
<version>1.0.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.release>21</maven.compiler.release>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.version>3.15.1</quarkus.platform.version>
<surefire-plugin.version>3.2.5</surefire-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-redis-client</artifactId>
</dependency>
<dependency>
<groupId>org.apache.opennlp</groupId>
<artifactId>opennlp-tools</artifactId>
<version>2.5.4</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<activation>
<property><name>native</name></property>
</activation>
<properties>
<quarkus.native.enabled>true</quarkus.native.enabled>
<quarkus.package.jar.enabled>false</quarkus.package.jar.enabled>
</properties>
</profile>
</profiles>
</project>
+17
View File
@@ -0,0 +1,17 @@
# Запасной вариант: тот же сервис на JVM, без сборки native.
# mvn package && docker build -f src/main/docker/Dockerfile.jvm -t pd-guard:jvm .
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.20
WORKDIR /deployments
COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/
COPY --chown=185 target/quarkus-app/*.jar /deployments/
COPY --chown=185 target/quarkus-app/app/ /deployments/app/
COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/
COPY --chown=185 config /deployments/config
EXPOSE 8080
USER 185
ENV JAVA_OPTS_APPEND="-XX:+UseZGC -XX:MaxRAMPercentage=75 -Dquarkus.http.host=0.0.0.0"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json
+15
View File
@@ -0,0 +1,15 @@
# Сборка образа с native-исполняемым файлом.
# mvn package -Dnative -Dquarkus.native.container-build=true
# docker build -f src/main/docker/Dockerfile.native -t pd-guard .
FROM quay.io/quarkus/quarkus-micro-image:2.0
WORKDIR /work
COPY --chown=1001:root target/*-runner /work/application
COPY --chown=1001:root config /work/config
EXPOSE 8080
USER 1001
ENV PDGUARD_SYSTEMS_FILE=/work/config/systems.json
ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"]
@@ -0,0 +1,48 @@
package ru.pdguard.api;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */
@Path("/admin")
public class AdminResource {
private final SystemsConfig systems;
private final RuleRegistry registry;
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems;
this.registry = registry;
}
@GET
@Path("/config")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, SystemPolicy> config() {
return systems.current();
}
@GET
@Path("/types")
@Produces(MediaType.APPLICATION_JSON)
public List<String> types() {
return registry.knownTypes();
}
@POST
@Path("/reload")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, SystemPolicy> reload() {
systems.reload();
return systems.current();
}
}
@@ -0,0 +1,17 @@
package ru.pdguard.api;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
/** Проба готовности для балансировщика и проверяющей системы. */
@Path("/health")
public class HealthResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String health() {
return "OK";
}
}
@@ -0,0 +1,107 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.smallrye.common.annotation.Blocking;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.Pipeline;
import java.util.concurrent.Semaphore;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After} вместо того,
* чтобы копить запросы и упереться в таймаут вызывающей стороны.
*/
@Path("/process")
public class ProcessResource {
private static final Logger LOG = Logger.getLogger(ProcessResource.class);
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
}
public record ProcessResponse(@JsonProperty("result") String result) {
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final Semaphore permits;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
@ConfigProperty(name = "pdguard.max-concurrent", defaultValue = "2000")
int maxConcurrent) {
this.pipeline = pipeline;
this.systems = systems;
this.permits = new Semaphore(maxConcurrent);
this.rejected = meters.counter("pdguard.requests.rejected", "reason", "overload");
this.malformed = meters.counter("pdguard.requests.rejected", "reason", "malformed");
this.forbidden = meters.counter("pdguard.requests.rejected", "reason", "system_disabled");
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Blocking
public Response process(ProcessRequest request, @HeaderParam(SYSTEM_HEADER) String systemId) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
malformed.increment();
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ProcessResponse("payload и payload_id обязательны"))
.build();
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.enabled()) {
forbidden.increment();
LOG.warnf("Системе %s обращение в модуль запрещено настройками", systemId);
return Response.status(Response.Status.FORBIDDEN)
.entity(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"))
.build();
}
if (!permits.tryAcquire()) {
rejected.increment();
return Response.status(429).header("Retry-After", "1").build();
}
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return Response.ok(new ProcessResponse(result)).build();
} catch (RuntimeException e) {
// Пять подряд невалидных ответов останавливают проверку, поэтому при
// внутреннем сбое возвращаем текст без изменений, а не 5xx.
LOG.errorf(e, "payload_id=%s обработка не удалась, текст возвращён без изменений",
request.payloadId());
return Response.ok(new ProcessResponse(request.payload())).build();
} finally {
permits.release();
}
}
}
@@ -0,0 +1,44 @@
package ru.pdguard.config;
import ru.pdguard.mask.MaskMode;
import java.util.Set;
/**
* Правила обработки для одной системы-потребителя.
*
* @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
* пин-код сам по себе безвреден, пин-код рядом с номером
* карты — уже нет; то же для даты без якорного слова
*/
public record SystemPolicy(boolean enabled, boolean demask, MaskMode maskMode,
Set<String> types, Set<String> requireCompanion) {
public static final String ALL = "*";
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy(
true, true, MaskMode.MASK, Set.of(ALL), Set.of("CVV", "PIN", "DATE"));
public SystemPolicy {
types = Set.copyOf(types);
requireCompanion = Set.copyOf(requireCompanion);
}
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion());
}
public boolean allows(String type) {
return types.contains(ALL) || types.contains(type);
}
public boolean needsCompanion(String type) {
return requireCompanion.contains(type);
}
}
@@ -0,0 +1,137 @@
package ru.pdguard.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.runtime.annotations.RegisterForReflection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import ru.pdguard.mask.MaskMode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
*
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
* перечитывается сам, когда меняется время его изменения; проверка выполняется
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
*/
@ApplicationScoped
public class SystemsConfig {
private static final Logger LOG = Logger.getLogger(SystemsConfig.class);
/** Имя политики, которая применяется к запросам без заголовка системы. */
public static final String DEFAULT_SYSTEM = "default";
private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */
@RegisterForReflection
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion) {
}
private final Path file;
private final ObjectMapper mapper;
private volatile Map<String, SystemPolicy> policies = Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
private volatile long fileTimestamp;
private volatile long lastCheck;
@Inject
public SystemsConfig(@ConfigProperty(name = "pdguard.systems-file", defaultValue = "config/systems.json")
String path, ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
reload();
}
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
public SystemPolicy policyFor(String systemId) {
refreshIfChanged();
Map<String, SystemPolicy> current = policies;
SystemPolicy policy = systemId == null ? null : current.get(systemId);
if (policy != null) {
return policy;
}
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
}
/** Известна ли система по имени. */
public boolean isKnown(String systemId) {
refreshIfChanged();
return systemId != null && policies.containsKey(systemId);
}
/** Текущие настройки — для отдачи в административном интерфейсе. */
public Map<String, SystemPolicy> current() {
refreshIfChanged();
return new TreeMap<>(policies);
}
/** Перечитать файл настроек немедленно. */
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
LOG.infof("Файл настроек %s не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies = Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
fileTimestamp = 0;
return;
}
try {
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
Map<String, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
policies = Map.copyOf(parsed);
LOG.infof("Настройки систем перечитаны из %s: %s", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.errorf(e, "Не удалось прочитать %s, продолжаем с прежними настройками", file.toAbsolutePath());
}
}
private void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(file)) {
return;
}
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
reload();
}
} catch (IOException e) {
LOG.debugf(e, "Не удалось проверить время изменения %s", file);
}
}
private static SystemPolicy toPolicy(SystemEntry entry) {
SystemPolicy base = SystemPolicy.DEFAULT;
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
Set<String> companions = entry.requireCompanion() == null
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
MaskMode mode = entry.maskMode() == null
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
return new SystemPolicy(
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions);
}
}
@@ -0,0 +1,174 @@
package ru.pdguard.core;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
/**
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
*
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
* страховка на случай, если идентификатор до сервиса не доехал.
*
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
*
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
* обходится без обращения по сети.
*/
@ApplicationScoped
public class PayloadStore {
/** Сколько протухших записей просматривается за одну операцию записи. */
private static final int SWEEP_PER_PUT = 4;
/** Пара «исходный текст — маска» с отпечатком и сроком жизни. */
public record Entry(String original, String masked, String fingerprint, long expiresAt) {
boolean alive(long now) {
return now < expiresAt;
}
int weight() {
return original.length() + masked.length();
}
}
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
private final AtomicLong charsHeld = new AtomicLong();
private final long maxChars;
private final long ttlMillis;
private final SharedIndex shared;
@Inject
public PayloadStore(
@ConfigProperty(name = "pdguard.store.max-chars", defaultValue = "134217728") long maxChars,
@ConfigProperty(name = "pdguard.store.ttl-minutes", defaultValue = "30") int ttlMinutes,
SharedIndex shared) {
this.maxChars = maxChars;
this.ttlMillis = ttlMinutes * 60_000L;
this.shared = shared;
}
/** Конструктор для тестов: только локальная память, общий слой выключен. */
public PayloadStore(long maxChars, int ttlMinutes) {
this(maxChars, ttlMinutes, SharedIndex.disabled());
}
public void put(String payloadId, String original, String masked) {
long now = System.currentTimeMillis();
Entry entry = new Entry(original, masked, fingerprint(masked), now + ttlMillis);
Entry replaced = byId.put(payloadId, entry);
byMaskFingerprint.put(entry.fingerprint(), entry);
insertionOrder.add(payloadId);
charsHeld.addAndGet(entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
evictWhileOverLimit();
shared.put(payloadId, original, masked, entry.fingerprint());
}
public Entry byId(String payloadId) {
Entry entry = byId.get(payloadId);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry;
}
if (entry != null) {
forget(payloadId, entry);
}
SharedIndex.SharedEntry fromShared = shared.byId(payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(payloadId, fromShared.original(), fromShared.masked());
return byId.get(payloadId);
}
/** Исходный текст по самой маске — когда {@code payload_id} не совпал. */
public String originalForMask(String masked) {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(fingerprint);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry.original();
}
return shared.originalForFingerprint(fingerprint);
}
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
public long charsHeld() {
return charsHeld.get();
}
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry == null) {
insertionOrder.poll();
continue;
}
if (entry.alive(now)) {
return;
}
insertionOrder.poll();
forget(oldest, entry);
}
}
private void evictWhileOverLimit() {
while (charsHeld.get() > maxChars) {
String oldest = insertionOrder.poll();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry != null) {
// ponytail: если тот же payload_id записали повторно, в очереди остался
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
forget(oldest, entry);
}
}
}
private void forget(String payloadId, Entry entry) {
if (byId.remove(payloadId, entry)) {
byMaskFingerprint.remove(entry.fingerprint(), entry);
charsHeld.addAndGet(-entry.weight());
}
}
private static String fingerprint(String value) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
}
}
}
+233
View File
@@ -0,0 +1,233 @@
package ru.pdguard.core;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.NameDictionary;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskContext;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
/**
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
*
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
* <ul>
* <li>идентификатор неизвестен — маскируем;</li>
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
* </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан
* совпасть с первым, иначе демаскирование по этому элементу развалится.
*/
@ApplicationScoped
public class Pipeline {
private static final Logger LOG = Logger.getLogger(Pipeline.class);
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
private static final int CHARS_PER_TOKEN = 4;
private final RuleRegistry registry;
private final Masker masker;
private final PayloadStore store;
private final MeterRegistry meters;
private final NameCascade cascade;
private final Timer maskTimer;
private final Timer unmaskTimer;
private final Counter tokensProcessed;
@Inject
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
this.store = store;
this.meters = meters;
this.cascade = cascade;
this.maskTimer = Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", "mask")
.register(meters);
this.unmaskTimer = Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", "unmask")
.register(meters);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
}
/** Конструктор для тестов второй ступени. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
}
public String process(String payload, String payloadId, SystemPolicy policy) {
long started = System.nanoTime();
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
PayloadStore.Entry known = store.byId(payloadId);
if (known != null) {
if (policy.demask() && payload.equals(known.masked())) {
LOG.debugf("payload_id=%s обратное преобразование по идентификатору", payloadId);
unmaskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return known.original();
}
if (payload.equals(known.original())) {
LOG.debugf("payload_id=%s повторная попытка, отдаём прежнюю маску", payloadId);
maskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return known.masked();
}
}
if (policy.demask()) {
String original = store.originalForMask(payload);
if (original != null) {
LOG.debugf("payload_id=%s обратное преобразование по отпечатку маски", payloadId);
unmaskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return original;
}
}
return mask(payload, payloadId, policy, started);
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (policy.allows(RuleRegistry.FIO)) {
// Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
}
spans = dropWellKnownNames(text, spans);
return dropLonelyCompanions(spans, policy);
}
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
List<Span> spans = findPersonalData(payload, policy);
String masked = apply(payload, spans, policy);
store.put(payloadId, payload, masked);
maskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
logFindings(payloadId, payload.length(), spans);
return masked;
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
* приоритетный, при равном приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
// фрагментов набираются тысячи.
NavigableMap<Integer, Span> accepted = new TreeMap<>();
for (Span candidate : candidates) {
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
if (before != null && before.getValue().overlaps(candidate)) {
continue;
}
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
if (after != null && after.getValue().overlaps(candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream()
.anyMatch(span -> !RuleRegistry.FIO.equals(span.type()));
if (otherPersonalDataPresent) {
return spans;
}
return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.toList();
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
Set<String> present = new HashSet<>();
for (Span span : spans) {
present.add(span.type());
}
if (present.size() > 1) {
return spans;
}
return spans.stream().filter(span -> !policy.needsCompanion(span.type())).toList();
}
private String apply(String text, List<Span> spans, SystemPolicy policy) {
if (spans.isEmpty()) {
return text;
}
MaskContext context = new MaskContext();
StringBuilder sb = new StringBuilder(text.length());
int cursor = 0;
for (Span span : spans) {
sb.append(text, cursor, span.start());
String value = text.substring(span.start(), span.end());
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
cursor = span.end();
}
sb.append(text, cursor, text.length());
return sb.toString();
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
* Сами значения не логируются ни на одном уровне.
*/
private void logFindings(String payloadId, int length, List<Span> spans) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (Span span : spans) {
counts.merge(span.type(), 1, Integer::sum);
}
counts.forEach((type, count) -> meters.counter("pdguard.pd.detected", "type", type).increment(count));
LOG.infof("payload_id=%s символов=%d найдено=%s", payloadId, length, counts);
}
}
@@ -0,0 +1,167 @@
package ru.pdguard.core;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.value.SetArgs;
import io.quarkus.redis.datasource.value.ValueCommands;
import io.quarkus.runtime.annotations.RegisterForReflection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
*
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
* Обратное же преобразование требует состояния: если прямой запрос обработал
* один узел, а обратный попал на другой, соответствие должно быть общим.
*
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
*
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
* съедал время ответа, команды ограничены по времени настройкой
* {@code quarkus.redis.timeout}, а после нескольких подряд неудач общий слой
* временно перестают опрашивать вовсе.
*/
@ApplicationScoped
public class SharedIndex {
private static final Logger LOG = Logger.getLogger(SharedIndex.class);
private static final String KEY_BY_ID = "pdg:id:";
private static final String KEY_BY_MASK = "pdg:mask:";
/** Сколько подряд неудач размыкает предохранитель. */
private static final int FAILURES_TO_OPEN = 3;
/** На сколько общий слой перестают опрашивать после размыкания. */
private static final long OPEN_MILLIS = 5_000;
/** Пара «исходный текст — маска», как она хранится в общем слое. */
@RegisterForReflection
public record SharedEntry(String original, String masked) {
}
private final boolean enabled;
private final Duration ttl;
private final Instance<RedisDataSource> redisSource;
private volatile ValueCommands<String, SharedEntry> pairs;
private volatile ValueCommands<String, String> originals;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(Instance<RedisDataSource> redisSource,
@ConfigProperty(name = "pdguard.store.backend", defaultValue = "memory") String backend,
@ConfigProperty(name = "pdguard.store.ttl-minutes", defaultValue = "30") int ttlMinutes) {
this.redisSource = redisSource;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30);
}
public boolean enabled() {
return enabled;
}
public void put(String payloadId, String original, String masked, String maskFingerprint) {
if (unavailable()) {
return;
}
try {
SetArgs expiry = new SetArgs().ex(ttl);
commands().set(KEY_BY_ID + payloadId, new SharedEntry(original, masked), expiry);
originalCommands().set(KEY_BY_MASK + maskFingerprint, original, expiry);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
}
}
public SharedEntry byId(String payloadId) {
if (unavailable()) {
return null;
}
try {
SharedEntry entry = commands().get(KEY_BY_ID + payloadId);
noteSuccess();
return entry;
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
public String originalForFingerprint(String maskFingerprint) {
if (unavailable()) {
return null;
}
try {
String original = originalCommands().get(KEY_BY_MASK + maskFingerprint);
noteSuccess();
return original;
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
/**
* Команды создаются при первом обращении: пока общий слой выключен,
* клиент Redis не создаётся и подключение не устанавливается.
*/
private ValueCommands<String, SharedEntry> commands() {
ValueCommands<String, SharedEntry> local = pairs;
if (local == null) {
local = redisSource.get().value(SharedEntry.class);
pairs = local;
}
return local;
}
private ValueCommands<String, String> originalCommands() {
ValueCommands<String, String> local = originals;
if (local == null) {
local = redisSource.get().value(String.class);
originals = local;
}
return local;
}
/** Общий слой выключен или предохранитель разомкнут. */
private boolean unavailable() {
return !enabled || System.currentTimeMillis() < silentUntil;
}
private void noteSuccess() {
if (consecutiveFailures.getAndSet(0) != 0) {
reported = false;
LOG.info("Общий слой снова доступен");
}
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
* проверяющая система считает ответ дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
}
if (!reported) {
reported = true;
LOG.errorf(cause, "Не удалось %s соответствие в общий слой, узел работает на своей памяти", action);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package ru.pdguard.core;
/**
* Найденный фрагмент персональных данных в исходном тексте.
*
* @param start индекс первого символа (включительно)
* @param end индекс за последним символом (исключительно)
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
* @param priority приоритет при разрешении перекрытий: больше — важнее
*/
public record Span(int start, int end, String type, int priority) {
public Span {
if (start < 0 || end <= start) {
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
}
}
public int length() {
return end - start;
}
public boolean overlaps(Span other) {
return start < other.end && other.start < end;
}
}
@@ -0,0 +1,205 @@
package ru.pdguard.detect;
import io.quarkus.runtime.Startup;
import jakarta.enterprise.context.ApplicationScoped;
import opennlp.tools.namefind.NameFinderME;
import opennlp.tools.namefind.TokenNameFinderModel;
import opennlp.tools.tokenize.SimpleTokenizer;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import ru.pdguard.core.Span;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Вторая ступень распознавания имён.
*
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
* микросекунд. Модель нужна там, где они бессильны: имена без русского
* словообразования и без отчества — «Нгуен Ван Ань», «Ким Сон Хо».
*
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в
* обычном запросе единицы, и на задержку это почти не влияет.
*
* <p>Модели нет — ступень выключена и поведение сервиса не меняется. Путь к файлу
* задаётся свойством {@code pdguard.ner.model}.
*
* <p>Сбой второй ступени не должен отражаться на первой: ошибка перехватывается
* здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
* исключение обнуляло бы маскирование целиком.
*/
@Startup
@ApplicationScoped
public class NameCascade {
private static final Logger LOG = Logger.getLogger(NameCascade.class);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться именем. */
private static final Pattern CANDIDATE = Pattern.compile(
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
private static final int PRIORITY = 73;
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
private static final int CONTEXT_CHARS = 60;
/** Сколько ждать свободный распознаватель, прежде чем обойтись правилами. */
private static final long BORROW_TIMEOUT_MILLIS = 50;
/** Текст для прогрева: важно не что в нём, а что модель отработала хотя бы раз. */
private static final String[] WARMUP_WORDS =
{"Клиент", "Иванов", "Иван", "Иванович", "обратился", "в", "отделение"};
private final BlockingQueue<NameFinderME> pool;
private final int maxCandidates;
private final boolean enabled;
private volatile boolean broken;
public NameCascade(
@ConfigProperty(name = "pdguard.ner.model") Optional<String> modelPath,
@ConfigProperty(name = "pdguard.ner.max-candidates", defaultValue = "16") int maxCandidates,
@ConfigProperty(name = "pdguard.ner.pool-size", defaultValue = "16") int poolSize) {
this.maxCandidates = maxCandidates;
TokenNameFinderModel model = load(modelPath);
this.enabled = model != null;
this.pool = enabled ? warmedPool(model, Math.max(1, poolSize)) : null;
}
/** Выключенная ступень — для тестов и для сборок без модели. */
public static NameCascade disabled() {
return new NameCascade(Optional.empty(), 0, 1);
}
public boolean enabled() {
return enabled;
}
/**
* Добавляет имена, которые не нашла первая ступень. Уже принятые фрагменты
* не трогаются: модель разбирает только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled || broken) {
return accepted;
}
NameFinderME finder = borrow();
if (finder == null) {
// Все распознаватели заняты: отвечаем по правилам, а не копим очередь.
return accepted;
}
try {
List<Span> found = new ArrayList<>(accepted);
int examined = 0;
Matcher m = CANDIDATE.matcher(text);
while (m.find() && examined < maxCandidates) {
if (coveredBy(accepted, m.start(), m.end())) {
continue;
}
examined++;
recognise(finder, text, m.start(), m.end(), found);
}
return found;
} catch (RuntimeException e) {
broken = true;
LOG.errorf(e, "Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам");
return accepted;
} finally {
finder.clearAdaptiveData();
pool.offer(finder);
}
}
private NameFinderME borrow() {
try {
return pool.poll(BORROW_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
private static void recognise(NameFinderME finder, String text,
int candidateStart, int candidateEnd, List<Span> sink) {
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
String region = text.substring(from, to);
opennlp.tools.util.Span[] tokens = SimpleTokenizer.INSTANCE.tokenizePos(region);
String[] words = new String[tokens.length];
for (int i = 0; i < tokens.length; i++) {
words[i] = region.substring(tokens[i].getStart(), tokens[i].getEnd());
}
for (opennlp.tools.util.Span name : finder.find(words)) {
int start = from + tokens[name.getStart()].getStart();
int end = from + tokens[name.getEnd() - 1].getEnd();
// Берём только то, что пересекается с кандидатом: контекст добавлен
// ради качества разбора, а не для расширения находки.
if (start < candidateEnd && candidateStart < end) {
sink.add(new Span(start, end, RuleRegistry.FIO, PRIORITY));
}
}
}
private static boolean coveredBy(List<Span> accepted, int start, int end) {
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
}
/**
* Готовые к работе распознаватели создаются на старте и сразу прогоняют текст.
*
* <p>{@link NameFinderME} хранит состояние между вызовами, поэтому одним
* экземпляром на несколько потоков пользоваться нельзя. Создание экземпляра
* вместе с первым разбором стоит сотни миллисекунд, и при создании по
* требованию эта цена доставалась первому запросу каждого рабочего потока.
* Пул снимает и то, и другое: к первому обращению всё создано и прогрето.
*/
private static BlockingQueue<NameFinderME> warmedPool(TokenNameFinderModel model, int size) {
long started = System.nanoTime();
BlockingQueue<NameFinderME> ready = new ArrayBlockingQueue<>(size);
for (int i = 0; i < size; i++) {
NameFinderME finder = new NameFinderME(model);
finder.find(WARMUP_WORDS);
finder.clearAdaptiveData();
ready.add(finder);
}
LOG.infof("Прогрев второй ступени: %d распознавателей за %d мс",
size, (System.nanoTime() - started) / 1_000_000);
return ready;
}
private TokenNameFinderModel load(Optional<String> modelPath) {
if (modelPath.isEmpty() || modelPath.get().isBlank()) {
LOG.info("Вторая ступень распознавания имён выключена: модель не задана");
return null;
}
Path file = Path.of(modelPath.get());
if (!Files.isReadable(file)) {
LOG.warnf("Модель %s недоступна, вторая ступень выключена", file.toAbsolutePath());
return null;
}
try (InputStream in = Files.newInputStream(file)) {
TokenNameFinderModel model = new TokenNameFinderModel(in);
LOG.infof("Вторая ступень распознавания имён включена, модель %s", file.toAbsolutePath());
return model;
} catch (IOException | RuntimeException e) {
// Испорченная модель не должна мешать сервису подняться: работают правила.
LOG.errorf(e, "Не удалось загрузить модель %s, вторая ступень выключена", file.toAbsolutePath());
return null;
}
}
}
@@ -0,0 +1,124 @@
package ru.pdguard.detect;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Словари для распознавания ФИО.
*
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
*
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
* персональными данными не является. Ограничение осознанное: клиент по фамилии
* Пушкин в тексте без других ПД замаскирован не будет.
*/
public final class NameDictionary {
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
.map(NameDictionary::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
private static final List<String> WELL_KNOWN_STEMS = load("/names/well-known.txt");
/** Не более скольких падежных букв дописывается к основе имени. */
private static final int MAX_INFLECTION = 3;
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
private NameDictionary() {
}
/**
* Отбрасывает у основы конечную гласную, которая меняется по падежам:
* Ольга → Ольг (Ольги, Ольге, Ольгой), Николай → Никола (Николая, Николаю).
*/
private static String withoutInflectedEnding(String stem) {
if (stem.length() >= 4 && "аяйь".indexOf(stem.charAt(stem.length() - 1)) >= 0) {
return stem.substring(0, stem.length() - 1);
}
return stem;
}
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
* приходится не больше четырёх обращений к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
// но это имя, а не фамилия.
if (GIVEN_NAMES.contains(lower)) {
return true;
}
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
if (GIVEN_NAMES.contains(lower.substring(0, length))
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
return true;
}
}
}
return false;
}
/** Содержит ли текст упоминание известного человека. */
public static boolean isWellKnown(String value) {
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
for (String stem : WELL_KNOWN_STEMS) {
if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) {
return true;
}
}
}
return false;
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
private static List<String> load(String resource) {
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
}
@@ -0,0 +1,45 @@
package ru.pdguard.detect;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* Классы, которые OpenNLP создаёт по имени, разбирая описание признаков внутри модели.
*
* <p>В обычной сборке это работает само, в native-образе — нет: класс, не упомянутый
* в коде, туда просто не попадает. Без регистрации загрузка модели проходит, а
* создание распознавателя падает с {@code ClassNotFoundException} на первом запросе.
*
* <p>Перечислены фабрики целиком, а не только те, что встречаются в текущей модели:
* набор признаков задаётся при обучении и может измениться без правки кода.
*/
@RegisterForReflection(classNames = {
"opennlp.tools.namefind.TokenNameFinderFactory",
"opennlp.tools.namefind.BioCodec",
"opennlp.tools.util.featuregen.AggregatedFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BigramNameFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BrownClusterBigramFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BrownClusterTokenClassFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BrownClusterTokenFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.CachedFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.CharacterNgramFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.DefinitionFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.DictionaryFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.DocumentBeginFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.POSTaggerNameFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.PosTaggerFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.PrefixFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.PreviousMapFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.SentenceFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.SuffixFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TokenClassFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TokenFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TokenPatternFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TrigramNameFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.WindowFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.WordClusterFeatureGeneratorFactory"
})
final class OpenNlpReflection {
private OpenNlpReflection() {
}
}
+90
View File
@@ -0,0 +1,90 @@
package ru.pdguard.detect;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
/**
* Одно правило детекции персональных данных.
*
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
* {@link RuleRegistry}; менять остальной код не требуется.
*
* @param type тип ПД, который распознаёт правило
* @param pattern регулярное выражение
* @param priority приоритет при разрешении перекрытий
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком.
* Несколько групп нужны, когда значение разорвано словами:
* «серия 4509 номер 123456»
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат);
* {@code null} — проверка не нужна
* @param veto шаблон окружения, при котором совпадение персональными данными не считается:
* адрес отделения банка не является ПД, хотя выглядит как адрес
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там,
* где форма совпадения сама по себе слишком общая: «Невский проспект»
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте.
* Проверка через {@code indexOf} на порядок дешевле запуска
* регулярного выражения и отсекает большинство правил на коротком
* запросе. Пустой список — правило запускается всегда
*/
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/** Сколько символов слева и справа от совпадения просматривает вето-шаблон. */
public static final int VETO_LOOKBEHIND = 80;
public static final int VETO_LOOKAHEAD = 40;
/** Правило без проверок, маскируется всё совпадение. */
public static Rule of(String type, String regex, int priority) {
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
public Rule groups(Integer... indexes) {
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
}
/** Принять совпадение, только если значение прошло проверку. */
public Rule validatedBy(Predicate<String> check) {
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
}
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
public Rule anchoredBy(String... required) {
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
}
/** Есть ли в тексте хоть один из якорей правила. */
public boolean mayMatch(String lowercasedText) {
if (anchors.isEmpty()) {
return true;
}
for (String anchor : anchors) {
if (lowercasedText.contains(anchor)) {
return true;
}
}
return false;
}
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
}
}
@@ -0,0 +1,421 @@
package ru.pdguard.detect;
import jakarta.enterprise.context.ApplicationScoped;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.Span;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
/**
* Реестр правил детекции и сам поиск ПД в тексте.
*
* <p>Правила разбиты на три уровня доверия:
* <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
* <li>однозначные по формату — email, телефон;</li>
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
* где сама по себе последовательность знаков ни о чём не говорит.</li>
* </ol>
*
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
* именно на них. На захватываемое значение регистронезависимость не распространяется:
* там, где значение опознаётся по заглавной букве, это существенно.
*/
@ApplicationScoped
public class RuleRegistry {
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
/**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
* «ИВАНОВ» распознавался наравне с «Иванов».
*/
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
/**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
* до вечера» оказывался под маской целиком.
*/
private static final String STREET_NAME =
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
/**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/
private static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
*/
private static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}";
private static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
/** «12 мая 1985 г.» */
private static final String DATE_MONTH_WORD =
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
private static final String DATE_WORDS =
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
+ "(?iu:ьего|ого|его)\\s+" + MONTH
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)";
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
/**
* Слова, при которых адрес принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является.
*/
/**
* Части адреса рядом. Улица, упомянутая в рассказе о городе, адресом клиента не
* является — ровно как адрес отделения банка из технического задания. Требование
* стояло только у постфиксной формы правила, префиксная его не имела.
*/
private static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
private static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
private static final List<Rule> RULES = List.of(
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
.groups(1)
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
.groups(1)
.anchoredBy("пин", "pin"),
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
.groups(1)
.anchoredBy("паспорт"),
// «серия 4509 номер 123456», «серии 45 09 № 123456»
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
// «серии 4509 за номером 123456», «серия 4509 № 123456».
Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
.groups(1, 2)
.anchoredBy("сери"),
Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
.groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "),
// --- Прочие документы, удостоверяющие личность ---
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("загранпаспорт", "заграничн"),
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("военн"),
Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
.groups(1)
.anchoredBy("свидетельств"),
Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1)
.anchoredBy("полис"),
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
.groups(1)
.anchoredBy("подразделени", "к/п"),
// --- Даты с явным якорем ---
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("рожден"),
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("родил"),
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("выдач"),
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
.groups(1)
.anchoredBy("держател", "cardholder", "на имя"),
// --- ФИО ---
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
// сознательно не используется: она захватывает глагол в начале
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
// Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
// Иванов И.И. и И.И. Иванов
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
// Имя Отчество без фамилии
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
.groups(1)
.anchoredBy("фио", "ф.и.о", "на имя"),
// «клиент Иванов Иван», «плательщик Петрова»
Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
.groups(1)
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
"контактное лицо", "исполнител", "держател"),
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName),
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName),
// --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
.validatedBy(Validators::luhn),
Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн"),
Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
.groups(1)
.validatedBy(Validators::snils),
// --- Уровень 2: формат однозначен сам по себе ---
Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"),
// --- Уровень 3: свободный текст после якорного слова ---
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть.
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
.groups(1)
.anchoredBy("выдан"),
Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("рождения"),
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("родил"),
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("гражданств"),
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),
Rule.of(ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY),
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город"),
Rule.of(ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
// принимается только рядом с другими частями адреса: иначе под маску попал бы
// любой рассказ про Невский проспект.
Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
Rule.of(ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"),
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"),
Rule.of(ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("стран"),
// --- Значения без якоря: принимаются только вместе с другими ПД ---
// ИНН физлица без якорного слова — только с верной контрольной суммой.
Rule.of(INN, "\\b\\d{12}\\b", 62)
.validatedBy(Validators::inn),
// Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа.
Rule.of(DATE, DATE_ANY, 58)
.validatedBy(Validators::date)
);
/** Все типы ПД, которые умеет распознавать сервис. */
public List<String> knownTypes() {
return RULES.stream().map(Rule::type).distinct().toList();
}
/**
* Находит все фрагменты ПД, разрешённые политикой системы.
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
String lowercased = text.toLowerCase(Locale.ROOT);
for (Rule rule : RULES) {
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
continue;
}
collect(rule, text, found);
}
return found;
}
private static void collect(Rule rule, String text, List<Span> sink) {
Matcher m = rule.pattern().matcher(text);
while (m.find()) {
for (int group : rule.groups()) {
int start = m.start(group);
int end = m.end(group);
if (start < 0 || end <= start) {
continue;
}
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
continue;
}
if (rule.veto() != null && rule.veto().matcher(surroundings(text, start, end)).find()) {
continue;
}
if (rule.context() != null && !rule.context().matcher(surroundings(text, start, end)).find()) {
continue;
}
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
}
}
private static String surroundings(String text, int start, int end) {
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
return text.substring(from, to);
}
}
@@ -0,0 +1,125 @@
package ru.pdguard.detect;
/**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
* которые по форме похожи на ПД, но ими не являются.
*/
public final class Validators {
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private Validators() {
}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) {
int sum = 0;
int digits = 0;
boolean doubled = false;
for (int i = value.length() - 1; i >= 0; i--) {
char c = value.charAt(i);
if (!Character.isDigit(c)) {
continue;
}
int d = c - '0';
digits++;
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
return digits >= 13 && digits <= 19 && sum % 10 == 0;
}
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
public static boolean inn(String value) {
int[] d = digits(value);
if (d.length == 10) {
return d[9] == checksum(d, INN_10);
}
if (d.length == 12) {
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
}
return false;
}
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
public static boolean snils(String value) {
int[] d = digits(value);
if (d.length != 11) {
return false;
}
int sum = 0;
for (int i = 0; i < 9; i++) {
sum += d[i] * (9 - i);
}
int control = sum < 100 ? sum : (sum == 100 || sum == 101 ? 0 : sum % 101 % 100);
return control == d[9] * 10 + d[10];
}
/**
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
* последовательности вроде {@code 192.168.1}.
*/
public static boolean date(String value) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
// «мая» само по себе однозначно указывает на дату.
for (int i = 0; i < value.length(); i++) {
if (Character.isLetter(value.charAt(i))) {
return true;
}
}
String[] parts = value.split("[.\\-/]");
if (parts.length != 3) {
return false;
}
int[] n = new int[3];
for (int i = 0; i < 3; i++) {
if (parts[i].isEmpty() || parts[i].length() > 4) {
return false;
}
n[i] = Integer.parseInt(parts[i]);
}
for (int y = 0; y < 3; y++) {
if (parts[y].length() == 4) {
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
}
}
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
}
/** Пара чисел похожа на «день и месяц» в любом порядке. */
private static boolean dayAndMonth(int a, int b) {
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
}
private static int checksum(int[] d, int[] weights) {
int sum = 0;
for (int i = 0; i < weights.length; i++) {
sum += d[i] * weights[i];
}
return sum % 11 % 10;
}
private static int[] digits(String value) {
int[] out = new int[value.length()];
int n = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isDigit(c)) {
out[n++] = c - '0';
}
}
int[] trimmed = new int[n];
System.arraycopy(out, 0, trimmed, 0, n);
return trimmed;
}
}
@@ -0,0 +1,33 @@
package ru.pdguard.mask;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiFunction;
/**
* Состояние одной операции маскирования.
*
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
* запроса для модели сохранится.
*
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/
public final class MaskContext {
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
private static final char SEPARATOR = '#';
private final Map<String, String> assigned = new HashMap<>();
private final Map<String, Integer> counters = new HashMap<>();
/**
* Замена для значения; при повторе возвращается ранее выданная.
*
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value,
key -> factory.apply(type, counters.merge(type, 1, Integer::sum)));
}
}
@@ -0,0 +1,14 @@
package ru.pdguard.mask;
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
}
+70
View File
@@ -0,0 +1,70 @@
package ru.pdguard.mask;
import jakarta.enterprise.context.ApplicationScoped;
import ru.pdguard.detect.RuleRegistry;
import java.util.Map;
import java.util.function.UnaryOperator;
/**
* Превращает найденное значение в замену согласно настройкам системы.
*
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
* безопасное поведение по умолчанию для вновь добавленных правил.
*/
@ApplicationScoped
public class Masker {
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
Map.entry(RuleRegistry.EMAIL, Strategies::email),
Map.entry(RuleRegistry.PHONE, EDGES),
Map.entry(RuleRegistry.CARD, EDGES),
Map.entry(RuleRegistry.INN, EDGES),
Map.entry(RuleRegistry.SNILS, EDGES),
Map.entry(RuleRegistry.PASSPORT, EDGES),
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES),
Map.entry(RuleRegistry.DEPT_CODE, EDGES),
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
// только последние. У паспорта РФ и водительского удостоверения серия
// из четырёх знаков, там открывается половина.
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES),
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES),
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials),
Map.entry(RuleRegistry.FIO, Strategies::initials),
// Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один.
Map.entry(RuleRegistry.CVV, Strategies::stars),
Map.entry(RuleRegistry.PIN, Strategies::stars),
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars),
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars),
Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars)
);
public String mask(String type, String value, MaskMode mode, MaskContext context) {
return switch (mode) {
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
};
}
}
@@ -0,0 +1,115 @@
package ru.pdguard.mask;
/**
* Способы преобразования найденного значения в маску.
*
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
* замаскированный текст остаётся читаемым для LLM и минимально отличается
* от эталона при посимвольном сравнении.
*/
public final class Strategies {
private static final char MASK = '*';
private Strategies() {
}
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isWhitespace(c) ? c : MASK);
}
return sb.toString();
}
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
* само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
}
return sb.toString();
}
/**
* Оставляет первые и последние значащие символы, остальные скрывает,
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
for (int i = 0; i < value.length(); i++) {
if (Character.isLetterOrDigit(value.charAt(i))) {
significant++;
}
}
if (significant <= head + tail) {
return stars(value);
}
StringBuilder sb = new StringBuilder(value.length());
int seen = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isLetterOrDigit(c)) {
sb.append(c);
continue;
}
boolean visible = seen < head || seen >= significant - tail;
sb.append(visible ? c : MASK);
seen++;
}
return sb.toString();
}
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
public static String initials(String value) {
StringBuilder sb = new StringBuilder();
boolean wordStart = true;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isLetter(c)) {
if (wordStart) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(Character.toUpperCase(c)).append('.');
wordStart = false;
}
} else {
wordStart = true;
}
}
return sb.isEmpty() ? stars(value) : sb.toString();
}
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
if (at <= 0 || at == value.length() - 1) {
return stars(value);
}
String local = value.substring(0, at);
String domain = value.substring(at + 1);
int dot = domain.lastIndexOf('.');
if (dot <= 0) {
return hideTail(local) + '@' + hideTail(domain);
}
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
}
private static String hideTail(String part) {
if (part.length() <= 1) {
return part;
}
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
}
}
@@ -0,0 +1,95 @@
package ru.pdguard.mask;
import ru.pdguard.detect.RuleRegistry;
/**
* Правдоподобные подставные значения вместо настоящих.
*
* <p>Модель получает текст, который выглядит естественно, и качество ответа
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
* исходное значение всегда даёт одну и ту же подстановку.
*/
final class Synthetic {
private static final String[] SURNAMES =
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS =
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() {
}
static String forType(String type, String value, int ordinal) {
int seed = Math.abs(value.hashCode());
return switch (type) {
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
+ " " + pick(PATRONYMICS, seed >> 6);
case RuleRegistry.CARDHOLDER -> "IVAN PETROV";
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
case RuleRegistry.CARD -> luhnCard(seed);
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT,
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
case RuleRegistry.INN -> digits(seed, 12);
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16);
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed);
case RuleRegistry.ADDRESS_CITY -> "Зареченск";
case RuleRegistry.ADDRESS_STREET -> "Сосновая";
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6);
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье";
case RuleRegistry.CVV -> digits(seed, 3);
case RuleRegistry.PIN -> digits(seed, 4);
// Для остальных типов правдоподобной замены нет — отдаём токен.
default -> "[" + type + "_" + ordinal + "]";
};
}
private static String pick(String[] options, int seed) {
return options[Math.floorMod(seed, options.length)];
}
private static String syntheticDate(int seed) {
int day = 1 + Math.floorMod(seed, 28);
int month = 1 + Math.floorMod(seed >> 5, 12);
int year = 1960 + Math.floorMod(seed >> 9, 45);
return String.format("%02d.%02d.%d", day, month, year);
}
private static String digits(int seed, int count) {
StringBuilder sb = new StringBuilder(count);
int value = Math.abs(seed);
for (int i = 0; i < count; i++) {
sb.append((char) ('0' + Math.floorMod(value, 10)));
value = value / 10 + (i + 1) * 7;
}
return sb.toString();
}
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
int sum = 0;
boolean doubled = true;
for (int i = body.length() - 1; i >= 0; i--) {
int d = body.charAt(i) - '0';
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
body.append((10 - sum % 10) % 10);
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
+ body.substring(8, 12) + " " + body.substring(12);
}
}
+50
View File
@@ -0,0 +1,50 @@
quarkus.http.port=8080
# Порт тестов уведён со стандартного 8081: его занимает узел кластера.
%test.quarkus.http.test-port=8089
quarkus.http.host=0.0.0.0
# Обработка идёт на рабочих потоках: текст на 100 000 токенов не должен
# занимать поток цикла событий.
quarkus.vertx.worker-pool-size=200
quarkus.http.limits.max-body-size=16M
# Словари имён читаются из classpath — в образ native их надо включить явно.
quarkus.native.resources.includes=names/*.txt
quarkus.log.level=INFO
quarkus.log.category."ru.pdguard".level=INFO
# Метрики Prometheus: latency и RPS считаются по pdguard_process_seconds,
# TPS — по pdguard_tokens_processed_total.
quarkus.micrometer.export.prometheus.path=/metrics
quarkus.micrometer.binder.http-server.enabled=true
# Общий слой соответствий для работы на нескольких узлах: memory или redis.
# При memory клиент Redis не создаётся и подключение не устанавливается.
pdguard.store.backend=memory
# Redis поднимаем сами, автоматический контейнер не нужен.
quarkus.redis.devservices.enabled=false
# Адрес требуется расширению уже на старте, но соединение устанавливается
# только при первой команде — а её не будет, пока backend=memory.
quarkus.redis.hosts=redis://localhost:6379
# Общий слой не должен утяжелять ответ: при недоступности Redis узел уходит
# на свою память через 200 мс, а не через штатные десять секунд.
quarkus.redis.timeout=200ms
# Список систем-потребителей. Файла нет — работают настройки по умолчанию.
pdguard.systems-file=config/systems.json
%test.pdguard.systems-file=src/test/resources/systems-test.json
# Вторая ступень распознавания имён. Свойство pdguard.ner.model не задано —
# ступень выключена и работают только правила. Модель обучается отдельно, см. README.
# Каждый неразобранный кандидат стоит около 240 мкс, поэтому их число
# на один запрос ограничено.
pdguard.ner.max-candidates=16
# Распознаватели создаются и прогреваются на старте, по одному на этот счётчик.
pdguard.ner.pool-size=16
# Порог, после которого сервис отвечает 429 вместо накопления очереди.
pdguard.max-concurrent=2000
# Ограничения хранилища соответствий: суммарный объём строк и срок жизни.
pdguard.store.max-chars=134217728
pdguard.store.ttl-minutes=30
+167
View File
@@ -0,0 +1,167 @@
# Основы русских личных имён. Правило дописывает до трёх строчных букв,
# поэтому падежные формы (Ивану, Иваном, Ивана) покрываются основой.
# Строка — одна основа; строки с # игнорируются.
Александр
Алексей
Анатолий
Андрей
Антон
Аркадий
Арсений
Артём
Артем
Артур
Богдан
Борис
Вадим
Валентин
Валерий
Василий
Виктор
Виталий
Владимир
Владислав
Вячеслав
Геннадий
Георгий
Герман
Глеб
Григорий
Даниил
Данил
Денис
Дмитрий
Евгений
Егор
Иван
Игорь
Илья
Кирилл
Константин
Леонид
Максим
Марк
Матвей
Михаил
Никита
Николай
Олег
Павел
Пётр
Петр
Роман
Руслан
Семён
Семен
Сергей
Станислав
Степан
Тимофей
Тимур
Фёдор
Федор
Эдуард
Юрий
Ярослав
Алёна
Алена
Алина
Алла
Анастасия
Ангелина
Анна
Антонина
Валентина
Валерия
Варвара
Вероника
Виктория
Галина
Дарья
Диана
Евгения
Екатерина
Елена
Елизавета
Жанна
Зинаида
Инна
Ирина
Карина
Кристина
Ксения
Лариса
Лидия
Любовь
Людмила
Маргарита
Марина
Мария
Надежда
Наталья
Наталия
Нина
Оксана
Ольга
Полина
Раиса
Регина
Светлана
София
Софья
Тамара
Татьяна
Ульяна
Юлия
Лев
Яков
Ян
Захар
Тарас
Савва
Мирон
Демид
Клим
Влас
Родион
Святослав
Всеволод
Игнат
Филипп
Лука
Назар
Платон
Прохор
Трофим
Фома
Эмиль
Юлиан
Тихон
Гавриил
Давид
Марат
Рустам
Яна
Алиса
Василиса
Агата
Злата
Милана
Дарина
Есения
Таисия
Инга
Вера
Эмма
Нелли
Алевтина
Клавдия
Лилия
Римма
Элина
Ева
Аделина
Амина
Динара
Лейла
Сабина
+45
View File
@@ -0,0 +1,45 @@
# Известные исторические и культурные фигуры. Упоминание такого имени
# персональными данными не является — если рядом нет ПД другого типа.
# Сравнение идёт по началу слова, поэтому падежи покрываются основой.
Пушкин
Лермонтов
Толстой
Достоевский
Гоголь
Чехов
Тургенев
Некрасов
Есенин
Маяковский
Ахматова
Цветаева
Булгаков
Пастернак
Чайковский
Ломоносов
Менделеев
Гагарин
Королёв
Суворов
Кутузов
Шекспир
Эйнштейн
Ньютон
Моцарт
Бетховен
Рахманинов
Репин
Айвазовский
Циолковский
Онегин
Печорин
Раскольников
Обломов
Чичиков
Базаров
Болконский
Каренин
Чацкий
Мцыри
Хлестаков
Митрофанушка
+27
View File
@@ -0,0 +1,27 @@
{
"default": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["*"],
"requireCompanion": ["CVV", "PIN", "DATE"]
},
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
},
"analytics": {
"enabled": true,
"demask": false,
"maskMode": "SYNTHETIC",
"types": ["*"]
},
"legacy-billing": {
"enabled": false,
"demask": false,
"maskMode": "MASK",
"types": ["*"]
}
}
+350
View File
@@ -0,0 +1,350 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.core.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Замер качества детекции на размеченных наборах.
*
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
* его оценка завышена и годится только как защита от ухудшений.
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
* настраивались — именно он показывает настоящее качество.
*
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
* поставило границу совпадения, и напрямую соотносятся с посимвольным
* сравнением замаскированного текста с эталоном.
*
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
* а расхождение в названии типа (скажем, место рождения против города) на
* качество маскирования не влияет.
*/
class BenchmarkTest {
/** Модель второй ступени; собирается отдельно, см. README. */
private static final String MODEL_PATH = "models/ru-ner-person.bin";
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
/** Размеченный пример: чистый текст и эталонные фрагменты. */
private record Sample(String text, List<Span> gold) {
}
/** Итог замера по одному набору. */
private record Result(double fioF1, double overallPrecision, double overallRecall,
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
}
/** Накопитель посимвольных совпадений по одному типу. */
private static final class Score {
private int truePositive;
private int falsePositive;
private int falseNegative;
private int gold() {
return truePositive + falseNegative;
}
private double precision() {
int found = truePositive + falsePositive;
return found == 0 ? 1.0 : (double) truePositive / found;
}
private double recall() {
return gold() == 0 ? 1.0 : (double) truePositive / gold();
}
private double f1() {
double p = precision();
double r = recall();
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
}
}
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
/**
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
* означает, что сломалось то, что раньше работало.
*/
@Test
void detectionQualityOnTuningSet() {
Result result = measure("/benchmark.txt", "набор отладки");
assertTrue(result.fioF1() >= 0.95,
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.95,
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
}
/**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
* отражают измеренное на нём качество, а не желаемое.
*/
@Test
void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.15,
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
}
/**
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
* показывать качество на данных, которых разработка не видела. Пороги здесь
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
*/
@Test
void detectionQualityOnSecondHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
new NameCascade(Optional.of(MODEL_PATH), 16, 4))
: pipeline;
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
assertTrue(result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
}
/**
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
*/
@Test
void detectionQualityWithNameCascade() {
Path model = Path.of(MODEL_PATH);
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(10_000_000L, 30), new NameCascade(Optional.of(MODEL_PATH), 16, 4));
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
assertTrue(result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
}
private Result measure(String resource, String title) {
return measure(pipeline, resource, title);
}
private Result measure(Pipeline stage, String resource, String title) {
List<Sample> samples = load(resource);
Map<String, Score> byType = new LinkedHashMap<>();
Score anyType = new Score();
int cleanTexts = 0;
int cleanTextsWithFalseHit = 0;
int goldFioSpans = 0;
int foundFioSpans = 0;
List<String> falseHits = new ArrayList<>();
List<String> missedFio = new ArrayList<>();
List<String> overMasked = new ArrayList<>();
for (Sample sample : samples) {
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
String[] goldChars = paint(sample.text().length(), sample.gold());
String[] foundChars = paint(sample.text().length(), found);
for (int i = 0; i < sample.text().length(); i++) {
account(byType, goldChars[i], foundChars[i]);
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
}
if (sample.gold().isEmpty()) {
cleanTexts++;
if (!found.isEmpty()) {
cleanTextsWithFalseHit++;
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
}
} else {
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
}
for (Span gold : sample.gold()) {
if (!RuleRegistry.FIO.equals(gold.type())) {
continue;
}
goldFioSpans++;
if (overlappedByFio(gold, found)) {
foundFioSpans++;
} else {
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
}
}
}
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
Score fio = byType.getOrDefault(RuleRegistry.FIO, new Score());
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
falsePositiveRate, foundFioSpans, goldFioSpans);
}
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
private static String[] paint(int length, List<Span> spans) {
String[] painted = new String[length];
for (Span span : spans) {
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
painted[i] = span.type();
}
}
return painted;
}
private static void account(Map<String, Score> byType, String gold, String found) {
if (gold != null) {
Score score = byType.computeIfAbsent(gold, t -> new Score());
if (gold.equals(found)) {
score.truePositive++;
} else {
score.falseNegative++;
}
}
if (found != null && !found.equals(gold)) {
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
}
}
private static void accountAnyType(Score score, boolean gold, boolean found) {
if (gold && found) {
score.truePositive++;
} else if (gold) {
score.falseNegative++;
} else if (found) {
score.falsePositive++;
}
}
private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream()
.anyMatch(span -> RuleRegistry.FIO.equals(span.type()) && span.overlaps(gold));
}
private static String fragment(String text, Span span) {
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
}
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
int from = -1;
for (int i = 0; i <= text.length(); i++) {
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
if (extra && from < 0) {
from = i;
} else if (!extra && from >= 0) {
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
from = -1;
}
}
}
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
StringBuilder out = new StringBuilder(4096);
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(), e.getValue().gold(), e.getValue().precision(),
e.getValue().recall(), e.getValue().f1())));
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
anyType.precision(), anyType.recall(), anyType.f1()));
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio);
appendList(out, "\nЛожные срабатывания:", falseHits);
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
System.out.println(out);
}
private static void appendList(StringBuilder out, String title, List<String> lines) {
if (lines.isEmpty()) {
return;
}
out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n'));
}
private static List<Sample> load(String resource) {
List<Sample> samples = new ArrayList<>();
try (InputStream in = BenchmarkTest.class.getResourceAsStream(resource);
BufferedReader reader = new BufferedReader(
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
samples.add(parse(trimmed));
}
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать " + resource, e);
}
return samples;
}
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
private static Sample parse(String line) {
StringBuilder text = new StringBuilder(line.length());
List<Span> gold = new ArrayList<>();
Matcher m = MARKUP.matcher(line);
int cursor = 0;
while (m.find()) {
text.append(line, cursor, m.start());
int start = text.length();
text.append(m.group(2));
gold.add(new Span(start, text.length(), m.group(1), 0));
cursor = m.end();
}
text.append(line, cursor, line.length());
return new Sample(text.toString(), gold);
}
}
@@ -0,0 +1,137 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Типы ПД, которые опознаются только рядом с якорным словом. */
class ContextDetectionTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksPassportInEveryNotation() {
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
assertHidden("ПАСПОРТ 4509123456", "4509123456");
assertHidden("Серия 4509 номер 123456", "4509");
assertHidden("серии 45 09 № 123456", "123456");
}
@Test
void masksPassportSeriesAndNumberSplitByWords() {
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
assertTrue(masked.contains("серия "), masked);
assertTrue(masked.contains("номер "), masked);
assertFalse(masked.contains("4509"), masked);
assertFalse(masked.contains("123456"), masked);
}
@Test
void masksDepartmentCode() {
assertHidden("Код подразделения 770-001", "770-001");
assertHidden("к/п 770001", "770001");
}
@Test
void masksIssuingAuthorityButNotTheDateAfterIt() {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
}
@Test
void masksDriverLicense() {
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
assertHidden("в/у 99 02 123456", "99 02 123456");
}
@Test
void masksCitizenship() {
assertHidden("Гражданство: РФ", "РФ");
assertHidden("гражданство Республики Беларусь", "Беларусь");
assertHidden("Гражданин России обратился", "России");
}
@Test
void masksBirthPlace() {
assertHidden("Место рождения: город Тверь, проживает в Москве", "город Тверь");
assertHidden("Родился в Нижнем Новгороде", "Нижнем Новгороде");
}
@Test
void masksCardholderName() {
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
}
@Test
void masksSecurityCodeAndPinCompletely() {
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
assertFalse(masked.contains("123,"), masked);
assertFalse(masked.contains("4321"), masked);
assertTrue(masked.contains("***"), masked);
}
@Test
void doesNotMaskPinWithoutAnyOtherPersonalData() {
String text = "Пин-код 1234 введён неверно";
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
}
@Test
void masksPinWhenCardNumberIsAlsoPresent() {
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
}
@Test
void anchorWordsAreCaseInsensitive() {
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
}
@Test
void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original);
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
assertTrue(masked.contains("код подразделения"), masked);
assertTrue(masked.contains("телефон"), masked);
assertFalse(masked.contains("4509 123456"), masked);
assertFalse(masked.contains("770301234550"), masked);
}
@Test
void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("4509 123456"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
}
@@ -0,0 +1,143 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Даты во всех вариантах записи и составляющие адреса. */
class DateAndAddressTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
@Test
void masksBirthDateInAnyPartOrder() {
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
assertHidden("Родился 12-05-1985", "12-05-1985");
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
}
@Test
void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая");
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
}
@Test
void keepsSeparatorsInMaskedDate() {
String masked = mask("Дата рождения 12.05.1985");
assertTrue(masked.endsWith("**.**.****"), masked);
}
@Test
void masksPassportIssueDate() {
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
}
@Test
void doesNotMaskDateWithoutAnyOtherPersonalData() {
String text = "Встреча перенесена на 12.05.2025, подтвердите";
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
}
@Test
void masksBareDateWhenOtherPersonalDataIsPresent() {
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
}
@Test
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
assertEquals(text, mask(text));
}
@Test
void masksAddressComponentsSeparately() {
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
assertFalse(masked.contains("125009"), masked);
assertFalse(masked.contains("Москва"), masked);
assertFalse(masked.contains("Тверская"), masked);
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
assertTrue(masked.contains("ул. "), masked);
}
@Test
void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked);
}
@Test
void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника"));
}
@Test
void masksMultiWordStreetName() {
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
assertFalse(masked.contains("Малая Никитская"), masked);
}
@Test
void masksIndexByAnchorWord() {
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
}
@Test
void doesNotMaskBankBranchAddress() {
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
}
@Test
void doesNotMaskOfficeAddress() {
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
assertEquals(text, mask(text));
}
@Test
void addressTypesAreConfigurableSeparately() {
SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY);
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
assertFalse(masked.contains("Москва"), masked);
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
}
@Test
void unmaskingRestoresTextWithDateAndAddress() {
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("12.05.1985"), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
}
+131
View File
@@ -0,0 +1,131 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** ФИО и защита от ложных срабатываний. */
class FioTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private String mask(String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
private void assertHidden(String text, String secret) {
String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertUnchanged(String text) {
assertEquals(text, mask(text), "ложное срабатывание");
}
@Test
void masksFullNameAsInitials() {
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
}
@Test
void masksNameAndPatronymicWithoutSurname() {
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
}
@Test
void masksFemalePatronymic() {
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
}
@Test
void masksSurnameWithInitialsInBothOrders() {
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
}
@Test
void masksSurnameNextToKnownGivenName() {
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
}
@Test
void masksLowercaseNameAfterExplicitAnchor() {
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
}
@Test
void masksNameAfterRoleAnchor() {
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
}
@Test
void doesNotMaskWellKnownPerson() {
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
assertUnchanged("Когда Гагарин полетел в космос");
}
@Test
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
}
@Test
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
assertUnchanged("Московский Кремль открыт для посещения");
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
assertUnchanged("Смоленская площадь закрыта на ремонт");
}
@Test
void doesNotMaskOrdinaryCapitalisedWords() {
assertUnchanged("Банк Открытие подтвердил лимит");
assertUnchanged("В Понедельник Отдел Согласует Договор");
}
@Test
void identificationIgnoresCase() {
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
}
@Test
void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
assertTrue(masked.contains("И. И. И."), masked);
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
}
@Test
void namesStayFastOnLargeText() {
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
String large = block.repeat(4000);
long started = System.nanoTime();
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("Иванов Иван Иванович"));
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
}
}
@@ -0,0 +1,70 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
class IdentityDocumentTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
}
private void assertMasked(String text, String payloadId, String expected) {
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
}
@Test
void masksForeignPassport() {
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
}
@Test
void masksMilitaryId() {
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
}
@Test
void masksBirthCertificate() {
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
}
@Test
void masksMedicalPolicy() {
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
}
/**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
* целиком, поэтому у этих документов открыты только последние знаки номера.
*/
@Test
void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
"Свидетельство о рождении **-** № ****56");
}
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
@Test
void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
"Водительское удостоверение 99** ****56");
}
}
@@ -0,0 +1,85 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Validators;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Виды замены: звёздочки, токены, правдоподобные значения. */
class MaskModeTest {
private final Pipeline pipeline =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private SystemPolicy policy(MaskMode mode) {
return new SystemPolicy(true, true, mode, Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion());
}
private String mask(MaskMode mode, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
}
@Test
void tokenModeNumbersEachType() {
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
assertTrue(masked.contains("[FIO_1]"), masked);
assertTrue(masked.contains("[EMAIL_1]"), masked);
}
@Test
void sameValueGetsSameTokenWithinRequest() {
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
}
@Test
void syntheticModeProducesPlausibleValues() {
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked);
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
}
@Test
void syntheticValuesAreStable() {
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
}
@Test
void unmaskingWorksInEveryMode() {
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
for (MaskMode mode : MaskMode.values()) {
String id = "mode-" + mode;
String masked = pipeline.process(original, id, policy(mode));
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
}
}
private static int count(String text, String fragment) {
int n = 0;
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
n++;
}
return n;
}
}
@@ -0,0 +1,56 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Вторая ступень не должна вредить первой. */
class NameCascadeTest {
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
private String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
new PayloadStore(1_000_000L, 30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
}
@Test
void withoutModelTheStageIsOff() {
NameCascade cascade = NameCascade.disabled();
assertFalse(cascade.enabled());
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
}
@Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade = new NameCascade(Optional.of(dir.resolve("нет-модели.bin").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
}
@Test
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
Path broken = dir.resolve("испорченная.bin");
Files.writeString(broken, "это не модель", StandardCharsets.UTF_8);
NameCascade cascade = new NameCascade(Optional.of(broken.toString()), 16, 4);
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
"маскирование по правилам обязано работать и без второй ступени");
}
}
@@ -0,0 +1,53 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Ограничения хранилища соответствий: объём и срок жизни. */
class PayloadStoreTest {
@Test
void returnsWhatWasStored() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put("id", "исходный текст", "маска");
PayloadStore.Entry entry = store.byId("id");
assertNotNull(entry);
assertEquals("исходный текст", entry.original());
assertEquals("маска", entry.masked());
assertEquals("исходный текст", store.originalForMask("маска"));
}
@Test
void forgetsEntriesAfterTheirLifetime() {
PayloadStore store = new PayloadStore(1_000_000L, 0);
store.put("id", "исходный текст", "маска");
assertNull(store.byId("id"), "запись с истёкшим сроком жизни не должна отдаваться");
assertNull(store.originalForMask("маска"));
}
@Test
void evictsOldestWhenOverSizeLimit() {
PayloadStore store = new PayloadStore(100L, 30);
for (int i = 0; i < 50; i++) {
store.put("id" + i, "текст номер " + i, "маска номер " + i);
}
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
assertNull(store.byId("id0"), "самая старая запись должна быть вытеснена");
assertNotNull(store.byId("id49"), "последняя запись должна остаться");
}
@Test
void unknownKeysReturnNothing() {
PayloadStore store = new PayloadStore(1_000_000L, 30);
assertNull(store.byId("нет такого"));
assertNull(store.originalForMask("нет такой маски"));
}
}
+138
View File
@@ -0,0 +1,138 @@
package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
class PipelineTest {
private static final String VALID_CARD = "4111 1111 1111 1111";
private static final String VALID_INN_12 = "770301234550";
private static final String VALID_SNILS = "112-233-445 95";
private Pipeline pipeline() {
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
}
private String mask(Pipeline pipeline, String text) {
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
}
@Test
void masksCardNumber() {
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
assertTrue(masked.contains("41** **** **** **11"), masked);
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
}
@Test
void keepsNumberThatFailsLuhn() {
String text = "Заказ 1234 5678 9012 3456 отгружен";
assertEquals(text, mask(pipeline(), text));
}
@Test
void masksEmailKeepingTopLevelDomain() {
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
assertEquals("Почта i**********@m***.ru для связи", masked);
}
@Test
void masksPhoneInAnyNotation() {
Pipeline pipeline = pipeline();
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
String masked = mask(pipeline, "Телефон " + phone);
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
assertTrue(masked.endsWith("67"), masked);
}
}
@Test
void masksInnByContextAndByChecksum() {
Pipeline pipeline = pipeline();
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
}
@Test
void masksSnils() {
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
assertFalse(masked.contains(VALID_SNILS), masked);
}
@Test
void unmaskingRestoresOriginalText() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
String id = "pair-1";
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertNotEquals(original, masked);
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
assertEquals(original, restored);
}
@Test
void retryReturnsSameMask() {
Pipeline pipeline = pipeline();
String original = "Карта " + VALID_CARD;
String id = "retry-1";
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
assertEquals(first, second);
}
@Test
void unmasksWhenPayloadIdIsUnknown() {
Pipeline pipeline = pipeline();
String original = "Почта ivan@mail.ru";
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
}
@Test
void textWithoutPersonalDataIsUnchanged() {
String text = "Расскажи о погоде в Москве завтра";
assertEquals(text, mask(pipeline(), text));
}
@Test
void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(RuleRegistry.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked);
}
@Test
void handlesLargeText() {
Pipeline pipeline = pipeline();
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
String large = block.repeat(4000);
long started = System.nanoTime();
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000;
assertFalse(masked.contains("ivan@mail.ru"));
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
}
}
@@ -0,0 +1,118 @@
package ru.pdguard;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
@QuarkusTest
class ProcessResourceTest {
private String post(String payload, String payloadId) {
return given()
.contentType("application/json")
.body(Map.of("payload", payload, "payload_id", payloadId))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
}
@Test
void maskAndUnmaskPair() {
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
String id = "8a77d363c7c044b49b41d7b8a448243a";
String masked = post(original, id);
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
}
@Test
void rejectsRequestWithoutRequiredFields() {
given()
.contentType("application/json")
.body(Map.of("payload", "текст"))
.when().post("/process")
.then().statusCode(400);
}
@Test
void healthProbeResponds() {
given().when().get("/health").then().statusCode(200).body(equalTo("OK"));
}
@Test
void disabledSystemIsRefused() {
given()
.contentType("application/json")
.header("X-System-Id", "disabled")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
.when().post("/process")
.then().statusCode(403);
}
@Test
void systemPolicySelectsMaskMode() {
String masked = given()
.contentType("application/json")
.header("X-System-Id", "crm")
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
.when().post("/process")
.then().statusCode(200)
.extract().path("result");
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
}
@Test
void unknownSystemFallsBackToDefaultPolicy() {
given()
.contentType("application/json")
.header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when().post("/process")
.then().statusCode(200)
.body("result", equalTo("почта i***@m***.ru"));
}
@Test
void metricsExposeLatencyAndTokenCounters() {
post("Клиент Иванов Иван Иванович", "metrics-1");
String body = given().when().get("/metrics").then().statusCode(200).extract().asString();
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки");
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
}
@Test
void metricsDoNotLeakPersonalData() {
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
String body = given().when().get("/metrics").then().statusCode(200).extract().asString();
org.junit.jupiter.api.Assertions.assertFalse(body.contains("Иванов"), "ПД попали в метрики");
org.junit.jupiter.api.Assertions.assertFalse(body.contains("4111"), "ПД попали в метрики");
}
@Test
void adminShowsSystemsAndTypes() {
given().when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
given().when().get("/admin/types").then().statusCode(200);
}
@Test
void textWithoutPersonalDataIsReturnedAsIs() {
given()
.contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when().post("/process")
.then().statusCode(200)
.body("result", equalTo("тестовая строка"))
.body("result", not(equalTo("")));
}
}
@@ -0,0 +1,90 @@
package ru.pdguard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.mask.MaskMode;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Чтение и горячая перезагрузка списка систем. */
class SystemsConfigTest {
private static final String CONTENT = """
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
"old": { "enabled": false }
}
""";
private SystemsConfig configAt(Path file) {
return new SystemsConfig(file.toString(), new ObjectMapper());
}
@Test
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
SystemPolicy crm = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, crm.maskMode());
assertFalse(crm.demask());
assertTrue(crm.allows("FIO"));
assertFalse(crm.allows("CARD"));
assertFalse(config.policyFor("old").enabled());
}
@Test
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
assertTrue(policy.enabled());
assertTrue(policy.allows("CARD"));
}
@Test
void worksWithoutConfigFile(@TempDir Path dir) {
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
}
@Test
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
}
@Test
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
SystemsConfig config = configAt(file);
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload();
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
"сломанный файл не должен ронять работающий сервис");
}
}
@@ -0,0 +1,66 @@
package ru.pdguard.tools;
import opennlp.tools.namefind.NameFinderME;
import opennlp.tools.namefind.NameSample;
import opennlp.tools.namefind.NameSampleDataStream;
import opennlp.tools.namefind.TokenNameFinderFactory;
import opennlp.tools.namefind.TokenNameFinderModel;
import opennlp.tools.util.InputStreamFactory;
import opennlp.tools.util.ObjectStream;
import opennlp.tools.util.PlainTextByLineStream;
import opennlp.tools.util.TrainingParameters;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Обучение модели для второй ступени распознавания имён.
*
* <p>Не часть сервиса: инструмент запускается вручную, результат кладётся рядом
* с приложением и подключается свойством {@code pdguard.ner.model}. Подготовка
* обучающего набора — в {@code tools/prepare-ner-corpus.py}.
*
* <pre>
* mvn -q test-compile
* java -cp target/test-classes:$(cat cp.txt) ru.pdguard.tools.NerTrainer train.txt models/ru-ner-person.bin
* </pre>
*/
public final class NerTrainer {
private static final int ITERATIONS = Integer.getInteger("ner.iterations", 300);
private static final int CUTOFF = Integer.getInteger("ner.cutoff", 1);
private NerTrainer() {
}
public static void main(String[] args) throws IOException {
if (args.length < 2) {
throw new IllegalArgumentException("Нужны два аргумента: обучающий набор и путь к модели");
}
Path data = Path.of(args[0]);
Path modelFile = Path.of(args[1]);
TrainingParameters parameters = new TrainingParameters();
parameters.put(TrainingParameters.ITERATIONS_PARAM, ITERATIONS);
parameters.put(TrainingParameters.CUTOFF_PARAM, CUTOFF);
parameters.put(TrainingParameters.ALGORITHM_PARAM,
System.getProperty("ner.algorithm", "MAXENT"));
InputStreamFactory source = () -> Files.newInputStream(data);
TokenNameFinderModel model;
try (ObjectStream<String> lines = new PlainTextByLineStream(source, StandardCharsets.UTF_8);
ObjectStream<NameSample> samples = new NameSampleDataStream(lines)) {
model = NameFinderME.train("ru", "person", samples, parameters, new TokenNameFinderFactory());
}
Files.createDirectories(modelFile.toAbsolutePath().getParent());
try (OutputStream out = Files.newOutputStream(modelFile)) {
model.serialize(out);
}
System.out.printf("Модель сохранена: %s, %d КБ%n",
modelFile.toAbsolutePath(), Files.size(modelFile) / 1024);
}
}
+106
View File
@@ -0,0 +1,106 @@
# Отложенный набор: составлен независимо от правил и не использовался для их настройки.
# Имена, формулировки и отрицательные примеры намеренно не пересекаются с основным
# набором. Формат тот же: {{ТИП:значение}}, строка без разметки — текст без ПД.
# --- ФИО: фамилии нетипичного словообразования ---
Поручитель {{FIO:Черных Андрей Петрович}} подтвердил согласие
Созаёмщик {{FIO:Долгих Мария Сергеевна}} подписала договор
Контактное лицо — {{FIO:Асланов Рустам Маратович}}
Ответственный исполнитель {{FIO:Петров-Водкин Сергей Иванович}}
Обратился {{FIO:Ким Сон Хо}} по вопросу перевода
Заявку подал {{FIO:Нгуен Ван Ань}}
Согласовано с {{FIO:Гаджиевым Артуром Рашидовичем}}
В списке значится {{FIO:Оганесян Карина Ашотовна}}
# --- ФИО: редкие отчества ---
Распоряжение подписал {{FIO:Владимир Ильич Ульянов}}
Приём ведёт {{FIO:Сергей Кузьмич Носов}}
Ответ дала {{FIO:Анна Фоминична Белова}}
Документ заверил {{FIO:Аполлинарий Никитич Греков}}
Обращение от {{FIO:Евлампии Саввичны Тороповой}}
# --- ФИО: падежи и порядок слов ---
Перевод в пользу {{FIO:Тихомировой Елены Владимировны}}
Доверенность на имя {{FIO:Родиона Захаровича Мельникова}}
Счёт открыт {{FIO:Яковом Демидовичем Соболевым}}
О {{FIO:Ларисе Тарасовне Гуровой}} сведений нет
{{FIO:Ульянова Злата}} записалась на консультацию
Заявление {{FIO:Милана Игнатьева}} рассмотрено
# --- ФИО: инициалы в разных написаниях ---
Утвердил {{FIO:Соболев Я. Д.}}
Исполнитель: {{FIO:Т.А. Гурова}}
Виза {{FIO:Мельников Р.З.}} получена
Согласование {{FIO:Е. В. Тихомирова}} завершено
# --- ФИО: регистр и явные якоря ---
Ф.И.О. заявителя: {{FIO:соболев яков демидович}}
ФИО {{FIO:ГУРОВА ЛАРИСА ТАРАСОВНА}}
Карта выпущена на имя {{FIO:мельников родион}}
Представитель {{FIO:Торопова Евлампия}} прибудет завтра
Владелец {{FIO:Греков Аполлинарий Никитич}}
# --- ФИО вместе с другими персональными данными ---
Клиент {{FIO:Носов Сергей Кузьмич}}, паспорт {{PASSPORT:45 09 № 123456}}, ИНН {{INN:7801234564}}
{{FIO:Белова Анна Фоминична}}, СНИЛС {{SNILS:216-048-357 45}}, телефон {{PHONE:8 (495) 123-45-67}}
Перевод от {{FIO:Тихомировой Е.В.}} на карту {{CARD:4276160012345675}}
{{FIO:Соболев Яков}}, дата рождения {{BIRTH_DATE:1985/05/12}}, место рождения {{BIRTH_PLACE:посёлок Лесной}}
Заявитель {{FIO:Гурова Л.Т.}}, почта {{EMAIL:L.GUROVA@EXAMPLE.CO.UK}}
{{FIO:Мельников Родион Захарович}}, адрес: г. {{ADDRESS_CITY:Санкт-Петербург}}, {{ADDRESS_STREET:Невский}} пр-т, д. {{ADDRESS_HOUSE:28}}
Держатель {{CARDHOLDER:PETR SOBOLEV}}, карта {{CARD:2200123456789019}}, код проверки {{CVV:456}}
{{FIO:Торопова Е.С.}}, водительское удостоверение {{DRIVER_LICENSE:78 12 345678}}
Клиент {{FIO:Греков А.Н.}}, гражданство {{CITIZENSHIP:Армении}}, дата выдачи {{PASSPORT_DATE:5 марта 1990 г.}}
# --- Несколько ФИО в одном тексте ---
Договор между {{FIO:Носовым Сергеем Кузьмичом}} и {{FIO:Беловой Анной Фоминичной}}
Присутствовали {{FIO:Соболев Я.Д.}}, {{FIO:Гурова Л.Т.}} и {{FIO:Мельников Р.З.}}
# --- Прочие типы ПД в новых написаниях ---
Телефон для связи {{PHONE:+7-916-123-45-67}}, запасной {{PHONE:89161234567}}
Почта {{EMAIL:ivan+bank@example.co.uk}} подтверждена
Паспорт серии {{PASSPORT:4509}} за номером {{PASSPORT:123456}}
Дата рождения {{BIRTH_DATE:12-05-1985}}, дата выдачи паспорта {{PASSPORT_DATE:2015.12.05}}
Родилась {{BIRTH_DATE:первого сентября 2001 года}} в {{BIRTH_PLACE:городе Вологда}}
Код подразделения {{DEPT_CODE:780 012}}, выдан {{PASSPORT_ISSUER:УМВД России по Санкт-Петербургу}}
Адрес регистрации: {{ADDRESS_POSTCODE:190000}}, город {{ADDRESS_CITY:Выборг}}, улица {{ADDRESS_STREET:Мира}}, дом {{ADDRESS_HOUSE:3}}, квартира {{ADDRESS_FLAT:12}}
Полис ОМС {{MEDICAL_POLICY:7801234567890123}} действителен
Загранпаспорт {{FOREIGN_PASSPORT:78 7654321}} получен
Военный билет {{MILITARY_ID:ВЕ 7654321}} сдан в архив
PIN {{PIN:4821}} к карте {{CARD:4276160012345675}} заблокирован
# --- Тексты без персональных данных ---
Третьяковская галерея открыта до восемнадцати часов
Разбери роман Булгакова про Воланда и Маргариту
Институт имени Курчатова объявил конкурс проектов
Улица Академика Королёва находится рядом с телецентром
Матч Спартак — Динамо перенесён на воскресенье
Бизнес-центр Романов Двор сдаётся в аренду
Аэропорт Шереметьево работает в обычном режиме
Ленинградский вокзал, платформа три, поезд опаздывает
Тургеневская библиотека закрыта на ремонт
Компания Яндекс открыла вакансию аналитика
Сбербанк Онлайн не открывается на старом телефоне
Премьер-министр выступил в парламенте с докладом
Площадь Восстания перекрыта из-за ремонта
Гостиница Космос принимает заявки на корпоратив
Какой лимит по кредитной карте можно установить
Объясни разницу между вкладом и накопительным счётом
Сформируй письмо клиентам об изменении тарифов
Напиши SQL-запрос для выборки активных договоров
Ошибка 404 при открытии страницы личного кабинета
Переведи сто долларов в рубли по курсу на сегодня
Собрание состоится в 15:30 в большой переговорной
Отчёт за 2024 год готов и отправлен на согласование
Сколько стоит выпуск дополнительной карты
Купи молоко, хлеб и сыр по дороге домой
Составь чек-лист для проверки договора аренды
Сгенерируй описание вакансии для младшего аналитика
Что такое овернайт и чем он отличается от вклада
Проверь орфографию и пунктуацию в этом абзаце
Расскажи про историю Невского проспекта
Опиши правила оформления командировочных расходов
Заказ 4276 1600 1234 5670 не прошёл проверку
Версия приложения 3.14.15 вышла вчера
Температура в Выборге опустилась до минус пяти
Подготовь презентацию на двадцать слайдов
Сравни два тарифных плана для малого бизнеса
+106
View File
@@ -0,0 +1,106 @@
# КОНТРОЛЬНЫЙ НАБОР. Правила по нему не настраиваются — иначе он потеряет смысл,
# как это произошло с benchmark-holdout.txt. Замер по нему делается изредка, чтобы
# увидеть настоящее качество; дефекты, которые он вскрывает, чинятся по первым двум
# наборам или по собственным примерам, а не подгонкой под эти строки.
#
# Единственная правка, сделанная после его появления, — ограничение правила улицы:
# оно дожёвывало строку до конца и требовало адресного окружения. Дефект был виден
# без всякого набора, а числа по ФИО от него не изменились.
#
# Составлен после доработки правил по первому отложенному набору. Имена, формулировки
# и отрицательные примеры не пересекаются ни с одним из предыдущих наборов.
#
# Набор намеренно жёстче: сюда собраны случаи, которые раньше не проверялись —
# имена, совпадающие с обычными словами, двойные фамилии, подписи в письмах,
# короткие формы записи и внутренняя переписка банка.
# --- ФИО: имена, совпадающие с нарицательными словами ---
Заявку оформила {{FIO:Вера Никитична Пахомова}}
Обращение приняла {{FIO:Надежда Сергеевна Ефимова}}
Договор подписала {{FIO:Любовь Аркадьевна Зотова}}
Счёт открыт на {{FIO:Веру Пахомову}}
Перевод получила {{FIO:Надежда Ефимова}}
# --- ФИО: двойные и дефисные ---
Согласовано с {{FIO:Соловьёвым-Седым Игорем Львовичем}}
Заявитель {{FIO:Мамедов-оглы Рустам}}
Клиент {{FIO:Анна-Мария Ковач}} записалась на приём
Обращение от {{FIO:Петрова-Водкина С.И.}}
# --- ФИО: подписи и обращения ---
Уважаемый {{FIO:Игорь Львович}}, ваша заявка одобрена
С уважением, {{FIO:Ефимова Н.С.}}, отдел сопровождения
Исполнил: {{FIO:Зотова Л.А.}}, тел {{PHONE:+7 916 1234567}}
Прошу согласовать. {{FIO:Пахомов В.Н.}}
# --- ФИО: перечисления ---
В комиссию вошли {{FIO:Ефимов Р.Т.}}, {{FIO:Зотова Л.А.}}, {{FIO:Пахомов В.Н.}}
Ответственные: {{FIO:Игорь Соловьёв}} и {{FIO:Рустам Мамедов}}
# --- ФИО: внутренняя переписка ---
Коллеги, клиент {{FIO:Ковач Анна}} жалуется на списание
Передал заявку {{FIO:Ефимовой Надежде Сергеевне}} на проверку
Свяжитесь с {{FIO:Зотовой}} по вопросу тарифа
Заявка закрыта, обращался {{FIO:Пахомов Вениамин Николаевич}}
# --- ФИО вместе с другими персональными данными ---
Клиент {{FIO:Пахомов Вениамин Николаевич}}, паспорт {{PASSPORT:5003 987654}}, ИНН {{INN:502601234547}}
{{FIO:Ефимова Надежда Сергеевна}}, СНИЛС {{SNILS:389-104-256 97}}, карта {{CARD:5555 6666 7777 8884}}
{{FIO:Зотова Л.А.}}, дата рождения {{BIRTH_DATE:12.05.85}}, почта {{EMAIL:zotova_la@example.org}}
Перевод от {{FIO:Соловьёва Игоря}} на карту {{CARD:4173567812345677}}
{{FIO:Ковач Анна-Мария}}, адрес: {{ADDRESS_POSTCODE:141400}}, г. {{ADDRESS_CITY:Химки}}, ул. {{ADDRESS_STREET:Совхозная}}, д. {{ADDRESS_HOUSE:5}}, кв. {{ADDRESS_FLAT:88}}
Паспорт РФ {{PASSPORT:5003 987654}}, код подразделения {{DEPT_CODE:500-012}}, выдан {{PASSPORT_ISSUER:ГУ МВД России по Московской области}}
{{FIO:Мамедов Рустам}}, гражданство {{CITIZENSHIP:Азербайджана}}, место рождения {{BIRTH_PLACE:город Баку}}
Держатель {{CARDHOLDER:ANNA KOVACH}}, CVV {{CVV:731}}, карта {{CARD:5555 6666 7777 8884}}
{{FIO:Пахомов В.Н.}}, водительское удостоверение {{DRIVER_LICENSE:50 03 987654}}
Дата выдачи паспорта {{PASSPORT_DATE:2015-12-05}}, дата рождения {{BIRTH_DATE:тридцатого апреля 1978 года}}
# --- Прочие типы в коротких и небрежных записях ---
Телефон {{PHONE:8-916-123-45-67}}, запасной {{PHONE:+7(916)1234567}}
Почта для связи {{EMAIL:v.pahomov+bank@example.co.uk}}
ИНН {{INN:7801234564}} проверен, карта {{CARD:4173567812345677}} активна
Полис {{MEDICAL_POLICY:5026012345470123}} действует до конца года
Загранпаспорт {{FOREIGN_PASSPORT:50 9876543}} продлён
Индекс {{ADDRESS_POSTCODE:141400}}, город {{ADDRESS_CITY:Химки}}, улица {{ADDRESS_STREET:Совхозная}}
Родился {{BIRTH_DATE:5 марта 1978 г.}} в {{BIRTH_PLACE:посёлке Новогорск}}
Серия {{PASSPORT:5003}} номер {{PASSPORT:987654}}, дата выдачи {{PASSPORT_DATE:05.12.2015}}
# --- Тексты без персональных данных ---
Надежда умирает последней, как говорится
Любовь к искусству не измеряется деньгами
Вера в успех проекта у команды есть
Роза ветров показывает направление
Лилия расцвела в июне на клумбе
Ростов-на-Дону и Комсомольск-на-Амуре соединит рейс
Спартак Москва проиграл в гостях
Иван Грозный правил долго и жестоко
Пётр Первый основал город на Неве
Лада Веста подорожала на десять процентов
ООО Ромашка зарегистрировано в Москве
Иванов и партнёры — это юридическая фирма
Третий Рим — концепция шестнадцатого века
Красная площадь закрыта на репетицию
Нижний Тагил и Верхняя Пышма расположены рядом
Большой театр открывает сезон в сентябре
Институт Склифосовского принимает по скорой
Музей Тропинина работает по будням
Проспект Вернадского перекрыт до вечера
Улица Королёва названа в честь конструктора
Объясни, чем аннуитетный платёж отличается от дифференцированного
Составь скрипт разговора с клиентом по просроченной задолженности
Проверь, корректно ли заполнено платёжное поручение
Сформируй выгрузку по операциям за прошлый квартал
Напиши инструкцию по подключению эквайринга
Какие лимиты действуют на снятие наличных в банкомате
Опиши процесс рассмотрения заявки на ипотеку
Сравни условия по трём накопительным счетам
Подготовь ответ на претензию по списанию комиссии
Расшифруй аббревиатуру ОКВЭД и приведи пример
Сколько дней рассматривается заявление о возврате
Сгенерируй тестовые данные для проверки формы
Ошибка при оплате картой, код 05, что это значит
Переведи описание продукта на английский
Сократи текст уведомления до двухсот знаков
Проверь орфографию в шаблоне письма клиенту
Собрание перенесено на десятое число, кабинет 412
Отчёт по кварталу готов, направлен руководителю
+132
View File
@@ -0,0 +1,132 @@
# Размеченный набор для замера качества детекции.
#
# Формат: персональные данные обёрнуты в {{ТИП:значение}}. Строка без разметки —
# текст, в котором ПД нет вовсе, и любое срабатывание на ней считается ложным.
# Строки, начинающиеся с #, и пустые строки пропускаются.
#
# Набор смещён в сторону ФИО: это самый сложный для распознавания тип и главный
# источник ложных срабатываний.
# --- ФИО: полное имя с отчеством, разные падежи ---
Клиент {{FIO:Иванов Иван Иванович}} обратился в отделение
Заявление подал {{FIO:Петров Сергей Николаевич}}
{{FIO:Сидорова Анна Петровна}} подтвердила согласие на обработку
Договор подписан с {{FIO:Кузнецовым Дмитрием Алексеевичем}}
Передайте документы {{FIO:Смирнову Олегу Ивановичу}}
Встреча с {{FIO:Ивановым Иваном Ивановичем}} перенесена на следующую неделю
Ответственный — {{FIO:Николаев Юрий Витальевич}}
Доверенность выдана на {{FIO:Белова Глеба Артёмовича}}
{{FIO:Троицкий Лев Маркович}} назначен ответственным
{{FIO:Иванидзе Нина Георгиевна}} подала обращение
Поручение принято от {{FIO:Ясенева Марка Тимуровича}}
Согласовано с {{FIO:Лаврентьевой Ольгой Никитичной}}
# --- ФИО: имя и отчество без фамилии ---
{{FIO:Иван Иванович}} ждёт ответа
Пригласите {{FIO:Ивана Сергеевича}} на встречу
Спросите у {{FIO:Марии Никитичны}} про заявку
Свяжитесь с {{FIO:Анной Ивановной}} по вопросу перевода
Документы передал {{FIO:Пётр Глебович}}
# --- ФИО: фамилия с инициалами ---
Заявление от {{FIO:Иванов И.И.}} принято к рассмотрению
Подписал {{FIO:И.И. Иванов}}
Согласовано: {{FIO:Петрова А.С.}}
Исполнитель {{FIO:Кузнецов Д.А.}}, срок до конца недели
Отчёт подготовил {{FIO:С.Н. Мещеряков}}
# --- ФИО: строчными и прописными буквами ---
ФИО: {{FIO:иванов иван иванович}}
фио: {{FIO:петрова анна ивановна}}
Ф.И.О. {{FIO:смирнов олег петрович}}
Карта оформлена на имя {{FIO:петров сергей}}
{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно
Заявитель: {{FIO:КУЗНЕЦОВА АННА СЕРГЕЕВНА}}
# --- ФИО: фамилия рядом с личным именем ---
{{FIO:Сергей Петров}} подтвердил заявку
{{FIO:Петров Сергей}} подтвердил заявку
Обращение {{FIO:Ольги Ковалёвой}} рассмотрено
Перевод получил {{FIO:Артём Тихомиров}}
Заявку оформила {{FIO:Марина Шевченко}}
Поручителем выступает {{FIO:Тимур Мкртчян}}
В списке значится {{FIO:Екатерина Бурмистрова}}
Счёт открыт на {{FIO:Никиту Кольцова}}
# --- ФИО: после ролевого слова ---
Клиент {{FIO:Петров Сергей}}, заявка одобрена
Плательщик {{FIO:Ковалёва}} подтвердила перевод
Сотрудник {{FIO:Николаев Юрий}} оформил заявку
Заказчик {{FIO:Шевченко Марина}} согласовал смету
Пациент {{FIO:Ким Елена}} записан на приём
Абонент {{FIO:Белов Глеб}} не отвечает
Владелец счёта {{FIO:Романов Артём Игоревич}}
Получатель {{FIO:Ясенев Марк}} подтвердил адрес
Действует в лице {{FIO:Иванова Ивана Ивановича}}
# --- ФИО вместе с другими персональными данными ---
Клиент {{FIO:Иванов Иван Иванович}}, паспорт {{PASSPORT:4509 123456}}, тел {{PHONE:+7 916 123-45-67}}
{{FIO:Петров С.А.}}, ИНН {{INN:770301234550}}, карта {{CARD:4111 1111 1111 1111}}
Почта {{EMAIL:ivan.petrov@mail.ru}} принадлежит клиенту {{FIO:Петрову Ивану}}
{{FIO:Сидорова Анна Петровна}}, дата рождения {{BIRTH_DATE:12.05.1985}}, гражданство {{CITIZENSHIP:РФ}}
Клиент {{FIO:Кузнецов Дмитрий Алексеевич}}, СНИЛС {{SNILS:112-233-445 95}}
Оформлено на {{FIO:Смирнова Олега}}, водительское удостоверение {{DRIVER_LICENSE:9902 123456}}
{{FIO:Белова Анна Ивановна}}, место рождения {{BIRTH_PLACE:город Тверь}}
Заявитель {{FIO:Троицкий Лев}}, загранпаспорт {{FOREIGN_PASSPORT:75 1234567}}
Клиент {{FIO:Александр Пушкин}}, паспорт {{PASSPORT:4509 123456}}
Держатель {{CARDHOLDER:IVAN PETROV}}, карта {{CARD:4111 1111 1111 1111}}, CVV {{CVV:123}}
# --- Несколько имён в одном тексте ---
Встречу проводят {{FIO:Иванов Иван Иванович}} и {{FIO:Петрова Анна Сергеевна}}
Согласовали {{FIO:Кузнецов Д.А.}} и {{FIO:Смирнов О.И.}}, возражений нет
# --- Прочие типы ПД для замера точности в реальном окружении ---
Паспорт {{PASSPORT:4509 123456}} выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}}, код подразделения {{DEPT_CODE:770-001}}
Серия {{PASSPORT:4509}} номер {{PASSPORT:123456}}, дата выдачи {{PASSPORT_DATE:12.05.2015}}
Адрес: {{ADDRESS_POSTCODE:125009}}, г. {{ADDRESS_CITY:Москва}}, ул. {{ADDRESS_STREET:Тверская}}, д. {{ADDRESS_HOUSE:7}}, кв. {{ADDRESS_FLAT:15}}
Свяжитесь: {{EMAIL:ivan.petrov@mail.ru}} или {{PHONE:+7 (916) 123-45-67}}
Дата рождения {{BIRTH_DATE:12 мая 1985 года}}
Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}}
Военный билет {{MILITARY_ID:АБ 1234567}} предъявлен
Полис ОМС {{MEDICAL_POLICY:1234567890123456}} оформлен
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-МЮ № 123456}}
ИНН {{INN:770301234550}} проверен в реестре
Карта {{CARD:5555 5555 5555 4444}} заблокирована
Гражданство {{CITIZENSHIP:Республики Беларусь}}, место рождения {{BIRTH_PLACE:город Минск}}
# --- Тексты без персональных данных: любое срабатывание здесь ложное ---
Напиши стихотворение в духе Александра Пушкина про осень
Сравни Толстого и Достоевского как прозаиков
Когда Гагарин полетел в космос
Расскажи о теории Менделеева про периодический закон
Разбери стихотворение Лермонтова Парус
Кто написал оперу Евгений Онегин
Московский Кремль открыт для посещения
Экскурсия в Нижний Новгород перенесена на май
Смоленская площадь закрыта на ремонт
Улица Тверская и Кутузовский проспект перекрыты
Театр имени Вахтангова открыл сезон
Премия имени Ломоносова вручена в декабре
Площадь Гагарина находится на юго-западе
Отделение банка на улице Тверская, дом 7 работает до 20:00
Дополнительный офис, г. Москва, ул. Арбат, д. 1
Банкомат по адресу Ленинский проспект, дом 30 не работает
Банк Открытие подтвердил лимит по овердрафту
Сбербанк России опубликовал квартальный отчёт
Газпром и Роснефть подписали соглашение
Встреча перенесена на 12.05.2025, подтвердите участие
Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута
Заказ 1234 5678 9012 3456 отгружен со склада
Пин-код 1234 введён неверно, попробуйте снова
Компания выросла на 20 процентов в 2024 году
В Понедельник Отдел Согласует Договор
Расскажи о погоде в Москве завтра
Объясни разницу между дебетовой и кредитной картой
Подготовь план обучения для команды разработки
Какие документы нужны для открытия счёта юридическому лицу
Сколько стоит обслуживание карты в год
Переведи текст на английский язык
Сгенерируй описание вакансии для аналитика данных
Что такое эквайринг и как он работает
Составь список задач на следующий спринт
Проверь орфографию в этом абзаце
+6
View File
@@ -0,0 +1,6 @@
{
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO", "PHONE", "EMAIL"] },
"analytics":{ "enabled": true, "demask": false, "maskMode": "SYNTHETIC", "types": ["*"] },
"disabled": { "enabled": false, "demask": false, "maskMode": "MASK", "types": ["*"] }
}
+38
View File
@@ -0,0 +1,38 @@
import sys
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50000
out, sent, inside, kept = [], [], False, 0
def flush():
global sent, inside
if sent:
if inside:
sent.append('<END>')
out.append(' '.join(sent))
sent, inside = [], False
for line in sys.stdin:
line = line.rstrip('\n')
if not line:
flush()
if len(out) >= limit:
break
continue
if line.startswith('#'):
continue
cols = line.split('\t')
if len(cols) < 10 or '-' in cols[0]:
continue
word, tag = cols[1], cols[9]
person = tag.endswith('-PER')
begins = tag.startswith('Tag=B-PER')
if person and (not inside or begins):
if inside:
sent.append('<END>')
sent.append('<START:person>')
inside = True
elif not person and inside:
sent.append('<END>')
inside = False
sent.append(word)
flush()
sys.stderr.write(f"предложений: {len(out)}, с персонами: {sum(1 for l in out if 'START:person' in l)}\n")
print('\n'.join(out[:limit]))
+69
View File
@@ -0,0 +1,69 @@
import glob, os, sys
def load_doc(base):
order, text = [], {}
for line in open(base + '.tokens', encoding='utf-8'):
p = line.split()
if len(p) >= 4:
order.append(p[0]); text[p[0]] = ' '.join(p[3:])
index = {tid: i for i, tid in enumerate(order)}
spans = {}
for line in open(base + '.spans', encoding='utf-8'):
p = line.split('#')[0].split()
if len(p) >= 6:
spans[p[0]] = (p[4], int(p[5]))
person = set()
for line in open(base + '.objects', encoding='utf-8'):
p = line.split('#')[0].split()
if len(p) < 3 or p[1] != 'Person':
continue
for sid in p[2:]:
if sid in spans:
first, cnt = spans[sid]
i = index.get(first)
if i is None:
continue
for j in range(i, min(i + cnt, len(order))):
person.add(order[j])
return order, text, person
def sentences(order, text, person, limit=40):
cur = []
for tid in order:
cur.append(tid)
if text[tid] in ('.', '!', '?', '…') or len(cur) >= limit:
yield cur; cur = []
if cur:
yield cur
def emit(order, text, person):
out = []
for sent in sentences(order, text, person):
words, inside = [], False
for tid in sent:
is_person = tid in person
if is_person and not inside:
words.append('<START:person>'); inside = True
elif not is_person and inside:
words.append('<END>'); inside = False
words.append(text[tid])
if inside:
words.append('<END>')
if any(t in person for t in sent) or len(out) % 3 == 0:
out.append(' '.join(words))
return out
root = sys.argv[1]
target = sys.argv[2]
lines, persons = [], 0
for part in ('devset', 'testset'):
for tok in sorted(glob.glob(os.path.join(root, part, '*.tokens'))):
base = tok[:-len('.tokens')]
order, text, person = load_doc(base)
persons += len(person)
lines.extend(emit(order, text, person))
with open(target, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines) + '\n')
print(f"предложений: {len(lines)}, размеченных токенов-персон: {persons}")
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Обучение модели для второй ступени распознавания имён.
#
# Модель в репозиторий не кладётся: она весит мегабайты и собирается из открытых
# корпусов за несколько минут. Без модели сервис работает на одних правилах.
#
# ./tools/train-ner.sh # быстрый вариант, только factRuEval
# ./tools/train-ner.sh full # плюс префикс Nerus, качество заметно выше
set -euo pipefail
cd "$(dirname "$0")/.."
MODE="${1:-quick}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "1. factRuEval-2016 — ручная разметка, 1965 предложений"
curl -sSL -o "$WORK/fre.tar.gz" \
https://codeload.github.com/dialogue-evaluation/factRuEval-2016/tar.gz/refs/heads/master
tar xzf "$WORK/fre.tar.gz" -C "$WORK"
python3 tools/factrueval-to-opennlp.py "$WORK/factRuEval-2016-master" "$WORK/train.txt"
if [ "$MODE" = "full" ]; then
echo "2. Nerus — автоматическая разметка, берём префикс потоком (400 тыс. предложений)"
curl -sS -r 0-400000000 \
https://storage.yandexcloud.net/natasha-nerus/data/nerus_lenta.conllu.gz \
| gunzip 2>/dev/null \
| python3 tools/conllu-to-opennlp.py 400000 >> "$WORK/train.txt"
fi
echo "3. Обучение, несколько минут"
mvn -q -B test-compile
mvn -q -B dependency:build-classpath -Dmdep.outputFile="$WORK/cp.txt"
mkdir -p models
java -Xmx6g -Dner.iterations=100 -Dner.cutoff=5 \
-cp "target/test-classes:target/classes:$(cat "$WORK/cp.txt")" \
ru.pdguard.tools.NerTrainer "$WORK/train.txt" models/ru-ner-person.bin
echo
echo "Готово. Включить: pdguard.ner.model=models/ru-ner-person.bin"