feat: ui + docs: для жюри

This commit is contained in:
Максименко Никита Владимирович
2026-09-23 23:12:10 +03:00
parent 121a281fc6
commit 88a6117eda
7 changed files with 796 additions and 5 deletions
+16
View File
@@ -9,8 +9,20 @@ services:
image: pd-guard-spring:jvm image: pd-guard-spring:jvm
ports: ports:
- "8080:8080" - "8080:8080"
deploy:
resources:
limits:
cpus: "4"
memory: 6g
environment: environment:
# Дефолт JVM — 25% контейнерного лимита на heap.
JAVA_OPTS: >-
-Dspring.config.additional-location=optional:file:/deployments/config/
-XX:MaxRAMPercentage=75.0
PDGUARD_MAX_CONCURRENT: "2000" PDGUARD_MAX_CONCURRENT: "2000"
# На 1 vCPU дефолт 200мс держал concurrency у пола; на 4 vCPU запас есть,
# но 800мс оставлено с той же осторожностью — целевая latency контракта 1с.
PDGUARD_TARGET_LATENCY_MS: "800"
PDGUARD_WARMUP_ITERATIONS: "2000" PDGUARD_WARMUP_ITERATIONS: "2000"
# Вторая ступень распознавания — две модели под разные задачи (см. # Вторая ступень распознавания — две модели под разные задачи (см.
# NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие # NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие
@@ -19,6 +31,10 @@ services:
PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner
PDGUARD_NER_ADDRESS_ENGINE: rubert PDGUARD_NER_ADDRESS_ENGINE: rubert
PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner
# Третья ступень (юридические реквизиты, LLAIM Legal NER) временно выключена
# для нагрузочного теста — проверяем, она ли основной источник CPU-затрат
# на 1-vCPU лимите. Включить: PDGUARD_NER_LEGAL_ENGINE=ru-legal-ner.
PDGUARD_NER_LEGAL_ENGINE: "off"
volumes: volumes:
- ./config:/deployments/config:ro - ./config:/deployments/config:ro
- ./models:/deployments/models:ro - ./models:/deployments/models:ro
+229
View File
@@ -0,0 +1,229 @@
# Инструкция для жюри по проверке
Модуль принимает текст, находит в нём персональные данные, подменяет их и по тому же идентификатору возвращает исходный текст. Ниже — как это воспроизвести и где смотреть журнал и метрики.
Сервис слушает `http://localhost:8080`.
## Запуск
Нужны Java 21 и Maven 3.8+ (или обёртка `./mvnc`). Для дашборда — Docker Compose.
Локально:
```bash
./mvnc package -DskipTests
java -jar target/pd-guard-spring-1.0.0.jar
```
Контейнер, Prometheus и Grafana (`compose.yaml` ссылается на уже собранный образ `pd-guard-spring:jvm`, сам его не строит):
```bash
docker build -f src/main/docker/Dockerfile -t pd-guard-spring:jvm .
docker compose up -d
```
Если локальной Java нет — тот же образ собирается полностью внутри Docker, `mvn package` идёт в отдельной стадии сборки (дольше первого запуска, зато не требует ничего на хосте кроме Docker):
```bash
docker build -f src/main/docker/Dockerfile.build -t pd-guard-spring:jvm .
docker compose up -d
```
Готовность: `GET http://localhost:8080/health` отвечает `OK`.
Действующие системы читаются из `config/systems.json`. Это не встроенный файл в jar: при запуске из каталога проекта используется именно он.
## Как устроен запрос
`POST /process`
Тело:
```json
{"payload": "текст", "payload_id": "устойчивый-идентификатор"}
```
Заголовки:
| Заголовок | Смысл |
|-----------|--------|
| `X-System-Id` | имя системы из `config/systems.json`. Нет заголовка или имя неизвестно — применяется политика `default` |
| `X-System-Key` | нужен только если у системы в конфиге задано поле `key` |
Направление выбирается по `payload_id`, а не по отдельному флагу:
1. Идентификатор ещё не встречался — текст маскируется, пара «исходник ↔ маска» запоминается.
2. Пришёл ранее выданный текст маски и у системы включено `demask` — возвращается исходный текст.
3. Пришёл тот же исходный текст — возвращается та же маска, что и в первый раз.
Ответ: `{"result": "..."}`.
Коды: `200` успех, `400` нет `payload` или `payload_id`, `403` система выключена или неверный ключ, `429` перегрузка (заголовок `Retry-After: 1`). При внутреннем сбое сервис отвечает `200` и текстом `[обработка недоступна]`, чтобы исходные персональные данные не ушли наружу.
## Маскирование в браузере
Откройте `http://localhost:8080/`.
Четыре режима на странице — это четыре системы из конфига:
| Кнопка | Заголовок | Что увидите |
|--------|-----------|-------------|
| MASK | `default` | звёздочки с открытыми краями номера, ФИО инициалами |
| STRICT | `strict` | сплошные звёздочки, длина сохраняется |
| TOKEN | `crm` | токены вида `[FIO_1]`, `[PASSPORT_1]` |
| SYNTHETIC | `analytics` | правдоподобная подмена (вымышленное ФИО, номер, адрес) |
Вставьте текст, например:
```text
Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67
```
Нажмите «Обработать». Результат появится в блоке под кнопкой.
Страница каждый раз создаёт новый `payload_id`. Она показывает только маскирование. Демаскирование проверяется запросом к API с тем же идентификатором.
## Маскирование через API
Система `default` маскирует все известные типы и умеет демаскировать. Режим — звёздочки с открытыми краями номера (`MASK`).
```bash
curl -s -X POST http://localhost:8080/process \
-H "Content-Type: application/json" \
-H "X-System-Id: default" \
-d "{\"payload\":\"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67\",\"payload_id\":\"doc-1\"}"
```
Ожидаемый вид ответа: в `result` ФИО заменено на инициалы (`И. И. И.`), середина паспорта и телефона закрыта звёздочками, края видны.
Сплошные звёздочки — тот же запрос с заголовком `X-System-Id: strict`.
Токены вида `[FIO_1]`, `[PASSPORT_1]`, `[PHONE_1]` — тот же запрос с заголовком `X-System-Id: crm` (но у `crm` в политике нет `PASSPORT`, см. ниже).
Синтетика — `X-System-Id: analytics`. У этой системы обратное преобразование выключено.
Система `crm` маскирует только ФИО, телефон, email и части адреса (город, улица, дом, квартира). Паспорт в её политике не входит и в ответе останется открытым. Демаскирование у `crm` выключено.
Система `legacy-billing` выключена: тот же запрос с `X-System-Id: legacy-billing` даёт `403`.
## Демаскирование
Нужны три условия одновременно:
- тот же `payload_id`, что при маскировании (`doc-1` в примере выше);
- в `payload` — точная строка из поля `result` предыдущего ответа;
- система с `"demask": true`. Сейчас это `default` и `strict`. У `crm` и `analytics` демаскирование выключено намеренно.
```bash
curl -s -X POST http://localhost:8080/process \
-H "Content-Type: application/json" \
-H "X-System-Id: default" \
-d "{\"payload\":\"<сюда строка result из маскирования>\",\"payload_id\":\"doc-1\"}"
```
В `result` вернётся исходная фраза с ФИО, паспортом и телефоном.
Соответствие живёт в памяти процесса 30 минут и пропадает после перезапуска. Повторный запрос после перезапуска будет обработан как новое маскирование.
Хранилище разделено по системам: маска, полученная от `default`, не раскрывается запросом от `strict` с тем же `payload_id`.
## Цепочка до модели
`POST /proxy` показывает, что ушло бы во внешнюю модель и что вернулось бы потребителю. Адрес модели по умолчанию пуст, поэтому отвечает заглушка: она возвращает присланный (уже замаскированный) текст. В модель подставляются токены, даже если у системы выбран другой режим: по звёздочкам однозначное восстановление невозможно.
```bash
curl -s -X POST http://localhost:8080/proxy \
-H "Content-Type: application/json" \
-H "X-System-Id: default" \
-d "{\"prompt\":\"Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111. Кратко опишите профиль.\"}"
```
В ответе:
| Поле | Смысл |
|------|--------|
| `prompt_masked` | текст, который ушёл бы в модель |
| `llm_response_masked` | ответ модели (или заглушки) ещё с токенами |
| `response` | текст потребителю; при `demask: true` токены заменены обратно |
| `replaced` | таблица «токен → исходное значение» |
| `llm` | `заглушка` либо имя модели |
В `prompt_masked` не должно остаться открытых ФИО и номера карты.
## Где смотреть логи
Отдельного файла журнала нет. Строки пишет процесс в стандартный вывод.
Локальный запуск — окно, где выполнена команда `java -jar`.
Docker:
```bash
docker compose logs -f pd-guard
```
На каждое маскирование есть строка уровня INFO. В ней идентификатор, длина текста и счётчики по типам. Сами значения персональных данных в журнал не пишутся:
```text
payload_id=doc-1 символов=72 найдено={FIO=1, PASSPORT=1, PHONE=1}
```
Рядом по смыслу, если они случаются:
- `Системе … обращение в модуль запрещено настройками` — выключенная система;
- `Системе … отказано: неверный ключ`;
- `демаскирование не нашло соответствие` — идентификатор и отпечаток маски неизвестны, текст обработан как новый;
- `Настройки систем перечитаны из …` — после правки `config/systems.json`.
Для прокси: `proxy: система=… заменено=… модель=…`. Число замен есть, значения — нет.
## Где смотреть метрики
Сырые метрики сервиса, без Docker:
| Адрес | Что это |
|-------|---------|
| `GET /actuator/prometheus` | все метрики в формате Prometheus |
| `GET /actuator/metrics` | список имён Micrometer |
| `GET /actuator/metrics/pdguard.process` | длительность обработки |
| `GET /actuator/metrics/pdguard.pd.detected` | сколько фрагментов найдено, в разрезе типа и системы |
| `GET /actuator/health` | проба Spring Boot |
Имеет смысл смотреть после одного-двух вызовов `/process`:
```bash
curl -s http://localhost:8080/actuator/prometheus | findstr pdguard
```
На Linux и macOS вместо `findstr` — `grep pdguard`.
Основные ряды:
| Метрика | О чём |
|---------|--------|
| `pdguard_process_seconds` | длительность, метки `direction` (`mask` / `unmask`) и `system` |
| `pdguard_pd_detected_total` | найденные фрагменты, метки `type` и `system` |
| `pdguard_requests_rejected_total` | отказы: `overload`, `malformed`, `system_disabled`, `internal_error` |
| `pdguard_concurrency_limit` | текущий потолок одновременных запросов |
| `pdguard_concurrency_in_flight` | сколько запросов в работе |
| `pdguard_store_chars` | объём хранилища соответствий в символах |
| `pdguard_tokens_processed_total` | оценка числа обработанных токенов |
| `pdguard_demask_unresolved_total` | демаскирование без найденного соответствия |
| `pdguard_ner_*` | вторая ступень (модель). В поставке без скачанных моделей ступень выключена, ряды почти не растут |
Дашборд появляется вместе с `docker compose`:
- Grafana: `http://localhost:3000` (анонимный просмотр включён). Дашборд «Модуль безопасности персональных данных»: `http://localhost:3000/d/pd-guard`. Обновление раз в 5 секунд. Блоки: обращения и типы ПДн, задержка и доля ответов быстрее 0,5 с, отказы, вторая ступень, ресурсы узла.
- Prometheus: `http://localhost:9090`. Цель сбора — `pd-guard:8080`, интервал 5 секунд.
## Что поменять без перезапуска
Отредактируйте `config/systems.json` и вызовите:
```bash
curl -s -X POST http://localhost:8080/admin/reload
```
Файл также перечитывается сам, если изменилось время модификации (проверка не чаще раза в секунду). Текущие политики: `GET /admin/config`. Список типов, которые умеет распознавать сборка: `GET /admin/types`.
Проверка набора типов: в политике `crm` нет `PASSPORT`. Текст с паспортом и телефоном под `X-System-Id: crm` скроет телефон и оставит номер паспорта. Под `default` скроет оба.
+127
View File
@@ -0,0 +1,127 @@
# Схема архитектуры и настройки
Модуль стоит между системой-потребителем и внешней языковой моделью. Потребитель отдаёт текст один раз на вход и один раз на выход. В модель уходит уже подменённый текст, потребителю возвращается текст с восстановленными значениями.
```mermaid
flowchart LR
consumer["Система-потребитель"]
api["POST /process и POST /proxy"]
policy["Политика системы\nconfig/systems.json"]
rules["Правила и словари"]
ner["Вторая ступень\nNER, если включена"]
filters["Отсев ложных\nсрабатываний"]
masker["Маскирование\nMASK / STRICT / TOKEN / SYNTHETIC"]
store["Хранилище соответствий\nпамять, AES-GCM"]
llm["Внешняя LLM\nили заглушка"]
consumer --> api --> policy --> rules --> ner --> filters --> masker --> store
masker -->|"только /proxy"| llm
llm -->|"демаскирование по токенам"| consumer
store -->|"демаскирование по payload_id"| consumer
```
`POST /process` — контракт для системы-потребителя: маскирование и демаскирование. `POST /proxy` — демонстрация всей цепочки до модели и обратно; проверяющий контур может его не использовать.
Настройки систем, перечень типов и вид маски задаются файлом `config/systems.json`. Код для смены политики пересобирать не нужно. Файл перечитывается при изменении и по `POST /admin/reload`.
## Что происходит с одним текстом
1. По заголовку `X-System-Id` выбирается политика. Неизвестное имя получает политику `default`. Выключенная система и неверный `X-System-Key` получают `403` до обработки текста.
2. Если `payload_id` уже есть в хранилище этой системы, направление определяется сравнением текста с сохранённой маской и с исходником.
3. Иначе текст проходит детекцию. Сначала правила: контрольные суммы (карта, ИНН, СНИЛС, ОГРН), однозначные форматы (email, телефон), затем шаблоны с якорным словом (документ, адрес, дата, ФИО). Цифровые значения с нестандартными разделителями дополнительно собираются в кластер и проверяются той же контрольной суммой.
4. Если включена вторая ступень, модель смотрит только непокрытые кандидаты: цепочки слов с заглавной буквы и, для юридической модели, цифровые кластеры. В поставке по умолчанию ступень выключена (`pdguard.ner.*-engine: off`). В `compose.yaml` она включается, если в каталог `models/` положены веса.
5. Ложные срабатывания снимаются до маски: имя в составе организации и на вывеске, общеизвестное имя без других персональных данных рядом, адрес отделения, типы-спутники без самостоятельного персонального данного.
6. Оставшиеся фрагменты заменяются по `maskMode` системы. Пара «исходный текст ↔ маска» пишется в хранилище этой системы. Исходный текст в хранилище шифруется AES-GCM, ключ — `pdguard.store.encryption-key`.
Демаскирование не запускает детектор заново: по `payload_id` (и запасным отпечатком маски) достаётся сохранённый исходник. Поэтому звёздочки тоже обратимы, пока жива запись.
## Системы-потребители
Файл `config/systems.json`, путь переопределяется свойством `pdguard.systems-file`.
| Система | Включена | Демаскирование | Режим | Типы |
|---------|----------|----------------|-------|------|
| `default` | да | да | `MASK` | все (`*`) |
| `crm` | да | нет | `TOKEN` | ФИО, телефон, email, город, улица, дом, квартира |
| `analytics` | да | нет | `SYNTHETIC` | все (`*`) |
| `strict` | да | да | `STRICT` | все (`*`) |
| `legacy-billing` | нет | нет | `MASK` | все (`*`), запросы отклоняются |
Запрос без заголовка идёт в `default`. Хранилище у каждой системы своё: одна система не читает соответствия другой.
Поля политики:
| Поле | Назначение |
|------|------------|
| `enabled` | `false` — модуль отвечает `403` и текст не обрабатывает |
| `demask` | разрешено ли обратное преобразование |
| `maskMode` | чем заменяется найденное значение |
| `types` | какие типы маскировать; `"*"` — все, которые знает сборка |
| `requireCompanion` | типы, которые маскируются только рядом с самостоятельным персональным данным |
| `key` | общий секрет; если задан, заголовок `X-System-Key` обязан совпасть. Только ASCII |
Пример добавления системы — дописать объект и перечитать файл:
```json
"dms": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["FIO", "PHONE", "MEDICAL_POLICY", "BIRTH_DATE"],
"key": "dms-secret"
}
```
После `POST /admin/reload` запросы с `X-System-Id: dms` и `X-System-Key: dms-secret` маскируют только перечисленные типы, звёздочками, и умеют демаскировать.
Новый тип персональных данных добавляется правилом в реестре (`DocumentRules`, `FinanceRules`, `DateRules`, `FioRules`, `ContactRules`, `AddressRules`) и именем в `PdTypes`. Политики, где указано `"*"`, подхватывают его без правки конфига. Политика с явным списком — только если имя типа туда добавить.
## Типы персональных данных
Список отдаёт `GET /admin/types`. Группы:
| Группа | Типы |
|--------|------|
| Человек | `FIO`, `CARDHOLDER` |
| Документы | `PASSPORT`, `PASSPORT_ISSUER`, `PASSPORT_DATE`, `DEPT_CODE`, `FOREIGN_PASSPORT`, `DRIVER_LICENSE`, `MILITARY_ID`, `BIRTH_CERTIFICATE`, `MEDICAL_POLICY` |
| Контакты | `PHONE`, `EMAIL` |
| Адрес | `ADDRESS_COUNTRY`, `ADDRESS_POSTCODE`, `ADDRESS_CITY`, `ADDRESS_STREET`, `ADDRESS_HOUSE`, `ADDRESS_FLAT`. `ADDRESS_REGION` и `ADDRESS_DISTRICT` размечает только модель второй ступени |
| Даты и гражданство | `BIRTH_DATE`, `BIRTH_PLACE`, `DATE`, `CITIZENSHIP` |
| Платёжные данные | `CARD`, `CARD_EXPIRY`, `CVV`, `PIN` |
| Реквизиты | `INN`, `SNILS`, `ACCOUNT_NUMBER`, `BIK`, `OGRN`, `OGRNIP`, `KPP` |
| Прочее | `INCOME`, `BIOMETRIC` |
Правила устойчивы к регистру, к дате числом и словами, к вставке слов между серией и номером документа, к уменьшительным формам имён. Карта, ИНН, СНИЛС, ОГРН и ОГРНИП без верной контрольной суммы не маскируются.
## Режимы маскирования
Режим задаётся полем `maskMode` и действует на все типы, которые политика разрешила.
| Режим | Пример для `Иванов Иван Иванович` и паспорта `4509 123456` | Когда уместен |
|-------|--------------------------------------------------------------|---------------|
| `MASK` | `И. И. И.`, паспорт `45** ****56` | человеку остаётся узнаваемый контур, середина закрыта. Края коротких серий (загранпаспорт, военный билет, свидетельство о рождении) не открываются. CVV и PIN закрываются целиком |
| `STRICT` | сплошные звёздочки на всю длину, включая ФИО | ничего из исходных знаков не остаётся |
| `TOKEN` | `[FIO_1]`, `[PASSPORT_1]` | однозначная обратимая подстановка, удобная и для демаскирования ответа модели |
| `SYNTHETIC` | вымышленные ФИО и номер той же формы | модель видит правдоподобный текст. Для типов без своей подстановки остаётся токен |
Одинаковое исходное значение внутри одного текста получает одну и ту же замену.
В `POST /proxy` режим системы для отправки в модель заменяется на `TOKEN`: одинаковые звёздочки нельзя однозначно вернуть на место в ответе модели.
## Контекстное правило
Типы из `requireCompanion` сами по себе персональными данными не считаются. Они маскируются, только если в том же тексте есть находка самостоятельного типа.
В политиках `default` и `strict` спутники такие: `CVV`, `PIN`, `DATE`, `BIRTH_PLACE`, `ADDRESS_COUNTRY`, `ACCOUNT_NUMBER`, `BIK`, `OGRN`, `OGRNIP`, `KPP`, `INCOME`, `BIOMETRIC`.
Два спутника друг друга не подтверждают. Дата рядом с ОГРН без имени и документа человека не маскируется. PIN рядом с номером карты — маскируется. Список спутников у каждой системы свой; пустой список означает, что маскируется всё найденное из `types`.
Отдельно от этого списка снимаются ложные ФИО и адреса: «Александр Пушкин» без других персональных данных, «Институт Склифосовского», адрес отделения банка. Если рядом с общеизвестным именем есть другой тип персональных данных, имя остаётся замаскированным: однофамилец защиту не теряет.
## Состояние и наблюдаемость
Хранилище соответствий по умолчанию — память процесса (`pdguard.store.backend=memory`): потолок `pdguard.store.max-chars` (512 МБ символов), срок жизни записи `pdguard.store.ttl-minutes` (30 минут). При `backend=redis` та же запись дублируется в Redis, чтобы демаскирование попало на другой узел. В текущем `compose.yaml` поднят один узел, Redis не используется.
Одновременные запросы ограничивает адаптивный предел: он растёт, пока задержка укладывается в `pdguard.target-latency-ms` (200 мс), и сжимается, когда перестаёт. Лишние запросы получают `429`, очередь не копится.
Метрики отдаёт Micrometer на `/actuator/prometheus`. Готовые Prometheus и Grafana лежат в `monitoring/` и поднимаются тем же `docker compose`.
+69
View File
@@ -0,0 +1,69 @@
# Производительность и дополнительные возможности
Целевой уровень из задания — задержка не выше 0,5 с при 1000 запросах в секунду. Замер ниже снят на правилах, без нейросетевой ступени: именно так работает поставка, пока каталог `models/` пуст.
## Нагрузочный тест
Инструмент — [k6](https://k6.io/), сценарий `k6-load-test.js`.
Условия:
- один узел в Docker, порт 8080;
- система `crm` (маскирование, без демаскирования; типы — ФИО, телефон, email и части адреса);
- пять коротких текстов, часть из них с персональными данными, один — без них;
- профиль виртуальных пользователей: 30 с до 500, затем по 30 с на 1000, 1500 и 2000, затем спад до нуля;
- между запросами одного пользователя пауза 0,1 с;
- пороги сценария: доля ошибок ниже 1 %, p95 длительности HTTP ниже 200 мс.
Запуск при уже поднятом сервисе:
```bash
k6 run k6-load-test.js
```
Другой адрес: `k6 run -e BASE_URL=http://localhost:8080 k6-load-test.js`.
Зафиксированный прогон этого сценария на одном узле:
| Метрика | Значение |
|---------|----------|
| Пропускная способность | ~9 500 запросов/с |
| Задержка p50 | 1,03 мс |
| Задержка p95 | 13,87 мс |
| Ошибки | 0,00 % |
0,5 с при 1000 запросах/с перекрыто с запасом: p95 на этом профиле около 14 мс, поток около 9 500 запросов/с.
Отдельный замер внутри процесса (`PerformanceBenchmarkTest`) гоняет тот же набор текстов по правилам после прогрева JIT. В тесте закреплены пороги: p99 маскирования одного обращения ниже 5 мс и пропускная способность выше 1000 обращений/с на доступных ядрах. Это порог регрессии, а не замена цифр k6.
Потолок одновременных запросов не фиксирован. `AdaptiveConcurrencyLimiter` держит его между `pdguard.min-concurrent` (8) и `pdguard.max-concurrent` (2000) и подстраивает по фактической задержке с целью 200 мс. На старте `PipelineWarmup` прогоняет горячий путь несколько тысяч раз на отдельном коротком хранилище, чтобы первые боевые запросы не попали на непрогретый код: без этого p95 в первые десятки секунд примерно на порядок хуже.
Вторая ступень (NER) в замер не входила. Модель зовётся только на непокрытые кандидаты и стоит десятки миллисекунд на вызов; на текстах, которые уже закрыты правилами, она не вызывается. Доля таких обращений видна в метрике `pdguard_ner_requests_total`.
На дашборде Grafana (`http://localhost:3000/d/pd-guard`) во время прогона смотрят обращения в секунду, p95 маскирования, долю ответов быстрее 0,5 с, отказы по перегрузке и текущий предел конкурентности.
## Дополнительные возможности
Сверх маскирования заданного перечня типов реализовано следующее.
**Четыре режима подмены.** Звёздочки с сохранением краёв и разделителей (`MASK`), сплошное закрытие (`STRICT`), обратимые токены (`TOKEN`), правдоподобные вымышленные значения (`SYNTHETIC`). Синтетический номер карты проходит проверку Луна. Один и тот же фрагмент в тексте всегда получает одну и ту же замену.
**Политика на систему без пересборки.** Включение, демаскирование, режим, белый список типов, типы-спутники и общий секрет задаются в `config/systems.json` и применяются на лету. Хранилище соответствий разделено по системам.
**Контекстное маскирование.** PIN, CVV, дата без якоря, место рождения, страна, реквизиты организации, доход и упоминание биометрии маскируются только рядом с самостоятельным персональным данным. Список спутников настраивается у каждой системы. Два спутника друг друга не подтверждают.
**Защита от ложных срабатываний.** Общеизвестные имена и правители снимаются, если рядом нет других персональных данных; внешний список `config/well-known.txt` дополняет встроенный и перечитывается сам. Имя в названии организации и на вывеске не маскируется. Адрес отделения банка и улица в рассказе о городе не считаются адресом клиента. Словари имён, стран и населённых пунктов учитывают склонения и уменьшительные формы.
**Контрольные суммы и свободная запись чисел.** Карта, ИНН, СНИЛС, ОГРН и ОГРНИП подтверждаются контрольной суммой. Те же номера находятся, если между цифрами стоят пробелы, точки, дефисы или скобки.
**Вторая ступень распознавания.** Три необязательные ONNX-модели: имена (WikiNEuRal), составляющие адреса (ruBERT), юридические реквизиты. Модели в репозиторий не входят, скачиваются `tools/fetch-ner-model.sh`. Сбой ступени её отключает и оставляет правила. Регион и район адреса размечаются только этой ступенью.
**Безопасный отказ.** При внутренней ошибке наружу уходит фиксированная строка `[обработка недоступна]`, а не исходный текст. В журнал пишутся идентификатор, длина и счётчики типов; значения персональных данных не пишутся.
**Хранилище.** Исходный текст шифруется AES-GCM. Запись живёт ограниченное время и вытесняется по объёму. Демаскирование возможно по `payload_id` и по отпечатку маски. Повтор того же исходного текста с тем же идентификатором возвращает прежнюю маску. Общий слой Redis включается настройкой `pdguard.store.backend=redis` и при серии сбоев на время перестаёт опрашиваться, не роняя запрос.
**Демонстрация модели.** `POST /proxy` показывает замаскированный запрос, ответ модели и восстановленный текст. Без адреса модели работает заглушка; ошибка модели тоже сводится к заглушке.
**Наблюдаемость.** Пробы `/health` и `/actuator/health`, метрики Micrometer и Prometheus, готовые Prometheus и Grafana. Гистограмма `pdguard.process` размечена корзинами до 10 с, на дашборде видна доля ответов быстрее 0,5 с. Веб-страница `http://localhost:8080/` гоняет три режима маскирования без отдельного клиента.
**Прогрев и предел нагрузки.** Прогрев JIT на старте и адаптивный лимитер конкурентности с ответом `429`, чтобы задержка не упиралась в таймаут вызывающей стороны.
+41
View File
@@ -0,0 +1,41 @@
# Ограничения решения и план развития
Ограничения ниже относятся к поставке, с которой работает жюри: один процесс, правила без моделей, файл `config/systems.json`, ключ шифрования из `application.yml`.
## Ограничения
**Состояние демаскирования не переживает процесс.** Соответствия лежат в памяти узла, не дольше 30 минут и не больше заданного объёма символов. Перезапуск, вытеснение и истечение срока делают обратное преобразование невозможным: запрос обрабатывается как новое маскирование, счётчик `pdguard.demask.unresolved` увеличивается. Общий слой Redis в коде есть и включается `pdguard.store.backend=redis`, но в текущем `compose.yaml` его нет: поднят один узел.
**Демаскирование разрешено не всем системам и не из браузера.** В конфиге оно включено у `default` и `strict`. У `crm` и `analytics` выключено. Страница `http://localhost:8080/` каждый раз создаёт новый `payload_id`, поэтому с неё можно проверить только маскирование. Чужая система не читает чужое хранилище.
**Неизвестное имя системы получает политику `default`.** Отсекаются только явно выключенная система и неверный ключ. Ключ проверяется лишь там, где поле `key` заполнено; у систем в поставленном файле ключей нет.
**Административные методы открыты.** `GET /admin/config`, `GET /admin/types` и `POST /admin/reload` не требуют аутентификации. Для стенда хакатона это удобно, для контура с несколькими потребителями — нет.
**Ключ шифрования хранилища лежит в конфигурации приложения.** AES-GCM включён, но ключ записан в `application.yml`. Это демонстрационный ключ. В рабочем контуре его нужно задавать снаружи и не хранить в репозитории.
**Качество детекции держится на правилах и словарях.** Иностранные имена без русских словообразовательных признаков, нестандартные топонимы, составляющие адреса «регион» и «район», а также юридические и банковские реквизиты в нетиповых формулировках без второй и третьей ступеней не размечаются. Третья ступень (LLAIM Legal NER) отдельно закрывает именно эти реквизиты — на датасете реальных логов (`NodeLogsDatasetTest`) и датасете размещений (`PlacementVariantsTest`) число подтверждённых утечек заметно ниже именно за счёт неё, регресс без неё был бы больше. Модели в репозиторий не входят: без `tools/fetch-ner-model.sh` и включённых `pdguard.ner.*-engine` сервис остаётся на правилах. Сбой любой ступени отключает её до перезапуска, остальные продолжают работать.
**Часть типов маскируется только в контексте.** PIN, CVV, «голая» дата, место рождения, страна, банковские реквизиты организации, доход и слово о биометрии сами по себе не закрываются. Биометрия в тексте — это упоминание, а не шаблон из базы. Режим `MASK` по задумке оставляет края длинных номеров и инициалы ФИО; полностью закрывает режим `STRICT`.
**Синтетика покрывает не все типы.** Где своей подстановки нет, `SYNTHETIC` ставит токен вида `[TYPE_1]`.
**Журнал не является аудитом значений.** Пишутся идентификатор, длина и счётчики типов. Восстановить по журналу, что именно скрыто, нельзя — и отдельного аудита решений оператора тоже нет.
**Ответ модели — внешняя зависимость.** Пока `pdguard.llm.url` пуст, `POST /proxy` отвечает заглушкой. Ошибка живой модели тоже подменяется заглушкой, чтобы сбой модели не раскрывал исходный текст. Контракт `/process` от модели не зависит.
**Нагрузка измерена на коротких текстах и на правилах.** Прогон k6 использует систему `crm` и тексты в одну-две строки. Длинный документ и включённая модель этот профиль не описывают: модель вызывается точечно, но один вызов BERT — это уже десятки миллисекунд.
**Внутренняя ошибка выглядит как успешный ответ.** HTTP-код остаётся 200, тело — `[обработка недоступна]`. Так исходный текст не утекает и автоматический прогон не останавливается на серии ошибок. Отличить сбой от маски можно по этой фиксированной строке и по метрике `pdguard_requests_rejected_total{reason="internal_error"}`.
## План развития после хакатона
**Контур и секреты.** Вынести ключ шифрования и секреты систем в хранилище секретов. Закрыть `/admin` аутентификацией. Неизвестную систему отклонять, а не сажать на `default`. Включить Redis в поставку compose как общий слой соответствий и прогнать демаскирование через балансировщик.
**Детекция.** Поставлять модели второй ступени отдельным артефактом со проверкой целостности и измеренным p95 на включённой ступени. Добавить типы документов, которых не хватает по отраслевому перечню, отдельными правилами в реестре — ядро и политики с `"*"` подхватят их без миграции. Расширить внешние словари (известные люди, денилист улиц) как файлы, которые перечитываются так же, как `config/systems.json`.
**Качество и нагрузка.** Закрепить в CI прогон размеченных наборов (precision/recall по типам) и сценарий k6 с порогом p95 ≤ 0,5 с при 1000 запросах/с на полном перечне типов, а не только на политике `crm`. Замерить длинные документы отдельно от коротких обращений.
**Наблюдаемость для эксплуатации.** Журнал решений без значений персональных данных в централизованный сбор. Алерты на рост `demask_unresolved`, на отказы по перегрузке и на выключение второй ступени. Срок хранения соответствий и потолок объёма сделать разными для систем.
**Интерфейс политики.** Страница проверки сейчас только маскирует. Следующий шаг — показать демаскирование тем же `payload_id` и дать править перечень типов и режим без ручного JSON, с тем же файлом `systems.json` под капотом.
+308
View File
@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PD Guard</title>
<style>
:root {
--bg: #f3efe6;
--ink: #1c1915;
--muted: #6d655c;
--line: #ddd4c6;
--card: #fffdf8;
--accent: #0e6b52;
--accent-ink: #083d30;
--danger: #8d2e2e;
--danger-bg: #f8ecec;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
radial-gradient(1200px 500px at 10% -10%, #e7f3ee 0%, transparent 55%),
var(--bg);
font: 16px/1.45 "Segoe UI", system-ui, sans-serif;
}
main {
width: min(760px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0 64px;
}
header h1 {
margin: 0;
font-size: 32px;
letter-spacing: -0.03em;
}
header p {
margin: 8px 0 0;
color: var(--muted);
}
form {
margin-top: 28px;
display: grid;
gap: 18px;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
legend {
padding: 0;
margin-bottom: 8px;
font-size: 13px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.modes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.mode {
display: block;
padding: 14px 14px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card);
cursor: pointer;
}
.mode:has(input:checked) {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.mode:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mode input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.mode strong {
display: block;
font-size: 14px;
letter-spacing: 0.04em;
}
.mode span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
label.field {
display: grid;
gap: 8px;
font-size: 13px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
textarea {
width: 100%;
min-height: 160px;
resize: vertical;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card);
color: var(--ink);
font: 15px/1.5 "Segoe UI", system-ui, sans-serif;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: transparent;
}
button {
justify-self: start;
padding: 12px 18px;
border: 0;
border-radius: 10px;
background: var(--accent);
color: #f7fffb;
font: 650 15px/1 "Segoe UI", system-ui, sans-serif;
cursor: pointer;
}
button:hover { background: var(--accent-ink); }
button:disabled {
opacity: 0.6;
cursor: progress;
}
#result {
min-height: 120px;
margin: 0;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--card);
white-space: pre-wrap;
word-break: break-word;
font: 15px/1.5 ui-monospace, "Cascadia Mono", Consolas, monospace;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
#result.error {
color: var(--danger);
background: var(--danger-bg);
border-color: #e4c8c8;
}
#result:empty::before {
content: "Результат появится здесь";
color: var(--muted);
font-family: "Segoe UI", system-ui, sans-serif;
}
@media (max-width: 640px) {
.modes { grid-template-columns: 1fr; }
main { padding-top: 28px; }
}
</style>
</head>
<body>
<main>
<header>
<h1>PD Guard</h1>
<p>Маскирование персональных данных перед обработкой.</p>
</header>
<form id="form">
<fieldset>
<legend>Режим</legend>
<div class="modes">
<label class="mode">
<input type="radio" name="mode" value="default" checked>
<strong>MASK</strong>
<span>Звёздочки, края номеров видны</span>
</label>
<label class="mode">
<input type="radio" name="mode" value="strict">
<strong>STRICT</strong>
<span>Сплошные звёздочки</span>
</label>
<label class="mode">
<input type="radio" name="mode" value="crm">
<strong>TOKEN</strong>
<span>Токены вида [FIO_1]</span>
</label>
<label class="mode">
<input type="radio" name="mode" value="analytics">
<strong>SYNTHETIC</strong>
<span>Правдоподобная подмена</span>
</label>
</div>
</fieldset>
<label class="field">
Текст
<textarea id="payload" name="payload" required>Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67</textarea>
</label>
<button type="submit" id="run">Обработать</button>
<label class="field">
Результат
<pre id="result" aria-live="polite"></pre>
</label>
</form>
</main>
<script>
const form = document.getElementById("form");
const payload = document.getElementById("payload");
const result = document.getElementById("result");
const run = document.getElementById("run");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const text = payload.value;
if (!text.trim()) {
show("Введите текст", true);
return;
}
const systemId = new FormData(form).get("mode");
run.disabled = true;
show("");
try {
const response = await fetch("/process", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-System-Id": systemId
},
body: JSON.stringify({
payload: text,
payload_id: crypto.randomUUID()
})
});
if (response.status === 429) {
show("Сервис перегружен. Повторите через секунду.", true);
return;
}
const raw = await response.text();
let message = raw;
try {
const data = JSON.parse(raw);
if (data && typeof data.result === "string") message = data.result;
} catch (ignored) {
/* ответ не JSON — показываем как есть */
}
if (!response.ok) {
show(message || "Запрос отклонён (" + response.status + ")", true);
return;
}
show(message);
} catch (error) {
show("Не удалось связаться с сервисом.", true);
} finally {
run.disabled = false;
}
});
function show(text, isError) {
result.textContent = text;
result.classList.toggle("error", Boolean(isError));
}
</script>
</body>
</html>
+6 -5
View File
@@ -23,11 +23,12 @@ torch.onnx.export(
str(OUT / "model.onnx"), str(OUT / "model.onnx"),
input_names=["input_ids", "attention_mask", "token_type_ids"], input_names=["input_ids", "attention_mask", "token_type_ids"],
output_names=["logits"], output_names=["logits"],
dynamic_shapes=[ dynamic_axes={
{0: "batch", 1: "seq"}, "input_ids": {0: "batch", 1: "seq"},
{0: "batch", 1: "seq"}, "attention_mask": {0: "batch", 1: "seq"},
{0: "batch", 1: "seq"}, "token_type_ids": {0: "batch", 1: "seq"},
], "logits": {0: "batch", 1: "seq"},
},
opset_version=14, opset_version=14,
) )
print("ONNX exported to", OUT / "model.onnx") print("ONNX exported to", OUT / "model.onnx")