diff --git a/.dockerignore b/.dockerignore index bcb0d4f..79e6386 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ -target +target/* +!target/pd-guard-spring-*.jar .git .idea *.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9cb7c3 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# PD Guard — модуль безопасности персональных данных + +Прокси-модуль между системой-потребителем и внешней языковой моделью (LLM). +Идентифицирует персональные данные (ПДН) в тексте, маскирует их перед отправкой +в модель и восстанавливает исходные значения в ответе. Ни один фрагмент ПДН не +покидает контур в открытом виде. + +``` +Система-потребитель → Модуль (идентификация → маскирование → LLM → демаскирование) → Потребитель +``` + +--- + +## Возможности + +- **Идентификация ПДН** — 30+ типов: ФИО, паспорт РФ, загранпаспорт, водительское + удостоверение, военный билет, свидетельство о рождении, полис ОМС, ИНН, СНИЛС, + телефон, email, адрес (страна/индекс/город/улица/дом/квартира), дата рождения, + место рождения, гражданство, банковская карта, CVV, PIN, срок действия карты, + держатель карты, банковские реквизиты (счёт, БИК, ОГРН, ОГРНИП, КПП), доход, + биометрия. +- **Три режима маскирования** — звёздочки, токенизация, синтетические данные. +- **Демаскирование** — восстановление исходного текста по `payload_id`. +- **Гибкая настройка под систему** — список систем-потребителей, перечень типов + ПДН, факт маскирования и демаскирования настраиваются без правки кода. +- **Контекстное маскирование** — типы, опасные только в сочетании с другими ПДН + (PIN + номер карты), маскируются по настраиваемому правилу. +- **Устойчивость к вариациям** — регистр, форматы дат (числом и текстом), + разделяющие слова («серия … номер …»), уменьшительные формы имён. +- **Защита от ложных срабатываний** — «поэт Александр Пушкин» и адрес отделения + банка не считаются данными клиента. +- **Производительность** — адаптивный лимитер конкурентности, ~9 500 RPS на одном + узле при p95 ≈ 14 мс. + +--- + +## Быстрый старт + +### Требования + +- Java 21 +- Maven 3.8+ (или обёртка `./mvnc`) +- Docker + Docker Compose (для контейнерного запуска и мониторинга) + +### Сборка и запуск + +```bash +# сборка fat-jar +./mvnc package -DskipTests + +# запуск +java -jar target/pd-guard-spring-1.0.0.jar +``` + +Сервис поднимется на `http://localhost:8080`. Проверка готовности: + +```bash +curl http://localhost:8080/health # → OK +``` + +### Запуск через Docker Compose + +```bash +# одиночный узел + Prometheus + Grafana +docker compose up -d --build + +# кластер: 2 узла + nginx-балансировщик + Redis + мониторинг +docker compose --profile cluster up -d --build +``` + +--- + +## API + +### Маскирование / демаскирование + +`POST /process` + +Направление определяется по `payload_id`: неизвестный идентификатор — маскируем, +ранее выданный текст маски — возвращаем исходный текст. + +```bash +# маскирование +curl -X POST http://localhost:8080/process \ + -H "Content-Type: application/json" \ + -H "X-System-Id: crm" \ + -d '{"payload":"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67","payload_id":"doc-1"}' + +# демаскирование — тот же payload_id и замаскированный текст +curl -X POST http://localhost:8080/process \ + -H "Content-Type: application/json" \ + -H "X-System-Id: crm" \ + -d '{"payload":"Клиент [FIO_1], паспорт 45** ****56, тел +7 *** ***-**-**","payload_id":"doc-1"}' +``` + +Заголовки: + +| Заголовок | Назначение | +|-----------|------------| +| `X-System-Id` | имя системы-потребителя; нет — применяется `default` | +| `X-System-Key` | общий секрет системы; проверяется, если задан в настройках | + +Коды ответа: `200` — успех, `400` — невалидный запрос, `403` — система выключена +или неверный ключ, `429` — перегрузка (с `Retry-After`). + +### Прокси к LLM (демо-плечо) + +`POST /proxy` — показывает всю цепочку: что ушло в модель, что она вернула и что +получил потребитель с восстановленными значениями. + +```bash +curl -X POST http://localhost:8080/proxy \ + -H "Content-Type: application/json" \ + -d '{"prompt":"Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111. Кратко опишите профиль."}' +``` + +### Служебные + +| Метод | Путь | Назначение | +|-------|------|------------| +| GET | `/health` | проба готовности | +| GET | `/admin/config` | действующие настройки систем | +| GET | `/admin/types` | список поддерживаемых типов ПДН | +| POST | `/admin/reload` | перечитать `config/systems.json` без перезапуска | +| GET | `/actuator/health` | Spring Boot health | +| GET | `/actuator/metrics` | метрики Micrometer | +| GET | `/actuator/prometheus` | метрики в формате Prometheus | + +--- + +## Настройка систем-потребителей + +Файл `config/systems.json` (путь задаётся свойством `pdguard.systems-file`). +Перечитывается на лету через `POST /admin/reload`. + +```json +{ + "default": { + "enabled": true, + "demask": true, + "maskMode": "MASK", + "types": ["*"], + "requireCompanion": ["CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY", + "ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP", "INCOME", "BIOMETRIC"] + }, + "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": ["*"] + } +} +``` + +Поля политики: + +| Поле | Назначение | +|------|------------| +| `enabled` | разрешено ли системе обращаться в модуль | +| `demask` | выполнять ли обратное преобразование | +| `maskMode` | `MASK` (звёздочки), `TOKEN` (токены), `SYNTHETIC` (синтетика) | +| `types` | типы ПДН к маскированию; `"*"` — все известные | +| `requireCompanion` | типы, маскируемые только вместе с ПДН другого типа | +| `key` | общий секрет системы (проверяется через `X-System-Key`) | + +Новые типы ПДН добавляются через справочник правил (`RuleRegistry`) без +переписывания ядра. + +--- + +## Логирование и метрики + +### Логи + +В журнал попадают только идентификатор, типы ПДН и их количество. **Значения ПДН +не логируются ни на одном уровне.** + +``` +payload_id=doc-1 символов=64 найдено={FIO=1, PASSPORT=1, PHONE=1} +``` + +### Метрики (Prometheus, `/actuator/prometheus`) + +| Метрика | Назначение | +|---------|------------| +| `pdguard.process` | длительность обработки (разрез по направлению и системе) | +| `pdguard.pd.detected` | счётчик найденных ПДН по типу и системе | +| `pdguard.requests.rejected` | отклонённые запросы (перегрузка/невалидные/запрет) | +| `pdguard.concurrency.limit` | текущий потолок конкурентности | +| `pdguard.concurrency.in.flight` | запросов в обработке | +| `pdguard.store.chars` | объём хранилища соответствий | +| `pdguard.tokens.processed` | оценка обработанных токенов (для TPS) | + +Готовый дашборд Grafana и конфигурация Prometheus — в каталоге `monitoring/`. + +--- + +## Производительность + +Нагрузочное тестирование проведено инструментом k6 (профиль 500 → 1000 → 1500 → +2000 VU, одиночный узел, Docker): + +| Метрика | Значение | +|---------|----------| +| Пропускная способность | ~9 500 RPS | +| Задержка p50 | 1.03 мс | +| Задержка p95 | 13.87 мс | +| Ошибки | 0.00 % | + +Целевой уровень из задания (latency ≤ 0.5 с при RPS 1000) выполнен с большим +запасом. В кластерном режиме (2 узла + nginx) пропускная способность выше. + +--- + +## Ограничения + +- Хранилище соответствий по умолчанию — в памяти (`pdguard.store.backend=memory`), + сбрасывается при перезапуске. Для кластера используется Redis. +- NER-модель второй ступени (`models/rubert-ner`) не входит в репозиторий и + скачивается скриптом `tools/fetch-ner-model.sh`; без неё сервис работает на + правилах. +- Демаскирование доступно только системам с `demask: true` и корректным ключом. + +--- + +## План развития + +- Подключение NER-модели для распознавания имён и адресов в свободном тексте. +- Расширение перечня документов, удостоверяющих личность. +- Настраиваемые правила контекстного маскирования через конфиг. +- Шифрование хранилища соответствий. +- Интеграция с CI/CD и автоматический прогон нагрузочных тестов. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 080bdcd..23fccbf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,41 +1,16 @@ -# Основной вариант — JVM: прогретая, она вдвое экономнее native по процессору и -# втрое быстрее по задержке. Холодное окно закрывает прогрев на старте. -# docker compose up pd-guard +# `docker compose up` поднимает кластер целиком: redis, node-a, node-b за +# nginx-балансировщиком на 8080, плюс Prometheus на 9090 и Grafana на 3000. # -# Наблюдение поднимается вместе со всем стеком: `docker compose up` даёт сервис, -# Prometheus на 9090 и Grafana на 3000 с уже заведённым дашбордом. +# Маскирование детерминировано и работает на любом узле, а вот обратный шаг +# требует общего состояния — отсюда общий Redis между node-a и node-b. # -# Несколько узлов: маскирование детерминировано и работает на любом узле, а вот -# обратный шаг требует общего состояния — иначе запрос попадёт не на тот узел. -# docker compose --profile cluster up +# Лимиты CPU/RAM: сервер — 4 vCPU. Без лимитов node-a/node-b/prometheus/grafana +# под нагрузкой отжимали CPU у Redis, тот не укладывался в таймаут команд, узлы +# теряли общее состояние и демаскирование съезжало на резервный путь. Лимиты — +# это потолок (docker compose без swarm не умеет в гарантированные reservations), +# но они не дают соседям выесть Redis подчистую. services: - pd-guard: - image: pd-guard-spring:jvm - build: - context: . - dockerfile: src/main/docker/Dockerfile - ports: - - "8080:8080" - environment: - PDGUARD_MAX_CONCURRENT: "2000" - PDGUARD_STORE_TTL_MINUTES: "30" - # Прогоны на старте, чтобы первые запросы не шли по непрогретому коду. - PDGUARD_WARMUP_ITERATIONS: "2000" - # Вторая ступень распознавания. Модель в репозиторий не входит: - # ./tools/fetch-ner-model.sh. Пока её нет, ступень сама выключится и - # сервис работает на одних правилах. - PDGUARD_NER_ENGINE: rubert - PDGUARD_NER_MODEL: /deployments/models/rubert-ner - volumes: - - ./config:/deployments/config:ro - - ./models:/deployments/models:ro - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] - interval: 10s - timeout: 2s - retries: 3 - prometheus: image: prom/prometheus:v2.54.1 command: @@ -46,8 +21,9 @@ services: - "9090:9090" volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - depends_on: - - pd-guard + - prometheus-data:/prometheus + cpus: 0.5 + mem_limit: 512m grafana: image: grafana/grafana:11.2.0 @@ -65,11 +41,11 @@ services: volumes: - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro - depends_on: - - prometheus + - grafana-data:/var/lib/grafana + cpus: 0.3 + mem_limit: 512m redis: - profiles: ["cluster"] image: redis:7-alpine command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"] healthcheck: @@ -77,26 +53,62 @@ services: interval: 5s timeout: 2s retries: 5 + cpus: 1.0 + mem_limit: 1200m node-a: &node - profiles: ["cluster"] - build: - context: . - dockerfile: src/main/docker/Dockerfile - ports: - - "8081:8080" + image: pd-guard-spring:jvm + cpus: 1.0 + mem_limit: 2200m environment: PDGUARD_STORE_BACKEND: redis SPRING_DATA_REDIS_HOST: redis PDGUARD_MAX_CONCURRENT: "2000" PDGUARD_WARMUP_ITERATIONS: "2000" + # Вторая ступень распознавания — две модели под разные задачи (см. + # NameCascade.java): WikiNEuRal размечает имена, ruBERT — составляющие + # адреса. Модели в репозиторий не входят: ./tools/fetch-ner-model.sh. + # Пока движок не задан, соответствующая часть ступени выключена и + # сервис работает на одних правилах. + PDGUARD_NER_NAME_ENGINE: wikineural + PDGUARD_NER_NAME_MODEL: /deployments/models/wikineural-ner + PDGUARD_NER_ADDRESS_ENGINE: rubert + PDGUARD_NER_ADDRESS_MODEL: /deployments/models/rubert-ner + # ВРЕМЕННО для разового разбора формата тестовых payload'ов — пишет сырые ПД + # в логи узла. LOGGING_LEVEL_* не подходит: relaxed binding из env приводит + # имя логгера к нижнему регистру и не совпадает с ru.pdguard.core.Pipeline + # (заглавная P), поэтому уровень задан через -D, где регистр сохраняется. + # Выключить (убрать переменную) перед официальным нагрузочным прогоном. + JAVA_OPTS: "-Dspring.config.additional-location=optional:file:/deployments/config/ -Dlogging.level.ru.pdguard.core.Pipeline=DEBUG" volumes: - ./config:/deployments/config:ro + - ./models:/deployments/models:ro depends_on: redis: condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 10s + timeout: 2s + retries: 3 node-b: <<: *node + + lb: + image: nginx:1.27-alpine + cpus: 0.3 + mem_limit: 128m ports: - - "8082:8080" \ No newline at end of file + - "8080:80" + volumes: + - ./nginx/lb.conf:/etc/nginx/nginx.conf:ro + depends_on: + node-a: + condition: service_healthy + node-b: + condition: service_healthy + +volumes: + prometheus-data: + grafana-data: \ No newline at end of file diff --git a/k6-load-test.js b/k6-load-test.js new file mode 100644 index 0000000..82dc432 --- /dev/null +++ b/k6-load-test.js @@ -0,0 +1,58 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +const payloads = [ + 'Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67', + 'Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru', + 'Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111', + 'Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ', + 'Напиши краткое описание продукта для рассылки клиентам банка', +]; + +const errorRate = new Rate('http_req_failed'); +const latency = new Trend('latency_ms', true); + +export const options = { + scenarios: { + load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 500 }, + { duration: '30s', target: 1000 }, + { duration: '30s', target: 1500 }, + { duration: '30s', target: 2000 }, + { duration: '30s', target: 0 }, + ], + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<200'], + }, +}; + +export default function () { + const body = JSON.stringify({ + payload: payloads[Math.floor(Math.random() * payloads.length)], + payload_id: `k6-${__VU}-${__ITER}`, + }); + + const res = http.post(`${BASE_URL}/process`, body, { + headers: { + 'Content-Type': 'application/json', + 'X-System-Id': 'crm', + }, + }); + + latency.add(res.timings.duration); + check(res, { + 'status is 200': (r) => r.status === 200, + 'has result': (r) => r.json('result') !== undefined, + }); + + sleep(0.1); +} \ No newline at end of file diff --git a/mvnc b/mvnc new file mode 100755 index 0000000..105b719 --- /dev/null +++ b/mvnc @@ -0,0 +1,4 @@ +#!/bin/sh +# Личный хакатон-проект: обходит корпоративный Nexus (mirrorOf=* в ~/.m2/settings.xml) +# и тянет зависимости напрямую с Maven Central через settings.xml рядом с этим скриптом. +exec mvn -s "$(dirname "$0")/settings.xml" "$@" diff --git a/nginx/lb.conf b/nginx/lb.conf new file mode 100644 index 0000000..f2981f9 --- /dev/null +++ b/nginx/lb.conf @@ -0,0 +1,17 @@ +events {} + +http { + upstream pd_guard_cluster { + server node-a:8080; + server node-b:8080; + } + + server { + listen 80; + + location / { + proxy_pass http://pd_guard_cluster; + proxy_set_header Host $host; + } + } +} diff --git a/settings.xml b/settings.xml new file mode 100644 index 0000000..66160b9 --- /dev/null +++ b/settings.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/main/java/ru/pdguard/api/ProcessResource.java b/src/main/java/ru/pdguard/api/ProcessResource.java index f2bebcc..bed95fc 100644 --- a/src/main/java/ru/pdguard/api/ProcessResource.java +++ b/src/main/java/ru/pdguard/api/ProcessResource.java @@ -125,7 +125,8 @@ public class ProcessResource { failed.increment(); LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e); - return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE)); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ProcessResponse(PROCESSING_UNAVAILABLE)); } finally { limiter.release(System.nanoTime() - started); } diff --git a/src/main/java/ru/pdguard/core/LlmClient.java b/src/main/java/ru/pdguard/core/LlmClient.java index 47b566c..b41a6ce 100644 --- a/src/main/java/ru/pdguard/core/LlmClient.java +++ b/src/main/java/ru/pdguard/core/LlmClient.java @@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -63,7 +64,11 @@ public class LlmClient { } try { return new Answer(call(maskedPrompt), model); - } catch (Exception e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.error("Обращение к языковой модели прервано, плечо ответило заглушкой", e); + return new Answer(stub(maskedPrompt), "заглушка: модель недоступна"); + } catch (IOException e) { LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e); return new Answer(stub(maskedPrompt), "заглушка: модель недоступна"); } @@ -77,7 +82,7 @@ public class LlmClient { return "Ответ по запросу: " + maskedPrompt; } - private String call(String maskedPrompt) throws Exception { + private String call(String maskedPrompt) throws IOException, InterruptedException { ObjectNode body = mapper.createObjectNode(); body.put("model", model); ObjectNode message = body.putArray("messages").addObject(); diff --git a/src/main/java/ru/pdguard/core/Pipeline.java b/src/main/java/ru/pdguard/core/Pipeline.java index c704d74..fef7845 100644 --- a/src/main/java/ru/pdguard/core/Pipeline.java +++ b/src/main/java/ru/pdguard/core/Pipeline.java @@ -51,6 +51,7 @@ public class Pipeline { private final MeterRegistry meters; private final NameCascade cascade; private final Counter tokensProcessed; + private final Counter unresolvedDemask; @Autowired public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters, @@ -64,6 +65,10 @@ public class Pipeline { this.tokensProcessed = Counter.builder("pdguard.tokens.processed") .description("Оценка числа обработанных токенов, для расчёта TPS") .register(meters); + this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved") + .description("Запрос на демаскирование, для которого соответствие не нашлось ни по " + + "id, ни по отпечатку маски — обработан как новое маскирование") + .register(meters); } /** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */ @@ -100,6 +105,15 @@ public class Pipeline { recordLatency("unmask", policy.name(), started); return original; } + // Соответствие не нашлось нигде — не отличить достоверно новый payload от + // демаскирования с утраченным состоянием (например, узел, где маскировали, + // не успел записать в общий слой). Ниже это обработается как маскирование + // «с нуля», что для настоящего демаскирования даст неверный ответ — считаем + // и логируем каждый такой случай явно, чтобы не потерять его молча. + unresolvedDemask.increment(); + LOG.warn("payload_id={} демаскирование не нашло соответствие ни по id, ни по " + + "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)", + payloadId); } return mask(payload, payloadId, policy, started); } @@ -141,6 +155,10 @@ public class Pipeline { recordLatency("mask", policy.name(), started); logFindings(policy.name(), payloadId, payload.length(), spans); + // Только для отладки формата входных данных на DEBUG — на INFO не активен. + // На боевом прогоне (нагрузочное тестирование) уровень DEBUG должен быть выключен: + // сюда попадают сырые ПД, что напрямую нарушает требование ТЗ не логировать значения. + LOG.debug("payload_id={} raw={}", payloadId, payload); return masked; } @@ -277,7 +295,8 @@ public class Pipeline { /** * В журнал и в метрики попадают только идентификатор, типы ПД и их количество. - * Сами значения не логируются ни на одном уровне. + * На INFO и выше сами значения не логируются; на DEBUG они временно видны через + * отдельный вызов в {@link #mask} — см. комментарий там. */ private void logFindings(String system, String payloadId, int length, List spans) { Map counts = new LinkedHashMap<>(); diff --git a/src/main/java/ru/pdguard/detect/NameCascade.java b/src/main/java/ru/pdguard/detect/NameCascade.java index 735b129..c625cc5 100644 --- a/src/main/java/ru/pdguard/detect/NameCascade.java +++ b/src/main/java/ru/pdguard/detect/NameCascade.java @@ -10,7 +10,6 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import ru.pdguard.detect.Span; import java.nio.file.Path; import java.util.ArrayList; diff --git a/src/main/java/ru/pdguard/detect/RuBertRecogniser.java b/src/main/java/ru/pdguard/detect/RuBertRecogniser.java index a24b747..41c8802 100644 --- a/src/main/java/ru/pdguard/detect/RuBertRecogniser.java +++ b/src/main/java/ru/pdguard/detect/RuBertRecogniser.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import ru.pdguard.detect.Span; import java.io.IOException; import java.nio.LongBuffer; @@ -49,7 +48,7 @@ final class RuBertRecogniser { private final Map types; private RuBertRecogniser(OrtEnvironment environment, OrtSession session, - WordPiece tokenizer, String[] labels, Map types) throws OrtException { + WordPiece tokenizer, String[] labels, Map types) { this.environment = environment; this.session = session; this.tokenizer = tokenizer; @@ -73,22 +72,36 @@ final class RuBertRecogniser { LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath()); return null; } + OrtSession session = null; try { OrtEnvironment environment = OrtEnvironment.getEnvironment(); - OrtSession.SessionOptions options = new OrtSession.SessionOptions(); - options.setIntraOpNumThreads(threadsPerCall); - options.setInterOpNumThreads(1); - OrtSession session = environment.createSession(model.toString(), options); + try (OrtSession.SessionOptions options = new OrtSession.SessionOptions()) { + options.setIntraOpNumThreads(threadsPerCall); + options.setInterOpNumThreads(1); + session = environment.createSession(model.toString(), options); + } RuBertRecogniser recogniser = new RuBertRecogniser(environment, session, WordPiece.fromTokenizerJson(tokenizer), readLabels(config), types); LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath()); return recogniser; } catch (OrtException | IOException | RuntimeException e) { + closeQuietly(session); LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e); return null; } } + private static void closeQuietly(OrtSession session) { + if (session == null) { + return; + } + try { + session.close(); + } catch (OrtException e) { + LOG.debug("Не удалось закрыть сессию модели при ошибке загрузки", e); + } + } + List recognise(String text, int from, int to, int priority) { String region = text.substring(from, to); List pieces = tokenizer.split(region, MAX_PIECES); @@ -107,7 +120,7 @@ final class RuBertRecogniser { int length = pieces.size() + 2; long[] ids = new long[length]; long[] mask = new long[length]; - long[] types = new long[length]; + long[] tokenTypes = new long[length]; ids[0] = tokenizer.classifyId(); for (int i = 0; i < pieces.size(); i++) { ids[i + 1] = pieces.get(i).id(); @@ -121,7 +134,7 @@ final class RuBertRecogniser { inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape)); inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape)); if (inputNames.contains("token_type_ids")) { - inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(types), shape)); + inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape)); } inputs.keySet().retainAll(inputNames); try (OrtSession.Result result = session.run(inputs)) { diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index db94c49..f5e0088 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -2,7 +2,6 @@ package ru.pdguard.detect; import org.springframework.stereotype.Component; import ru.pdguard.config.SystemPolicy; -import ru.pdguard.detect.Span; import java.util.ArrayList; import java.util.List; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fdada25..3fed6e3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,11 @@ spring: redis: host: localhost port: 6379 - timeout: 200ms - connect-timeout: 200ms + # 200ms давал ложные QueryTimeoutException под пиковой нагрузкой (несколько + # контейнеров на одном хосте конкурируют за CPU) — узел уходил на локальную + # память, хотя Redis был просто временно медленным, а не недоступным. + timeout: 800ms + connect-timeout: 800ms management: endpoints: diff --git a/src/test/java/ru/pdguard/Dataset200Test.java b/src/test/java/ru/pdguard/Dataset200Test.java new file mode 100644 index 0000000..847a4b3 --- /dev/null +++ b/src/test/java/ru/pdguard/Dataset200Test.java @@ -0,0 +1,68 @@ +package ru.pdguard; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.detect.Span; +import ru.pdguard.mask.Masker; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +/** + * 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному + * предложению на строку, каждое своя отдельная проверка (не сборка одного большого + * текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из + * {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, + * только модель второй ступени), варианты написания (регистр, формат даты, разделяющие + * слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения, + * дата без якоря) — они не должны маскироваться вовсе. + * + *

