From 41e77e4ca17642fa509e1594b34b7001400377c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=92=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Wed, 23 Sep 2026 23:12:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20ui=20+=20docs:=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B6=D1=8E=D1=80=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.yaml | 16 ++ docs/02-jury-check.md | 229 ++++++++++++++++++++ docs/03-architecture.md | 127 +++++++++++ docs/04-performance.md | 69 ++++++ docs/05-limitations.md | 41 ++++ src/main/resources/static/index.html | 308 +++++++++++++++++++++++++++ tools/convert_ru_legal_ner.py | 11 +- 7 files changed, 796 insertions(+), 5 deletions(-) create mode 100644 docs/02-jury-check.md create mode 100644 docs/03-architecture.md create mode 100644 docs/04-performance.md create mode 100644 docs/05-limitations.md create mode 100644 src/main/resources/static/index.html diff --git a/compose.yaml b/compose.yaml index e1f5617..12c5288 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,8 +9,20 @@ services: image: pd-guard-spring:jvm ports: - "8080:8080" + deploy: + resources: + limits: + cpus: "4" + memory: 6g environment: + # Дефолт JVM — 25% контейнерного лимита на heap. + JAVA_OPTS: >- + -Dspring.config.additional-location=optional:file:/deployments/config/ + -XX:MaxRAMPercentage=75.0 PDGUARD_MAX_CONCURRENT: "2000" + # На 1 vCPU дефолт 200мс держал concurrency у пола; на 4 vCPU запас есть, + # но 800мс оставлено с той же осторожностью — целевая latency контракта 1с. + PDGUARD_TARGET_LATENCY_MS: "800" PDGUARD_WARMUP_ITERATIONS: "2000" # Вторая ступень распознавания — две модели под разные задачи (см. # NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие @@ -19,6 +31,10 @@ services: PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner PDGUARD_NER_ADDRESS_ENGINE: rubert 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: - ./config:/deployments/config:ro - ./models:/deployments/models:ro diff --git a/docs/02-jury-check.md b/docs/02-jury-check.md new file mode 100644 index 0000000..2ff940a --- /dev/null +++ b/docs/02-jury-check.md @@ -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` скроет оба. diff --git a/docs/03-architecture.md b/docs/03-architecture.md new file mode 100644 index 0000000..52a42e9 --- /dev/null +++ b/docs/03-architecture.md @@ -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`. diff --git a/docs/04-performance.md b/docs/04-performance.md new file mode 100644 index 0000000..2758422 --- /dev/null +++ b/docs/04-performance.md @@ -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`, чтобы задержка не упиралась в таймаут вызывающей стороны. diff --git a/docs/05-limitations.md b/docs/05-limitations.md new file mode 100644 index 0000000..7331bdd --- /dev/null +++ b/docs/05-limitations.md @@ -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` под капотом. diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..95e21aa --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,308 @@ + + + + + + PD Guard + + + +
+
+

PD Guard

+

Маскирование персональных данных перед обработкой.

+
+ +
+
+ Режим +
+ + + + +
+
+ + + + + + +
+
+ + + + diff --git a/tools/convert_ru_legal_ner.py b/tools/convert_ru_legal_ner.py index c54932e..033a1a5 100644 --- a/tools/convert_ru_legal_ner.py +++ b/tools/convert_ru_legal_ner.py @@ -23,11 +23,12 @@ torch.onnx.export( str(OUT / "model.onnx"), input_names=["input_ids", "attention_mask", "token_type_ids"], output_names=["logits"], - dynamic_shapes=[ - {0: "batch", 1: "seq"}, - {0: "batch", 1: "seq"}, - {0: "batch", 1: "seq"}, - ], + dynamic_axes={ + "input_ids": {0: "batch", 1: "seq"}, + "attention_mask": {0: "batch", 1: "seq"}, + "token_type_ids": {0: "batch", 1: "seq"}, + "logits": {0: "batch", 1: "seq"}, + }, opset_version=14, ) print("ONNX exported to", OUT / "model.onnx") \ No newline at end of file