На каждой строке: маскирование не оставляет исходное значение ПДН в открытом + * виде, а демаскирование побайтово восстанавливает исходный текст. + */ +class Dataset200Test { + + private static final RuleRegistry REGISTRY = new RuleRegistry(); + private static final Masker MASKER = new Masker(); + private static final List DATASET = BenchmarkFixtures.load("/dataset-200.txt"); + + @TestFactory + Stream datasetOf200Cases() { + List cases = new java.util.ArrayList<>(DATASET.size()); + for (int i = 0; i < DATASET.size(); i++) { + BenchmarkFixtures.Sample sample = DATASET.get(i); + int index = i; + cases.add(dynamicTest( + String.format("#%03d: %s", index, preview(sample.text())), + () -> runCase(sample, index))); + } + return cases.stream(); + } + + private void runCase(BenchmarkFixtures.Sample sample, int index) { + Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30)); + String payloadId = "dataset200-" + index; + + String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); + for (Span gold : sample.gold()) { + String value = sample.text().substring(gold.start(), gold.end()); + assertFalse(masked.contains(value), + "ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value); + } + + String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); + assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст"); + } + + private static String preview(String text) { + return text.length() <= 40 ? text : text.substring(0, 40) + "..."; + } +} diff --git a/src/test/java/ru/pdguard/HugeDatasetTest.java b/src/test/java/ru/pdguard/HugeDatasetTest.java new file mode 100644 index 0000000..3b377a9 --- /dev/null +++ b/src/test/java/ru/pdguard/HugeDatasetTest.java @@ -0,0 +1,157 @@ +package ru.pdguard; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.detect.Span; +import ru.pdguard.mask.Masker; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +/** + * Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" + * (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы". + * + *

Тексты строятся перемешиванием строк из уже существующих размеченных наборов + * {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, + * пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения + * до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам + * {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 + * токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном + * тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов + * дополнительно проверяется, что маскирование укладывается в 5 секунд. + * + *

Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ. + */ +class HugeDatasetTest { + + private static final int TOTAL_CASES = 1000; + private static final int HUGE_CASES = 50; + private static final int CHARS_PER_TOKEN = 4; + private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN; + private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN; + /** Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним + * текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */ + private static final int LEAK_CHECK_MIN_LENGTH = 6; + /** Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы + * (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту + * от 0.70 — это и есть отправная точка, а не 0.85 из LargeTextTest, где + * участвует только benchmark-generated.txt, подстроенный под правила. */ + private static final double MAX_LEAK_RATE = 0.30; + + private static final RuleRegistry REGISTRY = new RuleRegistry(); + private static final Masker MASKER = new Masker(); + private static final List POOL = loadPool(); + + private static List loadPool() { + List pool = new ArrayList<>(); + for (String resource : List.of( + "/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt", + "/benchmark-bank-context.txt", "/benchmark-holdout.txt", + "/benchmark-holdout2.txt", "/benchmark-holdout3.txt")) { + pool.addAll(BenchmarkFixtures.load(resource)); + } + return pool; + } + + @TestFactory + Stream datasetOfThousandCases() { + List cases = new ArrayList<>(TOTAL_CASES); + for (int i = 0; i < TOTAL_CASES; i++) { + int targetChars = targetChars(i); + int index = i; + cases.add(dynamicTest( + String.format("#%04d, %d знаков (~%d токенов)", index, targetChars, targetChars / CHARS_PER_TOKEN), + () -> runCase(targetChars, index))); + } + return cases.stream(); + } + + /** + * Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — + * так тесты покрывают все порядки величины, а не только маленькие и не только большие. + * Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ. + */ + private static int targetChars(int index) { + int regular = TOTAL_CASES - HUGE_CASES; + if (index >= regular) { + int step = (HUGE_MAX_CHARS - HUGE_MIN_CHARS) / Math.max(1, HUGE_CASES - 1); + return HUGE_MIN_CHARS + (index - regular) * step; + } + double minChars = 80; + double maxChars = HUGE_MIN_CHARS - 1; + double ratio = (double) index / Math.max(1, regular - 1); + return (int) Math.round(minChars * Math.pow(maxChars / minChars, ratio)); + } + + private void runCase(int targetChars, int seed) { + Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30)); + BenchmarkFixtures.Sample sample = buildText(targetChars, seed); + String payloadId = "dataset-" + seed; + + long maskStarted = System.nanoTime(); + String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); + long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000; + + int checked = 0; + int leaked = 0; + for (Span gold : sample.gold()) { + String value = sample.text().substring(gold.start(), gold.end()); + if (value.length() >= LEAK_CHECK_MIN_LENGTH) { + checked++; + if (masked.contains(value)) { + leaked++; + } + } + } + if (checked > 0) { + // На малых текстах пара пропусков — статистический шум, не деградация детектора: + // абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель. + int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE)); + assertTrue(leaked <= allowed, + String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d", + leaked, checked, allowed)); + } + + String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); + assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст"); + + if (targetChars >= HUGE_MIN_CHARS) { + assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс"); + } + } + + /** Перемешивает строки пула детерминированно по seed и склеивает до нужного объёма. */ + private static BenchmarkFixtures.Sample buildText(int targetChars, long seed) { + List shuffled = new ArrayList<>(POOL); + Random random = new Random(seed); + StringBuilder text = new StringBuilder(targetChars + 1024); + List gold = new ArrayList<>(); + + while (text.length() < targetChars) { + Collections.shuffle(shuffled, random); + for (BenchmarkFixtures.Sample sample : shuffled) { + int offset = text.length(); + text.append(sample.text()).append('\n'); + for (Span span : sample.gold()) { + gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0)); + } + if (text.length() >= targetChars) { + break; + } + } + } + return new BenchmarkFixtures.Sample(text.toString(), gold); + } +} diff --git a/src/test/resources/dataset-200.txt b/src/test/resources/dataset-200.txt new file mode 100644 index 0000000..d1334a5 --- /dev/null +++ b/src/test/resources/dataset-200.txt @@ -0,0 +1,282 @@ +# ДАТАСЕТ ИЗ 200 СТРОК. Каждая строка — отдельный вручную составленный тестовый пример +# в разметке {{ТИП:значение}}, ровно как в остальных benchmark-*.txt. Покрывает все +# типы ПДН из RuleRegistry (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, +# только модель второй ступени) плюс несколько строк-ловушек без разметки: их система +# не обязана маскировать (известный человек, адрес отделения банка, дата без якоря). +# Companion-типы (CVV, PIN, DATE, BIRTH_PLACE, ADDRESS_COUNTRY, ACCOUNT_NUMBER, BIK, +# OGRN, OGRNIP, KPP, INCOME, BIOMETRIC) везде поставлены рядом с ФИО — в одиночку они +# не являются ПДН и намеренно не маскируются. + +# --- ФИО (10) --- +Заявление подала {{FIO:Кузнецова Елена Сергеевна}} в отделении банка +{{FIO:Романов Дмитрий Александрович}} обратился за консультацией +Договор подписан {{FIO:Беляев С.Н.}} +Клиент {{FIO:Морозова Ирина}} закрыла вклад досрочно +ФИО заявителя: {{FIO:Ткаченко Владимир Петрович}} +Получатель платежа — {{FIO:Никитина Анна Олеговна}} +Курьер передал посылку {{FIO:Соловьёву Егору Дмитриевичу}} +Уполномоченное лицо {{FIO:Григорьева О.В.}} подписала акт +Претензию подал {{FIO:Захаров Игорь Николаевич}} +пациент {{FIO:Орлова Татьяна Викторовна}} записан на приём + +# --- Email (7) --- +Пишите на {{EMAIL:e.kuznetsova@corp.ru}} по вопросам доставки +Рабочая почта {{EMAIL:d.romanov@bank-example.com}} +Резервный адрес {{EMAIL:backup.acc2026@yandex.ru}} +Уведомления приходят на {{EMAIL:notify.user77@gmail.com}} +Для восстановления пароля укажите {{EMAIL:i.morozova@mail.ru}} +служебный ящик {{EMAIL:Service.Desk@Company.RU}} +Клиентская поддержка: {{EMAIL:support-client@example.org}} + +# --- Телефон (7) --- +Контактный номер {{PHONE:+7 926 555-12-34}} +Телефон {{PHONE:8 (812) 700-11-22}} для справок +Мобильный {{PHONE:+7-903-444-55-66}} доступен с 9 до 18 +Позвоните по номеру {{PHONE:89261234567}} +Дежурный телефон {{PHONE:+7 (495) 987-65-43}} +тел {{PHONE:8-921-333-22-11}} +Номер для связи с курьером {{PHONE:+7 999 111-22-33}} + +# --- Карты (7, номера валидны по Луну) --- +Оплата картой {{CARD:5260 1815 9083 0164}} прошла успешно +Карта {{CARD:6131 8609 1390 9962}} привязана к счёту +Номер карты {{CARD:4308 2462 8194 8216}} подтверждён +Списание с карты {{CARD:5181 9093 7865 7979}} +Перевод на карту {{CARD:5432 3194 8757 4916}} выполнен +Возврат на карту {{CARD:1862 5276 0189 5557}} +Карта клиента {{CARD:9797 1147 1049 7469}} + +# --- CVV, спутник карты/ФИО (5) --- +Клиент {{FIO:Егорова Дарья Романовна}}, CVV {{CVV:482}} на обороте карты +Оплата картой {{CARD:5260 1815 9083 0164}}, CVV {{CVV:057}} введён верно +Клиент {{FIO:Волков Артём}}, код CVV {{CVV:913}} +Карта {{CARD:6131 8609 1390 9962}}, CVV {{CVV:204}} +Клиент {{FIO:Панова Е.И.}}, проверочный код CVV {{CVV:735}} + +# --- PIN, спутник карты/ФИО (5) --- +Клиент {{FIO:Белов Максим}}, пин-код {{PIN:7749}} +Карта {{CARD:4308 2462 8194 8216}}, ПИН-код {{PIN:2201}} +Клиент {{FIO:Гусева Виктория}}, пин-код {{PIN:6630}} от карты +Карта {{CARD:5181 9093 7865 7979}}, PIN {{PIN:8814}} +Клиент {{FIO:Мельников И.С.}}, пин-код {{PIN:0357}} действителен + +# --- Срок действия карты (5) --- +Срок действия {{CARD_EXPIRY:11/26}} карты подходит к концу +Карта {{CARD:5432 3194 8757 4916}} действительна до {{CARD_EXPIRY:03/28}} +Действительна до {{CARD_EXPIRY:07/25}}, карта отключена +exp {{CARD_EXPIRY:09/29}} указан на карте +Действительна до {{CARD_EXPIRY:12/27}} + +# --- Держатель карты (5) --- +Держатель карты {{CARDHOLDER:ANNA NIKITINA}} на лицевой стороне +На имя {{CARDHOLDER:DMITRY ROMANOV}} выпущена карта +cardholder {{CARDHOLDER:ELENA KUZNETSOVA}} указан латиницей +Держатель {{CARDHOLDER:PETR IVANOV}} подтвердил операцию +На имя {{CARDHOLDER:MARIA FEDOROVA}} оформлена дебетовая карта + +# --- ИНН (7, чек-суммы валидны) --- +ИНН {{INN:1043321811}} организации проверен +ИНН физлица {{INN:310341316440}} указан в справке +Налогоплательщик с ИНН {{INN:9600133898}} +ИНН {{INN:752553419246}} совпадает с реестром ФНС +Указан ИНН {{INN:1615594079}} контрагента +ИНН {{INN:832764835069}} налогоплательщика физлица +ИНН {{INN:8161849590}} организации-поставщика + +# --- СНИЛС (7, чек-суммы валидны) --- +СНИЛС {{SNILS:978-480-184-78}} застрахованного лица +СНИЛС {{SNILS:514-627-048-70}} указан в заявлении +Номер СНИЛС {{SNILS:281-489-325-05}} +СНИЛС работника {{SNILS:288-095-701-23}} в личном деле +СНИЛС {{SNILS:543-039-117-60}} для оформления пенсии +СНИЛС клиента {{SNILS:112-233-445 95}} +страховой номер {{SNILS:661-058-551 91}} + +# --- Паспорт РФ (7) --- +Паспорт {{PASSPORT:4611 234567}} гражданина РФ +Серия {{PASSPORT:46 11 № 234567}} паспорта представлена +Паспорт {{PASSPORT:4611234567}} действителен +паспорт {{PASSPORT:4009 555666}} гражданина России +Паспорт {{PASSPORT:4520998877}} предъявлен на входе +Серия и номер паспорта {{PASSPORT:45 20 998877}} +Паспорт {{PASSPORT:4611 111222}} Российской Федерации + +# --- Орган, выдавший паспорт (5) --- +выдан {{PASSPORT_ISSUER:ОУФМС России по г. Санкт-Петербургу}}, действителен +выдан {{PASSPORT_ISSUER:Отделом УФМС по Московской области}}, действителен +Паспорт выдан {{PASSPORT_ISSUER:УМВД России по Свердловской области}}, продлён +выдан {{PASSPORT_ISSUER:ТП УФМС России по Краснодарскому краю}}, актуален +выдан {{PASSPORT_ISSUER:Отделением УФМС по Республике Татарстан}}, проверен + +# --- Дата выдачи паспорта (5) --- +Дата выдачи {{PASSPORT_DATE:03.04.2016}} паспорта +дата выдачи {{PASSPORT_DATE:15.11.2019}} указана в документе +Дата выдачи документа {{PASSPORT_DATE:22.07.2013}} +Дата выдачи {{PASSPORT_DATE:01.01.2020}} паспорта РФ +дата выдачи паспорта {{PASSPORT_DATE:19.09.2011}} + +# --- Код подразделения (5) --- +Код подразделения {{DEPT_CODE:780-001}} указан в паспорте +код подразделения {{DEPT_CODE:500-123}} на второй странице +Код подразделения {{DEPT_CODE:770045}} без разделителя +к/п {{DEPT_CODE:940-777}} совпадает с базой +Код подразделения {{DEPT_CODE:220-014}} проверен + +# --- Водительское удостоверение (6) --- +Водительское удостоверение {{DRIVER_LICENSE:78 14 556677}} действительно +ВУ {{DRIVER_LICENSE:99 07 112233}} предъявлено инспектору +в/у {{DRIVER_LICENSE:50 22 998877}} категории B +Водительское удостоверение {{DRIVER_LICENSE:16 09 445566}} выдано в 2021 году +вод. удост. {{DRIVER_LICENSE:61 18 334455}} проверено +Водительское удостоверение {{DRIVER_LICENSE:23 05 667788}} действительно до 2031 года + +# --- Загранпаспорт (5) --- +Загранпаспорт {{FOREIGN_PASSPORT:75 2345678}} выдан консульством +заграничный паспорт {{FOREIGN_PASSPORT:64 1122334}} оформлен в МИД +Загранпаспорт {{FOREIGN_PASSPORT:71 9988776}} действителен до 2029 года +Загранпаспорт {{FOREIGN_PASSPORT:53 4455667}} нового образца +заграничный паспорт {{FOREIGN_PASSPORT:82 6677889}} гражданина РФ + +# --- Военный билет (4) --- +Военный билет {{MILITARY_ID:АБ 7788990}} на воинском учёте +военный билет {{MILITARY_ID:НВ 1122334}} представлен в военкомат +Военный билет {{MILITARY_ID:СК 5566778}} действителен +военный билет {{MILITARY_ID:ЮЗ 3344556}} проверен + +# --- Свидетельство о рождении (4) --- +Свидетельство о рождении {{BIRTH_CERTIFICATE:III-МЮ 445566}} выдано ЗАГС +свидетельство о рождении {{BIRTH_CERTIFICATE:I-АБ 112233}} представлено +Свидетельство о рождении {{BIRTH_CERTIFICATE:IV-ЕЖ 778899}} ребёнка +Свидетельство о рождении {{BIRTH_CERTIFICATE:II-НМ 998877}} приложено к заявлению + +# --- Полис ОМС (4) --- +Полис ОМС {{MEDICAL_POLICY:9876543210123456}} действует +полис {{MEDICAL_POLICY:1122334455667788}} представлен в клинике +Полис ОМС {{MEDICAL_POLICY:5544332211009988}} застрахованного +Полис {{MEDICAL_POLICY:6677889900112233}} действителен до конца года + +# --- Дата рождения (7) --- +Дата рождения {{BIRTH_DATE:23.08.1979}} клиента +Родился {{BIRTH_DATE:04.02.1995}} в роддоме №3 +дата рождения {{BIRTH_DATE:17 марта 1988 года}} +Дата рождения {{BIRTH_DATE:1990-06-30}} в формате ISO +Клиент родился {{BIRTH_DATE:11.11.1982}} +дата рождения {{BIRTH_DATE:29.02.2000}} указана в анкете +Дата рождения {{BIRTH_DATE:05.12.1975}} застрахованного лица + +# --- Место рождения, спутник ФИО (6) --- +Клиент {{FIO:Абрамова Светлана}}, место рождения {{BIRTH_PLACE:город Омск}} +Клиент {{FIO:Дьяконов Роман}}, родился в {{BIRTH_PLACE:Ростове-на-Дону}} +Клиент {{FIO:Ковалёва Н.П.}}, место рождения {{BIRTH_PLACE:село Ивановка}} +Клиент {{FIO:Шевченко Олег}}, родился в {{BIRTH_PLACE:Хабаровске}} +Клиент {{FIO:Юдина Кристина}}, место рождения {{BIRTH_PLACE:город Тула}} +Клиент {{FIO:Литвинов А.С.}}, родился в {{BIRTH_PLACE:Владивостоке}} + +# --- Гражданство, спутник ФИО (6) --- +Клиент {{FIO:Соколова Дарья}}, гражданство {{CITIZENSHIP:РФ}} +Клиент {{FIO:Петров Виктор}}, гражданин {{CITIZENSHIP:России}} +Клиент {{FIO:Алиев Тимур}}, гражданство {{CITIZENSHIP:Республики Казахстан}} +Клиент {{FIO:Ким Артур}}, гражданство {{CITIZENSHIP:Республики Узбекистан}} +Клиент {{FIO:Марченко Ольга}}, гражданство {{CITIZENSHIP:Украины}} +Клиент {{FIO:Бабаев Руслан}}, гражданство {{CITIZENSHIP:Республики Беларусь}} + +# --- Дата обращения, спутник ФИО (6) --- +Клиент {{FIO:Титова Евгения}}, дата обращения {{DATE:14.03.2025}} +Клиент {{FIO:Захаров Павел}}, дата {{DATE:2025-01-09}} заключения договора +Клиент {{FIO:Крылова Марина}}, дата {{DATE:07/06/2025}} подачи заявки +Клиент {{FIO:Орлов Денис}}, дата визита {{DATE:23.11.2025}} +Клиент {{FIO:Гаврилова Анна}}, дата {{DATE:5 мая 2025}} посещения +Клиент {{FIO:Рябов Станислав}}, дата платежа {{DATE:30.06.2025}} + +# --- Страна проживания, спутник ФИО (5) --- +Клиент {{FIO:Носова Виктория}}, страна проживания {{ADDRESS_COUNTRY:Армения}} +Клиент {{FIO:Тимофеев Глеб}}, адрес регистрации, страна {{ADDRESS_COUNTRY:Киргизия}} +Клиент {{FIO:Ефимова Кристина}}, страна проживания {{ADDRESS_COUNTRY:Таджикистан}} +Клиент {{FIO:Симонов Артём}}, гражданство и страна {{ADDRESS_COUNTRY:Молдова}} +Клиент {{FIO:Голубева Полина}}, проживает в стране {{ADDRESS_COUNTRY:Грузия}} + +# --- Почтовый индекс (5) --- +Индекс {{ADDRESS_POSTCODE:191025}} почтовый указан верно +индекс {{ADDRESS_POSTCODE:620014}} в адресе доставки +Почтовый индекс {{ADDRESS_POSTCODE:350000}} города +Индекс {{ADDRESS_POSTCODE:603000}} региона проживания +индекс {{ADDRESS_POSTCODE:236000}} указан в анкете + +# --- Город (5) --- +Город {{ADDRESS_CITY:Екатеринбург}} регистрации клиента +Город {{ADDRESS_CITY:Казань}} — постоянное место проживания +Город {{ADDRESS_CITY:Новосибирск}} указан в адресе +г. {{ADDRESS_CITY:Самара}} в графе регистрация +Город {{ADDRESS_CITY:Краснодар}} проживания заявителя + +# --- Улица (5) --- +Улица {{ADDRESS_STREET:Ленина}} дом указан в адресе +Улица {{ADDRESS_STREET:Гагарина}} — адрес регистрации +Улица {{ADDRESS_STREET:Мира}} дом по адресу проживания +адрес: улица {{ADDRESS_STREET:Садовая}} +Улица {{ADDRESS_STREET:Пушкинская}} дом в реквизитах клиента + +# --- Дом (5) --- +Дом {{ADDRESS_HOUSE:23}} корпус адреса регистрации +дом {{ADDRESS_HOUSE:5А}} по адресу проживания +Дом {{ADDRESS_HOUSE:14}} в адресе доставки +адрес, дом {{ADDRESS_HOUSE:101}} +Дом {{ADDRESS_HOUSE:7Б}} корпус 2 в адресе + +# --- Квартира (5) --- +Квартира {{ADDRESS_FLAT:42}} в адресе регистрации +кв. {{ADDRESS_FLAT:118}} по адресу проживания +Квартира {{ADDRESS_FLAT:9}} указана в анкете +адрес, квартира {{ADDRESS_FLAT:230}} +Квартира {{ADDRESS_FLAT:15А}} в адресе клиента + +# --- Расчётный счёт, спутник ФИО (5, 20 цифр) --- +Клиент {{FIO:Дегтярёва Инна}}, расчётный счёт {{ACCOUNT_NUMBER:40702810900000005678}} +Клиент {{FIO:Пахомов Артур}}, р/с {{ACCOUNT_NUMBER:40817810300000009012}} в банке +Клиент {{FIO:Максимова Дарья}}, лицевой счёт {{ACCOUNT_NUMBER:40817810100000003456}} +Клиент {{FIO:Кириллов Егор}}, расчетный счет {{ACCOUNT_NUMBER:40702810200000007890}} +Клиент {{FIO:Лаврова Юлия}}, р/с {{ACCOUNT_NUMBER:40817810500000001234}} открыт вчера + +# --- БИК, спутник ФИО (5) --- +Клиент {{FIO:Быков Николай}}, БИК {{BIK:044030653}} банка-получателя +Клиент {{FIO:Молчанова Вера}}, БИК {{BIK:044525225}} указан в реквизитах +Клиент {{FIO:Стрелков Артём}}, БИК {{BIK:044552766}} филиала +Клиент {{FIO:Осипова Кристина}}, БИК {{BIK:046577674}} отделения +Клиент {{FIO:Наумов Игорь}}, БИК {{BIK:040349722}} банка + +# --- ОГРН, спутник ФИО (4, чек-суммы валидны) --- +Директор {{FIO:Фомин И.П.}}, ОГРН {{OGRN:8121913619391}} организации +Директор {{FIO:Швецова А.Р.}}, ОГРН {{OGRN:9091699854358}} компании +Директор {{FIO:Кузьмин Д.О.}}, ОГРН {{OGRN:3462475107993}} юрлица +Директор {{FIO:Разина Е.В.}}, ОГРН {{OGRN:1183842513546}} организации + +# --- ОГРНИП, спутник ФИО (3, чек-суммы валидны) --- +ИП {{FIO:Воронцов Станислав Игоревич}}, ОГРНИП {{OGRNIP:278498084124111}} +ИП {{FIO:Ларионова Анна Валерьевна}}, ОГРНИП {{OGRNIP:824493534874014}} +ИП {{FIO:Гущин Роман Андреевич}}, ОГРНИП {{OGRNIP:640052427868014}} + +# --- КПП, спутник ФИО (4) --- +Заявитель {{FIO:Селезнёва И.К.}}, КПП {{KPP:770401001}} в реквизитах счёта +Заявитель {{FIO:Тарасов В.Н.}}, КПП {{KPP:502201001}} организации +Заявитель {{FIO:Игнатьева О.М.}}, КПП {{KPP:614501001}} филиала +Заявитель {{FIO:Бондарев С.А.}}, КПП {{KPP:781301001}} указан верно + +# --- Доход, спутник ФИО (4) --- +Клиент {{FIO:Романова Ксения}}, доход {{INCOME:120 000}} руб. подтверждён справкой 2-НДФЛ +Клиент {{FIO:Дорофеев Максим}}, заработная плата {{INCOME:87500}} перечисляется на карту +Клиент {{FIO:Смирнова Полина}}, доход {{INCOME:210 000}} руб. в месяц +Клиент {{FIO:Ефремов Артур}}, зарплата {{INCOME:65 000}} рублей + +# --- Биометрия, спутник ФИО (5) --- +Клиент {{FIO:Северов Илья}} прошёл верификацию через {{BIOMETRIC:биометрические данные}} +Клиент {{FIO:Тихонова Алина}}, оформлен {{BIOMETRIC:слепок голоса}} для системы {{BIOMETRIC:ЕБС}} +Клиент {{FIO:Пестов Роман}} сдал {{BIOMETRIC:скан лица}} при оформлении карты +Клиент {{FIO:Уварова Светлана}}, снят {{BIOMETRIC:отпечаток пальца}} в отделении +Клиент {{FIO:Дмитриев Олег}} подтвердил личность через {{BIOMETRIC:биометрический шаблон}} + +# --- Ловушки: без разметки, маскироваться не должны (5) --- +Стихи Александра Пушкина изучают в школе с пятого класса +Экскурсия по Дому-музею Толстого прошла в субботу +Институт Склифосовского принял пострадавшего в приёмный покой +Отделение банка находится на улице Строителей, рядом со станцией метро +Дата открытия нового отделения — 1 сентября, посетителей ждут с утра