refactor: подтянуть Legal NER, переформатировать код и добавить тесты

Слияние с 685ec97 (третья ступень NER для юридических реквизитов),
код приведён к google-java-format, добавлены юнит-тесты
AdaptiveConcurrencyLimiter/SystemsConfig/PayloadCipher.
This commit is contained in:
Максименко Никита Владимирович
2026-09-23 22:02:45 +03:00
parent 685ec977de
commit 1328a7b234
79 changed files with 7385 additions and 6430 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"default": { "default": {
"enabled": true, "enabled": true,
"demask": true, "demask": true,
"maskMode": "TOKEN", "maskMode": "MASK",
"types": ["*"], "types": ["*"],
"requireCompanion": [ "requireCompanion": [
"CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY", "CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
@@ -6,9 +6,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/** /**
* Точка входа Spring Boot приложения. * Точка входа Spring Boot приложения.
* *
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем * <p>Модуль безопасности персональных данных: прокси между системой-потребителем и LLM. Находит
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный * персональные данные, маскирует их и восстанавливает исходный текст на обратном шаге.
* текст на обратном шаге.
*/ */
@SpringBootApplication @SpringBootApplication
public class PdGuardApplication { public class PdGuardApplication {
@@ -1,5 +1,7 @@
package ru.pdguard.api; package ru.pdguard.api;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -7,9 +9,6 @@ import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig; import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */ /** Просмотр действующих настроек и принудительное их перечитывание. */
@RestController @RestController
public class AdminResource { public class AdminResource {
@@ -18,18 +18,16 @@ import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
/** /**
* Единственная точка входа контракта: маскирование и демаскирование по * Единственная точка входа контракта: маскирование и демаскирование по {@code payload_id}.
* {@code payload_id}.
* *
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка * <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка нет или система
* нет или система неизвестна — применяются настройки {@code default}, поэтому * неизвестна — применяются настройки {@code default}, поэтому контракт работает и без него.
* контракт работает и без него. Система, выключенная в настройках, получает * Система, выключенная в настройках, получает {@code 403}.
* {@code 403}.
* *
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — * <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки — не фиксированное
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} * число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter} сам находит потолок
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру, * конкурентности под то, сколько CPU реально досталось контейнеру, вместо того чтобы копить запросы
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны. * и упереться в таймаут вызывающей стороны.
*/ */
@RestController @RestController
public class ProcessResource { public class ProcessResource {
@@ -44,21 +42,19 @@ public class ProcessResource {
/** Имя метрики отклонённых запросов и имя её метки причины. */ /** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected"; private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason"; private static final String REASON_TAG = "reason";
/** /**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом * Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом шаге иначе
* шаге иначе выпустил бы наружу незамаскированные персональные данные. * выпустил бы наружу незамаскированные персональные данные.
*/ */
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]"; static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest( public record ProcessRequest(
@JsonProperty("payload") String payload, @JsonProperty("payload") String payload, @JsonProperty("payload_id") String payloadId) {}
@JsonProperty("payload_id") String payloadId) {
}
public record ProcessResponse(@JsonProperty("result") String result) { public record ProcessResponse(@JsonProperty("result") String result) {}
}
private final Pipeline pipeline; private final Pipeline pipeline;
private final SystemsConfig systems; private final SystemsConfig systems;
@@ -68,13 +64,17 @@ public class ProcessResource {
private final Counter forbidden; private final Counter forbidden;
private final Counter failed; private final Counter failed;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters, public ProcessResource(
Pipeline pipeline,
SystemsConfig systems,
MeterRegistry meters,
@Value("${pdguard.min-concurrent:8}") int minConcurrent, @Value("${pdguard.min-concurrent:8}") int minConcurrent,
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent, @Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) { @Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
this.pipeline = pipeline; this.pipeline = pipeline;
this.systems = systems; this.systems = systems;
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis); this.limiter =
new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload"); this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed"); this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled"); this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
@@ -84,11 +84,14 @@ public class ProcessResource {
} }
@PostMapping("/process") @PostMapping("/process")
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request, public ResponseEntity<ProcessResponse> process(
@RequestBody(required = false) ProcessRequest request,
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId, @RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) { @RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
if (request == null || request.payload() == null if (request == null
|| request.payloadId() == null || request.payloadId().isBlank()) { || request.payload() == null
|| request.payloadId() == null
|| request.payloadId().isBlank()) {
malformed.increment(); malformed.increment();
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны")); .body(new ProcessResponse("payload и payload_id обязательны"));
@@ -123,8 +126,8 @@ public class ProcessResource {
// ради чего сервис и существует. Ответ фиксированный: он ничего не // ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных. // раскрывает и не выглядит порчей данных.
failed.increment(); failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ", LOG.error(
request.payloadId(), e); "payload_id={} обработка не удалась, отдан безопасный ответ", request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE)); return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally { } finally {
limiter.release(System.nanoTime() - started); limiter.release(System.nanoTime() - started);
+29 -20
View File
@@ -1,6 +1,7 @@
package ru.pdguard.api; package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -14,8 +15,6 @@ import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.LlmClient; import ru.pdguard.core.LlmClient;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import java.util.Map;
/** /**
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком. * Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
* *
@@ -23,25 +22,23 @@ import java.util.Map;
* потребитель → маскирование → LLM → демаскирование → потребитель * потребитель → маскирование → LLM → демаскирование → потребитель
* </pre> * </pre>
* *
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что * <p>В ответе видны все три текста — что ушло в модель, что она вернула и что получил потребитель.
* получил потребитель. Это и есть доказательство, что в модель не попало ничего * Это и есть доказательство, что в модель не попало ничего незамаскированного, а ответ вернулся с
* незамаскированного, а ответ вернулся с восстановленными значениями. * восстановленными значениями.
* *
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по * <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по идентификатору
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не * целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не годятся — одна и та же маска
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда * отвечала бы разным значениям, — поэтому здесь всегда применяется обратимая подстановка,
* применяется обратимая подстановка, независимо от режима маскирования системы. * независимо от режима маскирования системы.
* *
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в * <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в {@link ProcessResource}.
* {@link ProcessResource}.
*/ */
@RestController @RestController
public class ProxyResource { public class ProxyResource {
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class); private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
public record ProxyRequest(@JsonProperty("prompt") String prompt) { public record ProxyRequest(@JsonProperty("prompt") String prompt) {}
}
public record ProxyResponse( public record ProxyResponse(
@JsonProperty("prompt_masked") String promptMasked, @JsonProperty("prompt_masked") String promptMasked,
@@ -66,7 +63,8 @@ public class ProxyResource {
} }
@PostMapping("/proxy") @PostMapping("/proxy")
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request, public ResponseEntity<ProxyResponse> proxy(
@RequestBody(required = false) ProxyRequest request,
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId, @RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) { @RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) { if (request == null || request.prompt() == null || request.prompt().isBlank()) {
@@ -81,18 +79,29 @@ public class ProxyResource {
} }
if (!policy.enabled()) { if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN) return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null, .body(
new ProxyResponse(
null,
null,
null,
null,
null,
"Системе " + systemId + " обращение в модуль запрещено")); "Системе " + systemId + " обращение в модуль запрещено"));
} }
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy); Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
LlmClient.Answer answer = llm.ask(masked.text()); LlmClient.Answer answer = llm.ask(masked.text());
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text(); String restored =
policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
LOG.info("proxy: система={} заменено={} модель={}", LOG.info(
policy.name(), masked.restorations().size(), answer.source()); "proxy: система={} заменено={} модель={}",
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored, policy.name(),
masked.restorations(), answer.source(), null)); masked.restorations().size(),
answer.source());
return ResponseEntity.ok(
new ProxyResponse(
masked.text(), answer.text(), restored, masked.restorations(), answer.source(), null));
} }
/** Возвращает исходные значения на место подстановок в ответе модели. */ /** Возвращает исходные значения на место подстановок в ответе модели. */
@@ -8,10 +8,9 @@ import ru.pdguard.detect.NameDictionary;
/** /**
* Конфигурация словарей распознавания ФИО. * Конфигурация словарей распознавания ФИО.
* *
* <p>{@link NameDictionary} работает через статические методы и не требует * <p>{@link NameDictionary} работает через статические методы и не требует экземпляра, но путь к
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при * внешнему файлу денилиста задаётся из настроек при старте. Бин здесь нужен только для того, чтобы
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение * Spring подставил значение {@code pdguard.well-known-file} и передал его словарю.
* {@code pdguard.well-known-file} и передал его словарю.
*/ */
@Configuration @Configuration
public class DictionaryConfiguration { public class DictionaryConfiguration {
@@ -1,42 +1,40 @@
package ru.pdguard.config; package ru.pdguard.config;
import java.util.Set;
import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.PdTypes;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import java.util.Set;
/** /**
* Правила обработки для одной системы-потребителя. * Правила обработки для одной системы-потребителя.
* *
* @param name имя системы; им же разделяется хранилище соответствий, * @param name имя системы; им же разделяется хранилище соответствий, чтобы одна система не могла
* чтобы одна система не могла достать данные другой * достать данные другой
* @param enabled разрешено ли системе обращаться в модуль * @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование * @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение * @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные * @param types типы ПД к маскированию; {@code "*"} — все известные
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан * @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан совпасть, иначе
* совпасть, иначе имя системы можно было бы просто назвать. * имя системы можно было бы просто назвать. Только знаки ASCII: заголовки HTTP передаются в
* Только знаки ASCII: заголовки HTTP передаются в Latin-1, * Latin-1, и кириллица в ключе до сервиса доедет искажённой
* и кириллица в ключе до сервиса доедет искажённой * @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: пин-код сам по
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа: * себе безвреден, пин-код рядом с номером карты — уже нет; то же для даты без якорного слова,
* пин-код сам по себе безвреден, пин-код рядом с номером * места рождения («Нижний Новгород» в рассказе о городе — не адрес клиента) и страны («цены
* карты — уже нет; то же для даты без якорного слова, места * выросли в Казахстане» — не гражданство). Сюда же банковские реквизиты — счёт, БИК, ОГРН,
* рождения («Нижний Новгород» в рассказе о городе — не адрес * ОГРНИП, КПП: сами по себе они опознают организацию или счёт, а не человека, и в перечне типов
* клиента) и страны («цены выросли в Казахстане» — не гражданство). * из задания их нет. Рядом с именем клиента они становятся его данными и маскируются. Сюда же
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП: * доход и биометрия. Сумма заработка без человека — статистика («доход домохозяйств вырос до 74
* сами по себе они опознают организацию или счёт, а не человека, * 500 руб»), а не персональные данные. Биометрия же в тексте не встречается вовсе: это шаблон в
* и в перечне типов из задания их нет. Рядом с именем клиента * базе, и правило маскирует лишь само упоминание, то есть слово, а не данные. Чувствителен
* они становятся его данными и маскируются. * здесь факт, что биометрию сдал названный человек, — а он и существует только при имени рядом
* Сюда же доход и биометрия. Сумма заработка без человека —
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
* персональные данные. Биометрия же в тексте не встречается
* вовсе: это шаблон в базе, и правило маскирует лишь само
* упоминание, то есть слово, а не данные. Чувствителен здесь
* факт, что биометрию сдал названный человек, — а он и
* существует только при имени рядом
*/ */
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode, public record SystemPolicy(
Set<String> types, Set<String> requireCompanion, String key) { String name,
boolean enabled,
boolean demask,
MaskMode maskMode,
Set<String> types,
Set<String> requireCompanion,
String key) {
public static final String ALL = "*"; public static final String ALL = "*";
@@ -44,11 +42,27 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
public static final String DEFAULT_NAME = "default"; public static final String DEFAULT_NAME = "default";
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */ /** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy( public static final SystemPolicy DEFAULT =
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL), new SystemPolicy(
Set.of(PdTypes.CVV, PdTypes.PIN, PdTypes.DATE, PdTypes.BIRTH_PLACE, PdTypes.ADDRESS_COUNTRY, DEFAULT_NAME,
PdTypes.ACCOUNT_NUMBER, PdTypes.BIK, PdTypes.OGRN, PdTypes.OGRNIP, PdTypes.KPP, true,
PdTypes.INCOME, PdTypes.BIOMETRIC), null); true,
MaskMode.MASK,
Set.of(ALL),
Set.of(
PdTypes.CVV,
PdTypes.PIN,
PdTypes.DATE,
PdTypes.BIRTH_PLACE,
PdTypes.ADDRESS_COUNTRY,
PdTypes.ACCOUNT_NUMBER,
PdTypes.BIK,
PdTypes.OGRN,
PdTypes.OGRNIP,
PdTypes.KPP,
PdTypes.INCOME,
PdTypes.BIOMETRIC),
null);
public SystemPolicy { public SystemPolicy {
types = Set.copyOf(types); types = Set.copyOf(types);
@@ -57,8 +71,8 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */ /** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) { public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK, return new SystemPolicy(
Set.of(types), DEFAULT.requireCompanion(), null); DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion(), null);
} }
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */ /** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
@@ -68,7 +82,8 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
} }
return java.security.MessageDigest.isEqual( return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8), key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8)); (presentedKey == null ? "" : presentedKey)
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
} }
public boolean allows(String type) { public boolean allows(String type) {
@@ -1,12 +1,6 @@
package ru.pdguard.config; package ru.pdguard.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.mask.MaskMode;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -14,17 +8,22 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Set; import java.util.Set;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.mask.MaskMode;
/** /**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой. * Список систем, которым разрешено обращаться в модуль, и правила для каждой.
* *
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл * <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл перечитывается сам,
* перечитывается сам, когда меняется время его изменения; проверка выполняется * когда меняется время его изменения; проверка выполняется не чаще раза в секунду, чтобы не ходить
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе. * в файловую систему на каждом запросе. Файла нет — работают настройки по умолчанию, и сервис
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него. * поднимается без него.
*/ */
@Component @Component
public final class SystemsConfig { public final class SystemsConfig {
@@ -37,8 +36,13 @@ public final class SystemsConfig {
private static final long RECHECK_MILLIS = 1000; private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */ /** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode, public record SystemEntry(
List<String> types, List<String> requireCompanion, String key) { Boolean enabled,
Boolean demask,
String maskMode,
List<String> types,
List<String> requireCompanion,
String key) {
public SystemEntry { public SystemEntry {
types = types == null ? null : List.copyOf(types); types = types == null ? null : List.copyOf(types);
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion); requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
@@ -53,8 +57,8 @@ public final class SystemsConfig {
private volatile long fileTimestamp; private volatile long fileTimestamp;
private volatile long lastCheck; private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path, public SystemsConfig(
ObjectMapper mapper) { @Value("${pdguard.systems-file:config/systems.json}") String path, ObjectMapper mapper) {
this.file = Path.of(path); this.file = Path.of(path);
this.mapper = mapper; this.mapper = mapper;
reload(); reload();
@@ -87,15 +91,20 @@ public final class SystemsConfig {
public final synchronized void reload() { public final synchronized void reload() {
lastCheck = System.currentTimeMillis(); lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) { if (!Files.isReadable(file)) {
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath()); LOG.info(
"Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT)); policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
fileTimestamp = 0; fileTimestamp = 0;
return; return;
} }
try { try {
fileTimestamp = Files.getLastModifiedTime(file).toMillis(); fileTimestamp = Files.getLastModifiedTime(file).toMillis();
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file), Map<String, SystemEntry> entries =
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class)); mapper.readValue(
Files.readAllBytes(file),
mapper
.getTypeFactory()
.constructMapType(TreeMap.class, String.class, SystemEntry.class));
Map<String, SystemPolicy> parsed = new TreeMap<>(); Map<String, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry))); entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT); parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
@@ -103,7 +112,8 @@ public final class SystemsConfig {
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet()); LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) { } catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки. // Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e); LOG.error(
"Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
} }
} }
@@ -128,13 +138,21 @@ public final class SystemsConfig {
private static SystemPolicy toPolicy(String name, SystemEntry entry) { private static SystemPolicy toPolicy(String name, SystemEntry entry) {
SystemPolicy base = SystemPolicy.DEFAULT; SystemPolicy base = SystemPolicy.DEFAULT;
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types()); Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
Set<String> companions = entry.requireCompanion() == null Set<String> companions =
? base.requireCompanion() : new HashSet<>(entry.requireCompanion()); entry.requireCompanion() == null
MaskMode mode = entry.maskMode() == null ? base.requireCompanion()
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT)); : new HashSet<>(entry.requireCompanion());
return new SystemPolicy(name, MaskMode mode =
entry.maskMode() == null
? base.maskMode()
: MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
return new SystemPolicy(
name,
entry.enabled() == null || entry.enabled(), entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(), entry.demask() == null || entry.demask(),
mode, types, companions, entry.key()); mode,
types,
companions,
entry.key());
} }
} }
@@ -1,53 +1,43 @@
package ru.pdguard.core; package ru.pdguard.core;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.TimeUnit;
/** /**
* Предел одновременных запросов, который сам подстраивается под задержку, * Предел одновременных запросов, который сам подстраивается под задержку, а не задан фиксированным
* а не задан фиксированным числом. Растёт, пока обработка укладывается в * числом. Растёт, пока обработка укладывается в целевое время, и сжимается, как только перестаёт —
* целевое время, и сжимается, как только перестаёт — вместо того чтобы * вместо того чтобы копить очередь и подходить к таймауту вызывающей стороны.
* копить очередь и подходить к таймауту вызывающей стороны.
* *
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. * <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime. availableProcessors()} под
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется * квотой {@code --cpus} в cgroups не меняется (это не affinity, а квота), поэтому в контейнере с
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно * долей ядра оно показывает все ядра хоста и как источник предела не годится. Задержка —
* показывает все ядра хоста и как источник предела не годится. Задержка —
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере. * наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
* *
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой * <p>Шаг регулировки привязан к времени, не к числу запросов: при первой версии предел менялся на
* версии предел менялся на каждый завершённый запрос, и на высоком RPS * каждый завершённый запрос, и на высоком RPS тысячи «быстрых» замеров прилетали за миллисекунды —
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал * предел успевал разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, и то же
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась, * самое повторялось после каждого восстановления. Проверено нагрузочным тестом: без привязки к
* и то же самое повторялось после каждого восстановления. Проверено * времени p95 на перегрузке доходил до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил * сжаться. Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним шагом на основе
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться. * среднего за окно — так скорость регулировки не зависит от того, насколько высок входящий RPS.
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
* шагом на основе среднего за окно — так скорость регулировки не зависит
* от того, насколько высок входящий RPS.
* *
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start * <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start из TCP) здесь не
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и * подходит: там обратная связь — RTT, миллисекунды, и лишний виток роста стоит дёшево. Здесь
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа * обратная связь — время ответа заявки, и под перегрузкой оно само составляет секунды: предел
* заявки, и под перегрузкой оно само составляет секунды: предел успевает * успевает удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый сигнал о деградации,
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый * и уже принятые заявки не исчезают из очереди, даже если следующим окном предел тут же обрушить.
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже * Проверено нагрузочным тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* если следующим окном предел тут же обрушить. Проверено нагрузочным * Линейный рост копит риск медленно, и первый плохой сигнал останавливает его на порядок раньше.
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с. * Сжатие — вдвое, а не на единицу: на перегрузке дешевле один раз отрезать с запасом, чем несколько
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает * окон подряд плавно подходить к безопасному уровню, пока заявки продолжают копиться.
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
* подходить к безопасному уровню, пока заявки продолжают копиться.
* *
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе * <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе окна может добавить
* окна может добавить образец в уже подводимый итог или отбросить один, * образец в уже подводимый итог или отбросить один, не больше; при масштабах в десятки-сотни
* не больше; при масштабах в десятки-сотни образцов на окно это не видно. * образцов на окно это не видно. Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix * {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из осторожности к GraalVM
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из * native-image: незнакомая рефлексия в чужой библиотеке — это ровно тот класс проблем, из-за
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой * которого модели второй ступени понадобилась отдельная настройка сборки.
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
* ступени понадобилась отдельная настройка сборки.
*/ */
public final class AdaptiveConcurrencyLimiter { public final class AdaptiveConcurrencyLimiter {
@@ -68,9 +58,11 @@ public final class AdaptiveConcurrencyLimiter {
} }
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */ /** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) { AdaptiveConcurrencyLimiter(
int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) { if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit); throw new IllegalArgumentException(
"Некорректные границы предела: " + minLimit + ".." + maxLimit);
} }
this.minLimit = minLimit; this.minLimit = minLimit;
this.maxLimit = maxLimit; this.maxLimit = maxLimit;
+25 -21
View File
@@ -2,11 +2,6 @@ package ru.pdguard.core;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -15,17 +10,20 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Optional; import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** /**
* Обращение к языковой модели для демонстрационного плеча. * Обращение к языковой модели для демонстрационного плеча.
* *
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно. * <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно. Для демонстрации
* Для демонстрации этого достаточно, потому что проверяется не качество ответа * этого достаточно, потому что проверяется не качество ответа модели, а то, что в модель ушёл
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся * замаскированный текст, а потребителю вернулся восстановленный.
* восстановленный.
* *
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, * <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки, а причина
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя. * попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
*/ */
@Component @Component
public class LlmClient { public class LlmClient {
@@ -33,8 +31,7 @@ public class LlmClient {
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class); private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
/** Что вернула модель и кто именно ответил. */ /** Что вернула модель и кто именно ответил. */
public record Answer(String text, String source) { public record Answer(String text, String source) {}
}
private final Optional<String> url; private final Optional<String> url;
private final Optional<String> apiKey; private final Optional<String> apiKey;
@@ -75,8 +72,8 @@ public class LlmClient {
} }
/** /**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что * Ответ содержит присланный текст целиком: так на демонстрации видно, что подстановки вернулись
* подстановки вернулись на свои места при обратном преобразовании. * на свои места при обратном преобразовании.
*/ */
private static String stub(String maskedPrompt) { private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt; return "Ответ по запросу: " + maskedPrompt;
@@ -89,19 +86,26 @@ public class LlmClient {
message.put("role", "user"); message.put("role", "user");
message.put("content", maskedPrompt); message.put("content", maskedPrompt);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get())) HttpRequest.Builder request =
HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout) .timeout(timeout)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString( .POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(body), StandardCharsets.UTF_8)); mapper.writeValueAsString(body), StandardCharsets.UTF_8));
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key)); apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
HttpResponse<String> response = http.send(request.build(), HttpResponse<String> response =
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) { if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("модель ответила " + response.statusCode()); throw new IllegalStateException("модель ответила " + response.statusCode());
} }
return mapper.readTree(response.body()) return mapper
.path("choices").path(0).path("message").path("content").asText(); .readTree(response.body())
.path("choices")
.path(0)
.path("message")
.path("content")
.asText();
} }
} }
@@ -3,24 +3,21 @@ package ru.pdguard.core;
import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter; import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import java.time.Duration;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/** /**
* Настройка распределений для метрик времени. * Настройка распределений для метрик времени.
* *
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. * <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум. Этого хватает
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно * на среднее, но не на перцентили, а именно они описывают SLA: важно не среднее время ответа, а то,
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма * сколько запросов уложилось в срок. Гистограмма добавляет ряды по корзинам, и {@code
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по * histogram_quantile} в Prometheus считает по ним p50, p95 и p99.
* ним p50, p95 и p99.
* *
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти * <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти миллисекунды до десяти
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и * секунд. Без явных границ Micrometer создаёт их сам и заметно больше, а каждая корзина — это
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое * отдельный временной ряд на каждое сочетание меток.
* сочетание меток.
*/ */
@Configuration @Configuration
public class MetricsConfiguration { public class MetricsConfiguration {
@@ -40,7 +37,8 @@ public class MetricsConfiguration {
public MeterFilter histogramsForTimers() { public MeterFilter histogramsForTimers() {
return new MeterFilter() { return new MeterFilter() {
@Override @Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) { public DistributionStatisticConfig configure(
Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) { if (!needsHistogram(id.getName())) {
return config; return config;
} }
@@ -1,28 +1,26 @@
package ru.pdguard.core; package ru.pdguard.core;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
import java.util.HexFormat; import java.util.HexFormat;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** /**
* Шифрование исходных персональных данных в хранилище. * Шифрование исходных персональных данных в хранилище.
* *
* <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если * <p>ПДН не должны лежать в памяти и в общем слое в открытом виде: даже если процесс или Redis
* процесс или Redis скомпрометированы, исходные значения остаются недоступными * скомпрометированы, исходные значения остаются недоступными без ключа. Используется AES-GCM —
* без ключа. Используется AES-GCM — аутентифицированное шифрование, которое * аутентифицированное шифрование, которое защищает и от подмены шифротекста.
* защищает и от подмены шифротекста.
* *
* <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в * <p>Ключ задаётся настройкой {@code pdguard.store.encryption-key} (32 байта в hex). Пока ключ не
* hex). Пока ключ не задан, шифрование выключено — это нужно для тестов и для * задан, шифрование выключено — это нужно для тестов и для сборки, где хранилище не содержит
* сборки, где хранилище не содержит чувствительных данных. * чувствительных данных.
*/ */
@Component @Component
public class PayloadCipher { public class PayloadCipher {
@@ -36,7 +34,10 @@ public class PayloadCipher {
private final SecureRandom random = new SecureRandom(); private final SecureRandom random = new SecureRandom();
public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) { public PayloadCipher(@Value("${pdguard.store.encryption-key:}") String hexKey) {
this.key = hexKey == null || hexKey.isBlank() ? null : new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM); this.key =
hexKey == null || hexKey.isBlank()
? null
: new SecretKeySpec(HexFormat.of().parseHex(hexKey), ALGORITHM);
} }
/** Выключенное шифрование — для тестов и сборки без ключа. */ /** Выключенное шифрование — для тестов и сборки без ключа. */
+30 -31
View File
@@ -1,9 +1,5 @@
package ru.pdguard.core; package ru.pdguard.core;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
@@ -12,30 +8,31 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** /**
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование. * Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
* *
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — * <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски — страховка на
* страховка на случай, если идентификатор до сервиса не доехал. * случай, если идентификатор до сервиса не доехал.
* *
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет * <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет совпадение по самому
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в * тексту запроса, и без такого разделения он превращался бы в способ достать чужие данные: маски
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому, * детерминированы и низкоэнтропийны, поэтому, прислав «Клиент И. И. И., паспорт 45** ****56», можно
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ * было бы получить в ответ исходные значения из запроса другого потребителя. Разделение
* исходные значения из запроса другого потребителя. Разделение ограничивает это * ограничивает это пределами одной системы, которая и так видит свои данные.
* пределами одной системы, которая и так видит свои данные.
* *
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное * <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное время:
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не * персональные данные не должны залёживаться в памяти, а крупные тексты не должны исчерпать кучу.
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо * Вытеснение идёт в порядке добавления и выполняется прямо на записи — отдельного потока и внешней
* на записи — отдельного потока и внешней библиотеки кеширования не требуется. * библиотеки кеширования не требуется.
* *
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, * <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда, а чтение при
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на * промахе по локальной памяти идёт в него. Это нужно при работе на нескольких узлах: обратный
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял * запрос легко попадает не на тот узел, который выполнял прямой. Локальная память при этом остаётся
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь * первым уровнем, и обычный путь обходится без обращения по сети.
* обходится без обращения по сети.
*/ */
@Component @Component
public class PayloadStore { public class PayloadStore {
@@ -44,8 +41,8 @@ public class PayloadStore {
private static final int SWEEP_PER_PUT = 4; private static final int SWEEP_PER_PUT = 4;
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */ /** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
public record Entry(String system, String original, String masked, public record Entry(
String fingerprint, long expiresAt) { String system, String original, String masked, String fingerprint, long expiresAt) {
boolean alive(long now) { boolean alive(long now) {
return now < expiresAt; return now < expiresAt;
@@ -120,9 +117,8 @@ public class PayloadStore {
} }
/** /**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт * Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт только в
* только в пределах той же системы: чужую маску подобрать и обменять на исходные * пределах той же системы: чужую маску подобрать и обменять на исходные данные нельзя.
* данные нельзя.
*/ */
public String originalForMask(String system, String masked) { public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked); String fingerprint = fingerprint(masked);
@@ -137,9 +133,8 @@ public class PayloadStore {
public long charsHeld() { public long charsHeld() {
return charsHeld.get(); return charsHeld.get();
} }
/**
* Убирает протухшие записи с головы очереди, не более нескольких за раз. /** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
*/
private void sweepExpired(long now) { private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) { for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek(); String oldest = insertionOrder.peek();
@@ -187,8 +182,12 @@ public class PayloadStore {
if (entry == null) { if (entry == null) {
return null; return null;
} }
return new Entry(entry.system(), cipher.decrypt(entry.original()), entry.masked(), return new Entry(
entry.fingerprint(), entry.expiresAt()); entry.system(),
cipher.decrypt(entry.original()),
entry.masked(),
entry.fingerprint(),
entry.expiresAt());
} }
private static String fingerprint(String value) { private static String fingerprint(String value) {
+77 -60
View File
@@ -4,6 +4,14 @@ import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -19,26 +27,19 @@ import ru.pdguard.mask.MaskContext;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
/** /**
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование. * Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
* *
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса: * <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
*
* <ul> * <ul>
* <li>идентификатор неизвестен — маскируем;</li> * <li>идентификатор неизвестен — маскируем;
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li> * <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li> * <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.
* </ul> * </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан *
* совпасть с первым, иначе демаскирование по этому элементу развалится. * Последний случай — повторная попытка проверяющей системы: ответ обязан совпасть с первым, иначе
* демаскирование по этому элементу развалится.
*/ */
@Component @Component
public class Pipeline { public class Pipeline {
@@ -57,7 +58,11 @@ public class Pipeline {
private final Counter unresolvedDemask; private final Counter unresolvedDemask;
@Autowired @Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters, public Pipeline(
RuleRegistry registry,
Masker masker,
PayloadStore store,
MeterRegistry meters,
NameCascade cascade) { NameCascade cascade) {
this.registry = registry; this.registry = registry;
this.masker = masker; this.masker = masker;
@@ -65,11 +70,14 @@ public class Pipeline {
this.meters = meters; this.meters = meters;
this.cascade = cascade; this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld); meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed") this.tokensProcessed =
Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS") .description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters); .register(meters);
this.unresolvedDemask = Counter.builder("pdguard.demask.unresolved") this.unresolvedDemask =
.description("Запрос на демаскирование, для которого соответствие не нашлось ни по " Counter.builder("pdguard.demask.unresolved")
.description(
"Запрос на демаскирование, для которого соответствие не нашлось ни по "
+ "id, ни по отпечатку маски — обработан как новое маскирование") + "id, ни по отпечатку маски — обработан как новое маскирование")
.register(meters); .register(meters);
} }
@@ -114,7 +122,8 @@ public class Pipeline {
// «с нуля», что для настоящего демаскирования даст неверный ответ — считаем // «с нуля», что для настоящего демаскирования даст неверный ответ — считаем
// и логируем каждый такой случай явно, чтобы не потерять его молча. // и логируем каждый такой случай явно, чтобы не потерять его молча.
unresolvedDemask.increment(); unresolvedDemask.increment();
LOG.warn("payload_id={} демаскирование не нашло соответствие ни по id, ни по " LOG.warn(
"payload_id={} демаскирование не нашло соответствие ни по id, ни по "
+ "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)", + "отпечатку маски — payload обработан как новый (см. pdguard.demask.unresolved)",
payloadId); payloadId);
} }
@@ -122,9 +131,9 @@ public class Pipeline {
} }
/** /**
* Длительность обработки с разрезом по направлению и системе-потребителю. * Длительность обработки с разрезом по направлению и системе-потребителю. Метрики берутся из
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками, * реестра по тегам: систем немного и они заданы настройками, поэтому разрастания рядов не будет,
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу. * а разрез по потребителям виден сразу.
*/ */
private void recordLatency(String direction, String system, long startedNanos) { private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process") Timer.builder("pdguard.process")
@@ -136,9 +145,9 @@ public class Pipeline {
} }
/** /**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение * Фрагменты, которые будут замаскированы: поиск по правилам, разрешение перекрытий и все
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции * отсечения. Отдельный метод нужен, чтобы качество детекции можно было измерить, не разбирая
* можно было измерить, не разбирая замаскированный текст обратно. * замаскированный текст обратно.
*/ */
public List<Span> findPersonalData(String text, SystemPolicy policy) { public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy)); List<Span> spans = resolveOverlaps(registry.detect(text, policy));
@@ -162,12 +171,14 @@ public class Pipeline {
} }
/** /**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более * Оставляет непересекающиеся фрагменты: при конфликте побеждает более приоритетный, при равном
* приоритетный, при равном приоритете — более длинный. * приоритете — более длинный.
*/ */
static List<Span> resolveOverlaps(List<Span> spans) { static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans); List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed() candidates.sort(
Comparator.comparingInt(Span::priority)
.reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed()) .thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start)); .thenComparingInt(Span::start));
@@ -196,27 +207,27 @@ public class Pipeline {
} }
/** /**
* Убирает имена, стоящие в названиях организаций и объектов на карте: * Убирает имена, стоящие в названиях организаций и объектов на карте: «Институт Склифосовского»,
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка * «Музей Тропинина», «улица Королёва». Проверка не зависит от того, есть ли в тексте другие ПД:
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает * слово перед именем решает само по себе.
* само по себе.
*/ */
static List<Span> dropOrganisationNames(String text, List<Span> spans) { static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream() return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type()) .filter(
span ->
!PdTypes.FIO.equals(span.type())
|| !OrganisationDetector.precededByOrganisation(text, span.start())) || !OrganisationDetector.precededByOrganisation(text, span.start()))
.toList(); .toList();
} }
/** /**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными * Убирает имена известных людей: «стихи Александра Пушкина» персональными данными не являются.
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о * Если же в тексте есть ПД другого типа, речь идёт о конкретном человеке, и имя остаётся
* конкретном человеке, и имя остаётся замаскированным — однофамилец * замаскированным — однофамилец исторической фигуры защиту не теряет.
* исторической фигуры защиту не теряет.
*/ */
static List<Span> dropWellKnownNames(String text, List<Span> spans) { static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream() boolean otherPersonalDataPresent =
.anyMatch(span -> !PdTypes.FIO.equals(span.type())); spans.stream().anyMatch(span -> !PdTypes.FIO.equals(span.type()));
if (otherPersonalDataPresent) { if (otherPersonalDataPresent) {
return spans; return spans;
} }
@@ -226,9 +237,9 @@ public class Pipeline {
} }
/** /**
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим * Известный человек по самому спану («Пушкина») или по спану вместе со следующим словом
* словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от * («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от прозвища, а {@code
* прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком. * REGNAL_NAME} распознаёт только двухсловную форму целиком.
*/ */
private static boolean isWellKnownHere(String text, Span span) { private static boolean isWellKnownHere(String text, Span span) {
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) { if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
@@ -246,15 +257,13 @@ public class Pipeline {
} }
/** /**
* Убирает типы, которые опасны только в сочетании с другими ПД. * Убирает типы, которые опасны только в сочетании с другими ПД. Пин-код в отрыве от номера карты
* Пин-код в отрыве от номера карты не является персональными данными, * не является персональными данными, рядом с номером карты — является.
* рядом с номером карты — является.
* *
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь * <p>Спутником считается только находка самостоятельного типа. Раньше здесь сравнивалось число
* сравнивалось число различных типов, и два спутника заверяли друг друга: * различных типов, и два спутника заверяли друг друга: «Оплата 01.02.2025, ОГРН 1027700132195»
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека * маскировалась целиком, хотя человека в тексте нет, а дата и ОГРН по отдельности персональными
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются. * данными не являются. Сочетание двух несамостоятельных типов самостоятельным не становится.
* Сочетание двух несамостоятельных типов самостоятельным не становится.
*/ */
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) { static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) { for (Span span : spans) {
@@ -276,14 +285,21 @@ public class Pipeline {
/** /**
* Маскирует текст и отдаёт таблицу обратной замены. * Маскирует текст и отдаёт таблицу обратной замены.
* *
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить * <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить его целиком
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно. * по идентификатору нельзя, замену приходится делать пофрагментно. Звёздочки для этого не годятся
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным * — одна и та же маска может отвечать разным значениям, — поэтому режим замены здесь всегда
* значениям, — поэтому режим замены здесь всегда обратимый. * обратимый.
*/ */
public Masked maskWithRestorations(String text, SystemPolicy policy) { public Masked maskWithRestorations(String text, SystemPolicy policy) {
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(), SystemPolicy reversible =
MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key()); new SystemPolicy(
policy.name(),
policy.enabled(),
policy.demask(),
MaskMode.TOKEN,
policy.types(),
policy.requireCompanion(),
policy.key());
List<Span> spans = findPersonalData(text, reversible); List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) { if (spans.isEmpty()) {
return new Masked(text, Map.of()); return new Masked(text, Map.of());
@@ -315,16 +331,17 @@ public class Pipeline {
} }
/** /**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество. * В журнал и в метрики попадают только идентификатор, типы ПД и их количество. На INFO и выше
* На INFO и выше сами значения не логируются; на DEBUG они временно видны через * сами значения не логируются; на DEBUG они временно видны через отдельный вызов в {@link #mask}
* отдельный вызов в {@link #mask} — см. комментарий там. * — см. комментарий там.
*/ */
private void logFindings(String system, String payloadId, int length, List<Span> spans) { private void logFindings(String system, String payloadId, int length, List<Span> spans) {
Map<String, Integer> counts = new LinkedHashMap<>(); Map<String, Integer> counts = new LinkedHashMap<>();
for (Span span : spans) { for (Span span : spans) {
counts.merge(span.type(), 1, Integer::sum); counts.merge(span.type(), 1, Integer::sum);
} }
counts.forEach((type, count) -> counts.forEach(
(type, count) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count)); meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts); LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
} }
@@ -1,6 +1,7 @@
package ru.pdguard.core; package ru.pdguard.core;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.util.Set;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -11,24 +12,20 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.Set;
/** /**
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код. * Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
* *
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по * <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по интерпретируемому
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно * и наспех скомпилированному коду, и p95 оказывается примерно вдесятеро хуже установившегося.
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли * Несколько тысяч прогонов на старте занимают доли секунды и переводят горячий путь на
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут * оптимизирующий компилятор до того, как придут настоящие запросы.
* настоящие запросы.
* *
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим * <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим хранилищем: настоящие
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя. * соответствия «текст ↔ маска» замусорить нельзя.
* *
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики * <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики показывают долю
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту * запросов, дошедших до модели, а тысячи служебных прогонов эту долю исказили бы до неузнаваемости.
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при * Сама модель прогревается отдельно, при создании своего пула.
* создании своего пула.
*/ */
@Component @Component
public class PipelineWarmup { public class PipelineWarmup {
@@ -48,7 +45,9 @@ public class PipelineWarmup {
private final Masker masker; private final Masker masker;
private final int iterations; private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker, public PipelineWarmup(
RuleRegistry registry,
Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) { @Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry; this.registry = registry;
this.masker = masker; this.masker = masker;
@@ -62,10 +61,17 @@ public class PipelineWarmup {
return; return;
} }
long started = System.nanoTime(); long started = System.nanoTime();
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1), Pipeline scratch =
NameCascade.disabled()); new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1), NameCascade.disabled());
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK, SystemPolicy policy =
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null); new SystemPolicy(
SystemPolicy.DEFAULT_NAME,
true,
true,
MaskMode.MASK,
Set.of(SystemPolicy.ALL),
SystemPolicy.DEFAULT.requireCompanion(),
null);
for (int i = 0; i < iterations; i++) { for (int i = 0; i < iterations; i++) {
String text = SAMPLES[i % SAMPLES.length]; String text = SAMPLES[i % SAMPLES.length];
@@ -73,7 +79,9 @@ public class PipelineWarmup {
String masked = scratch.process(text, id, policy); String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy); scratch.process(masked, id, policy);
} }
LOG.info("Прогрев обработки: {} прогонов за {} мс", LOG.info(
iterations, (System.nanoTime() - started) / 1_000_000); "Прогрев обработки: {} прогонов за {} мс",
iterations,
(System.nanoTime() - started) / 1_000_000);
} }
} }
+4 -6
View File
@@ -3,15 +3,13 @@ package ru.pdguard.core;
/** /**
* Ключ, однозначно разделяющий системы-потребители. * Ключ, однозначно разделяющий системы-потребители.
* *
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может * <p>Длина имени в начале снимает вопрос о разделителе: имя системы может содержать любые знаки, и
* содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы. * без длины «a:b» и «ab:» были бы неразличимы. Используется и в локальном хранилище, и в общем слое
* Используется и в локальном хранилище, и в общем слое — единая реализация * — единая реализация вместо двух копий.
* вместо двух копий.
*/ */
final class ScopedKey { final class ScopedKey {
private ScopedKey() { private ScopedKey() {}
}
static String of(String system, String key) { static String of(String system, String key) {
String owner = system == null ? "" : system; String owner = system == null ? "" : system;
+27 -25
View File
@@ -2,30 +2,28 @@ package ru.pdguard.core;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/** /**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах. * Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
* *
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. * <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат. Обратное же
* Обратное же преобразование требует состояния: если прямой запрос обработал * преобразование требует состояния: если прямой запрос обработал один узел, а обратный попал на
* один узел, а обратный попал на другой, соответствие должно быть общим. * другой, соответствие должно быть общим.
* *
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не * <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не выставлена, к Redis не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной. * обращаются вовсе и зависимость остаётся неактивной.
* *
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до * <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до локальной памяти
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не * узла, а ошибка попадает в журнал. Чтобы простой Redis не съедал время ответа, команды ограничены
* съедал время ответа, команды ограничены по времени настройкой * по времени настройкой {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой * слой временно перестают опрашивать вовсе.
* временно перестают опрашивать вовсе.
*/ */
@Component @Component
public class SharedIndex { public class SharedIndex {
@@ -39,8 +37,7 @@ public class SharedIndex {
private static final long OPEN_MILLIS = 5_000; private static final long OPEN_MILLIS = 5_000;
/** Пара «исходный текст — маска», как она хранится в общем слое. */ /** Пара «исходный текст — маска», как она хранится в общем слое. */
public record SharedEntry(String original, String masked) { public record SharedEntry(String original, String masked) {}
}
private final boolean enabled; private final boolean enabled;
private final Duration ttl; private final Duration ttl;
@@ -52,7 +49,8 @@ public class SharedIndex {
private volatile long silentUntil; private volatile long silentUntil;
private volatile boolean reported; private volatile boolean reported;
public SharedIndex(StringRedisTemplate redis, public SharedIndex(
StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend, @Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes, @Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper, ObjectMapper mapper,
@@ -73,15 +71,16 @@ public class SharedIndex {
return enabled; return enabled;
} }
public void put(String system, String payloadId, String original, String masked, public void put(
String maskFingerprint) { String system, String payloadId, String original, String masked, String maskFingerprint) {
if (unavailable()) { if (unavailable()) {
return; return;
} }
try { try {
String encrypted = cipher.encrypt(original); String encrypted = cipher.encrypt(original);
redis.opsForValue().set(ScopedKey.of(system, payloadId), redis
toJson(new SharedEntry(encrypted, masked)), ttl); .opsForValue()
.set(ScopedKey.of(system, payloadId), toJson(new SharedEntry(encrypted, masked)), ttl);
redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl); redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
noteSuccess(); noteSuccess();
} catch (RuntimeException e) { } catch (RuntimeException e) {
@@ -97,7 +96,9 @@ public void put(String system, String payloadId, String original, String masked,
String json = redis.opsForValue().get(ScopedKey.of(system, payloadId)); String json = redis.opsForValue().get(ScopedKey.of(system, payloadId));
noteSuccess(); noteSuccess();
SharedEntry entry = json == null ? null : fromJson(json); SharedEntry entry = json == null ? null : fromJson(json);
return entry == null ? null : new SharedEntry(cipher.decrypt(entry.original()), entry.masked()); return entry == null
? null
: new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
} catch (RuntimeException e) { } catch (RuntimeException e) {
noteFailure("прочитать", e); noteFailure("прочитать", e);
return null; return null;
@@ -147,9 +148,9 @@ public void put(String system, String payloadId, String original, String masked,
} }
/** /**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько * После нескольких неудач подряд общий слой перестают опрашивать на несколько секунд: иначе
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а * каждый запрос платил бы таймаутом за недоступный Redis, а проверяющая система считает ответ
* проверяющая система считает ответ дольше десяти секунд неответом. * дольше десяти секунд неответом.
*/ */
private void noteFailure(String action, RuntimeException cause) { private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) { if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
@@ -157,7 +158,8 @@ public void put(String system, String payloadId, String original, String masked,
} }
if (!reported) { if (!reported) {
reported = true; reported = true;
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause); LOG.error(
"Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
} }
} }
} }
@@ -1,7 +1,5 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.util.List;
import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_GAP; import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_GAP;
import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_VALUE; import static ru.pdguard.detect.RulePatterns.CITIZENSHIP_VALUE;
import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY; import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY;
@@ -9,61 +7,91 @@ import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
import static ru.pdguard.detect.RulePatterns.STREET_NAME; import static ru.pdguard.detect.RulePatterns.STREET_NAME;
import static ru.pdguard.detect.RuleRegistry.ADDRESS_NEARBY; import static ru.pdguard.detect.RuleRegistry.ADDRESS_NEARBY;
import java.util.List;
/** Правила распознавания органа выдачи паспорта, места рождения, гражданства и адреса. */ /** Правила распознавания органа выдачи паспорта, места рождения, гражданства и адреса. */
final class AddressRules { final class AddressRules {
private AddressRules() { private AddressRules() {}
}
static final List<Rule> RULES = List.of( static final List<Rule> RULES =
List.of(
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит, // «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа // её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть. // осталась бы замаскированной только его часть.
// Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся» // Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
// (обычное слово, не про выдачу документа). // (обычное слово, не про выдачу документа).
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)" Rule.of(
PdTypes.PASSPORT_ISSUER,
"(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
+ "\\W{0,3}([^,;\\n]{3,90}?)" + "\\W{0,3}([^,;\\n]{3,90}?)"
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78) + "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)",
78)
.groups(1) .groups(1)
.anchoredBy("выдан", "выдал", "выдач"), .anchoredBy("выдан", "выдал", "выдач"),
// «совпадает с указанным в анкете: X» — второе упоминание органа выдачи // «совпадает с указанным в анкете: X» — второе упоминание органа выдачи
// под собственным якорем, без бэкреференса на первое. // под собственным якорем, без бэкреференса на первое.
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)" Rule.of(
+ "(?=[,;.\\n]|\\s*$)", 78) PdTypes.PASSPORT_ISSUER,
"(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)"
+ "(?=[,;.\\n]|\\s*$)",
78)
.groups(1) .groups(1)
.anchoredBy("указанн"), .anchoredBy("указанн"),
// «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря, // «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря,
// до слова «совпадает» или конца фразы. // до слова «совпадает» или конца фразы.
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)" Rule.of(
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)", 78) PdTypes.PASSPORT_ISSUER,
"(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)"
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)",
78)
.groups(1) .groups(1)
.anchoredBy("орган выдачи"), .anchoredBy("орган выдачи"),
Rule.of(
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76) PdTypes.BIRTH_PLACE,
"(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)",
76)
.groups(1) .groups(1)
.anchoredBy("рождения"), .anchoredBy("рождения"),
Rule.of(
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+" PdTypes.BIRTH_PLACE,
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76) "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)",
76)
.groups(1) .groups(1)
.anchoredBy("родил"), .anchoredBy("родил"),
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» — // ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
// между якорем и значением бывает несколько слов, не только пунктуация. // между якорем и значением бывает несколько слов, не только пунктуация.
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном. // Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP Rule.of(
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80) PdTypes.CITIZENSHIP,
"(?iu:гражданств)\\w*"
+ CITIZENSHIP_GAP
+ "("
+ CITIZENSHIP_VALUE
+ ")(?:\\s*[,/]\\s*("
+ CITIZENSHIP_VALUE
+ "))?",
80)
.groups(1, 2) .groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry) .validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("гражданств"), .anchoredBy("гражданств"),
// ин/ка/ина/ки — именительный/родительный; ином/кой — творительный // ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
// («гражданином», «гражданкой»). // («гражданином», «гражданкой»).
Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+" Rule.of(
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 75) PdTypes.CITIZENSHIP,
"(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
+ "("
+ CITIZENSHIP_VALUE
+ ")(?:\\s*[,/]\\s*("
+ CITIZENSHIP_VALUE
+ "))?",
75)
.groups(1, 2) .groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry) .validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("граждан"), .anchoredBy("граждан"),
@@ -74,28 +102,36 @@ final class AddressRules {
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"), .anchoredBy("индекс"),
Rule.of(
Rule.of(PdTypes.ADDRESS_POSTCODE, PdTypes.ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74) "\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))",
74)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY), .vetoedBy(ORGANISATION_NEARBY),
// Не только «г.»: перепись, на которой проверяется словарь, покрывает // Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево» // сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём. // из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(PdTypes.ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b" Rule.of(
PdTypes.ADDRESS_CITY,
"(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b" + "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73) + "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b",
73)
.groups(1) .groups(1)
.validatedBy(ToponymDictionary::isKnownSettlement) .validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", .anchoredBy(
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"), "г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", "дер.",
"х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(PdTypes.ADDRESS_STREET, Rule.of(
PdTypes.ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*" "(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)" + "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
+ "\\W{0,3}(" + STREET_NAME + ")", 73) + "\\W{0,3}("
+ STREET_NAME
+ ")",
73)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY) .requiringNear(ADDRESS_NEARBY)
@@ -104,29 +140,32 @@ final class AddressRules {
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому // «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
// принимается только рядом с другими частями адреса: иначе под маску попал бы // принимается только рядом с другими частями адреса: иначе под маску попал бы
// любой рассказ про Невский проспект. // любой рассказ про Невский проспект.
Rule.of(PdTypes.ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+" Rule.of(
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73) PdTypes.ADDRESS_STREET,
"\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b",
73)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY) .requiringNear(ADDRESS_NEARBY)
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"), .anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
Rule.of(
Rule.of(PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72) "(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b",
72)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"), .anchoredBy("д.", "дом"),
Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"), .anchoredBy("кв"),
Rule.of(
Rule.of(PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)" "(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71) + "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b",
71)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("стран") .anchoredBy("стран"));
);
} }
@@ -1,17 +1,16 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.util.List;
import static ru.pdguard.detect.RulePatterns.ROLE_GAP; import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
import java.util.List;
/** Правила распознавания контактных и идентификационных данных: телефон, email, ИНН, СНИЛС. */ /** Правила распознавания контактных и идентификационных данных: телефон, email, ИНН, СНИЛС. */
final class ContactRules { final class ContactRules {
private ContactRules() { private ContactRules() {}
}
static final List<Rule> RULES = List.of(
static final List<Rule> RULES =
List.of(
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84) Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
.groups(1) .groups(1)
.anchoredBy("инн"), .anchoredBy("инн"),
@@ -20,19 +19,22 @@ final class ContactRules {
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84) Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84)
.groups(1) .groups(1)
.anchoredBy("инн/кпп"), .anchoredBy("инн/кпп"),
Rule.of(
Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84) PdTypes.SNILS,
"(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b",
84)
.groups(1) .groups(1)
.validatedBy(Validators::snils), .validatedBy(Validators::snils),
// \b7, не только +7: номер без плюса («79031119955») тоже встречается. // \b7, не только +7: номер без плюса («79031119955») тоже встречается.
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), Rule.of(
PdTypes.PHONE,
"(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}["
+ " -]{0,2}\\d{2}\\b",
82),
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80) Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"), .anchoredBy("@"),
// ИНН физлица без якорного слова — только с верной контрольной суммой. // ИНН физлица без якорного слова — только с верной контрольной суммой.
Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62) Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62).validatedBy(Validators::inn));
.validatedBy(Validators::inn)
);
} }
@@ -4,27 +4,26 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
/** /**
* Словарь названий стран — проверка того, что значение, пойманное правилом * Словарь названий стран — проверка того, что значение, пойманное правилом {@code CITIZENSHIP},
* {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное * действительно похоже на страну, а не на произвольное слово с заглавной буквы после якоря
* слово с заглавной буквы после якоря «гражданство». * «гражданство».
* *
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания * <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в России», «из
* («в России», «из Казахстана») и формы прилагательных («российская», * Казахстана») и формы прилагательных («российская», «российское») тем самым покрываются без
* «российское») тем самым покрываются без отдельного разбора морфологии. * отдельного разбора морфологии. Основа «российск» покрывает и «Российская», и «российская», и
* Основа «российск» покрывает и «Российская», и «российская», и «российское». * «российское».
*/ */
public final class CountryDictionary { public final class CountryDictionary {
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt"); private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
private CountryDictionary() { private CountryDictionary() {}
}
/** /**
* Похоже ли значение на название страны из словаря в любом падеже и регистре. * Похоже ли значение на название страны из словаря в любом падеже и регистре.
* *
* <p>Проверяются префиксы значения по множеству, а не каждая основа по * <p>Проверяются префиксы значения по множеству, а не каждая основа по значению: префиксов у
* значению: префиксов у слова не больше, чем в нём букв. * слова не больше, чем в нём букв.
*/ */
public static boolean isKnownCountry(String value) { public static boolean isKnownCountry(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT); String lower = value.strip().toLowerCase(Locale.ROOT);
+19 -18
View File
@@ -1,45 +1,46 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.util.List;
import static ru.pdguard.detect.RulePatterns.DATE_ANY; import static ru.pdguard.detect.RulePatterns.DATE_ANY;
import static ru.pdguard.detect.RulePatterns.DATE_GAP; import static ru.pdguard.detect.RulePatterns.DATE_GAP;
import java.util.List;
/** Правила распознавания дат: рождения, выдачи документа и дат без якорного слова. */ /** Правила распознавания дат: рождения, выдачи документа и дат без якорного слова. */
final class DateRules { final class DateRules {
private DateRules() { private DateRules() {}
}
static final List<Rule> RULES = List.of( static final List<Rule> RULES =
List.of(
Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" Rule.of(
+ DATE_GAP + "(" + DATE_ANY + ")", 87) PdTypes.BIRTH_DATE,
"(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" + DATE_GAP + "(" + DATE_ANY + ")",
87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("рожден"), .anchoredBy("рожден"),
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))" + DATE_GAP + "(" + DATE_ANY + ")", 87)
Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))"
+ DATE_GAP + "(" + DATE_ANY + ")", 87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("родил"), .anchoredBy("родил"),
Rule.of(
Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87) PdTypes.BIRTH_DATE,
"(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)",
87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"), .anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)" Rule.of(
+ DATE_GAP + "(" + DATE_ANY + ")", 87) PdTypes.PASSPORT_DATE,
"(?iu:дат\\p{L}*\\s+выдачи)" + DATE_GAP + "(" + DATE_ANY + ")",
87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("выдач"), .anchoredBy("выдач"),
// Дата без якорного слова персональными данными сама по себе не является: // Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа. // маскируется, только если в тексте есть ПД другого типа.
Rule.of(PdTypes.DATE, DATE_ANY, 58) Rule.of(PdTypes.DATE, DATE_ANY, 58).validatedBy(Validators::date));
.validatedBy(Validators::date)
);
} }
+16 -20
View File
@@ -3,40 +3,36 @@ package ru.pdguard.detect;
import java.util.Locale; import java.util.Locale;
/** /**
* Общий приём для словарей, сравнивающих слово из текста с основой из списка: * Общий приём для словарей, сравнивающих слово из текста с основой из списка: личные имена ({@link
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}). * NameDictionary}) и города ({@link ToponymDictionary}).
* *
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», * <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове», «Пушкин» →
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на * «Пушкина») — там основы из списка достаточно как есть. Слова на гласную меняют последнюю букву
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») — * («Москва» → «Москве», «Ольга» → «Ольге») — для них сравнение идёт по основе без неё.
* для них сравнение идёт по основе без неё.
* *
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется * <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется целиком («Дзержинский» →
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается), * «Дзержинского», «-ий» на «-ого», а не дописывается), поэтому для них отсечения одной буквы
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу * недостаточно — основа обрезается сразу до «ск». Для улиц в честь людей это не редкий случай, а
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица * основной: «улица Дзержинского», «улица Островского» пишутся только в родительном падеже,
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще. * именительный там не встречается вообще.
*/ */
final class Declension { final class Declension {
/** /**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский * Падежные окончания прилагательного склонения на «-ск-»: мужской, женский и средний род, все
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского» * падежи. Проверяются от длинных к коротким — «-ского» не должно потеряться из-за более короткого
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п. * совпадения на «-ким» и т.п.
*/ */
private static final String[] ADJECTIVE_ENDINGS = { private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский" "ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
}; };
private Declension() { private Declension() {}
}
/** /**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную — * Отбрасывает у основы окончание, которое меняется по падежам: гласную — у обычных слов, целиком
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий. * «-ск-»-окончание — у прилагательных фамилий. Слова короче четырёх букв не трогает — короткая
* Слова короче четырёх букв не трогает — короткая основа и так шире * основа и так шире большинства падежных форм.
* большинства падежных форм.
*/ */
static String withoutInflectedEnding(String word) { static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT); String lower = word.toLowerCase(Locale.ROOT);
@@ -1,85 +1,113 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.util.List;
import static ru.pdguard.detect.RulePatterns.ROLE_GAP; import static ru.pdguard.detect.RulePatterns.ROLE_GAP;
import static ru.pdguard.detect.RulePatterns.SERIES_AND_NUMBER; import static ru.pdguard.detect.RulePatterns.SERIES_AND_NUMBER;
import java.util.List;
/** Правила распознавания документов, удостоверяющих личность, и кодов подразделений. */ /** Правила распознавания документов, удостоверяющих личность, и кодов подразделений. */
final class DocumentRules { final class DocumentRules {
private DocumentRules() { private DocumentRules() {}
}
static final List<Rule> RULES = List.of( static final List<Rule> RULES =
List.of(
// CVV: латиница, кириллическая транслитерация («цвв», «сививи») и // CVV: латиница, кириллическая транслитерация («цвв», «сививи») и
// описательные якоря («код на обороте карты»). Между якорем и числом // описательные якоря («код на обороте карты»). Между якорем и числом
// допускаются слова («CVV код 321», «CVV указан код 123») и длинные // допускаются слова («CVV код 321», «CVV указан код 123») и длинные
// разделители («код на обороте карты 789»). // разделители («код на обороте карты 789»).
Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты" Rule.of(
PdTypes.CVV,
"(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты"
+ "|код\\s+проверки|защитный\\s+код)\\b)" + "|код\\s+проверки|защитный\\s+код)\\b)"
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b", 92) + "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b",
92)
.groups(1) .groups(1)
.anchoredBy("cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"), .anchoredBy(
"cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"),
// PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом // PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом
// допускаются слова («ПИН-код карты 2468») и длинные разделители // допускаются слова («ПИН-код карты 2468») и длинные разделители
// («Пин Код: 1234»). // («Пин Код: 1234»).
Rule.of(PdTypes.PIN, "(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)" Rule.of(
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b", 92) PdTypes.PIN,
"(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)"
+ "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b",
92)
.groups(1) .groups(1)
.anchoredBy("пин", "pin"), .anchoredBy("пин", "pin"),
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456» // «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
Rule.of(PdTypes.PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?" Rule.of(
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90) PdTypes.PASSPORT,
"(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
+ "\\W{0,10}("
+ SERIES_AND_NUMBER
+ ")\\b",
90)
.groups(1) .groups(1)
.anchoredBy("паспорт"), .anchoredBy("паспорт"),
// «серия 4509 номер 123456», «серии 45 09 № 123456» // «серия 4509 номер 123456», «серии 45 09 № 123456»
// Между серией и номером помещается слово: «серия 4509 номер 123456», // Между серией и номером помещается слово: «серия 4509 номер 123456»,
// «серии 4509 за номером 123456», «серия 4509 № 123456». // «серии 4509 за номером 123456», «серия 4509 № 123456».
Rule.of(PdTypes.PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90) Rule.of(
PdTypes.PASSPORT,
"(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b",
90)
.groups(1, 2) .groups(1, 2)
.anchoredBy("сери"), .anchoredBy("сери"),
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890». // Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" Rule.of(
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89) PdTypes.DRIVER_LICENSE,
"(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?("
+ SERIES_AND_NUMBER
+ ")\\b",
89)
.groups(1) .groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "), .anchoredBy("водительск", "в/у", "вод.", "ву "),
Rule.of(
Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" PdTypes.FOREIGN_PASSPORT,
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89) "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b",
89)
.groups(1) .groups(1)
.anchoredBy("загранпаспорт", "заграничн"), .anchoredBy("загранпаспорт", "заграничн"),
Rule.of(
Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" PdTypes.MILITARY_ID,
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89) "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b",
89)
.groups(1) .groups(1)
.anchoredBy("военн"), .anchoredBy("военн"),
Rule.of(
Rule.of(PdTypes.BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)" PdTypes.BIRTH_CERTIFICATE,
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89) "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b",
89)
.groups(1) .groups(1)
.anchoredBy("свидетельств"), .anchoredBy("свидетельств"),
Rule.of(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89) Rule.of(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1) .groups(1)
.anchoredBy("полис"), .anchoredBy("полис"),
// ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и // ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и
// значением есть слово («стоит»/«объекта»), не только пунктуация. // значением есть слово («стоит»/«объекта»), не только пунктуация.
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP Rule.of(
+ "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) PdTypes.DEPT_CODE,
"(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP + "(\\d{3}\\s?-?\\s?\\d{3})\\b",
88)
.groups(1) .groups(1)
.anchoredBy("подразделени", "к/п"), .anchoredBy("подразделени", "к/п"),
// «770-001 — таков код подразделения» — значение перед якорем. // «770-001 — таков код подразделения» — значение перед якорем.
Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}" Rule.of(
+ "(?iu:код\\w*\\s+подразделения)", 88) PdTypes.DEPT_CODE,
"\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}"
+ "(?iu:код\\w*\\s+подразделения)",
88)
.groups(1) .groups(1)
.anchoredBy("подразделени") .anchoredBy("подразделени"));
);
} }
@@ -1,30 +1,33 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.util.List;
import static ru.pdguard.detect.RulePatterns.HOLDER_STEM; import static ru.pdguard.detect.RulePatterns.HOLDER_STEM;
import java.util.List;
/** Правила распознавания банковских реквизитов, карты, ОГРН/КПП и держателя карты. */ /** Правила распознавания банковских реквизитов, карты, ОГРН/КПП и держателя карты. */
final class FinanceRules { final class FinanceRules {
private FinanceRules() { private FinanceRules() {}
}
static final List<Rule> RULES = List.of( static final List<Rule> RULES =
List.of(
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|" Rule.of(
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83) PdTypes.ACCOUNT_NUMBER,
"(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b",
83)
.groups(1) .groups(1)
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"), .anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("бик"),
Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("бик"),
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения. // «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}" Rule.of(
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83) PdTypes.CARD_EXPIRY,
"(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
+ "(\\d{2}\\s?/\\s?\\d{2})\\b",
83)
.groups(1) .groups(1)
.anchoredBy("срок действия", "действительн", "exp"), .anchoredBy("срок действия", "действительн", "exp"),
@@ -35,38 +38,40 @@ final class FinanceRules {
.groups(1) .groups(1)
.validatedBy(Validators::ogrnip) .validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"), .anchoredBy("огрнип"),
Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1) .groups(1)
.validatedBy(Validators::ogrn) .validatedBy(Validators::ogrn)
.anchoredBy("огрн"), .anchoredBy("огрн"),
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83).groups(1).anchoredBy("кпп"),
Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил // стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную. // бы только «доход 85000», вплотную.
Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?" Rule.of(
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76) PdTypes.INCOME,
"(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b",
76)
.groups(1) .groups(1)
.anchoredBy("доход", "заработн", "зарплат"), .anchoredBy("доход", "заработн", "зарплат"),
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного // Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
// значения для захвата нет: маскируется якорная фраза целиком. // значения для захвата нет: маскируется якорная фраза целиком.
Rule.of(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)" Rule.of(
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81) PdTypes.BIOMETRIC,
"(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)",
81)
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"), .anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
Rule.of(
Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" PdTypes.CARDHOLDER,
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b",
86)
.groups(1) .groups(1)
.anchoredBy(HOLDER_STEM, "cardholder", "на имя"), .anchoredBy(HOLDER_STEM, "cardholder", "на имя"),
// --- Уровень 1: подтверждается контрольной суммой --- // --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85) Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85).validatedBy(Validators::luhn));
.validatedBy(Validators::luhn)
);
} }
+76 -24
View File
@@ -1,30 +1,36 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.util.List;
import static ru.pdguard.detect.RulePatterns.CAPITALISED; import static ru.pdguard.detect.RulePatterns.CAPITALISED;
import static ru.pdguard.detect.RulePatterns.HOLDER_STEM; import static ru.pdguard.detect.RulePatterns.HOLDER_STEM;
import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY; import static ru.pdguard.detect.RulePatterns.ORGANISATION_NEARBY;
import static ru.pdguard.detect.RulePatterns.PATRONYMIC; import static ru.pdguard.detect.RulePatterns.PATRONYMIC;
import static ru.pdguard.detect.RulePatterns.SURNAME; import static ru.pdguard.detect.RulePatterns.SURNAME;
import java.util.List;
/** Правила распознавания ФИО — от полной тройки с ролевым словом до одиночного имени по словарю. */ /** Правила распознавания ФИО — от полной тройки с ролевым словом до одиночного имени по словарю. */
final class FioRules { final class FioRules {
private FioRules() { private FioRules() {}
}
static final List<Rule> RULES = List.of( static final List<Rule> RULES =
List.of(
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь // Свободная тройка «любое слово с заглавной + имя + отчество» здесь
// сознательно не используется: она захватывает глагол в начале // сознательно не используется: она захватывает глагол в начале
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), Rule.of(
PdTypes.FIO,
"\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b",
79),
// Имя Отчество Фамилия — второй распространённый порядок слов. // Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79), Rule.of(
PdTypes.FIO,
"\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b",
79),
// Иванов И.И. и И.И. Иванов // Иванов И.И. и И.И. Иванов
Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79), Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
@@ -34,27 +40,52 @@ final class FioRules {
Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77), Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру // «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(PdTypes.FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)" Rule.of(
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77) PdTypes.FIO,
"(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b",
77)
.groups(1) .groups(1)
.anchoredBy("фио", "ф.и.о", "на имя"), .anchoredBy("фио", "ф.и.о", "на имя"),
// «клиент Иванов Иван», «плательщик Петрова» // «клиент Иванов Иван», «плательщик Петрова»
Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик" Rule.of(
PdTypes.FIO,
"(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител" + "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*" + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77) + "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b",
77)
.groups(1) .groups(1)
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", .anchoredBy(
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик", "клиент",
"заемщик", "заявител", "доверител", "вкладчик", "ответственн", "заказчик",
"контактное лицо", "исполнител", HOLDER_STEM), "пациент",
"сотрудник",
"владел",
"плательщик",
"получател",
"абонент",
"в лице",
"представител",
"поручител",
"заёмщик",
"заемщик",
"заявител",
"доверител",
"вкладчик",
"ответственн",
"контактное лицо",
"исполнител",
HOLDER_STEM),
// «клиент иван иванов», «поручитель петрович» — строчные имена после // «клиент иван иванов», «поручитель петрович» — строчные имена после
// ролевого слова. Регистр снимает требование к заглавной букве, а словарь // ролевого слова. Регистр снимает требование к заглавной букве, а словарь
// имён отсекает «клиент пришёл в офис». // имён отсекает «клиент пришёл в офис».
Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик" Rule.of(
PdTypes.FIO,
"(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител" + "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател" + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател"
@@ -63,14 +94,36 @@ final class FioRules {
// движку «отдать» уже съеденное падежное окончание ролевого слова и // движку «отдать» уже съеденное падежное окончание ролевого слова и
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов». // захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
+ "|\\bпоручител)\\p{L}*+" + "|\\bпоручител)\\p{L}*+"
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77) + "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])",
77)
.groups(1) .groups(1)
.validatedBy(NameDictionary::containsNamePart) .validatedBy(NameDictionary::containsNamePart)
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", .anchoredBy(
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик", "клиент",
"заемщик", "заявител", "доверител", "вкладчик", "ответственн", "заказчик",
"контактное лицо", "исполнител", HOLDER_STEM, "отправител", "бенефициар", "пациент",
"доверенное лицо", "наследник", "созаемщик"), "сотрудник",
"владел",
"плательщик",
"получател",
"абонент",
"в лице",
"представител",
"поручител",
"заёмщик",
"заемщик",
"заявител",
"доверител",
"вкладчик",
"ответственн",
"контактное лицо",
"исполнител",
HOLDER_STEM,
"отправител",
"бенефициар",
"доверенное лицо",
"наследник",
"созаемщик"),
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже // «Тверская улица» и тому подобное. Имя проверяется по множеству уже
@@ -95,6 +148,5 @@ final class FioRules {
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел». // имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70) Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
.groups(1) .groups(1)
.validatedBy(NameDictionary::isStandaloneNameCandidate) .validatedBy(NameDictionary::isStandaloneNameCandidate));
);
} }
+171 -97
View File
@@ -5,13 +5,6 @@ import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
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.config.SystemPolicy;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -23,28 +16,33 @@ import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.slf4j.Logger;
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.config.SystemPolicy;
/** /**
* Вторая ступень распознавания. * Вторая ступень распознавания.
* *
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки * <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки микросекунд.
* микросекунд. Модель нужна там, где они бессильны: имена без русского * Модель нужна там, где они бессильны: имена без русского словообразования и нестандартные
* словообразования и нестандартные топонимы. * топонимы.
* *
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из * <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из двух-трёх слов с
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном * заглавной буквы, которые первая ступень не покрыла. Их в обычном запросе единицы, и на задержку
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее * это почти не влияет. Дороже модель — тем важнее такая экономия: у BERT вызов стоит десятки
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый * миллисекунд, и звать его на каждый запрос было бы невозможно.
* запрос было бы невозможно.
* *
* <p>Используются две модели под разные задачи: одна размечает имена (например, * <p>Используются две модели под разные задачи: одна размечает имена (например, WikiNEuRal, который
* WikiNEuRal, который не распознаёт известных личностей), другая — составляющие * не распознаёт известных личностей), другая — составляющие адреса (например, ruBERT с детальными
* адреса (например, ruBERT с детальными метками страны, региона, района, города, * метками страны, региона, района, города, улицы и дома). Каждая модель зовётся только на
* улицы и дома). Каждая модель зовётся только на непокрытые кандидаты. * непокрытые кандидаты.
* *
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: * <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет: ошибка
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают * перехватывается здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
* правила. Иначе одно исключение обнуляло бы маскирование целиком. * исключение обнуляло бы маскирование целиком.
*/ */
@Component @Component
public class NameCascade { public class NameCascade {
@@ -53,15 +51,16 @@ public class NameCascade {
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */ /** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests"; private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени"; private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome"; private static final String OUTCOME_TAG = "outcome";
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */ /** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> NAME_TYPES = Map.of( private static final Map<String, String> NAME_TYPES = Map.of("PER", PdTypes.FIO);
"PER", PdTypes.FIO);
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */ /** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Map<String, String> ADDRESS_TYPES = Map.of( private static final Map<String, String> ADDRESS_TYPES =
Map.of(
"COUNTRY", PdTypes.ADDRESS_COUNTRY, "COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", PdTypes.ADDRESS_REGION, "REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", PdTypes.ADDRESS_DISTRICT, "DISTRICT", PdTypes.ADDRESS_DISTRICT,
@@ -70,11 +69,12 @@ public class NameCascade {
"HOUSE", PdTypes.ADDRESS_HOUSE); "HOUSE", PdTypes.ADDRESS_HOUSE);
/** /**
* Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых * Метки LLAIM Legal NER в типы ПД: юридические реквизиты и документы, которых нет в общих
* нет в общих моделях. ADDRESS не сопоставляется — ruBERT размечает адреса * моделях. ADDRESS не сопоставляется — ruBERT размечает адреса детальнее. ORG, CASE_NUMBER и
* детальнее. ORG, CASE_NUMBER и POSITION аналогов в {@link PdTypes} не имеют. * POSITION аналогов в {@link PdTypes} не имеют.
*/ */
private static final Map<String, String> LEGAL_TYPES = Map.of( private static final Map<String, String> LEGAL_TYPES =
Map.of(
"PER", PdTypes.FIO, "PER", PdTypes.FIO,
"INN", PdTypes.INN, "INN", PdTypes.INN,
"OGRN", PdTypes.OGRN, "OGRN", PdTypes.OGRN,
@@ -86,17 +86,19 @@ public class NameCascade {
"DATE", PdTypes.DATE); "DATE", PdTypes.DATE);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */ /** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE = Pattern.compile( private static final Pattern CANDIDATE =
Pattern.compile(
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}", "\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** /**
* Кандидат для LLAIM Legal NER: одиночное слово или цифровой кластер * Кандидат для LLAIM Legal NER: одиночное слово или цифровой кластер (10–19 цифр с
* (10–19 цифр с разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт) * разделителями). Юридические реквизиты (ИНН, СНИЛС, паспорт) не являются словами с заглавной
* не являются словами с заглавной буквы, поэтому для них нужен отдельный * буквы, поэтому для них нужен отдельный проход, не влияющий на кандидатов моделей имён и
* проход, не влияющий на кандидатов моделей имён и адресов. * адресов.
*/ */
private static final Pattern LEGAL_CANDIDATE = Pattern.compile( private static final Pattern LEGAL_CANDIDATE =
Pattern.compile(
"(?:\\p{Lu}[\\p{L}-]+|\\p{Ll}[\\p{L}-]+|\\d(?:[\\s.\\-/()]?\\d){9,18})", "(?:\\p{Lu}[\\p{L}-]+|\\p{Ll}[\\p{L}-]+|\\d(?:[\\s.\\-/()]?\\d){9,18})",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
@@ -114,11 +116,12 @@ public class NameCascade {
private volatile boolean broken; private volatile boolean broken;
/** /**
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение * Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение {@code engaged} ко
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит, * всем обращениям и есть та доля, от которой зависит, посильна ли тяжёлая модель на боевом
* посильна ли тяжёлая модель на боевом трафике. * трафике.
*/ */
private final Counter engaged; private final Counter engaged;
private final Counter withoutCandidates; private final Counter withoutCandidates;
private final Counter busy; private final Counter busy;
private final Counter candidates; private final Counter candidates;
@@ -140,49 +143,85 @@ public class NameCascade {
this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES); this.addressRecogniser = create(addressEngine, addressModel, ADDRESS_TYPES);
this.legalRecogniser = create(legalEngine, legalModel, LEGAL_TYPES); this.legalRecogniser = create(legalEngine, legalModel, LEGAL_TYPES);
this.concurrent = new Semaphore(Math.max(1, poolSize)); this.concurrent = new Semaphore(Math.max(1, poolSize));
this.engaged = Counter.builder(NER_REQUESTS_METRIC) this.engaged =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION) .description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "engaged").register(meters); .tag(OUTCOME_TAG, "engaged")
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC) .register(meters);
this.withoutCandidates =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION) .description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "no_candidates").register(meters); .tag(OUTCOME_TAG, "no_candidates")
this.busy = Counter.builder(NER_REQUESTS_METRIC) .register(meters);
this.busy =
Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION) .description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "busy").register(meters); .tag(OUTCOME_TAG, "busy")
this.candidates = Counter.builder("pdguard.ner.candidates") .register(meters);
.description("Участки текста, отданные модели").register(meters); this.candidates =
this.duration = Timer.builder("pdguard.ner.duration") Counter.builder("pdguard.ner.candidates")
.description("Время работы второй ступени").register(meters); .description("Участки текста, отданные модели")
.register(meters);
this.duration =
Timer.builder("pdguard.ner.duration")
.description("Время работы второй ступени")
.register(meters);
} }
/** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */ /** Конструктор для тестов: движки задаются конфигом, метрики — реестром. */
private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) { private NameCascade(EngineConfig config, int maxCandidates, int poolSize, MeterRegistry meters) {
this(config.nameEngine(), config.nameModel().orElse(""), this(
config.addressEngine(), config.addressModel().orElse(""), config.nameEngine(),
config.legalEngine(), config.legalModel().orElse(""), config.nameModel().orElse(""),
maxCandidates, poolSize, meters); config.addressEngine(),
config.addressModel().orElse(""),
config.legalEngine(),
config.legalModel().orElse(""),
maxCandidates,
poolSize,
meters);
} }
/** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */ /** Конструктор для тестов: одна модель для имён, метрики никуда не отдаются. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) { public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
this(new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()), this(
maxCandidates, poolSize, new SimpleMeterRegistry()); new EngineConfig(engine, modelPath, "off", Optional.empty(), "off", Optional.empty()),
maxCandidates,
poolSize,
new SimpleMeterRegistry());
} }
/** Конструктор для тестов двух моделей: метрики никуда не отдаются. */ /** Конструктор для тестов двух моделей: метрики никуда не отдаются. */
public NameCascade(String nameEngine, Optional<String> nameModel, public NameCascade(
String addressEngine, Optional<String> addressModel, String nameEngine,
int maxCandidates, int poolSize) { Optional<String> nameModel,
this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()), String addressEngine,
maxCandidates, poolSize, new SimpleMeterRegistry()); Optional<String> addressModel,
int maxCandidates,
int poolSize) {
this(
new EngineConfig(
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates,
poolSize,
new SimpleMeterRegistry());
} }
/** Конструктор для тестов двух моделей с явным реестром метрик. */ /** Конструктор для тестов двух моделей с явным реестром метрик. */
public NameCascade(String nameEngine, Optional<String> nameModel, public NameCascade(
String addressEngine, Optional<String> addressModel, String nameEngine,
int maxCandidates, int poolSize, MeterRegistry meters) { Optional<String> nameModel,
this(new EngineConfig(nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()), String addressEngine,
maxCandidates, poolSize, meters); Optional<String> addressModel,
int maxCandidates,
int poolSize,
MeterRegistry meters) {
this(
new EngineConfig(
nameEngine, nameModel, addressEngine, addressModel, "off", Optional.empty()),
maxCandidates,
poolSize,
meters);
} }
/** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */ /** Конструктор для тестов трёх моделей: метрики никуда не отдаются. */
@@ -191,16 +230,18 @@ public class NameCascade {
} }
/** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */ /** Конфигурация трёх движков второй ступени: имя, адрес и юридические реквизиты. */
public record EngineConfig(String nameEngine, Optional<String> nameModel, public record EngineConfig(
String addressEngine, Optional<String> addressModel, String nameEngine,
String legalEngine, Optional<String> legalModel) { Optional<String> nameModel,
} String addressEngine,
Optional<String> addressModel,
String legalEngine,
Optional<String> legalModel) {}
/** /**
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный * Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный конструктор, а не обычный
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности * путь: иначе в журнале рядом с сообщением о готовности распознавателя появлялось бы сообщение о
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно, * его выключении, и было бы непонятно, что в итоге работает.
* что в итоге работает.
*/ */
private NameCascade() { private NameCascade() {
this.maxCandidates = 0; this.maxCandidates = 0;
@@ -221,30 +262,39 @@ public class NameCascade {
} }
public boolean enabled() { public boolean enabled() {
return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null) && !broken; return (nameRecogniser != null || addressRecogniser != null || legalRecogniser != null)
&& !broken;
} }
/** /**
* Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы * Покрывает ли каскад хоть один тип, разрешённый политикой. Нужно, чтобы {@code Pipeline} звал
* {@code Pipeline} звал вторую ступень не только ради ФИО, но и ради адресов * вторую ступень не только ради ФИО, но и ради адресов и юридических реквизитов, которые
* и юридических реквизитов, которые размечает LLAIM Legal NER. * размечает LLAIM Legal NER.
*/ */
public boolean coversAny(SystemPolicy policy) { public boolean coversAny(SystemPolicy policy) {
if (!enabled()) { if (!enabled()) {
return false; return false;
} }
return policy.allows(PdTypes.FIO) return policy.allows(PdTypes.FIO)
|| policy.allows(PdTypes.ADDRESS_COUNTRY) || policy.allows(PdTypes.ADDRESS_REGION) || policy.allows(PdTypes.ADDRESS_COUNTRY)
|| policy.allows(PdTypes.ADDRESS_DISTRICT) || policy.allows(PdTypes.ADDRESS_CITY) || policy.allows(PdTypes.ADDRESS_REGION)
|| policy.allows(PdTypes.ADDRESS_STREET) || policy.allows(PdTypes.ADDRESS_HOUSE) || policy.allows(PdTypes.ADDRESS_DISTRICT)
|| policy.allows(PdTypes.INN) || policy.allows(PdTypes.OGRN) || policy.allows(PdTypes.SNILS) || policy.allows(PdTypes.ADDRESS_CITY)
|| policy.allows(PdTypes.PASSPORT) || policy.allows(PdTypes.PHONE) || policy.allows(PdTypes.EMAIL) || policy.allows(PdTypes.ADDRESS_STREET)
|| policy.allows(PdTypes.ACCOUNT_NUMBER) || policy.allows(PdTypes.DATE); || policy.allows(PdTypes.ADDRESS_HOUSE)
|| policy.allows(PdTypes.INN)
|| policy.allows(PdTypes.OGRN)
|| policy.allows(PdTypes.SNILS)
|| policy.allows(PdTypes.PASSPORT)
|| policy.allows(PdTypes.PHONE)
|| policy.allows(PdTypes.EMAIL)
|| policy.allows(PdTypes.ACCOUNT_NUMBER)
|| policy.allows(PdTypes.DATE);
} }
/** /**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не * Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не трогаются: модели
* трогаются: модели разбирают только непокрытые участки. * разбирают только непокрытые участки.
*/ */
public List<Span> addMissedNames(String text, List<Span> accepted) { public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) { if (!enabled()) {
@@ -294,7 +344,11 @@ public class NameCascade {
} }
} }
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink, private void collect(
String text,
int candidateStart,
int candidateEnd,
List<Span> sink,
RuBertRecogniser recogniser) { RuBertRecogniser recogniser) {
if (recogniser == null) { if (recogniser == null) {
return; return;
@@ -315,8 +369,11 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил, // полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД. // чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
if (nameFound) { if (nameFound) {
sink.removeIf(span -> PdTypes.FIO.equals(span.type()) sink.removeIf(
&& span.start() < candidateEnd && candidateStart < span.end() span ->
PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd
&& candidateStart < span.end()
&& span.priority() > PRIORITY); && span.priority() > PRIORITY);
} }
} }
@@ -327,17 +384,31 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
} }
/** /**
* Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО * Слова-маркеры ПД, которые модель иногда ошибочно помечает как ФИО («ИНН», «СНИЛС», «паспорт»).
* («ИНН», «СНИЛС», «паспорт»). Такие находки — шум: это не имена, а * Такие находки — шум: это не имена, а обозначения реквизитов, и маскировать их как ФИО нельзя.
* обозначения реквизитов, и маскировать их как ФИО нельзя.
*/ */
private static final Set<String> PD_MARKERS = Set.of( private static final Set<String> PD_MARKERS =
"инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", Set.of(
"телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство"); "инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство");
/** /**
* Принимает находку модели, если она пересекается с кандидатом и проходит * Принимает находку модели, если она пересекается с кандидатом и проходит те же условия, что и
* те же условия, что и находки правил. * находки правил.
*/ */
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) { private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради // Берём только пересекающееся с кандидатом: контекст добавлен ради
@@ -358,13 +429,16 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|| RuleRegistry.hasAddressContext(text, span.start(), span.end()); || RuleRegistry.hasAddressContext(text, span.start(), span.end());
} }
private static RuBertRecogniser create(String engine, String modelPath, Map<String, String> types) { private static RuBertRecogniser create(
String engine, String modelPath, Map<String, String> types) {
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip(); String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) { if ("off".equals(chosen) || modelPath == null || modelPath.isBlank()) {
LOG.info("Вторая ступень распознавания выключена"); LOG.info("Вторая ступень распознавания выключена");
return null; return null;
} }
if (!"rubert".equals(chosen) && !"wikineural".equals(chosen) && !"ru-legal-ner".equals(chosen)) { if (!"rubert".equals(chosen)
&& !"wikineural".equals(chosen)
&& !"ru-legal-ner".equals(chosen)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen); LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null; return null;
} }
@@ -1,8 +1,5 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
@@ -11,29 +8,31 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* Словари для распознавания ФИО. * Словари для распознавания ФИО.
* *
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: * <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало: «Тверская» по
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени. * окончанию похожа на фамилию, но рядом с ней нет личного имени.
* *
* <p>Список известных людей решает обратную задачу — упоминание Пушкина * <p>Список известных людей решает обратную задачу — упоминание Пушкина персональными данными не
* персональными данными не является. Ограничение осознанное: клиент по фамилии * является. Ограничение осознанное: клиент по фамилии Пушкин в тексте без других ПД замаскирован не
* Пушкин в тексте без других ПД замаскирован не будет. * будет.
* *
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх * <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх него можно дописать
* него можно дописать своих публичных лиц без пересборки — файл по пути * своих публичных лиц без пересборки — файл по пути {@code pdguard.well-known-file} (по умолчанию
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt}) * {@code config/well-known.txt}) перечитывается сам при изменении, тем же приёмом, что {@code
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json} * systems.json} в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время изменения,
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время * содержимое читается заново только когда оно другое.
* изменения, содержимое читается заново только когда оно другое.
*/ */
public final class NameDictionary { public final class NameDictionary {
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class); private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
private static final List<String> GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream() private static final List<String> GIVEN_NAME_STEMS =
ResourceLoader.lines("/names/given-names.txt", true).stream()
.map(Declension::withoutInflectedEnding) .map(Declension::withoutInflectedEnding)
.distinct() .distinct()
.sorted(Comparator.comparingInt(String::length).reversed()) .sorted(Comparator.comparingInt(String::length).reversed())
@@ -42,7 +41,8 @@ public final class NameDictionary {
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён. // что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream() private static final Set<String> BUNDLED_WELL_KNOWN_STEMS =
ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding) .map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet()); .collect(Collectors.toUnmodifiableSet());
@@ -63,11 +63,12 @@ public final class NameDictionary {
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн"; "велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/** /**
* Имя правителя: личное имя плюс порядковое числительное или прозвище — * Имя правителя: личное имя плюс порядковое числительное или прозвище — «Пётр Первый», «Иван
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый». * Грозный», «Екатерина Вторая», «Ярослав Мудрый». Задано правилом, а не перечнем: правителей
* Задано правилом, а не перечнем: правителей много, а форма записи одна. * много, а форма записи одна.
*/ */
private static final Pattern REGNAL_NAME = Pattern.compile( private static final Pattern REGNAL_NAME =
Pattern.compile(
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$", "^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ); Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
@@ -75,25 +76,42 @@ public final class NameDictionary {
private static final int MAX_INFLECTION = 3; private static final int MAX_INFLECTION = 3;
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */ /** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of( private static final Set<String> SURNAME_SUFFIXES =
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", Set.of(
"ович", "евич", "овна", "евна", "овы", "евы", "ины"); "ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына", "ович", "евич", "овна",
"евна", "овы", "евы", "ины");
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream() private static final Set<String> GIVEN_NAMES =
GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT)) .map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet()); .collect(Collectors.toUnmodifiableSet());
/** /**
* Слова-маркеры персональных данных и реквизитов, которые по словообразованию * Слова-маркеры персональных данных и реквизитов, которые по словообразованию совпадают с
* совпадают с основами имён («ИНН» — основа имени «Инна») и потому ложно * основами имён («ИНН» — основа имени «Инна») и потому ложно распознаются как ФИО. Это
* распознаются как ФИО. Это аббревиатуры, а не имена. * аббревиатуры, а не имена.
*/ */
private static final Set<String> PD_MARKERS = Set.of( private static final Set<String> PD_MARKERS =
"инн", "снилс", "огрн", "огрнип", "кпп", "бик", "паспорт", "счёт", "счет", Set.of(
"телефон", "email", "почта", "дата", "адрес", "полис", "свидетельство", "ву"); "инн",
"снилс",
"огрн",
"огрнип",
"кпп",
"бик",
"паспорт",
"счёт",
"счет",
"телефон",
"email",
"почта",
"дата",
"адрес",
"полис",
"свидетельство",
"ву");
private NameDictionary() { private NameDictionary() {}
}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */ /** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() { public static NameDictionary create() {
@@ -101,9 +119,9 @@ public final class NameDictionary {
} }
/** /**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения * Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения из конфигурации
* из конфигурации Spring-бина; статические методы словаря работают без * Spring-бина; статические методы словаря работают без экземпляра, поэтому путь хранится в
* экземпляра, поэтому путь хранится в статическом поле. * статическом поле.
*/ */
public static void configure(String wellKnownFile) { public static void configure(String wellKnownFile) {
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
@@ -118,9 +136,9 @@ public final class NameDictionary {
/** /**
* Есть ли среди слов личное имя из словаря в любом падеже. * Есть ли среди слов личное имя из словаря в любом падеже.
* *
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним * <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним веток пришлось
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово * бы перебирать в каждой позиции текста, здесь же на слово приходится не больше четырёх обращений
* приходится не больше четырёх обращений к хеш-таблице. * к хеш-таблице.
*/ */
public static boolean containsGivenName(String value) { public static boolean containsGivenName(String value) {
for (String word : value.split(WORD_SPLIT)) { for (String word : value.split(WORD_SPLIT)) {
@@ -134,7 +152,9 @@ public final class NameDictionary {
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное // «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за // «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть. // человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) { for (int length = Math.max(1, lower.length() - MAX_INFLECTION);
length < lower.length();
length++) {
if (GIVEN_NAMES.contains(lower.substring(0, length)) if (GIVEN_NAMES.contains(lower.substring(0, length))
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) { && !SURNAME_SUFFIXES.contains(lower.substring(length))) {
return true; return true;
@@ -145,9 +165,8 @@ public final class NameDictionary {
} }
/** /**
* Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется * Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется для строчных имён
* для строчных имён после ролевого слова («клиент иван иванов»), где регистр * после ролевого слова («клиент иван иванов»), где регистр не подсказывает, что перед нами имя.
* не подсказывает, что перед нами имя.
*/ */
public static boolean containsNamePart(String value) { public static boolean containsNamePart(String value) {
for (String word : value.split(WORD_SPLIT)) { for (String word : value.split(WORD_SPLIT)) {
@@ -163,12 +182,11 @@ public final class NameDictionary {
} }
/** /**
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова * Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова или соседнего
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное * личного имени рядом, самое слабое основание для ФИО. Точное совпадение с личным именем
* совпадение с личным именем принимается в любом регистре («иван» тоже имя), * принимается в любом регистре («иван» тоже имя), а вот словообразовательная эвристика
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) — * (фамилия/отчество по окончанию) — только с заглавной буквы: без этого «законов», «домов»,
* только с заглавной буквы: без этого «законов», «домов», «холодов» — * «холодов» — обычные родительные падежи, а не фамилии — ложно матчились бы.
* обычные родительные падежи, а не фамилии — ложно матчились бы.
*/ */
public static boolean isStandaloneNameCandidate(String word) { public static boolean isStandaloneNameCandidate(String word) {
String lower = word.toLowerCase(Locale.ROOT); String lower = word.toLowerCase(Locale.ROOT);
@@ -190,9 +208,10 @@ public final class NameDictionary {
} }
/** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */ /** Окончания, по которым слово похоже на фамилию: Иванов, Петрова, Троицкий, Шевченко. */
private static final Set<String> SURNAME_ENDINGS = Set.of( private static final Set<String> SURNAME_ENDINGS =
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", Set.of(
"цкий", "цкая", "енко", "ко", "ук", "юк", "ян", "швили", "дзе"); "ов", "ев", "ёв", "ин", "ын", "ский", "ская", "ского", "ской", "ском", "цкий", "цкая",
"енко", "ко", "ук", "юк", "ян", "швили", "дзе");
/** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */ /** Фамилия по словообразованию. Набор окончаний вместо regex: проще и без CANON_EQ. */
private static boolean isSurname(String lower) { private static boolean isSurname(String lower) {
@@ -205,13 +224,11 @@ public final class NameDictionary {
} }
/** /**
* Содержит ли текст упоминание известного человека — из сборки или дописанных * Содержит ли текст упоминание известного человека — из сборки или дописанных сверху.
* сверху.
* *
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову: * <p>Проверяются префиксы слова по множеству, а не каждая основа по слову: при тысяче с лишним
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary}, * записей (столько городов в {@link ToponymDictionary}, тот же приём) перебор списка на каждое
* тот же приём) перебор списка на каждое слово текста был бы заметен, а * слово текста был бы заметен, а префиксов у слова — не больше, чем в нём букв.
* префиксов у слова — не больше, чем в нём букв.
*/ */
public static boolean isWellKnown(String value) { public static boolean isWellKnown(String value) {
if (REGNAL_NAME.matcher(value.strip()).matches()) { if (REGNAL_NAME.matcher(value.strip()).matches()) {
@@ -243,7 +260,8 @@ public final class NameDictionary {
WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis(); WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!java.nio.file.Files.isReadable(path)) { if (!java.nio.file.Files.isReadable(path)) {
if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) { if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список", LOG.info(
"Внешний файл денилиста {} исчез, остаётся только встроенный список",
path.toAbsolutePath()); path.toAbsolutePath());
} }
WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
@@ -253,15 +271,18 @@ public final class NameDictionary {
try { try {
WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis(); WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) { for (String line :
java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim()); String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed); merged.add(trimmed);
} }
} }
WELL_KNOWN_STATE.current = Set.copyOf(merged); WELL_KNOWN_STATE.current = Set.copyOf(merged);
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных", LOG.info(
path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); "Денилист дополнен из {}: {} имён сверх встроенных",
path.toAbsolutePath(),
merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (java.io.IOException e) { } catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список. // Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e); LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
@@ -269,7 +290,9 @@ public final class NameDictionary {
} }
private static Set<String> currentWellKnownStems() { private static Set<String> currentWellKnownStems() {
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE, return ResourceLoader.refreshIfChanged(
EXTERNAL_FILE,
WELL_KNOWN_STATE,
lines -> { lines -> {
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : lines) { for (String line : lines) {
@@ -3,30 +3,31 @@ package ru.pdguard.detect;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* Проверка, стоит ли перед именем слово, относящее его к организации или * Проверка, стоит ли перед именем слово, относящее его к организации или объекту на карте.
* объекту на карте.
* *
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена * <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена в названиях, а не
* в названиях, а не персональные данные. Отличие от списка известных людей в том, * персональные данные. Отличие от списка известных людей в том, что здесь решает не само имя, а
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв * слово перед ним: клиент по фамилии Королёв защиту не теряет, а улица Королёва под маску не
* защиту не теряет, а улица Королёва под маску не попадает. * попадает.
*/ */
public final class OrganisationDetector { public final class OrganisationDetector {
/** /**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже, * Маркер организации вплотную перед именем. Слово может стоять в любом падеже, между ним и именем
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова». * допускается «имени» или «им.» — «Премия имени Ломоносова».
*/ */
private static final Pattern ORGANISATION_BEFORE = Pattern.compile( private static final Pattern ORGANISATION_BEFORE =
"(?iu:" + String.join("|", ResourceLoader.lines("/names/organisations.txt", false)) + ")\\p{L}*" Pattern.compile(
"(?iu:"
+ String.join("|", ResourceLoader.lines("/names/organisations.txt", false))
+ ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$", + "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */ /** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40; private static final int ORGANISATION_LOOKBEHIND = 40;
private OrganisationDetector() { private OrganisationDetector() {}
}
public static boolean precededByOrganisation(String text, int nameStart) { public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND); int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
+8 -7
View File
@@ -3,16 +3,14 @@ package ru.pdguard.detect;
/** /**
* Имена типов персональных данных, которые умеет распознавать сервис. * Имена типов персональных данных, которые умеет распознавать сервис.
* *
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в * <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в правилах, и в
* правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках * маскировании ({@link ru.pdguard.mask.Masker}), и в политиках ({@link
* ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках * ru.pdguard.config.SystemPolicy}), и в синтетических подстановках ({@link
* ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не * ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не расходилось между слоями.
* расходилось между слоями.
*/ */
public final class PdTypes { public final class PdTypes {
private PdTypes() { private PdTypes() {}
}
public static final String EMAIL = "EMAIL"; public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE"; public static final String PHONE = "PHONE";
@@ -37,8 +35,10 @@ public final class PdTypes {
public static final String ADDRESS_STREET = "ADDRESS_STREET"; public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE"; public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT"; public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Регион и район размечает только модель второй ступени: правил под них нет. */ /** Регион и район размечает только модель второй ступени: правил под них нет. */
public static final String ADDRESS_REGION = "ADDRESS_REGION"; public static final String ADDRESS_REGION = "ADDRESS_REGION";
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT"; public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String FIO = "FIO"; public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT"; public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
@@ -48,6 +48,7 @@ public final class PdTypes {
/** Банковские реквизиты сверх платёжной карты. */ /** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER"; public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK"; public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY"; public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME"; public static final String INCOME = "INCOME";
@@ -18,18 +18,16 @@ import org.slf4j.LoggerFactory;
/** /**
* Общие приёмы чтения словарей и внешних файлов. * Общие приёмы чтения словарей и внешних файлов.
* *
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, * <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, пустые и
* пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем) * комментарии отбрасываются. Внешние файлы (денилист, настройки систем) перечитываются, когда
* перечитываются, когда меняется время их изменения, и не чаще раза в секунду — * меняется время их изменения, и не чаще раза в секунду — чтобы не ходить в файловую систему на
* чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда, * каждом запросе. Обе задачи вынесены сюда, чтобы не дублировать их в каждом словаре.
* чтобы не дублировать их в каждом словаре.
*/ */
final class ResourceLoader { final class ResourceLoader {
private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class); private static final Logger LOG = LoggerFactory.getLogger(ResourceLoader.class);
private ResourceLoader() { private ResourceLoader() {}
}
/** /**
* Читает строки ресурса, отбрасывая пустые и комментарии. * Читает строки ресурса, отбрасывая пустые и комментарии.
@@ -42,12 +40,15 @@ final class ResourceLoader {
if (in == null) { if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource); throw new IllegalStateException("Словарь не найден в сборке: " + resource);
} }
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { try (BufferedReader reader =
return reader.lines() new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader
.lines()
.map(String::trim) .map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#")) .filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct() .distinct()
.sorted(sortByLength .sorted(
sortByLength
? Comparator.comparingInt(String::length).reversed() ? Comparator.comparingInt(String::length).reversed()
: Comparator.naturalOrder()) : Comparator.naturalOrder())
.toList(); .toList();
@@ -57,23 +58,21 @@ final class ResourceLoader {
} }
} }
/** /** Читает строки ресурса в множество, отбрасывая пустые и комментарии. */
* Читает строки ресурса в множество, отбрасывая пустые и комментарии.
*/
static java.util.Set<String> set(String resource) { static java.util.Set<String> set(String resource) {
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet()); return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
} }
/** /**
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза * Перечитывает внешний файл, когда меняется время его изменения, не чаще раза в секунду.
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее. * Возвращает текущее содержимое; при недоступности файла — прежнее.
* *
* @param path путь к файлу * @param path путь к файлу
* @param state состояние проверки (время последней проверки и mtime файла) * @param state состояние проверки (время последней проверки и mtime файла)
* @param reader как превратить строки файла в итоговое значение * @param reader как превратить строки файла в итоговое значение
*/ */
static <T> T refreshIfChanged(Path path, FileWatchState<T> state, static <T> T refreshIfChanged(
Function<List<String>, T> reader) { Path path, FileWatchState<T> state, Function<List<String>, T> reader) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (now - state.lastCheck < state.recheckMillis) { if (now - state.lastCheck < state.recheckMillis) {
return state.current; return state.current;
@@ -86,7 +85,8 @@ final class ResourceLoader {
long mtime = Files.getLastModifiedTime(path).toMillis(); long mtime = Files.getLastModifiedTime(path).toMillis();
if (mtime != state.mtime) { if (mtime != state.mtime) {
state.mtime = mtime; state.mtime = mtime;
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8).stream() List<String> lines =
Files.readAllLines(path, StandardCharsets.UTF_8).stream()
.map(String::trim) .map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#")) .filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList(); .toList();
@@ -6,9 +6,6 @@ import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession; import ai.onnxruntime.OrtSession;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.nio.LongBuffer; import java.nio.LongBuffer;
import java.nio.file.Files; import java.nio.file.Files;
@@ -19,19 +16,19 @@ import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход. * Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
* *
* <p>В отличие от правил, он опознаёт имена без русского словообразования и * <p>В отличие от правил, он опознаёт имена без русского словообразования и нестандартные топонимы.
* нестандартные топонимы. Метки модели ложатся почти один в один * Метки модели ложатся почти один в один на типы из технического задания: имя, отчество, фамилия,
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район, * страна, регион, район, город, улица, дом.
* город, улица, дом.
* *
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на * <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на вызов, — поэтому её
* вызов, — поэтому её зовут только на участках, которые не разобрала первая * зовут только на участках, которые не разобрала первая ступень. Одновременных вызовов не больше,
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с * чем задано: иначе один запрос с десятком кандидатов занял бы все ядра.
* десятком кандидатов занял бы все ядра.
*/ */
final class RuBertRecogniser { final class RuBertRecogniser {
@@ -47,8 +44,12 @@ final class RuBertRecogniser {
private final Set<String> inputNames; private final Set<String> inputNames;
private final Map<String, String> types; private final Map<String, String> types;
private RuBertRecogniser(OrtEnvironment environment, OrtSession session, private RuBertRecogniser(
WordPiece tokenizer, String[] labels, Map<String, String> types) { OrtEnvironment environment,
OrtSession session,
WordPiece tokenizer,
String[] labels,
Map<String, String> types) {
this.environment = environment; this.environment = environment;
this.session = session; this.session = session;
this.tokenizer = tokenizer; this.tokenizer = tokenizer;
@@ -58,9 +59,9 @@ final class RuBertRecogniser {
} }
/** /**
* Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} * Загружает модель из каталога с файлами {@code model.onnx}, {@code tokenizer.json} и {@code
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null}, * config.json}. Каталог недоступен или испорчен — вернётся {@code null}, и сервис продолжит
* и сервис продолжит работать на правилах. * работать на правилах.
* *
* @param types соответствие меток модели типам ПД сервиса * @param types соответствие меток модели типам ПД сервиса
*/ */
@@ -80,8 +81,13 @@ final class RuBertRecogniser {
options.setInterOpNumThreads(1); options.setInterOpNumThreads(1);
session = environment.createSession(model.toString(), options); session = environment.createSession(model.toString(), options);
} }
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session, RuBertRecogniser recogniser =
WordPiece.fromTokenizerJson(tokenizer), readLabels(config), types); new RuBertRecogniser(
environment,
session,
WordPiece.fromTokenizerJson(tokenizer),
readLabels(config),
types);
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath()); LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser; return recogniser;
} catch (OrtException | IOException | RuntimeException e) { } catch (OrtException | IOException | RuntimeException e) {
@@ -132,9 +138,12 @@ final class RuBertRecogniser {
Map<String, OnnxTensor> inputs = new HashMap<>(); Map<String, OnnxTensor> inputs = new HashMap<>();
try { try {
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape)); inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape)); inputs.put(
"attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
if (inputNames.contains("token_type_ids")) { if (inputNames.contains("token_type_ids")) {
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape)); inputs.put(
"token_type_ids",
OnnxTensor.createTensor(environment, LongBuffer.wrap(tokenTypes), shape));
} }
inputs.keySet().retainAll(inputNames); inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) { try (OrtSession.Result result = session.run(inputs)) {
@@ -151,11 +160,15 @@ final class RuBertRecogniser {
} }
/** /**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. * Собирает подряд идущие подслова одной сущности в фрагменты исходного текста. Схема разметки
* Схема разметки различает начало, середину, конец и одиночный токен, но для * различает начало, середину, конец и одиночный токен, но для сборки достаточно смены типа:
* сборки достаточно смены типа: границы участков и так проставлены по словам. * границы участков и так проставлены по словам.
*/ */
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority, private static List<Span> toSpans(
List<WordPiece.Piece> pieces,
String[] tags,
int offset,
int priority,
Map<String, String> types) { Map<String, String> types) {
List<Span> spans = new ArrayList<>(); List<Span> spans = new ArrayList<>();
String currentType = null; String currentType = null;
+45 -31
View File
@@ -7,29 +7,34 @@ import java.util.regex.Pattern;
/** /**
* Одно правило детекции персональных данных. * Одно правило детекции персональных данных.
* *
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в * <p>Добавление нового типа ПД — это добавление одного {@code Rule} в {@link RuleRegistry}; менять
* {@link RuleRegistry}; менять остальной код не требуется. * остальной код не требуется.
* *
* @param type тип ПД, который распознаёт правило * @param type тип ПД, который распознаёт правило
* @param pattern регулярное выражение * @param pattern регулярное выражение
* @param priority приоритет при разрешении перекрытий * @param priority приоритет при разрешении перекрытий
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. * @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком. Несколько
* Несколько групп нужны, когда значение разорвано словами: * групп нужны, когда значение разорвано словами: «серия 4509 номер 123456»
* «серия 4509 номер 123456» * @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); {@code null}
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат); * — проверка не нужна
* {@code null} — проверка не нужна * @param veto шаблон окружения, при котором совпадение персональными данными не считается: адрес
* @param veto шаблон окружения, при котором совпадение персональными данными не считается: * отделения банка не является ПД, хотя выглядит как адрес
* адрес отделения банка не является ПД, хотя выглядит как адрес * @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, где форма
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там, * совпадения сама по себе слишком общая: «Невский проспект» это адрес рядом с домом и индексом
* где форма совпадения сама по себе слишком общая: «Невский проспект» * и просто топоним в рассказе о городе
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе * @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. Проверка через
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте. * {@code indexOf} на порядок дешевле запуска регулярного выражения и отсекает большинство
* Проверка через {@code indexOf} на порядок дешевле запуска * правил на коротком запросе. Пустой список — правило запускается всегда
* регулярного выражения и отсекает большинство правил на коротком
* запросе. Пустой список — правило запускается всегда
*/ */
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups, public record Rule(
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) { String type,
Pattern pattern,
int priority,
List<Integer> groups,
Predicate<String> validator,
Pattern veto,
Pattern context,
List<String> anchors) {
public Rule { public Rule {
groups = List.copyOf(groups); groups = List.copyOf(groups);
@@ -39,29 +44,29 @@ public record Rule(String type, Pattern pattern, int priority, List<Integer> gro
/** /**
* Флаги компиляции для всех правил. * Флаги компиляции для всех правил.
* *
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} * <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W} и {@code \b} в
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде * Java охватывают только латиницу, и якорные слова вроде «водительское удостоверение» не
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает * находятся. {@code UNICODE_CASE} делает {@code (?i)} корректным для кириллицы.
* {@code (?i)} корректным для кириллицы.
*/ */
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE; private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/** /**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон. * Сколько символов слева и справа от совпадения просматривает вето-шаблон.
* *
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город, * <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город, улица, дом — в одном
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома * предложении) расстояние от «отделение» до номера дома часто превышает 80 знаков за счёт
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский * длинного названия региона («Ханты-Мансийский автономный округ», «Кабардино-Балкарская
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным * Республика»). Найдено нагрузочным тестом на 60 реальных адресах из официального реестра — с
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков * окном в 80 знаков вето не срабатывало на части из них.
* вето не срабатывало на части из них.
*/ */
public static final int VETO_LOOKBEHIND = 150; public static final int VETO_LOOKBEHIND = 150;
public static final int VETO_LOOKAHEAD = 40; public static final int VETO_LOOKAHEAD = 40;
/** Правило без проверок, маскируется всё совпадение. */ /** Правило без проверок, маскируется всё совпадение. */
public static Rule of(String type, String regex, int priority) { public static Rule of(String type, String regex, int priority) {
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of()); return new Rule(
type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
} }
/** Маскировать только перечисленные группы, а не всё совпадение. */ /** Маскировать только перечисленные группы, а не всё совпадение. */
@@ -94,11 +99,20 @@ public record Rule(String type, Pattern pattern, int priority, List<Integer> gro
/** Принять совпадение, только если рядом встретилось указанное слово. */ /** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) { public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors); return new Rule(
type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
} }
/** Отбросить совпадение, если рядом встретилось указанное слово. */ /** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) { public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors); return new Rule(
type,
pattern,
priority,
groups,
validator,
Pattern.compile(regex, FLAGS),
context,
anchors);
} }
} }
@@ -1,19 +1,18 @@
package ru.pdguard.detect; package ru.pdguard.detect;
/** /**
* Общие фрагменты регулярных выражений, переиспользуемые между группами правил * Общие фрагменты регулярных выражений, переиспользуемые между группами правил в {@link
* в {@link RuleRegistry}. Вынесены отдельно, чтобы не дублировать их в каждой * RuleRegistry}. Вынесены отдельно, чтобы не дублировать их в каждой группе — «серия и номер»,
* группе — «серия и номер», разрывы между якорем и значением, формы дат и т.п. * разрывы между якорем и значением, формы дат и т.п. встречаются в правилах разных категорий
* встречаются в правилах разных категорий (документы, банк, ФИО, адрес). * (документы, банк, ФИО, адрес).
*/ */
final class RulePatterns { final class RulePatterns {
private RulePatterns() { private RulePatterns() {}
}
/** /**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы * Слово с заглавной буквы; остальные буквы любого регистра, чтобы «ИВАНОВ» распознавался наравне
* «ИВАНОВ» распознавался наравне с «Иванов». * с «Иванов».
*/ */
static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
@@ -24,51 +23,49 @@ final class RulePatterns {
static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}"; static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
/** /**
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением * То же самое, но только строчные слова-филлеры: ролевые слова перед значением гражданства всегда
* гражданства всегда строчные («бенефициара», «поручителя»), а само значение — * строчные («бенефициара», «поручителя»), а само значение — с заглавной («Республики»,
* с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно * «Соединенные»). Обычный {@link #ROLE_GAP} жадно поглощал бы и заглавное слово значения как
* поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя * будто это ролевое слово, оставляя значение только хвостом («Республики Беларусь» → «Беларусь»).
* значение только хвостом («Республики Беларусь» → «Беларусь»).
*/ */
static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}"; static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
/** /**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел — * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — «Тверская», «Малая
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно: * Никитская», «8 Марта». Ограничение по форме обязательно: без него правило дожёвывало строку до
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт * конца, и «Проспект Вернадского перекрыт до вечера» оказывался под маской целиком.
* до вечера» оказывался под маской целиком.
*/ */
static final String STREET_NAME = static final String STREET_NAME =
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}"; "(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
/** /**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. * Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. Хвост из двух букв
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а. * покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/ */
static final String SURNAME = static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)" "\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}"; + "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/** /**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается. * Отчество: признак надёжный, ни одно другое слово так не оканчивается. Основы даны без падежного
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне. * окончания — Иванович, Ивановича, Ивановне.
*/ */
static final String PATRONYMIC = static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич" "\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}"; + "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/** /**
* Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456», * Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456», а также с
* а также с произвольным числом пробелов и словом «номер» между частями — * произвольным числом пробелов и словом «номер» между частями — «12 34 номер 567890» (реальный
* «12 34 номер 567890» (реальный кейс из бланка). * кейс из бланка).
*/ */
static final String SERIES_AND_NUMBER = static final String SERIES_AND_NUMBER =
"\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}"; "\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}";
/** /**
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер * Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер «ммм» (в логах
* «ммм» (в логах встречается и латинская «M»). Сокращения нужны, потому что * встречается и латинская «M»). Сокращения нужны, потому что в датах вида «15 ЯНВ 10» месяц
* в датах вида «15 ЯНВ 10» месяц записан тремя буквами. * записан тремя буквами.
*/ */
static final String MONTH = static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр" "(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр"
@@ -85,7 +82,9 @@ final class RulePatterns {
/** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */ /** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */
static final String DATE_MONTH_WORD = static final String DATE_MONTH_WORD =
"\\b\\d{1,2}\\s*[-/.]?\\s*" + MONTH + "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b" "\\b\\d{1,2}\\s*[-/.]?\\s*"
+ MONTH
+ "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b"
+ "(?:\\s*(?iu:года|г\\.|г\\b))?"; + "(?:\\s*(?iu:года|г\\.|г\\b))?";
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */ /** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
@@ -93,40 +92,48 @@ final class RulePatterns {
"\\b(?:(?iu:двадцать|тридцать)\\s+)?" "\\b(?:(?iu:двадцать|тридцать)\\s+)?"
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат" + "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)" + "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
+ "(?iu:ьего|ого|его|ое)\\s+" + MONTH + "(?iu:ьего|ого|его|ое)\\s+"
+ MONTH
+ "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)"; + "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)";
/** Любая из записей даты; внутри только незахватывающие группы. */ /** Любая из записей даты; внутри только незахватывающие группы. */
static final String DATE_ANY = static final String DATE_ANY =
"(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + "|" "(?:"
+ DATE_DIGITS_SPACE + "|" + DATE_DAY_MONTH + ")"; + DATE_WORDS
+ "|"
+ DATE_MONTH_WORD
+ "|"
+ DATE_DIGITS
+ "|"
+ DATE_DIGITS_SPACE
+ "|"
+ DATE_DAY_MONTH
+ ")";
/** /**
* Промежуток между якорем даты («дата рождения») и самой датой: слова, * Промежуток между якорем даты («дата рождения») и самой датой: слова, скобочные группы («(день и
* скобочные группы («(день и месяц)») и знаки препинания. Без скобочной * месяц)») и знаки препинания. Без скобочной ветки «Дата рождения клиента (день и месяц): 15
* ветки «Дата рождения клиента (день и месяц): 15 января» не находилась бы: * января» не находилась бы: «день и месяц» — это слова, а не дата. Ветка со словами требует
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела * пробела перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, которая тоже
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, * матчит пробелы, — это приводило к катастрофическому возврату на длинных текстах. Отдельная
* которая тоже матчит пробелы, — это приводило к катастрофическому * ветка с дефисом нужна для слитных слов без пробела внутри: «клиента-нерезидента» — дефис сам по
* возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных * себе ловится веткой {@code \W}, но следующие за ним буквы без пробела перед ними не покрывала
* слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится * ни одна ветка.
* веткой {@code \W}, но следующие за ним буквы без пробела перед ними не
* покрывала ни одна ветка.
*/ */
static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}"; static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
/** /**
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ * Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ «республики X»
* «республики X» — частые формы отдельным списком; последняя ветка — страна из * — частые формы отдельным списком; последняя ветка — страна из 1-4 слов с заглавной буквы
* 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост * («Армения», «Соединенные Штаты Америки»). Хвост идёт после якоря «гражданств», поэтому
* идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт. * «Двойное» перед якорем не попадёт.
*/ */
static final String CITIZENSHIP_VALUE = static final String CITIZENSHIP_VALUE =
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}"; "\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
/** /**
* Слова, при которых адрес/имя принадлежит организации, а не человеку: * Слова, при которых адрес/имя принадлежит организации, а не человеку: адрес отделения банка
* адрес отделения банка персональными данными не является. * персональными данными не является.
*/ */
static final String ORGANISATION_NEARBY = static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)"; "(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
@@ -1,8 +1,5 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -10,36 +7,38 @@ import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
/** /**
* Реестр правил детекции и сам поиск ПД в тексте. * Реестр правил детекции и сам поиск ПД в тексте.
* *
* <p>Правила разбиты на три уровня доверия: * <p>Правила разбиты на три уровня доверия:
*
* <ol> * <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li> * <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
* <li>однозначные по формату — email, телефон;</li> * <li>однозначные по формату — email, телефон;
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, * <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, где
* где сама по себе последовательность знаков ни о чём не говорит.</li> * сама по себе последовательность знаков ни о чём не говорит.
* </ol> * </ol>
* *
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен * <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен именно на них.
* именно на них. На захватываемое значение регистронезависимость не распространяется: * На захватываемое значение регистронезависимость не распространяется: там, где значение опознаётся
* там, где значение опознаётся по заглавной букве, это существенно. * по заглавной букве, это существенно.
* *
* <p>Сами правила сгруппированы по категориям в отдельных классах пакета — * <p>Сами правила сгруппированы по категориям в отдельных классах пакета — {@link DocumentRules},
* {@link DocumentRules}, {@link FinanceRules}, {@link DateRules}, {@link FioRules}, * {@link FinanceRules}, {@link DateRules}, {@link FioRules}, {@link ContactRules}, {@link
* {@link ContactRules}, {@link AddressRules} — чтобы каждая категория читалась * AddressRules} — чтобы каждая категория читалась отдельно от остальных. Здесь их списки только
* отдельно от остальных. Здесь их списки только объединяются и используются. * объединяются и используются.
*/ */
@Component @Component
public class RuleRegistry { public class RuleRegistry {
/** /**
* Слова, при которых адрес принадлежит организации, а не человеку: * Слова, при которых адрес принадлежит организации, а не человеку: адрес отделения банка
* адрес отделения банка персональными данными не является. Части адреса рядом: * персональными данными не является. Части адреса рядом: улица, упомянутая в рассказе о городе,
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно * адресом клиента не является — ровно как адрес отделения банка из технического задания.
* как адрес отделения банка из технического задания. Требование стояло только * Требование стояло только у постфиксной формы правила, префиксная его не имела.
* у постфиксной формы правила, префиксная его не имела.
*/ */
public static final String ADDRESS_NEARBY = public static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)"; "(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
@@ -48,27 +47,33 @@ public class RuleRegistry {
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */ /** Адресные типы, которые вне адресного окружения персональными данными не являются. */
private static final Set<String> ADDRESS_TYPES = Set.of( private static final Set<String> ADDRESS_TYPES =
PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY, Set.of(
PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE); PdTypes.ADDRESS_COUNTRY,
PdTypes.ADDRESS_REGION,
PdTypes.ADDRESS_DISTRICT,
PdTypes.ADDRESS_CITY,
PdTypes.ADDRESS_STREET,
PdTypes.ADDRESS_HOUSE,
PdTypes.ADDRESS_FLAT,
PdTypes.ADDRESS_POSTCODE);
/** /**
* Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), * Приоритет находок нормализации цифровых ПД: выше правила ИНН без якоря (62), ниже якорных
* ниже якорных правил (84+). Нормализация находит то, что жёсткие шаблоны * правил (84+). Нормализация находит то, что жёсткие шаблоны пропустили из-за нестандартных
* пропустили из-за нестандартных разделителей, и не должна перебивать находки * разделителей, и не должна перебивать находки с якорным словом.
* с якорным словом.
*/ */
private static final int NORMALISED_PRIORITY = 63; private static final int NORMALISED_PRIORITY = 63;
/** /**
* Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними * Цифровой кластер: от 10 до 19 цифр с произвольными разделителями между ними (пробел, дефис,
* (пробел, дефис, точка, слэш, скобки). Негативные просмотры не дают захватить * точка, слэш, скобки). Негативные просмотры не дают захватить часть более длинного числа.
* часть более длинного числа. Разделители вычищаются, и чистая цифровая строка * Разделители вычищаются, и чистая цифровая строка прогоняется через контрольную сумму — так
* прогоняется через контрольную сумму — так находятся ИНН/СНИЛС/карта/ОГРН(ИП) * находятся ИНН/СНИЛС/карта/ОГРН(ИП) в свободной форме, где жёсткий шаблон ломается на
* в свободной форме, где жёсткий шаблон ломается на нестандартном разделителе. * нестандартном разделителе.
*/ */
private static final Pattern DIGIT_CLUSTER = Pattern.compile( private static final Pattern DIGIT_CLUSTER =
"(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)"); Pattern.compile("(?<!\\d)\\d(?:[\\s.\\-/()]?\\d){9,18}(?!\\d)");
/** Вычищает разделители из цифрового кластера: оставляет только цифры. */ /** Вычищает разделители из цифрового кластера: оставляет только цифры. */
private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]"); private static final Pattern NON_DIGIT = Pattern.compile("[^\\d]");
@@ -78,17 +83,22 @@ public class RuleRegistry {
} }
/** /**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам * Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам второй ступени
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в * проверку нужно навязать снаружи: модель размечает «Москву» в названии клуба и «Вернадского» в
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом. * названии проспекта наравне с настоящим адресом.
*/ */
public static boolean hasAddressContext(String text, int start, int end) { public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find(); return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
} }
private static final List<Rule> RULES = Stream.of( private static final List<Rule> RULES =
DocumentRules.RULES, FinanceRules.RULES, DateRules.RULES, Stream.of(
FioRules.RULES, ContactRules.RULES, AddressRules.RULES) DocumentRules.RULES,
FinanceRules.RULES,
DateRules.RULES,
FioRules.RULES,
ContactRules.RULES,
AddressRules.RULES)
.flatMap(List::stream) .flatMap(List::stream)
.toList(); .toList();
@@ -98,8 +108,8 @@ public class RuleRegistry {
} }
/** /**
* Находит все фрагменты ПД, разрешённые политикой системы. * Находит все фрагменты ПД, разрешённые политикой системы. Перекрытия здесь не разрешаются — это
* Перекрытия здесь не разрешаются — это делает вызывающая сторона. * делает вызывающая сторона.
*/ */
public List<Span> detect(String text, SystemPolicy policy) { public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>(); List<Span> found = new ArrayList<>();
@@ -115,14 +125,16 @@ public class RuleRegistry {
} }
/** /**
* Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными * Ищет цифровые ПД в свободной форме: последовательности цифр с произвольными разделителями,
* разделителями, которые жёсткие шаблоны правил пропустили. Разделители * которые жёсткие шаблоны правил пропустили. Разделители вычищаются, и чистая строка проверяется
* вычищаются, и чистая строка проверяется контрольной суммой — ложные * контрольной суммой — ложные срабатывания отсекаются так же, как и в правилах.
* срабатывания отсекаются так же, как и в правилах.
*/ */
private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> sink) { private static void collectNormalisedDigits(String text, SystemPolicy policy, List<Span> sink) {
if (!policy.allows(PdTypes.CARD) && !policy.allows(PdTypes.INN) && !policy.allows(PdTypes.SNILS) if (!policy.allows(PdTypes.CARD)
&& !policy.allows(PdTypes.OGRN) && !policy.allows(PdTypes.OGRNIP)) { && !policy.allows(PdTypes.INN)
&& !policy.allows(PdTypes.SNILS)
&& !policy.allows(PdTypes.OGRN)
&& !policy.allows(PdTypes.OGRNIP)) {
return; return;
} }
Matcher m = DIGIT_CLUSTER.matcher(text); Matcher m = DIGIT_CLUSTER.matcher(text);
@@ -136,10 +148,10 @@ public class RuleRegistry {
} }
/** /**
* Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 * Определяет тип ПД по чистой цифровой строке и контрольной сумме. Для 13 и 15 цифр сначала
* цифр сначала пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный * пробуются ОГРН/ОГРНИП: они специфичнее карты по длине, и валидный ОГРН не должен случайно стать
* ОГРН не должен случайно стать номером карты (карта самостоятельна, ОГРН — только * номером карты (карта самостоятельна, ОГРН — только спутник, и одинокий ОГРН убирается в {@code
* спутник, и одинокий ОГРН убирается в {@code Pipeline}). * Pipeline}).
*/ */
private static String typeFor(String digits) { private static String typeFor(String digits) {
int length = digits.length(); int length = digits.length();
@@ -191,7 +203,10 @@ public class RuleRegistry {
} }
} }
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */ /**
* Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и
* контекст.
*/
private static boolean isValidGroup(Rule rule, String text, int start, int end) { private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) { if (start < 0 || end <= start) {
return false; return false;
@@ -4,43 +4,39 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
/** /**
* Словарь населённых пунктов России — проверка того, что значение, пойманное * Словарь населённых пунктов России — проверка того, что значение, пойманное правилом {@code
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город, * ADDRESS_CITY}, действительно похоже на существующий город, село, посёлок или другой населённый
* село, посёлок или другой населённый пункт, а не на произвольное слово с * пункт, а не на произвольное слово с заглавной буквы после якоря.
* заглавной буквы после якоря.
* *
* <p>Не только официальные города (~1100 по классификатору): перепись * <p>Не только официальные города (~1100 по классификатору): перепись добавляет сёла, деревни,
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево» * хутора, станицы — «рп. Ильинское», «с. Кукуево» из ТЗ находятся ровно за счёт неё. Какой
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта * конкретно тип населённого пункта стоит перед названием, определяет якорь самого правила в {@link
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry}, * RuleRegistry}, а не этот словарь — он только подтверждает, что название реальное.
* а не этот словарь — он только подтверждает, что название реальное.
* *
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания * <p>Сравнение по началу слова, а не точным совпадением: падежные окончания («в Москве», «из
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора * Казани») тем самым покрываются без отдельного разбора морфологии, как и у известных людей в
* морфологии, как и у известных людей в {@link NameDictionary}. * {@link NameDictionary}.
*/ */
public final class ToponymDictionary { public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream() private static final Set<String> SETTLEMENT_STEMS =
ResourceLoader.set("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding) .map(Declension::withoutInflectedEnding)
.collect(java.util.stream.Collectors.toUnmodifiableSet()); .collect(java.util.stream.Collectors.toUnmodifiableSet());
private ToponymDictionary() { private ToponymDictionary() {}
}
/** /**
* Похоже ли значение на название населённого пункта из словаря в любом * Похоже ли значение на название населённого пункта из словаря в любом падеже.
* падеже.
* *
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» → * <p>Названия на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»), поэтому
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак. * начало слова из словаря — уже достаточный признак. Названия на гласную меняют последнюю букву
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для * («Москва» → «Москве»), для них сравнение идёт по основе без неё — так же, как с личными именами
* них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}. * в {@link NameDictionary}.
* *
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 * <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000 основ по значению:
* основ по значению: перебор списка на каждое совпадение правила был бы * перебор списка на каждое совпадение правила был бы на порядки дороже, чем нужно — префиксов у
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв. * слова не больше, чем в нём букв.
*/ */
public static boolean isKnownSettlement(String value) { public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT); String lower = value.strip().toLowerCase(Locale.ROOT);
+13 -15
View File
@@ -1,8 +1,8 @@
package ru.pdguard.detect; package ru.pdguard.detect;
/** /**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности, * Проверки контрольных сумм. Отсекают случайные числовые последовательности, которые по форме
* которые по форме похожи на ПД, но ими не являются. * похожи на ПД, но ими не являются.
*/ */
public final class Validators { public final class Validators {
@@ -10,8 +10,7 @@ public final class Validators {
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8}; private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8}; private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private Validators() { private Validators() {}
}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */ /** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) { public static boolean luhn(String value) {
@@ -38,9 +37,9 @@ public final class Validators {
} }
/** /**
* Контрольная цифра Луна для последовательности цифр: дописывается к телу * Контрольная цифра Луна для последовательности цифр: дописывается к телу номера, чтобы весь
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при * номер прошёл проверку {@link #luhn}. Используется при генерации правдоподобных подставных
* генерации правдоподобных подставных номеров карт. * номеров карт.
*/ */
public static int luhnCheckDigit(String body) { public static int luhnCheckDigit(String body) {
int sum = 0; int sum = 0;
@@ -109,10 +108,10 @@ public final class Validators {
} }
/** /**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor}, * Остаток от деления первых {@code count} цифр как одного числа на {@code divisor}, взятый по
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки * младшему разряду. Числовое накопление по цифрам, а не парсинг строки в {@code long}: у ОГРНИП
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же * 14 цифр — на грани переполнения {@code int}, и это тот же приём, что уже применяется к самой
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}. * длинной последовательности в {@link #luhn}.
*/ */
private static int modReduce(int[] d, int count, int divisor) { private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0; long remainder = 0;
@@ -123,10 +122,9 @@ public final class Validators {
} }
/** /**
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985}, * Дата в числовой записи при любом порядке частей: {@code 12.05.1985}, {@code 05/12/1985}, {@code
* {@code 05/12/1985}, {@code 1985-05-12}, {@code 15 03 1990}, а также день * 1985-05-12}, {@code 15 03 1990}, а также день и месяц без года: {@code 15 03}, {@code 15/03}.
* и месяц без года: {@code 15 03}, {@code 15/03}. Отсекает похожие по форме * Отсекает похожие по форме последовательности вроде {@code 192.168.1}.
* последовательности вроде {@code 192.168.1}.
*/ */
public static boolean date(String value) { public static boolean date(String value) {
// Запись с названием месяца словом в дополнительной проверке не нуждается: // Запись с названием месяца словом в дополнительной проверке не нуждается:
+17 -19
View File
@@ -2,7 +2,6 @@ package ru.pdguard.detect;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@@ -18,14 +17,14 @@ import java.util.Map;
/** /**
* Разбиение текста на подслова так, как это делает токенизатор BERT. * Разбиение текста на подслова так, как это делает токенизатор BERT.
* *
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java * <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java подтягивает
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься * нативные библиотеки во время работы, а контейнер должен подниматься без обращений в сеть. Правила
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря: * здесь простые и целиком описаны форматом словаря: разбить по пробелам и знакам препинания, затем
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой * каждое слово — жадно по самой длинной подходящей записи словаря, продолжения помечаются префиксом
* длинной подходящей записи словаря, продолжения помечаются префиксом «##». * «##».
* *
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку * <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку модели не
* модели не перенести обратно на строку. * перенести обратно на строку.
*/ */
final class WordPiece { final class WordPiece {
@@ -35,8 +34,7 @@ final class WordPiece {
private static final String CONTINUATION = "##"; private static final String CONTINUATION = "##";
/** Подслово и его границы в исходном тексте. */ /** Подслово и его границы в исходном тексте. */
record Piece(int id, int start, int end) { record Piece(int id, int start, int end) {}
}
private final Map<String, Integer> vocabulary; private final Map<String, Integer> vocabulary;
private final int unknownId; private final int unknownId;
@@ -52,7 +50,8 @@ final class WordPiece {
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException { static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000); Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader = new BufferedReader( try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) { new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line; String line;
int index = 0; int index = 0;
@@ -64,9 +63,8 @@ final class WordPiece {
} }
/** /**
* Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели * Читает словарь из {@code tokenizer.json} Hugging Face. Некоторые модели (например, WikiNEuRal)
* (например, WikiNEuRal) не кладут отдельный {@code vocab.txt}, а хранят * не кладут отдельный {@code vocab.txt}, а хранят словарь внутри токенизатора.
* словарь внутри токенизатора.
*/ */
static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException { static WordPiece fromTokenizerJson(Path tokenizerFile) throws IOException {
JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile)); JsonNode root = new ObjectMapper().readTree(Files.readAllBytes(tokenizerFile));
@@ -101,8 +99,8 @@ final class WordPiece {
} }
/** /**
* Границы слов: разделителями считаются пробельные символы и знаки препинания, * Границы слов: разделителями считаются пробельные символы и знаки препинания, причём знак
* причём знак препинания сам становится отдельным словом. * препинания сам становится отдельным словом.
*/ */
private static List<int[]> words(String text) { private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>(); List<int[]> result = new ArrayList<>();
@@ -112,18 +110,18 @@ final class WordPiece {
boolean separator = Character.isWhitespace(c) || isPunctuation(c); boolean separator = Character.isWhitespace(c) || isPunctuation(c);
if (separator) { if (separator) {
if (start >= 0) { if (start >= 0) {
result.add(new int[]{start, i}); result.add(new int[] {start, i});
start = -1; start = -1;
} }
if (isPunctuation(c)) { if (isPunctuation(c)) {
result.add(new int[]{i, i + 1}); result.add(new int[] {i, i + 1});
} }
} else if (start < 0) { } else if (start < 0) {
start = i; start = i;
} }
} }
if (start >= 0) { if (start >= 0) {
result.add(new int[]{start, text.length()}); result.add(new int[] {start, text.length()});
} }
return result; return result;
} }
@@ -8,9 +8,8 @@ import java.util.function.BiFunction;
/** /**
* Состояние одной операции маскирования. * Состояние одной операции маскирования.
* *
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если * <p>Одинаковые значения в пределах запроса получают одинаковую замену: если клиент упомянут
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл * дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл запроса для модели сохранится.
* запроса для модели сохранится.
* *
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется. * <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/ */
@@ -29,7 +28,9 @@ public final class MaskContext {
* @param factory получает тип ПД и порядковый номер значения этого типа * @param factory получает тип ПД и порядковый номер значения этого типа
*/ */
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) { public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> { return assigned.computeIfAbsent(
type + SEPARATOR + value,
key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum)); String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value); restorations.put(replacement, value);
return replacement; return replacement;
@@ -37,8 +38,8 @@ public final class MaskContext {
} }
/** /**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст * Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст возвращается не
* возвращается не целиком, а изменённым — например, в ответе языковой модели. * целиком, а изменённым — например, в ответе языковой модели.
*/ */
public Map<String, String> restorations() { public Map<String, String> restorations() {
return Map.copyOf(restorations); return Map.copyOf(restorations);
+4 -5
View File
@@ -7,11 +7,10 @@ public enum MaskMode {
MASK, MASK,
/** /**
* Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в * Звёздочки без исключений: каждый тип закрывается целиком, даже те, что в {@link #MASK} частично
* {@link #MASK} частично открыты (края номера) или превращаются в инициалы * открыты (края номера) или превращаются в инициалы (ФИО {@code Иванов Иван Иванович} → {@code
* (ФИО {@code Иванов Иван Иванович} → {@code ******* **** *********}, не * ******* **** *********}, не {@code И. И. И.} — инициалы всё ещё выдают число слов и первую
* {@code И. И. И.} — инициалы всё ещё выдают число слов и первую букву * букву каждого).
* каждого).
*/ */
STRICT, STRICT,
+7 -11
View File
@@ -1,16 +1,15 @@
package ru.pdguard.mask; package ru.pdguard.mask;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.PdTypes;
import java.util.Map; import java.util.Map;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.PdTypes;
/** /**
* Превращает найденное значение в замену согласно настройкам системы. * Превращает найденное значение в замену согласно настройкам системы.
* *
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — * <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком — безопасное поведение по
* безопасное поведение по умолчанию для вновь добавленных правил. * умолчанию для вновь добавленных правил.
*/ */
@Component @Component
public class Masker { public class Masker {
@@ -18,7 +17,8 @@ public class Masker {
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2); private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2); private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries( private static final Map<String, UnaryOperator<String>> BY_TYPE =
Map.ofEntries(
Map.entry(PdTypes.EMAIL, Strategies::email), Map.entry(PdTypes.EMAIL, Strategies::email),
Map.entry(PdTypes.PHONE, EDGES), Map.entry(PdTypes.PHONE, EDGES),
Map.entry(PdTypes.CARD, EDGES), Map.entry(PdTypes.CARD, EDGES),
@@ -42,14 +42,12 @@ public class Masker {
// мало знаков, чтобы открывать хотя бы один. // мало знаков, чтобы открывать хотя бы один.
Map.entry(PdTypes.CVV, Strategies::stars), Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(PdTypes.PIN, Strategies::stars), Map.entry(PdTypes.PIN, Strategies::stars),
Map.entry(PdTypes.PASSPORT_ISSUER, Strategies::stars), Map.entry(PdTypes.PASSPORT_ISSUER, Strategies::stars),
// У дат сохраняем разделители: модель видит, что это дата, но не какая. // У дат сохраняем разделители: модель видит, что это дата, но не какая.
Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars), Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars),
Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars), Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars),
Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars), Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars),
@@ -60,7 +58,6 @@ public class Masker {
Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars), Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars),
Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars), Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars),
Map.entry(PdTypes.CITIZENSHIP, Strategies::stars), Map.entry(PdTypes.CITIZENSHIP, Strategies::stars),
Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES), Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES),
Map.entry(PdTypes.OGRN, EDGES), Map.entry(PdTypes.OGRN, EDGES),
Map.entry(PdTypes.OGRNIP, EDGES), Map.entry(PdTypes.OGRNIP, EDGES),
@@ -69,8 +66,7 @@ public class Masker {
Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
Map.entry(PdTypes.BIK, Strategies::stars), Map.entry(PdTypes.BIK, Strategies::stars),
Map.entry(PdTypes.INCOME, Strategies::stars), Map.entry(PdTypes.INCOME, Strategies::stars),
Map.entry(PdTypes.BIOMETRIC, Strategies::stars) Map.entry(PdTypes.BIOMETRIC, Strategies::stars));
);
public String mask(String type, String value, MaskMode mode, MaskContext context) { public String mask(String type, String value, MaskMode mode, MaskContext context) {
return switch (mode) { return switch (mode) {
+9 -12
View File
@@ -3,16 +3,14 @@ package ru.pdguard.mask;
/** /**
* Способы преобразования найденного значения в маску. * Способы преобразования найденного значения в маску.
* *
* <p>Все стратегии сохраняют длину и разделители исходного значения: так * <p>Все стратегии сохраняют длину и разделители исходного значения: так замаскированный текст
* замаскированный текст остаётся читаемым для LLM и минимально отличается * остаётся читаемым для LLM и минимально отличается от эталона при посимвольном сравнении.
* от эталона при посимвольном сравнении.
*/ */
public final class Strategies { public final class Strategies {
private static final char MASK = '*'; private static final char MASK = '*';
private Strategies() { private Strategies() {}
}
/** Каждый непробельный символ заменяется на «*». */ /** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) { public static String stars(String value) {
@@ -25,9 +23,8 @@ public final class Strategies {
} }
/** /**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, * Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****}, {@code
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, * 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели, само значение — нет.
* само значение — нет.
*/ */
public static String starsKeepingPunctuation(String value) { public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length()); StringBuilder sb = new StringBuilder(value.length());
@@ -39,8 +36,8 @@ public final class Strategies {
} }
/** /**
* Оставляет первые и последние значащие символы, остальные скрывает, * Оставляет первые и последние значащие символы, остальные скрывает, разделители сохраняет:
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}. * {@code 4509 123456} → {@code 45** ****56}.
*/ */
public static String keepEdges(String value, int head, int tail) { public static String keepEdges(String value, int head, int tail) {
int significant = 0; int significant = 0;
@@ -89,8 +86,8 @@ public final class Strategies {
} }
/** /**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона. * Адрес почты: видны первая буква имени ящика, первая буква домена и зона. {@code
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru} * ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/ */
public static String email(String value) { public static String email(String value) {
int at = value.lastIndexOf('@'); int at = value.lastIndexOf('@');
+41 -19
View File
@@ -6,38 +6,55 @@ import ru.pdguard.detect.Validators;
/** /**
* Правдоподобные подставные значения вместо настоящих. * Правдоподобные подставные значения вместо настоящих.
* *
* <p>Модель получает текст, который выглядит естественно, и качество ответа * <p>Модель получает текст, который выглядит естественно, и качество ответа страдает меньше, чем от
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же * звёздочек. Значения детерминированы: одно и то же исходное значение всегда даёт одну и ту же
* исходное значение всегда даёт одну и ту же подстановку. * подстановку.
*/ */
final class Synthetic { final class Synthetic {
private static final String[] SURNAMES = private static final String[] SURNAMES = {
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"}; "Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"
};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"}; private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS = private static final String[] PATRONYMICS = {
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"}; "Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"
};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"}; private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() { private Synthetic() {}
}
static String forType(String type, String value, int ordinal) { static String forType(String type, String value, int ordinal) {
int seed = value.hashCode() & Integer.MAX_VALUE; int seed = value.hashCode() & Integer.MAX_VALUE;
return switch (type) { return switch (type) {
case PdTypes.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) case PdTypes.FIO ->
+ " " + pick(PATRONYMICS, seed >> 6); pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) + " " + pick(PATRONYMICS, seed >> 6);
case PdTypes.CARDHOLDER -> "IVAN PETROV"; case PdTypes.CARDHOLDER -> "IVAN PETROV";
case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) case PdTypes.PHONE ->
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2); "+7 9"
+ digits(seed, 2)
+ " "
+ digits(seed >> 4, 3)
+ "-"
+ digits(seed >> 8, 2)
+ "-"
+ digits(seed >> 12, 2);
case PdTypes.CARD -> luhnCard(seed); case PdTypes.CARD -> luhnCard(seed);
case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT, case PdTypes.PASSPORT,
PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); PdTypes.DRIVER_LICENSE,
PdTypes.FOREIGN_PASSPORT,
PdTypes.MILITARY_ID ->
digits(seed, 4) + " " + digits(seed >> 6, 6);
case PdTypes.INN -> digits(seed, 12); case PdTypes.INN -> digits(seed, 12);
case PdTypes.MEDICAL_POLICY -> digits(seed, 16); case PdTypes.MEDICAL_POLICY -> digits(seed, 16);
case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) case PdTypes.SNILS ->
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2); digits(seed, 3)
+ "-"
+ digits(seed >> 4, 3)
+ "-"
+ digits(seed >> 8, 3)
+ " "
+ digits(seed >> 12, 2);
case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed); case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
case PdTypes.ADDRESS_CITY -> "Зареченск"; case PdTypes.ADDRESS_CITY -> "Зареченск";
case PdTypes.ADDRESS_STREET -> "Сосновая"; case PdTypes.ADDRESS_STREET -> "Сосновая";
@@ -79,7 +96,12 @@ final class Synthetic {
private static String luhnCard(int seed) { private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
body.append(Validators.luhnCheckDigit(body.toString())); body.append(Validators.luhnCheckDigit(body.toString()));
return body.substring(0, 4) + " " + body.substring(4, 8) + " " return body.substring(0, 4)
+ body.substring(8, 12) + " " + body.substring(12); + " "
+ body.substring(4, 8)
+ " "
+ body.substring(8, 12)
+ " "
+ body.substring(12);
} }
} }
+58 -38
View File
@@ -1,5 +1,9 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,11 +11,6 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */ /** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */
class BankTypesTest { class BankTypesTest {
@@ -20,18 +19,19 @@ class BankTypesTest {
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
} }
/** /**
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они * Банковские реквизиты маскируются рядом с данными человека. Сами по себе они опознают
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их * организацию или счёт, а не клиента, и в перечне типов из задания их нет — поэтому они
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата. * переведены в {@code requireCompanion}, как пин-код и дата.
*/ */
@Test @Test
void masksAccountNumberNextToPersonalData() { void masksAccountNumberNextToPersonalData() {
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", assertHidden(
"40702810500000001234"); "Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234", "40702810500000001234");
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234"); assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
} }
@@ -42,19 +42,24 @@ class BankTypesTest {
@Test @Test
void keepsBankDetailsWithoutAnyPersonalData() { void keepsBankDetailsWithoutAnyPersonalData() {
for (String text : new String[]{ for (String text :
new String[] {
"Расчётный счёт 40702810500000001234 открыт вчера", "Расчётный счёт 40702810500000001234 открыт вчера",
"БИК 044525593 банка-получателя", "БИК 044525593 банка-получателя",
"ОГРН 1027700132195 организации", "ОГРН 1027700132195 организации",
"КПП 770101001 указан в реквизитах"}) { "КПП 770101001 указан в реквизитах"
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), }) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"реквизиты без человека персональными данными не являются"); "реквизиты без человека персональными данными не являются");
} }
} }
@Test @Test
void masksCardExpiryButNotCardNumber() { void masksCardExpiryButNotCardNumber() {
String masked = pipeline.process( String masked =
pipeline.process(
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT); "Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("09/27"), masked); assertFalse(masked.contains("09/27"), masked);
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked); assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
@@ -69,21 +74,25 @@ class BankTypesTest {
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */ /** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
@Test @Test
void ogrnDoesNotSwallowOgrnip() { void ogrnDoesNotSwallowOgrnip() {
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", String masked =
"ogrnip-1", SystemPolicy.DEFAULT); pipeline.process(
"ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT);
assertFalse(masked.contains("304500116000157"), masked); assertFalse(masked.contains("304500116000157"), masked);
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked); assertFalse(
masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
} }
/** /**
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». * Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН». Число подобрано
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно * так, чтобы не проходить заодно и Луна — иначе оно всё равно маскировалось бы, но уже как номер
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал. * карты, и тест ничего бы не показывал.
*/ */
@Test @Test
void doesNotMaskOgrnWithBrokenChecksum() { void doesNotMaskOgrnWithBrokenChecksum() {
String text = "ОГРН 1027700132190 организации"; String text = "ОГРН 1027700132190 организации";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРН"); "число с неверной контрольной суммой не является настоящим ОГРН");
} }
@@ -91,7 +100,9 @@ class BankTypesTest {
@Test @Test
void doesNotMaskOgrnipWithBrokenChecksum() { void doesNotMaskOgrnipWithBrokenChecksum() {
String text = "ОГРНИП 304500116000150 предпринимателя"; String text = "ОГРНИП 304500116000150 предпринимателя";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"число с неверной контрольной суммой не является настоящим ОГРНИП"); "число с неверной контрольной суммой не является настоящим ОГРНИП");
} }
@@ -107,16 +118,20 @@ class BankTypesTest {
} }
/** /**
* Сумма заработка без человека — статистика или описание продукта. Опознать по * Сумма заработка без человека — статистика или описание продукта. Опознать по ней никого нельзя,
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает, * а для прокси к языковой модели вымаранное число означает, что вопрос про среднюю зарплату по
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем. * отрасли отвечать уже не на чем.
*/ */
@Test @Test
void keepsIncomeWithoutAnyPersonalData() { void keepsIncomeWithoutAnyPersonalData() {
for (String text : new String[]{ for (String text :
new String[] {
"По данным Росстата доход домохозяйств вырос до 74 500 руб", "По данным Росстата доход домохозяйств вырос до 74 500 руб",
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) { "Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), }) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"сумма заработка без человека персональными данными не является"); "сумма заработка без человека персональными данными не является");
} }
} }
@@ -128,28 +143,33 @@ class BankTypesTest {
} }
/** /**
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само * Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само упоминание — слово, а
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает * не данные. Без человека рядом такая замена скрывает ноль сведений и разрушает смысл фразы.
* ноль сведений и разрушает смысл фразы.
*/ */
@Test @Test
void keepsBiometricMentionWithoutAnyPersonalData() { void keepsBiometricMentionWithoutAnyPersonalData() {
for (String text : new String[]{ for (String text :
new String[] {
"Банк внедрил биометрические данные в обслуживание клиентов", "Банк внедрил биометрические данные в обслуживание клиентов",
"Сдать биометрию можно через ЕБС в любом отделении"}) { "Сдать биометрию можно через ЕБС в любом отделении"
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), }) {
assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"упоминание биометрии без человека персональными данными не является"); "упоминание биометрии без человека персональными данными не является");
} }
} }
/** /**
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и * Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и ОГРН самостоятельным
* ОГРН самостоятельным не становится, человека в таком тексте нет. * не становится, человека в таком тексте нет.
*/ */
@Test @Test
void twoCompanionTypesDoNotVouchForEachOther() { void twoCompanionTypesDoNotVouchForEachOther() {
String text = "Оплата 01.02.2025, ОГРН 1027700132195"; String text = "Оплата 01.02.2025, ОГРН 1027700132195";
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT), assertEquals(
text,
pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
"спутники заверили друг друга в отсутствие настоящих ПД"); "спутники заверили друг друга в отсутствие настоящих ПД");
} }
} }
+10 -11
View File
@@ -1,7 +1,5 @@
package ru.pdguard; package ru.pdguard;
import ru.pdguard.detect.Span;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -12,29 +10,30 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import ru.pdguard.detect.Span;
/** /**
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется * Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется и {@link BenchmarkTest}
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest} * (замер качества по строкам), и {@link LargeTextTest} (те же строки, перемешанные и склеенные в
* (те же строки, перемешанные и склеенные в большой текст). * большой текст).
*/ */
final class BenchmarkFixtures { final class BenchmarkFixtures {
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}"); private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
/** Размеченный пример: чистый текст и эталонные фрагменты. */ /** Размеченный пример: чистый текст и эталонные фрагменты. */
record Sample(String text, List<Span> gold) { record Sample(String text, List<Span> gold) {}
}
private BenchmarkFixtures() { private BenchmarkFixtures() {}
}
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */ /** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
static List<Sample> load(String resource) { static List<Sample> load(String resource) {
List<Sample> samples = new ArrayList<>(); List<Sample> samples = new ArrayList<>();
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource); try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
BufferedReader reader = new BufferedReader( BufferedReader reader =
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) { new BufferedReader(
new InputStreamReader(
Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
String trimmed = line.trim(); String trimmed = line.trim();
+175 -99
View File
@@ -1,14 +1,7 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue;
import ru.pdguard.config.SystemPolicy; import static org.junit.jupiter.api.Assumptions.assumeTrue;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -18,39 +11,47 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue; import ru.pdguard.config.SystemPolicy;
import static org.junit.jupiter.api.Assumptions.assumeTrue; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
/** /**
* Замер качества детекции на размеченных наборах. * Замер качества детекции на размеченных наборах.
* *
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому * <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому его оценка
* его оценка завышена и годится только как защита от ухудшений. * завышена и годится только как защита от ухудшений. {@code benchmark-holdout.txt} составлен
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не * независимо и на нём правила не настраивались — именно он показывает настоящее качество.
* настраивались — именно он показывает настоящее качество.
* *
* <p>Метрики посимвольные: так они не зависят от того, где именно правило * <p>Метрики посимвольные: так они не зависят от того, где именно правило поставило границу
* поставило границу совпадения, и напрямую соотносятся с посимвольным * совпадения, и напрямую соотносятся с посимвольным сравнением замаскированного текста с эталоном.
* сравнением замаскированного текста с эталоном.
* *
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, * <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты, а расхождение в
* а расхождение в названии типа (скажем, место рождения против города) на * названии типа (скажем, место рождения против города) на качество маскирования не влияет.
* качество маскирования не влияет.
*/ */
class BenchmarkTest { class BenchmarkTest {
/** /**
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это * Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это видно по заголовку
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}. * отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
*/ */
private static final String ENGINE = System.getProperty("bench.engine", "rubert"); private static final String ENGINE = System.getProperty("bench.engine", "rubert");
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner"); private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
/** Итог замера по одному набору. */ /** Итог замера по одному набору. */
private record Result(double fioF1, double overallPrecision, double overallRecall, private record Result(
double falsePositiveRate, int foundFioSpans, int goldFioSpans) { double fioF1,
} double overallPrecision,
double overallRecall,
double falsePositiveRate,
int foundFioSpans,
int goldFioSpans) {}
/** Накопитель посимвольных совпадений по одному типу. */ /** Накопитель посимвольных совпадений по одному типу. */
private static final class Score { private static final class Score {
@@ -82,129 +83,162 @@ class BenchmarkTest {
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
/** /**
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение * Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение означает, что
* означает, что сломалось то, что раньше работало. * сломалось то, что раньше работало.
*/ */
@Test @Test
void detectionQualityOnTuningSet() { void detectionQualityOnTuningSet() {
Result result = measure("/benchmark.txt", "набор отладки"); Result result = measure("/benchmark.txt", "набор отладки");
assertTrue(result.fioF1() >= 0.95, assertTrue(result.fioF1() >= 0.95, String.format("F1 по ФИО упал до %.3f", result.fioF1()));
String.format("F1 по ФИО упал до %.3f", result.fioF1())); assertTrue(
assertTrue(result.overallRecall() >= 0.95, result.overallRecall() >= 0.95,
String.format("полнота по всем типам упала до %.3f", result.overallRecall())); String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.05, assertTrue(
result.falsePositiveRate() <= 0.05,
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate())); String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
} }
/** /**
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они * Отложенный набор: правила на нём не настраивались. Пороги ниже — они отражают измеренное на нём
* отражают измеренное на нём качество, а не желаемое. * качество, а не желаемое.
*/ */
@Test @Test
void detectionQualityOnHoldoutSet() { void detectionQualityOnHoldoutSet() {
Result result = measure("/benchmark-holdout.txt", "отложенный набор"); Result result = measure("/benchmark-holdout.txt", "отложенный набор");
assertTrue(result.fioF1() >= 0.75, assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1())); String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.75, assertTrue(
result.overallRecall() >= 0.75,
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall())); String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.15, assertTrue(
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate())); result.falsePositiveRate() <= 0.15,
String.format(
"ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
} }
/** /**
* Второй контрольный набор, составленный после того, как первый дважды повлиял * Второй контрольный набор, составленный после того, как первый дважды повлиял на правила. На нём
* на правила. На нём не настраивалось ничего — он и показывает настоящее * не настраивалось ничего — он и показывает настоящее качество. Пороги низкие намеренно: тест
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение. * ловит обвал, а не сторожит значение.
*/ */
@Test @Test
void detectionQualityOnThirdHoldoutSet() { void detectionQualityOnThirdHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH)) Pipeline stage =
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)) new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline; : pipeline;
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор"); Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
assertTrue(result.fioF1() >= 0.70, assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1())); String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70, assertTrue(
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall())); result.overallRecall() >= 0.70,
String.format(
"полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
} }
/** /**
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы * Контрольный набор. Правила по нему не настраиваются: он существует, чтобы показывать качество
* показывать качество на данных, которых разработка не видела. Пороги здесь * на данных, которых разработка не видела. Пороги здесь низкие намеренно — тест ловит обвал, а не
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение. * сторожит достигнутое значение. Замер идёт со второй ступенью, если модель собрана, иначе на
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах. * одних правилах.
*/ */
@Test @Test
void detectionQualityOnSecondHoldoutSet() { void detectionQualityOnSecondHoldoutSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH)) Pipeline stage =
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)) new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline; : pipeline;
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор"); Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
assertTrue(result.fioF1() >= 0.70, assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1())); String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70, assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall())); String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
} }
/** /**
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), * Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента), расширенный денилист,
* расширенный денилист, обобщённое companion-правило (место рождения, * обобщённое companion-правило (место рождения, страна) и новые банковские типы. Собран
* страна) и новые банковские типы. Собран специально под соответствующие * специально под соответствующие доработки — пороги ниже, чем у набора отладки, но проверяют
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то, * именно то, что было доработано, а не общее качество остального пайплайна.
* что было доработано, а не общее качество остального пайплайна.
*/ */
@Test @Test
void detectionQualityOnBankContextSet() { void detectionQualityOnBankContextSet() {
Result result = measure("/benchmark-bank-context.txt", "банковский контекст"); Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
assertTrue(result.fioF1() >= 0.70, assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1())); String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70, assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall())); String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
assertTrue(result.falsePositiveRate() <= 0.10, assertTrue(
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate())); result.falsePositiveRate() <= 0.10,
String.format(
"ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
} }
/** /**
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации * Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации написания, не
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под * встречавшиеся ни в одном из остальных наборов. Правила под него не настраивались; пороги низкие
* него не настраивались; пороги низкие по той же причине, что и у второго * по той же причине, что и у второго отложенного набора — тест ловит обвал, а не сторожит
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение. * достигнутое значение.
*/ */
@Test @Test
void detectionQualityOnGeneratedSet() { void detectionQualityOnGeneratedSet() {
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH)) Pipeline stage =
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), Files.isReadable(Path.of(MODEL_PATH))
? new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)) new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
: pipeline; : pipeline;
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор"); Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
assertTrue(result.fioF1() >= 0.70, assertTrue(
result.fioF1() >= 0.70,
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1())); String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
assertTrue(result.overallRecall() >= 0.70, assertTrue(
result.overallRecall() >= 0.70,
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall())); String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
} }
/** /**
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет — * Тот же отложенный набор, но со включённой второй ступенью. Модели нет — проверка пропускается:
* проверка пропускается: в сборке без модели сервис работает на одних правилах. * в сборке без модели сервис работает на одних правилах.
*/ */
@Test @Test
void detectionQualityWithNameCascade() { void detectionQualityWithNameCascade() {
Path model = Path.of(MODEL_PATH); Path model = Path.of(MODEL_PATH);
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана"); assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(), Pipeline withCascade =
new PayloadStore(10_000_000L, 30), new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)); new Pipeline(
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена"); new RuleRegistry(),
new Masker(),
new PayloadStore(10_000_000L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
Result result =
measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
assertTrue(result.fioF1() >= 0.75, assertTrue(
result.fioF1() >= 0.75,
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1())); String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
} }
@@ -259,13 +293,28 @@ class BenchmarkTest {
} }
} }
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans, report(
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked); title,
samples.size(),
byType,
anyType,
goldFioSpans,
foundFioSpans,
cleanTexts,
cleanTextsWithFalseHit,
missedFio,
falseHits,
overMasked);
Score fio = byType.getOrDefault(PdTypes.FIO, new Score()); Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts; double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
return new Result(fio.f1(), anyType.precision(), anyType.recall(), return new Result(
falsePositiveRate, foundFioSpans, goldFioSpans); fio.f1(),
anyType.precision(),
anyType.recall(),
falsePositiveRate,
foundFioSpans,
goldFioSpans);
} }
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */ /** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
@@ -304,8 +353,7 @@ class BenchmarkTest {
} }
private static boolean overlappedByFio(Span gold, List<Span> found) { private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream() return found.stream().anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
.anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
} }
private static String fragment(String text, Span span) { private static String fragment(String text, Span span) {
@@ -313,7 +361,8 @@ class BenchmarkTest {
} }
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */ /** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) { private static void collectOverMasked(
String text, String[] gold, String[] found, List<String> sink) {
int from = -1; int from = -1;
for (int i = 0; i <= text.length(); i++) { for (int i = 0; i <= text.length(); i++) {
boolean extra = i < text.length() && found[i] != null && gold[i] == null; boolean extra = i < text.length() && found[i] != null && gold[i] == null;
@@ -326,25 +375,53 @@ class BenchmarkTest {
} }
} }
private void report(String title, int samples, Map<String, Score> byType, Score anyType, private void report(
int goldFio, int foundFio, int cleanTexts, int falseHitTexts, String title,
List<String> missedFio, List<String> falseHits, List<String> overMasked) { int samples,
Map<String, Score> byType,
Score anyType,
int goldFio,
int foundFio,
int cleanTexts,
int falseHitTexts,
List<String> missedFio,
List<String> falseHits,
List<String> overMasked) {
StringBuilder out = new StringBuilder(4096); StringBuilder out = new StringBuilder(4096);
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n"); out.append("\n=== ")
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1")); .append(title)
.append(": ")
.append(samples)
.append(" размеченных строк ===\n\n");
out.append(
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream() byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed()) .sorted(
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
e.getKey(), e.getValue().gold(), e.getValue().precision(), .forEach(
e.getValue().recall(), e.getValue().f1()))); e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(), out.append(
anyType.precision(), anyType.recall(), anyType.f1())); String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
"ЛЮБОЙ ТИП", anyType.gold(), anyType.precision(), anyType.recall(), anyType.f1()));
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n", out.append(
String.format(
"%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio)); foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n", out.append(
String.format(
"Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts)); falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
appendList(out, "\nНе найденные ФИО:", missedFio); appendList(out, "\nНе найденные ФИО:", missedFio);
@@ -361,5 +438,4 @@ class BenchmarkTest {
out.append(title).append('\n'); out.append(title).append('\n');
lines.forEach(line -> out.append(" ").append(line).append('\n')); lines.forEach(line -> out.append(" ").append(line).append('\n'));
} }
} }
@@ -1,12 +1,12 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import ru.pdguard.core.PayloadCipher; import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest @SpringBootTest
class CipherEnabledTest { class CipherEnabledTest {
@Autowired PayloadCipher cipher; @Autowired PayloadCipher cipher;
+8 -5
View File
@@ -1,22 +1,25 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher;
/** Проверка ключа шифрования из application.yml. */ /** Проверка ключа шифрования из application.yml. */
class CipherKeyTest { class CipherKeyTest {
private static final String KEY = "46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573"; private static final String KEY =
"46a38b200c6df557a5fd2c8a57ad3fec6b710b9f3e1fef1451d121a094f63573";
@Test @Test
void keyIsValidAes256() { void keyIsValidAes256() {
PayloadCipher cipher = new PayloadCipher(KEY); PayloadCipher cipher = new PayloadCipher(KEY);
assertTrue(cipher.enabled(), "ключ должен включать шифрование"); assertTrue(cipher.enabled(), "ключ должен включать шифрование");
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
assertEquals(original, cipher.decrypt(cipher.encrypt(original)), assertEquals(
original,
cipher.decrypt(cipher.encrypt(original)),
"round-trip с ключом из application.yml должен работать"); "round-trip с ключом из application.yml должен работать");
} }
} }
@@ -1,19 +1,17 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Типы ПД, которые опознаются только рядом с якорным словом. */ /** Типы ПД, которые опознаются только рядом с якорным словом. */
class ContextDetectionTest { class ContextDetectionTest {
@@ -26,7 +24,8 @@ class ContextDetectionTest {
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
} }
@Test @Test
@@ -58,7 +57,8 @@ class ContextDetectionTest {
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015"); String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
assertFalse(masked.contains("ОУФМС"), masked); assertFalse(masked.contains("ОУФМС"), masked);
assertFalse(masked.contains("12.05.2015"), masked); assertFalse(masked.contains("12.05.2015"), masked);
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked); assertTrue(
masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
} }
@Test @Test
@@ -79,7 +79,8 @@ class ContextDetectionTest {
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется // Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте // («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
// на распознавание якоря рядом добавлен телефон. // на распознавание якоря рядом добавлен телефон.
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь"); assertHidden(
"Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде"); assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
} }
@@ -138,7 +139,8 @@ class ContextDetectionTest {
@Test @Test
void complexSentenceKeepsSurroundingWords() { void complexSentenceKeepsSurroundingWords() {
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, " String original =
"Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67"; + "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
String masked = mask(original); String masked = mask(original);
@@ -151,7 +153,8 @@ class ContextDetectionTest {
@Test @Test
void unmaskingRestoresComplexSentence() { void unmaskingRestoresComplexSentence() {
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, " String original =
"Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111"; + "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
String id = "complex-1"; String id = "complex-1";
+20 -19
View File
@@ -1,5 +1,11 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
@@ -9,30 +15,23 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/** /**
* 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному * 200 вручную составленных текстовых тестов из {@code dataset-200.txt} — по одному предложению на
* предложению на строку, каждое своя отдельная проверка (не сборка одного большого * строку, каждое своя отдельная проверка (не сборка одного большого текста, как в {@link
* текста, как в {@link HugeDatasetTest}). Набор покрывает все типы ПДН из * HugeDatasetTest}). Набор покрывает все типы ПДН из {@link RuleRegistry} (кроме
* {@link RuleRegistry} (кроме ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, * ADDRESS_REGION/ADDRESS_DISTRICT — для них нет правил, только модель второй ступени), варианты
* только модель второй ступени), варианты написания (регистр, формат даты, разделяющие * написания (регистр, формат даты, разделяющие слова) и несколько строк-ловушек без разметки
* слова) и несколько строк-ловушек без разметки (известный человек, адрес отделения, * (известный человек, адрес отделения, дата без якоря) — они не должны маскироваться вовсе.
* дата без якоря) — они не должны маскироваться вовсе.
* *
* <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом * <p>На каждой строке: маскирование не оставляет исходное значение ПДН в открытом виде, а
* виде, а демаскирование побайтово восстанавливает исходный текст. * демаскирование побайтово восстанавливает исходный текст.
*/ */
class Dataset200Test { class Dataset200Test {
private static final RuleRegistry REGISTRY = new RuleRegistry(); private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker(); private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-200.txt"); private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-200.txt");
@TestFactory @TestFactory
Stream<DynamicTest> datasetOf200Cases() { Stream<DynamicTest> datasetOf200Cases() {
@@ -40,7 +39,8 @@ class Dataset200Test {
for (int i = 0; i < DATASET.size(); i++) { for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i); BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i; int index = i;
cases.add(dynamicTest( cases.add(
dynamicTest(
String.format("#%03d: %s", index, preview(sample.text())), String.format("#%03d: %s", index, preview(sample.text())),
() -> runCase(sample, index))); () -> runCase(sample, index)));
} }
@@ -54,7 +54,8 @@ class Dataset200Test {
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
for (Span gold : sample.gold()) { for (Span gold : sample.gold()) {
String value = sample.text().substring(gold.start(), gold.end()); String value = sample.text().substring(gold.start(), gold.end());
assertFalse(masked.contains(value), assertFalse(
masked.contains(value),
"ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value); "ПДН типа " + gold.type() + " утекло в замаскированный текст: " + value);
} }
@@ -1,5 +1,10 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -8,12 +13,6 @@ import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Даты во всех вариантах записи и составляющие адреса. */ /** Даты во всех вариантах записи и составляющие адреса. */
class DateAndAddressTest { class DateAndAddressTest {
@@ -26,7 +25,8 @@ class DateAndAddressTest {
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
} }
@Test @Test
@@ -41,7 +41,8 @@ class DateAndAddressTest {
@Test @Test
void masksBirthDateWrittenWithWords() { void masksBirthDateWrittenWithWords() {
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985"); assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года", assertHidden(
"Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
"двенадцатого мая"); "двенадцатого мая");
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа"); assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
} }
@@ -87,7 +88,8 @@ class DateAndAddressTest {
@Test @Test
void streetNameDoesNotSwallowTheRestOfTheSentence() { void streetNameDoesNotSwallowTheRestOfTheSentence() {
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта"); String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
assertTrue(masked.contains("перекрыта из-за ремонта"), assertTrue(
masked.contains("перекрыта из-за ремонта"),
"название улицы это одно-три слова, а не остаток предложения: " + masked); "название улицы это одно-три слова, а не остаток предложения: " + masked);
assertFalse(masked.contains("Сосновая"), masked); assertFalse(masked.contains("Сосновая"), masked);
} }
@@ -95,7 +97,8 @@ class DateAndAddressTest {
@Test @Test
void doesNotMaskStreetMentionedOutsideAnAddress() { void doesNotMaskStreetMentionedOutsideAnAddress() {
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера")); assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
assertEquals("Улица Весенняя названа в честь праздника", assertEquals(
"Улица Весенняя названа в честь праздника",
mask("Улица Весенняя названа в честь праздника")); mask("Улица Весенняя названа в честь праздника"));
} }
@@ -133,7 +136,8 @@ class DateAndAddressTest {
@Test @Test
void unmaskingRestoresTextWithDateAndAddress() { void unmaskingRestoresTextWithDateAndAddress() {
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, " String original =
"Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456"; + "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
String id = "date-addr-1"; String id = "date-addr-1";
+9 -8
View File
@@ -1,5 +1,10 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,12 +12,6 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** ФИО и защита от ложных срабатываний. */ /** ФИО и защита от ложных срабатываний. */
class FioTest { class FioTest {
@@ -25,7 +24,8 @@ class FioTest {
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
} }
private void assertUnchanged(String text) { private void assertUnchanged(String text) {
@@ -106,7 +106,8 @@ class FioTest {
@Test @Test
void unmaskingRestoresNames() { void unmaskingRestoresNames() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, " String original =
"Клиент Иванов Иван Иванович, паспорт 4509 123456, "
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67"; + "дата рождения 12.05.1985, телефон +7 916 123-45-67";
String id = "fio-1"; String id = "fio-1";
+57 -39
View File
@@ -1,5 +1,14 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
@@ -9,28 +18,18 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/** /**
* Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" * Датасет из 1000 прогонов разной длины — критерий 3.5 из "Критерии_оценивания_альфа" (обработка
* (обработка текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы". * текстов до 100 000 токенов) и стоп-сигнал по утечке ПДН из "критерии_фрейм_топы".
* *
* <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов * <p>Тексты строятся перемешиванием строк из уже существующих размеченных наборов {@code
* {@code benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, * benchmark-*.txt} (17+ типов ПДН из ТЗ) — отдельный датасет с нуля не заводится, пул размеченных
* пул размеченных примеров и так покрывает все типы. Длина растёт от одного предложения * примеров и так покрывает все типы. Длина растёт от одного предложения до 400 000 знаков (100 000
* до 400 000 знаков (100 000 токенов при 4 знака/токен — так же, как считает сам * токенов при 4 знака/токен — так же, как считает сам {@link Pipeline}); не менее {@link
* {@link Pipeline}); не менее {@link #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 * #HUGE_CASES} прогонов лежат в полосе 90 000-100 000 токенов. На каждом прогоне проверяются:
* токенов. На каждом прогоне проверяются: отсутствие ПДН в открытом виде в замаскированном * отсутствие ПДН в открытом виде в замаскированном тексте и побайтовое совпадение после
* тексте и побайтовое совпадение после демаскирования; на прогонах из полосы 90-100к токенов * демаскирования; на прогонах из полосы 90-100к токенов дополнительно проверяется, что маскирование
* дополнительно проверяется, что маскирование укладывается в 5 секунд. * укладывается в 5 секунд.
* *
* <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ. * <p>Полный прогон класса занимает пару минут — это ожидаемо на объёме, требуемом ТЗ.
*/ */
@@ -41,13 +40,19 @@ class HugeDatasetTest {
private static final int CHARS_PER_TOKEN = 4; private static final int CHARS_PER_TOKEN = 4;
private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN; private static final int HUGE_MIN_CHARS = 90_000 * CHARS_PER_TOKEN;
private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN; private static final int HUGE_MAX_CHARS = 100_000 * CHARS_PER_TOKEN;
/** Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним
* текстом пула — их из проверки на утечку исключаем, длинные ПДН проверяем всегда. */ /**
* Короткие значения (PIN, номер дома и т.п.) чаще случайно совпадают с посторонним текстом пула —
* их из проверки на утечку исключаем, длинные ПДН проверяем всегда.
*/
private static final int LEAK_CHECK_MIN_LENGTH = 6; private static final int LEAK_CHECK_MIN_LENGTH = 6;
/** Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
* (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту /**
* от 0.70 — это и есть отправная точка, а не 0.85 из LargeTextTest, где * Допустимая доля утечек на прогон. Пул включает настоящие holdout-наборы
* участвует только benchmark-generated.txt, подстроенный под правила. */ * (benchmark-holdout*.txt), на которых BenchmarkTest сам принимает полноту от 0.70 — это и есть
* отправная точка, а не 0.85 из LargeTextTest, где участвует только benchmark-generated.txt,
* подстроенный под правила.
*/
private static final double MAX_LEAK_RATE = 0.30; private static final double MAX_LEAK_RATE = 0.30;
private static final RuleRegistry REGISTRY = new RuleRegistry(); private static final RuleRegistry REGISTRY = new RuleRegistry();
@@ -56,10 +61,15 @@ class HugeDatasetTest {
private static List<BenchmarkFixtures.Sample> loadPool() { private static List<BenchmarkFixtures.Sample> loadPool() {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(); List<BenchmarkFixtures.Sample> pool = new ArrayList<>();
for (String resource : List.of( for (String resource :
"/benchmark.txt", "/benchmark-generated.txt", "/benchmark-pdn-types.txt", List.of(
"/benchmark-bank-context.txt", "/benchmark-holdout.txt", "/benchmark.txt",
"/benchmark-holdout2.txt", "/benchmark-holdout3.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)); pool.addAll(BenchmarkFixtures.load(resource));
} }
return pool; return pool;
@@ -71,17 +81,20 @@ class HugeDatasetTest {
for (int i = 0; i < TOTAL_CASES; i++) { for (int i = 0; i < TOTAL_CASES; i++) {
int targetChars = targetChars(i); int targetChars = targetChars(i);
int index = i; int index = i;
cases.add(dynamicTest( cases.add(
String.format("#%04d, %d знаков (~%d токенов)", index, targetChars, targetChars / CHARS_PER_TOKEN), dynamicTest(
String.format(
"#%04d, %d знаков (~%d токенов)",
index, targetChars, targetChars / CHARS_PER_TOKEN),
() -> runCase(targetChars, index))); () -> runCase(targetChars, index)));
} }
return cases.stream(); return cases.stream();
} }
/** /**
* Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — * Длина растёт по логарифмической шкале от предложения до порога "огромного" текста — так тесты
* так тесты покрывают все порядки величины, а не только маленькие и не только большие. * покрывают все порядки величины, а не только маленькие и не только большие. Последние {@link
* Последние {@link #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ. * #HUGE_CASES} индексов — обязательная полоса 90-100к токенов из ТЗ.
*/ */
private static int targetChars(int index) { private static int targetChars(int index) {
int regular = TOTAL_CASES - HUGE_CASES; int regular = TOTAL_CASES - HUGE_CASES;
@@ -96,7 +109,8 @@ class HugeDatasetTest {
} }
private void runCase(int targetChars, int seed) { private void runCase(int targetChars, int seed) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30)); Pipeline pipeline =
new Pipeline(REGISTRY, MASKER, new PayloadStore(targetChars * 2L + 4096, 30));
BenchmarkFixtures.Sample sample = buildText(targetChars, seed); BenchmarkFixtures.Sample sample = buildText(targetChars, seed);
String payloadId = "dataset-" + seed; String payloadId = "dataset-" + seed;
@@ -119,8 +133,10 @@ class HugeDatasetTest {
// На малых текстах пара пропусков — статистический шум, не деградация детектора: // На малых текстах пара пропусков — статистический шум, не деградация детектора:
// абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель. // абсолютный запас на такие случаи не даёт доле "перевесить" маленький знаменатель.
int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE)); int allowed = Math.max(4, (int) Math.ceil(checked * MAX_LEAK_RATE));
assertTrue(leaked <= allowed, assertTrue(
String.format("утечка ПДН в замаскированном тексте: %d из %d, допустимо %d", leaked <= allowed,
String.format(
"утечка ПДН в замаскированном тексте: %d из %d, допустимо %d",
leaked, checked, allowed)); leaked, checked, allowed));
} }
@@ -128,7 +144,9 @@ class HugeDatasetTest {
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст"); assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
if (targetChars >= HUGE_MIN_CHARS) { if (targetChars >= HUGE_MIN_CHARS) {
assertTrue(maskMillis < 5000, "маскирование " + targetChars + " знаков заняло " + maskMillis + " мс"); assertTrue(
maskMillis < 5000,
"маскирование " + targetChars + " знаков заняло " + maskMillis + " мс");
} }
} }
@@ -1,5 +1,9 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,11 +11,6 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Документы, удостоверяющие личность, помимо паспорта РФ. */ /** Документы, удостоверяющие личность, помимо паспорта РФ. */
class IdentityDocumentTest { class IdentityDocumentTest {
@@ -20,7 +19,8 @@ class IdentityDocumentTest {
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT); String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
} }
private void assertMasked(String text, String payloadId, String expected) { private void assertMasked(String text, String payloadId, String expected) {
@@ -48,15 +48,17 @@ class IdentityDocumentTest {
} }
/** /**
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая — * У загранпаспорта, военного билета и свидетельства о рождении серия короткая — две цифры или две
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна * буквы. Открой маска первые два знака, серия была бы видна целиком, поэтому у этих документов
* целиком, поэтому у этих документов открыты только последние знаки номера. * открыты только последние знаки номера.
*/ */
@Test @Test
void hidesShortDocumentSeriesCompletely() { void hidesShortDocumentSeriesCompletely() {
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67"); assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67"); assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1", assertMasked(
"Свидетельство о рождении II-МЮ № 123456",
"bc-1",
"Свидетельство о рождении **-** № ****56"); "Свидетельство о рождении **-** № ****56");
} }
@@ -64,7 +66,7 @@ class IdentityDocumentTest {
@Test @Test
void keepsHalfOfFourCharacterSeries() { void keepsHalfOfFourCharacterSeries() {
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56"); assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
assertMasked("Водительское удостоверение 9902 123456", "dl-1", assertMasked(
"Водительское удостоверение 99** ****56"); "Водительское удостоверение 9902 123456", "dl-1", "Водительское удостоверение 99** ****56");
} }
} }
+49 -44
View File
@@ -1,14 +1,7 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals;
import ru.pdguard.config.SystemPolicy; import static org.junit.jupiter.api.Assertions.assertTrue;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.Span;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -17,19 +10,23 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Random; import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals; import ru.pdguard.config.SystemPolicy;
import static org.junit.jupiter.api.Assertions.assertTrue; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
/** /**
* Качество и скорость на большом тексте — не повторе одного и того же * Качество и скорость на большом тексте — не повторе одного и того же предложения, а перемешанных
* предложения, а перемешанных строках из {@code benchmark-generated.txt} * строках из {@code benchmark-generated.txt} (все типы ПД вперемешку с чистым текстом), растянутых
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ * до объёма из ТЗ (около 100 000 токенов, ~400 КБ по оценке из README).
* (около 100 000 токенов, ~400 КБ по оценке из README).
* *
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не * <p>Раздутый повтором одной строки текст проверяет только то, что цикл не падает на объёме: под
* падает на объёме: под маской всегда один и тот же тип, а остальные правила * маской всегда один и тот же тип, а остальные правила не задействуются вовсе. Здесь размер и
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе. * разнообразие проверяются вместе.
*/ */
class LargeTextTest { class LargeTextTest {
@@ -40,12 +37,13 @@ class LargeTextTest {
private static final int TARGET_CHARS = 400_000; private static final int TARGET_CHARS = 400_000;
/** /**
* Перемешивает исходные строки (фиксированный seed — детерминированный * Перемешивает исходные строки (фиксированный seed — детерминированный тест) и склеивает их через
* тест) и склеивает их через перенос строки, пока не наберётся целевой * перенос строки, пока не наберётся целевой объём. Смещения золотых фрагментов пересчитываются
* объём. Смещения золотых фрагментов пересчитываются под общий текст. * под общий текст.
*/ */
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) { private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt")); List<BenchmarkFixtures.Sample> pool =
new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
Random random = new Random(seed); Random random = new Random(seed);
StringBuilder text = new StringBuilder(targetChars + 1024); StringBuilder text = new StringBuilder(targetChars + 1024);
List<Span> gold = new ArrayList<>(); List<Span> gold = new ArrayList<>();
@@ -67,16 +65,16 @@ class LargeTextTest {
} }
/** /**
* Маскирование и обратное преобразование на большом тексте дают * Маскирование и обратное преобразование на большом тексте дают побайтово тот же результат, что и
* побайтово тот же результат, что и исходный текст — при объёме на * исходный текст — при объёме на порядок больше, чем в остальных тестах, и с разнородным
* порядок больше, чем в остальных тестах, и с разнородным содержимым, * содержимым, а не одним повторяющимся предложением.
* а не одним повторяющимся предложением.
*/ */
@Test @Test
void roundTripOnLargeMixedText() { void roundTripOnLargeMixedText() {
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1); BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), Pipeline pipeline =
new PayloadStore(large.text().length() * 2L, 30)); new Pipeline(
new RuleRegistry(), new Masker(), new PayloadStore(large.text().length() * 2L, 30));
long maskStarted = System.nanoTime(); long maskStarted = System.nanoTime();
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT); String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
@@ -87,17 +85,19 @@ class LargeTextTest {
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000; long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст"); assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс"); assertTrue(
maskMillis < 5000,
"маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс"); assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n", System.out.printf(
"%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
large.text().length(), maskMillis, unmaskMillis); large.text().length(), maskMillis, unmaskMillis);
} }
/** /**
* Полнота детекции не должна проседать на объёме: каждый золотой * Полнота детекции не должна проседать на объёме: каждый золотой фрагмент из перемешанных строк
* фрагмент из перемешанных строк обязан быть найден в общем потоке * обязан быть найден в общем потоке текста, а не только когда он единственный в маленькой строке.
* текста, а не только когда он единственный в маленькой строке.
*/ */
@Test @Test
void recallHoldsAtScale() { void recallHoldsAtScale() {
@@ -112,17 +112,19 @@ class LargeTextTest {
} }
} }
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size(); double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall); System.out.printf(
"%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
assertTrue(recall >= 0.85, assertTrue(
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size())); recall >= 0.85,
String.format(
"полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
} }
/** /**
* Вторая ступень ограничена числом кандидатов на запрос * Вторая ступень ограничена числом кандидатов на запрос ({@code pdguard.ner.max-candidates}),
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен * поэтому объём текста не должен превращать её в квадратичную нагрузку — проверяем на том же
* превращать её в квадратичную нагрузку — проверяем на том же большом * большом тексте, что и остальные тесты, а не на маленьком образце.
* тексте, что и остальные тесты, а не на маленьком образце.
*/ */
@Test @Test
void nameCascadeStaysBoundedOnLargeText() { void nameCascadeStaysBoundedOnLargeText() {
@@ -132,7 +134,10 @@ class LargeTextTest {
return; return;
} }
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3); BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), Pipeline pipeline =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(large.text().length() * 2L, 30), new PayloadStore(large.text().length() * 2L, 30),
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4)); new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
@@ -140,8 +145,8 @@ class LargeTextTest {
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT); pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
long millis = (System.nanoTime() - started) / 1_000_000; long millis = (System.nanoTime() - started) / 1_000_000;
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n", System.out.printf(
large.text().length(), millis); "%nБольшой текст со второй ступенью: %d знаков за %d мс%n", large.text().length(), millis);
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс"); assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
} }
} }
+38 -23
View File
@@ -1,5 +1,14 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -9,23 +18,12 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Проверка утечек из датасета {@code leak-dataset.txt}. * Проверка утечек из датасета {@code leak-dataset.txt}.
* *
* <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в * <p>Датасет собран из логов pd-guard-node-logs.txt: это уникальные тексты, в которых узел не нашёл
* которых узел не нашёл ПД ({@code найдено={}}), хотя маркер персональных данных * ПД ({@code найдено={}}), хотя маркер персональных данных в тексте есть. Тест прогоняет каждый
* в тексте есть. Тест прогоняет каждый текст через {@link Pipeline} и требует, * текст через {@link Pipeline} и требует, чтобы детекция нашла хотя бы одно ПД из перечня типов.
* чтобы детекция нашла хотя бы одно ПД из перечня типов.
*/ */
class LeakDiagTest { class LeakDiagTest {
@@ -40,23 +38,39 @@ class LeakDiagTest {
List<String> remaining = new ArrayList<>(); List<String> remaining = new ArrayList<>();
for (String raw : leaks) { for (String raw : leaks) {
List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT); List<Span> spans = p.findPersonalData(raw, SystemPolicy.DEFAULT);
boolean found = spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO) boolean found =
|| s.type().equals(PdTypes.BIRTH_DATE) || s.type().equals(PdTypes.PASSPORT_DATE) spans.stream()
|| s.type().equals(PdTypes.CVV) || s.type().equals(PdTypes.PIN) .anyMatch(
|| s.type().equals(PdTypes.INN) || s.type().equals(PdTypes.PHONE) s ->
|| s.type().equals(PdTypes.CARD) || s.type().equals(PdTypes.DRIVER_LICENSE) s.type().equals(PdTypes.FIO)
|| s.type().equals(PdTypes.CITIZENSHIP) || s.type().equals(PdTypes.BIRTH_PLACE)); || s.type().equals(PdTypes.BIRTH_DATE)
|| s.type().equals(PdTypes.PASSPORT_DATE)
|| s.type().equals(PdTypes.CVV)
|| s.type().equals(PdTypes.PIN)
|| s.type().equals(PdTypes.INN)
|| s.type().equals(PdTypes.PHONE)
|| s.type().equals(PdTypes.CARD)
|| s.type().equals(PdTypes.DRIVER_LICENSE)
|| s.type().equals(PdTypes.CITIZENSHIP)
|| s.type().equals(PdTypes.BIRTH_PLACE));
if (found) { if (found) {
fixed++; fixed++;
} else { } else {
remaining.add(raw); remaining.add(raw);
} }
} }
System.out.println("Всего утечек: " + leaks.size() + ", исправлено: " + fixed + ", осталось: " + remaining.size()); System.out.println(
"Всего утечек: "
+ leaks.size()
+ ", исправлено: "
+ fixed
+ ", осталось: "
+ remaining.size());
for (String raw : remaining) { for (String raw : remaining) {
System.out.println(" ОСТАЛОСЬ: " + raw); System.out.println(" ОСТАЛОСЬ: " + raw);
} }
assertTrue(remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size()); assertTrue(
remaining.size() <= leaks.size() / 2, "осталось слишком много утечек: " + remaining.size());
} }
private static List<String> readDataset() { private static List<String> readDataset() {
@@ -65,7 +79,8 @@ class LeakDiagTest {
if (in == null) { if (in == null) {
throw new IllegalStateException("Датасет не найден в сборке: " + DATASET); throw new IllegalStateException("Датасет не найден в сборке: " + DATASET);
} }
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { try (BufferedReader r =
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line; String line;
while ((line = r.readLine()) != null) { while ((line = r.readLine()) != null) {
if (!line.isBlank()) { if (!line.isBlank()) {
+28 -16
View File
@@ -1,5 +1,13 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -9,15 +17,6 @@ import ru.pdguard.detect.Validators;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Виды замены: звёздочки, токены, правдоподобные значения. */ /** Виды замены: звёздочки, токены, правдоподобные значения. */
class MaskModeTest { class MaskModeTest {
@@ -25,8 +24,14 @@ class MaskModeTest {
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30)); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
private SystemPolicy policy(MaskMode mode) { private SystemPolicy policy(MaskMode mode) {
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode, return new SystemPolicy(
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null); SystemPolicy.DEFAULT_NAME,
true,
true,
mode,
Set.of(SystemPolicy.ALL),
SystemPolicy.DEFAULT.requireCompanion(),
null);
} }
private String mask(MaskMode mode, String text) { private String mask(MaskMode mode, String text) {
@@ -38,8 +43,11 @@ class MaskModeTest {
String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456"); String masked = mask(MaskMode.STRICT, "Клиент Иванов Иван Иванович, паспорт 4509 123456");
assertFalse(masked.contains("Иванов"), masked); assertFalse(masked.contains("Иванов"), masked);
assertFalse(masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked); assertFalse(
assertTrue(masked.contains("****** **** ********"), "ожидались звёздочки по длине каждого слова: " + masked); masked.contains("И. И. И."), "STRICT не должен превращать ФИО в инициалы: " + masked);
assertTrue(
masked.contains("****** **** ********"),
"ожидались звёздочки по длине каждого слова: " + masked);
assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked); assertTrue(masked.contains("**** ******"), "край паспорта не должен открываться: " + masked);
} }
@@ -52,7 +60,8 @@ class MaskModeTest {
@Test @Test
void sameValueGetsSameTokenWithinRequest() { void sameValueGetsSameTokenWithinRequest() {
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru"); String masked =
mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
assertEquals(2, count(masked, "[EMAIL_1]"), masked); assertEquals(2, count(masked, "[EMAIL_1]"), masked);
assertEquals(1, count(masked, "[EMAIL_2]"), masked); assertEquals(1, count(masked, "[EMAIL_2]"), masked);
} }
@@ -66,7 +75,8 @@ class MaskModeTest {
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked); Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
assertTrue(card.find(), masked); assertTrue(card.find(), masked);
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна"); assertTrue(
Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
} }
@Test @Test
@@ -88,7 +98,9 @@ class MaskModeTest {
private static int count(String text, String fragment) { private static int count(String text, String fragment) {
int n = 0; int n = 0;
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) { for (int i = text.indexOf(fragment);
i >= 0;
i = text.indexOf(fragment, i + fragment.length())) {
n++; n++;
} }
return n; return n;
+17 -14
View File
@@ -1,5 +1,13 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
@@ -9,23 +17,14 @@ import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** Вторая ступень не должна вредить первой. */ /** Вторая ступень не должна вредить первой. */
class NameCascadeTest { class NameCascadeTest {
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
private String mask(NameCascade cascade, String payloadId) { private String mask(NameCascade cascade, String payloadId) {
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), Pipeline pipeline =
new PayloadStore(1_000_000L, 30), cascade); new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT); return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
} }
@@ -38,7 +37,9 @@ class NameCascadeTest {
@Test @Test
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) { void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4); NameCascade cascade =
new NameCascade(
"rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень"); assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1")); assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
} }
@@ -47,13 +48,15 @@ class NameCascadeTest {
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException { void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
Path broken = dir.resolve("испорченная-модель"); Path broken = dir.resolve("испорченная-модель");
Files.createDirectories(broken); Files.createDirectories(broken);
for (String name : new String[]{"model_int8.onnx", "vocab.txt", "config.json"}) { for (String name : new String[] {"model_int8.onnx", "vocab.txt", "config.json"}) {
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8); Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
} }
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4); NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень"); assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"), assertEquals(
"Клиент И. И. И., паспорт 45** ****56",
mask(cascade, "broken-1"),
"маскирование по правилам обязано работать и без второй ступени"); "маскирование по правилам обязано работать и без второй ступени");
} }
} }
@@ -1,5 +1,9 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,14 +11,9 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** /**
* Имя в названии организации или объекта на карте персональными данными не является. * Имя в названии организации или объекта на карте персональными данными не является. Решает слово
* Решает слово перед именем, а не само имя: однофамилец защиту не теряет. * перед именем, а не само имя: однофамилец защиту не теряет.
*/ */
class OrganisationNamesTest { class OrganisationNamesTest {
@@ -27,26 +26,30 @@ class OrganisationNamesTest {
@Test @Test
void keepsNamesInsideInstitutionNames() { void keepsNamesInsideInstitutionNames() {
for (String text : new String[]{ for (String text :
new String[] {
"Институт Мечникова принимает по записи", "Институт Мечникова принимает по записи",
"Музей Верещагина работает по будням", "Музей Верещагина работает по будням",
"Театр Станиславского открыл сезон", "Театр Станиславского открыл сезон",
"Библиотека Некрасова закрыта на ремонт", "Библиотека Некрасова закрыта на ремонт",
"Премия имени Ломоносова вручена в декабре", "Премия имени Ломоносова вручена в декабре",
"Больница Боткина приняла пациентов", "Больница Боткина приняла пациентов",
"Стадион Яшина отремонтирован"}) { "Стадион Яшина отремонтирован"
}) {
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно"); assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
} }
} }
@Test @Test
void keepsNamesInsidePlaceNames() { void keepsNamesInsidePlaceNames() {
for (String text : new String[]{ for (String text :
new String[] {
"Улица Королёва названа в честь конструктора", "Улица Королёва названа в честь конструктора",
"Проспект Вернадского перекрыт до вечера", "Проспект Вернадского перекрыт до вечера",
"Площадь Гагарина находится на юго-западе", "Площадь Гагарина находится на юго-западе",
"Набережная Макарова уходит к заливу", "Набережная Макарова уходит к заливу",
"Мост Кадырова разведут ночью"}) { "Мост Кадырова разведут ночью"
}) {
assertEquals(text, mask(text), "топоним маскировать не нужно"); assertEquals(text, mask(text), "топоним маскировать не нужно");
} }
} }
@@ -54,24 +57,28 @@ class OrganisationNamesTest {
@Test @Test
void masksRealClientWithTheSameSurname() { void masksRealClientWithTheSameSurname() {
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456"); String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
assertFalse(masked.contains("Королёв Сергей Павлович"), assertFalse(
masked.contains("Королёв Сергей Павлович"),
"однофамилец объекта на карте остаётся под защитой: " + masked); "однофамилец объекта на карте остаётся под защитой: " + masked);
} }
@Test @Test
void markerOnlyCountsRightBeforeTheName() { void markerOnlyCountsRightBeforeTheName() {
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой"); String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
assertFalse(masked.contains("Иванова Ивана Ивановича"), assertFalse(
masked.contains("Иванова Ивана Ивановича"),
"слово-маркер действует только вплотную перед именем: " + masked); "слово-маркер действует только вплотную перед именем: " + masked);
} }
@Test @Test
void keepsRulerNames() { void keepsRulerNames() {
for (String text : new String[]{ for (String text :
new String[] {
"Василий Тёмный правил недолго", "Василий Тёмный правил недолго",
"Ярослав Мудрый составил свод законов", "Ярослав Мудрый составил свод законов",
"Екатерина Вторая издала указ", "Екатерина Вторая издала указ",
"Алексей Тишайший принимал послов"}) { "Алексей Тишайший принимал послов"
}) {
assertEquals(text, mask(text), "имя правителя персональными данными не является"); assertEquals(text, mask(text), "имя правителя персональными данными не является");
} }
} }
@@ -79,14 +86,16 @@ class OrganisationNamesTest {
@Test @Test
void masksClientEvenIfNameLooksRegnal() { void masksClientEvenIfNameLooksRegnal() {
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456"); String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
assertFalse(masked.contains("Василий Тёмный"), assertFalse(
masked.contains("Василий Тёмный"),
"рядом с паспортными данными это конкретный человек: " + masked); "рядом с паспортными данными это конкретный человек: " + masked);
} }
@Test @Test
void doesNotSuppressSoleTraderName() { void doesNotSuppressSoleTraderName() {
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547"); String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
assertFalse(masked.contains("Пахомов Вениамин Николаевич"), assertFalse(
masked.contains("Пахомов Вениамин Николаевич"),
"имя предпринимателя — это персональные данные: " + masked); "имя предпринимателя — это персональные данные: " + masked);
} }
} }
@@ -1,17 +1,18 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadCipher; import ru.pdguard.core.PayloadCipher;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
/** Шифрование персональных данных в хранилище. */ /** Шифрование персональных данных в хранилище. */
class PayloadCipherTest { class PayloadCipherTest {
/** 32 байта в hex — валидный AES-256 ключ. */ /** 32 байта в hex — валидный AES-256 ключ. */
private static final String KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; private static final String KEY =
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test @Test
void encryptDecryptRoundTrip() { void encryptDecryptRoundTrip() {
@@ -33,7 +34,8 @@ class PayloadCipherTest {
@Test @Test
void storeStoresEncryptedButReturnsPlaintext() { void storeStoresEncryptedButReturnsPlaintext() {
PayloadCipher cipher = new PayloadCipher(KEY); PayloadCipher cipher = new PayloadCipher(KEY);
PayloadStore store = new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher); PayloadStore store =
new PayloadStore(1_000_000L, 30, ru.pdguard.core.SharedIndex.disabled(), cipher);
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String masked = "Клиент И. И. И., паспорт 45** ****56"; String masked = "Клиент И. И. И., паспорт 45** ****56";
@@ -44,7 +46,9 @@ class PayloadCipherTest {
assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст"); assertEquals(original, entry.original(), "чтение по id должно вернуть исходный текст");
// Чтение по маске возвращает исходный текст. // Чтение по маске возвращает исходный текст.
assertEquals(original, store.originalForMask("test", masked), assertEquals(
original,
store.originalForMask("test", masked),
"чтение по маске должно вернуть исходный текст"); "чтение по маске должно вернуть исходный текст");
} }
} }
+13 -9
View File
@@ -1,13 +1,13 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import ru.pdguard.core.PayloadStore;
/** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */ /** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */
class PayloadStoreTest { class PayloadStoreTest {
@@ -54,20 +54,24 @@ class PayloadStoreTest {
} }
/** /**
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и * Поиск по маске идёт только внутри своей системы. Маски детерминированы и низкоэнтропийны: без
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять * разделения чужую маску можно было бы подобрать и обменять на исходные данные другого
* на исходные данные другого потребителя. * потребителя.
*/ */
@Test @Test
void oneSystemCannotReadAnotherSystemData() { void oneSystemCannotReadAnotherSystemData() {
PayloadStore store = new PayloadStore(1_000_000L, 30); PayloadStore store = new PayloadStore(1_000_000L, 30);
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И."); store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
assertNull(store.originalForMask("analytics", "И. И. И."), assertNull(
store.originalForMask("analytics", "И. И. И."),
"чужую маску нельзя обменять на исходный текст"); "чужую маску нельзя обменять на исходный текст");
assertNull(store.byId("analytics", "общий-id"), assertNull(
store.byId("analytics", "общий-id"),
"совпадение идентификатора у другой системы не даёт доступа"); "совпадение идентификатора у другой системы не даёт доступа");
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."), assertEquals(
"Иванов Иван Иванович",
store.originalForMask("crm", "И. И. И."),
"своя система свои данные по-прежнему получает"); "своя система свои данные по-прежнему получает");
} }
} }
@@ -1,5 +1,12 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -8,21 +15,12 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Оценка эффективности детекции по каждому типу ПДН в отдельности. * Оценка эффективности детекции по каждому типу ПДН в отдельности.
* *
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого * <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого типа ПДН. Для
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 — * каждого типа считается посимвольная точность, полнота и F1 — так видно, какие типы детектор
* так видно, какие типы детектор находит надёжно, а какие пропускает или * находит надёжно, а какие пропускает или маскирует сверх меры.
* маскирует сверх меры.
*/ */
class PdnTypeEfficiencyTest { class PdnTypeEfficiencyTest {
@@ -69,9 +67,11 @@ class PdnTypeEfficiencyTest {
account(byType, goldChars[i], foundChars[i]); account(byType, goldChars[i], foundChars[i]);
} }
for (Span gold : sample.gold()) { for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold)); boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) { if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>()) missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end())); .add(sample.text().substring(gold.start(), gold.end()));
} }
} }
@@ -87,7 +87,8 @@ class PdnTypeEfficiencyTest {
if (isCompanion(e.getKey())) { if (isCompanion(e.getKey())) {
continue; continue;
} }
assertTrue(e.getValue().f1() >= 0.8, assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1())); String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
} }
} }
@@ -98,7 +99,8 @@ class PdnTypeEfficiencyTest {
} }
StringBuilder out = new StringBuilder(); StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n"); out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> { missed.forEach(
(type, values) -> {
out.append(type).append(": ").append(String.join(" | ", values)).append('\n'); out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
}); });
System.out.println(out); System.out.println(out);
@@ -136,13 +138,22 @@ class PdnTypeEfficiencyTest {
private void report(Map<String, Score> byType) { private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048); StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n"); out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1")); out.append(
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream() byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed()) .sorted(
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
e.getKey(), e.getValue().gold(), e.getValue().precision(), .forEach(
e.getValue().recall(), e.getValue().f1()))); e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
System.out.println(out); System.out.println(out);
} }
@@ -1,15 +1,10 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
@@ -21,19 +16,21 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assumptions.assumeTrue; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import static org.junit.jupiter.api.Assertions.assertTrue; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker;
/** /**
* Замер производительности: задержка одиночного обращения и пропускная * Замер производительности: задержка одиночного обращения и пропускная способность под нагрузкой.
* способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}. * Не тест качества — он в {@link BenchmarkTest}.
* *
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой * <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой сборке без модели.
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел * Перед замером пайплайн прогревается, чтобы JIT успел скомпилировать горячий путь, — иначе первые
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый * замеры покажут интерпретируемый код и занизят результат в разы.
* код и занизят результат в разы.
*/ */
class PerformanceBenchmarkTest { class PerformanceBenchmarkTest {
@@ -47,9 +44,9 @@ class PerformanceBenchmarkTest {
}; };
/** /**
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает * Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает вторая ступень (модель).
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели, * Нужны, чтобы честно измерить стоимость модели, а не правила, которые в типовых текстах уже всё
* а не правила, которые в типовых текстах уже всё покрыли. * покрыли.
*/ */
private static final String[] CASCADE_PAYLOADS = { private static final String[] CASCADE_PAYLOADS = {
"Готье и Руссо пришли на встречу в офис", "Готье и Руссо пришли на встречу в офис",
@@ -101,15 +98,22 @@ class PerformanceBenchmarkTest {
double unmaskUs = nanosToMicros(unmaskNanos); double unmaskUs = nanosToMicros(unmaskNanos);
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n"); System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n", System.out.printf(
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0, "Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs); maskNanos[MEASURE / 2] / 1000.0,
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n", maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0, maskNanos[(int) (MEASURE * 0.99)] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs); maskUs);
System.out.printf(
"Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
unmaskNanos[MEASURE / 2] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0,
unmaskUs);
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды. // Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000, assertTrue(
maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс"); "p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
} }
@@ -126,7 +130,10 @@ class PerformanceBenchmarkTest {
List<Future<Long>> futures = new ArrayList<>(); List<Future<Long>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) { for (int t = 0; t < threads; t++) {
final int threadId = t; final int threadId = t;
futures.add(pool.submit((Callable<Long>) () -> { futures.add(
pool.submit(
(Callable<Long>)
() -> {
long local = 0; long local = 0;
for (int i = 0; i < perThread; i++) { for (int i = 0; i < perThread; i++) {
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length]; String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
@@ -151,7 +158,8 @@ class PerformanceBenchmarkTest {
double rps = requests / (wallNanos / 1e9); double rps = requests / (wallNanos / 1e9);
double avgUs = totalNanos / (double) requests / 1000.0; double avgUs = totalNanos / (double) requests / 1000.0;
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests); System.out.printf(
"%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs); System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps); assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
@@ -172,9 +180,13 @@ class PerformanceBenchmarkTest {
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана"); assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
MeterRegistry meters = new SimpleMeterRegistry(); MeterRegistry meters = new SimpleMeterRegistry();
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(), Pipeline withCascade =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(10_000_000L, 30), new PayloadStore(10_000_000L, 30),
new NameCascade("rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters)); new NameCascade(
"rubert", Optional.of(model.toString()), "off", Optional.empty(), 16, 4, meters));
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные. // Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
for (int i = 0; i < 200; i++) { for (int i = 0; i < 200; i++) {
@@ -194,18 +206,24 @@ class PerformanceBenchmarkTest {
Arrays.sort(maskNanos); Arrays.sort(maskNanos);
int n = maskNanos.length; int n = maskNanos.length;
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n"); System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n", System.out.printf(
maskNanos[n / 2] / 1000.0, maskNanos[(int) (n * 0.95)] / 1000.0, "Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
maskNanos[(int) (n * 0.99)] / 1000.0, nanosToMicros(maskNanos)); maskNanos[n / 2] / 1000.0,
maskNanos[(int) (n * 0.95)] / 1000.0,
maskNanos[(int) (n * 0.99)] / 1000.0,
nanosToMicros(maskNanos));
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count(); double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
double candidates = meters.counter("pdguard.ner.candidates").count(); double candidates = meters.counter("pdguard.ner.candidates").count();
System.out.printf("Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates); System.out.printf(
"Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение // Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
// должно укладываться в десятки миллисекунд. // должно укладываться в десятки миллисекунд.
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000, assertTrue(
maskNanos[(int) (n * 0.99)] < 200_000_000,
"p99 маскирования со второй ступенью превысил 200 мс: " "p99 маскирования со второй ступенью превысил 200 мс: "
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс"); + maskNanos[(int) (n * 0.99)] / 1_000_000
+ " мс");
} }
} }
+11 -10
View File
@@ -1,5 +1,11 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -8,13 +14,6 @@ import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */ /** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
class PipelineTest { class PipelineTest {
@@ -53,7 +52,7 @@ class PipelineTest {
@Test @Test
void masksPhoneInAnyNotation() { void masksPhoneInAnyNotation() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) { for (String phone : new String[] {"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
String masked = mask(pipeline, "Телефон " + phone); String masked = mask(pipeline, "Телефон " + phone);
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked); assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
assertTrue(masked.endsWith("67"), masked); assertTrue(masked.endsWith("67"), masked);
@@ -116,9 +115,11 @@ class PipelineTest {
void systemPolicyDisablesSelectedTypes() { void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL); SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail); String masked =
pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked); assertTrue(
masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
assertFalse(masked.contains("ivan@mail.ru"), masked); assertFalse(masked.contains("ivan@mail.ru"), masked);
} }
@@ -1,5 +1,15 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestFactory;
@@ -11,41 +21,36 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/** /**
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже * Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже известных типов
* известных типов относительно якорного слова: расстояние до якоря, обратный порядок * относительно якорного слова: расстояние до якоря, обратный порядок (значение перед якорем),
* (значение перед якорем), падеж/число анкера, структурированные форматы * падеж/число анкера, структурированные форматы (JSON/CSV/markdown-таблица/XML/key=value),
* (JSON/CSV/markdown-таблица/XML/key=value), несколько значений одного типа в одном * несколько значений одного типа в одном поле, значение в кавычках/скобках. Гипотезы построены на
* поле, значение в кавычках/скобках. Гипотезы построены на 55 утечках из * 55 утечках из {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
* {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
* Разбор по категориям — в отчёте, приложенном к задаче. * Разбор по категориям — в отчёте, приложенном к задаче.
* *
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки * <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки (падение конкретного
* (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные * кейса в {@link #leakSummary()} ожидаемо), часть — контрольные позитивные случаи, которые обязаны
* позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт, * оставаться зелёными: если один из них упадёт, значит новое размещение сломало то, что раньше
* значит новое размещение сломало то, что раньше работало. * работало.
*/ */
class PlacementVariantsTest { class PlacementVariantsTest {
private static final RuleRegistry REGISTRY = new RuleRegistry(); private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker(); private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-placements.txt"); private static final List<BenchmarkFixtures.Sample> DATASET =
BenchmarkFixtures.load("/dataset-placements.txt");
private static final int LEAK_CHECK_MIN_LENGTH = 3; private static final int LEAK_CHECK_MIN_LENGTH = 3;
private static final NameCascade CASCADE = modelsPresent() private static final NameCascade CASCADE =
? new NameCascade("wikineural", Optional.of("models/wikineural-ner"), modelsPresent()
"rubert", Optional.of("models/rubert-ner"), 16, 4) ? new NameCascade(
"wikineural",
Optional.of("models/wikineural-ner"),
"rubert",
Optional.of("models/rubert-ner"),
16,
4)
: NameCascade.disabled(); : NameCascade.disabled();
private static boolean modelsPresent() { private static boolean modelsPresent() {
@@ -59,7 +64,8 @@ class PlacementVariantsTest {
for (int i = 0; i < DATASET.size(); i++) { for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i); BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i; int index = i;
cases.add(dynamicTest( cases.add(
dynamicTest(
String.format("#%02d: %s", index, preview(sample.text())), String.format("#%02d: %s", index, preview(sample.text())),
() -> runRoundTrip(sample, index))); () -> runRoundTrip(sample, index)));
} }
@@ -77,11 +83,10 @@ class PlacementVariantsTest {
} }
/** /**
* Сводка утечек одним прогоном — печатает список по типам и падает, только если * Сводка утечек одним прогоном — печатает список по типам и падает, только если утечек стало
* утечек стало больше 11, то есть если что-то из уже маскируемого сегодня * больше 11, то есть если что-то из уже маскируемого сегодня размещения перестало маскироваться.
* размещения перестало маскироваться. Было 20 при составлении датасета, после * Было 20 при составлении датасета, после точечных фиксов RuleRegistry (расширенный разрыв
* точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный * якорь-значение, обратный порядок для дат/гражданства/кода подразделения) осталось 11.
* порядок для дат/гражданства/кода подразделения) осталось 11.
*/ */
@Test @Test
void leakSummary() { void leakSummary() {
@@ -107,12 +112,15 @@ class PlacementVariantsTest {
} }
} }
} }
report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked)); report.append(
String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count))); byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count)));
System.out.println(report); System.out.println(report);
assertTrue(leaked <= 9, assertTrue(
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + leaked); leaked <= 9,
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: "
+ leaked);
} }
private static String preview(String text) { private static String preview(String text) {
+144 -64
View File
@@ -1,30 +1,31 @@
package ru.pdguard; package ru.pdguard;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.not;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */ /** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProcessResourceTest { class ProcessResourceTest {
@LocalServerPort @LocalServerPort int port;
int port;
private String post(String payload, String payloadId) { private String post(String payload, String payloadId) {
return given() return given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", payload, "payload_id", payloadId)) .body(Map.of("payload", payload, "payload_id", payloadId))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.extract().path("result"); .then()
.statusCode(200)
.extract()
.path("result");
} }
@Test @Test
@@ -43,8 +44,10 @@ class ProcessResourceTest {
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", "текст")) .body(Map.of("payload", "текст"))
.when().post("/process") .when()
.then().statusCode(400); .post("/process")
.then()
.statusCode(400);
} }
@Test @Test
@@ -59,20 +62,26 @@ class ProcessResourceTest {
.contentType("application/json") .contentType("application/json")
.header("X-System-Id", "disabled") .header("X-System-Id", "disabled")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1")) .body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
.when().post("/process") .when()
.then().statusCode(403); .post("/process")
.then()
.statusCode(403);
} }
@Test @Test
void systemPolicySelectsMaskMode() { void systemPolicySelectsMaskMode() {
String masked = given() String masked =
given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.header("X-System-Id", "crm") .header("X-System-Id", "crm")
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2")) .body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.extract().path("result"); .then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked); org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
} }
@@ -84,8 +93,10 @@ class ProcessResourceTest {
.contentType("application/json") .contentType("application/json")
.header("X-System-Id", "неизвестная-система") .header("X-System-Id", "неизвестная-система")
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3")) .body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.then()
.statusCode(200)
.body("result", equalTo("почта i***@m***.ru")); .body("result", equalTo("почта i***@m***.ru"));
} }
@@ -93,110 +104,177 @@ class ProcessResourceTest {
void metricsExposeLatencyAndTokenCounters() { void metricsExposeLatencyAndTokenCounters() {
post("Клиент Иванов Иван Иванович", "metrics-1"); post("Клиент Иванов Иван Иванович", "metrics-1");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString(); String body =
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки"); given()
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS"); .port(port)
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД"); .when()
.get("/actuator/prometheus")
.then()
.statusCode(200)
.extract()
.asString();
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_process_seconds"), "нет метрики задержки");
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
} }
/** /**
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны, * Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны, поэтому без
* поэтому без разделения, прислав чужую маску, можно было бы получить исходные * разделения, прислав чужую маску, можно было бы получить исходные данные другого потребителя.
* данные другого потребителя.
*/ */
@Test @Test
void anotherSystemCannotExchangeMaskForOriginal() { void anotherSystemCannotExchangeMaskForOriginal() {
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456"; String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
String id = "cross-system-1"; String id = "cross-system-1";
String masked = given() String masked =
given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", original, "payload_id", id)) .body(Map.of("payload", original, "payload_id", id))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.extract().path("result"); .then()
.statusCode(200)
.extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked); org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
String byOther = given() String byOther =
given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.header("X-System-Id", "other") .header("X-System-Id", "other")
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id")) .body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.extract().path("result"); .then()
org.junit.jupiter.api.Assertions.assertNotEquals(original, byOther, .statusCode(200)
"чужая система не должна получать исходный текст по маске"); .extract()
.path("result");
org.junit.jupiter.api.Assertions.assertNotEquals(
original, byOther, "чужая система не должна получать исходный текст по маске");
String bySameSystem = given() String bySameSystem =
given()
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", masked, "payload_id", id)) .body(Map.of("payload", masked, "payload_id", id))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.extract().path("result"); .then()
org.junit.jupiter.api.Assertions.assertEquals(original, bySameSystem, .statusCode(200)
"своя система по своему идентификатору исходный текст получает"); .extract()
.path("result");
org.junit.jupiter.api.Assertions.assertEquals(
original, bySameSystem, "своя система по своему идентификатору исходный текст получает");
} }
@Test @Test
void systemWithKeyRequiresIt() { void systemWithKeyRequiresIt() {
given().port(port).contentType("application/json") given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "guarded") .header("X-System-Id", "guarded")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1")) .body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
.when().post("/process").then().statusCode(403); .when()
.post("/process")
.then()
.statusCode(403);
given().port(port).contentType("application/json") given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "guarded") .header("X-System-Id", "guarded")
.header("X-System-Key", "wrong-key") .header("X-System-Key", "wrong-key")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2")) .body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
.when().post("/process").then().statusCode(403); .when()
.post("/process")
.then()
.statusCode(403);
given().port(port).contentType("application/json") given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "guarded") .header("X-System-Id", "guarded")
.header("X-System-Key", "s3cret-key-2026") .header("X-System-Key", "s3cret-key-2026")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3")) .body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
.when().post("/process").then().statusCode(200); .when()
.post("/process")
.then()
.statusCode(200);
} }
@Test @Test
void systemWithoutKeyWorksWithoutIt() { void systemWithoutKeyWorksWithoutIt() {
given().port(port).contentType("application/json") given()
.port(port)
.contentType("application/json")
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4")) .body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
.when().post("/process").then().statusCode(200); .when()
.post("/process")
.then()
.statusCode(200);
} }
@Test @Test
void metricsCountSecondStageInvocations() { void metricsCountSecondStageInvocations() {
post("Клиент Иванов Иван Иванович", "ner-metrics-1"); post("Клиент Иванов Иван Иванович", "ner-metrics-1");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString(); String body =
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_requests_total"), given()
"нет счётчика обращений ко второй ступени"); .port(port)
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_candidates_total"), .when()
"нет счётчика участков, отданных модели"); .get("/actuator/prometheus")
.then()
.statusCode(200)
.extract()
.asString();
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_ner_requests_total"), "нет счётчика обращений ко второй ступени");
org.junit.jupiter.api.Assertions.assertTrue(
body.contains("pdguard_ner_candidates_total"), "нет счётчика участков, отданных модели");
} }
@Test @Test
void metricsDoNotLeakPersonalData() { void metricsDoNotLeakPersonalData() {
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2"); post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString(); String body =
given()
.port(port)
.when()
.get("/actuator/prometheus")
.then()
.statusCode(200)
.extract()
.asString();
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними. // Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
// Утечка возможна только через имена и метки, поэтому значения отбрасываем. // Утечка возможна только через имена и метки, поэтому значения отбрасываем.
String namesAndLabels = body.lines() String namesAndLabels =
body.lines()
.filter(line -> !line.startsWith("#")) .filter(line -> !line.startsWith("#"))
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line) .map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
.reduce("", (a, b) -> a + "\n" + b); .reduce("", (a, b) -> a + "\n" + b);
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("Иванов"), "ПД попали в метрики"); org.junit.jupiter.api.Assertions.assertFalse(
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("4111"), "ПД попали в метрики"); namesAndLabels.contains("Иванов"), "ПД попали в метрики");
org.junit.jupiter.api.Assertions.assertFalse(
namesAndLabels.contains("4111"), "ПД попали в метрики");
} }
@Test @Test
void adminShowsSystemsAndTypes() { void adminShowsSystemsAndTypes() {
given().port(port).when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN")); given()
.port(port)
.when()
.get("/admin/config")
.then()
.statusCode(200)
.body("crm.maskMode", equalTo("TOKEN"));
given().port(port).when().get("/admin/types").then().statusCode(200); given().port(port).when().get("/admin/types").then().statusCode(200);
} }
@@ -206,8 +284,10 @@ class ProcessResourceTest {
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1")) .body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
.when().post("/process") .when()
.then().statusCode(200) .post("/process")
.then()
.statusCode(200)
.body("result", equalTo("тестовая строка")) .body("result", equalTo("тестовая строка"))
.body("result", not(equalTo(""))); .body("result", not(equalTo("")));
} }
+35 -21
View File
@@ -1,23 +1,21 @@
package ru.pdguard; package ru.pdguard;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Map;
import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import io.restassured.path.json.JsonPath;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */ /** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProxyResourceTest { class ProxyResourceTest {
@LocalServerPort @LocalServerPort int port;
int port;
private static final String PROMPT = private static final String PROMPT =
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru"; "Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
@@ -27,9 +25,12 @@ class ProxyResourceTest {
.port(port) .port(port)
.contentType("application/json") .contentType("application/json")
.body(Map.of("prompt", prompt)) .body(Map.of("prompt", prompt))
.when().post("/proxy") .when()
.then().statusCode(200) .post("/proxy")
.extract().jsonPath(); .then()
.statusCode(200)
.extract()
.jsonPath();
} }
@Test @Test
@@ -56,12 +57,13 @@ class ProxyResourceTest {
void responseShowsTheWholeChain() { void responseShowsTheWholeChain() {
JsonPath json = proxy(PROMPT); JsonPath json = proxy(PROMPT);
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"), assertTrue(
json.getString("prompt_masked").contains("[FIO_1]"),
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked")); "в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"), assertTrue(
json.getString("llm_response_masked").contains("[FIO_1]"),
"ответ модели ещё содержит подстановки"); "ответ модели ещё содержит подстановки");
assertFalse(json.getString("response").contains("[FIO_1]"), assertFalse(json.getString("response").contains("[FIO_1]"), "потребителю подстановки не видны");
"потребителю подстановки не видны");
assertEquals("заглушка", json.getString("llm")); assertEquals("заглушка", json.getString("llm"));
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой"); assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
} }
@@ -69,20 +71,32 @@ class ProxyResourceTest {
@Test @Test
void textWithoutPersonalDataPassesThrough() { void textWithoutPersonalDataPassesThrough() {
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом"); JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked")); assertEquals(
"Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
} }
@Test @Test
void rejectsEmptyPrompt() { void rejectsEmptyPrompt() {
given().port(port).contentType("application/json").body(Map.of("prompt", " ")) given()
.when().post("/proxy").then().statusCode(400); .port(port)
.contentType("application/json")
.body(Map.of("prompt", " "))
.when()
.post("/proxy")
.then()
.statusCode(400);
} }
@Test @Test
void disabledSystemIsRefused() { void disabledSystemIsRefused() {
given().port(port).contentType("application/json") given()
.port(port)
.contentType("application/json")
.header("X-System-Id", "disabled") .header("X-System-Id", "disabled")
.body(Map.of("prompt", PROMPT)) .body(Map.of("prompt", PROMPT))
.when().post("/proxy").then().statusCode(403); .when()
.post("/proxy")
.then()
.statusCode(403);
} }
} }
+12 -14
View File
@@ -1,5 +1,9 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,17 +11,11 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** /**
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями * Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями на сёла, посёлки,
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь * деревни, хутора, станицы, аулы и аалы — раньше словарь ограничивался официальными городами
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево» * (~1100), и «рп. Ильинское»/«с. Кукуево» из ТЗ не находились вообще, дело было не в качестве
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что * детекции, а в том, что искать было негде.
* искать было негде.
*/ */
class SettlementTest { class SettlementTest {
@@ -30,7 +28,8 @@ class SettlementTest {
private void assertHidden(String text, String secret) { private void assertHidden(String text, String secret) {
String masked = mask(text); String masked = mask(text);
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»"); assertFalse(
masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
} }
@Test @Test
@@ -107,9 +106,8 @@ class SettlementTest {
} }
/** /**
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого * «с.» перед числом — обычная запись страницы («с. 25»), а не населённого пункта. Якорь не должен
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную * на этом срабатывать: правило требует заглавную букву сразу после якоря, а не цифру.
* букву сразу после якоря, а не цифру.
*/ */
@Test @Test
void pageReferenceIsNotMistakenForSettlement() { void pageReferenceIsNotMistakenForSettlement() {
@@ -1,5 +1,9 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -7,18 +11,12 @@ import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/** /**
* Составные названия улиц в честь людей — тот же класс ложных срабатываний, * Составные названия улиц в честь людей — тот же класс ложных срабатываний, что и «Богдана
* что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО * Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО без ролевого слова ловит «имя +
* без ролевого слова ловит «имя + фамилия по словообразованию», а улица в * фамилия по словообразованию», а улица в честь исторической фигуры выглядит точно так же.
* честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица * Одиночная фамилия («улица Ленина») под это правило не подпадает вообще — ему нужны два слова,
* Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому * поэтому все примеры здесь двухсловные, реальные названия улиц.
* все примеры здесь двухсловные, реальные названия улиц.
*/ */
class StreetDenylistTest { class StreetDenylistTest {
@@ -30,7 +28,8 @@ class StreetDenylistTest {
} }
private void assertUnmasked(String text) { private void assertUnmasked(String text) {
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text); assertEquals(
text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
} }
@Test @Test
@@ -69,9 +68,8 @@ class StreetDenylistTest {
} }
/** /**
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не * Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не применяется — если в тексте
* применяется — если в тексте всё-таки есть настоящие персональные данные, * всё-таки есть настоящие персональные данные, совпадение с историческим именем их не прикрывает.
* совпадение с историческим именем их не прикрывает.
*/ */
@Test @Test
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() { void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
+13 -11
View File
@@ -1,25 +1,25 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig; import ru.pdguard.config.SystemsConfig;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Чтение и горячая перезагрузка списка систем. */ /** Чтение и горячая перезагрузка списка систем. */
class SystemsConfigTest { class SystemsConfigTest {
private static final String CONTENT = """ private static final String CONTENT =
"""
{ {
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] }, "default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] }, "crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
@@ -84,7 +84,9 @@ class SystemsConfigTest {
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8); Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
config.reload(); config.reload();
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(), assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"сломанный файл не должен ронять работающий сервис"); "сломанный файл не должен ронять работающий сервис");
} }
} }
@@ -1,5 +1,13 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -9,32 +17,26 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов. * Оценка двухмодельной архитектуры: WikiNEuRal для имён, ruBERT для адресов.
* *
* <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса * <p>Набор {@code benchmark-two-model.txt} проверяет, что имена клиентов и адреса маскируются, а
* маскируются, а известные личности — нет. Для каждого типа считается посимвольная * известные личности — нет. Для каждого типа считается посимвольная точность, полнота и F1.
* точность, полнота и F1.
*/ */
class TwoModelBenchmarkTest { class TwoModelBenchmarkTest {
private final Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), private final Pipeline pipeline =
new Pipeline(
new RuleRegistry(),
new Masker(),
new PayloadStore(10_000_000L, 30), new PayloadStore(10_000_000L, 30),
new NameCascade( new NameCascade(
new NameCascade.EngineConfig( new NameCascade.EngineConfig(
"wikineural", Optional.of("models/wikineural-ner"), "wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"), "rubert", Optional.of("models/rubert-ner"),
"off", Optional.empty()), "off", Optional.empty()),
16, 4)); 16,
4));
private static final class Score { private static final class Score {
private int truePositive; private int truePositive;
@@ -75,9 +77,11 @@ class TwoModelBenchmarkTest {
account(byType, goldChars[i], foundChars[i]); account(byType, goldChars[i], foundChars[i]);
} }
for (Span gold : sample.gold()) { for (Span gold : sample.gold()) {
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold)); boolean hit =
found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
if (!hit) { if (!hit) {
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>()) missed
.computeIfAbsent(gold.type(), t -> new ArrayList<>())
.add(sample.text().substring(gold.start(), gold.end())); .add(sample.text().substring(gold.start(), gold.end()));
} }
} }
@@ -88,7 +92,8 @@ class TwoModelBenchmarkTest {
// Каждый тип должен быть найден с F1 не ниже 0.8. // Каждый тип должен быть найден с F1 не ниже 0.8.
for (Map.Entry<String, Score> e : byType.entrySet()) { for (Map.Entry<String, Score> e : byType.entrySet()) {
assertTrue(e.getValue().f1() >= 0.8, assertTrue(
e.getValue().f1() >= 0.8,
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1())); String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
} }
} }
@@ -120,12 +125,21 @@ class TwoModelBenchmarkTest {
private void report(Map<String, Score> byType) { private void report(Map<String, Score> byType) {
StringBuilder out = new StringBuilder(2048); StringBuilder out = new StringBuilder(2048);
out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n"); out.append("\n=== Эффективность двухмодельной архитектуры ===\n\n");
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1")); out.append(
String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
byType.entrySet().stream() byType.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed()) .sorted(
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
e.getKey(), e.getValue().gold(), e.getValue().precision(), .forEach(
e.getValue().recall(), e.getValue().f1()))); e ->
out.append(
String.format(
"%-20s %8d %8.3f %8.3f %8.3f%n",
e.getKey(),
e.getValue().gold(),
e.getValue().precision(),
e.getValue().recall(),
e.getValue().f1())));
System.out.println(out); System.out.println(out);
} }
@@ -135,8 +149,9 @@ class TwoModelBenchmarkTest {
} }
StringBuilder out = new StringBuilder(); StringBuilder out = new StringBuilder();
out.append("\n=== Не распознанные значения по типам ===\n"); out.append("\n=== Не распознанные значения по типам ===\n");
missed.forEach((type, values) -> out.append(type).append(": ") missed.forEach(
.append(String.join(" | ", values)).append('\n')); (type, values) ->
out.append(type).append(": ").append(String.join(" | ", values)).append('\n'));
System.out.println(out); System.out.println(out);
} }
} }
@@ -1,5 +1,9 @@
package ru.pdguard; package ru.pdguard;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
@@ -10,22 +14,20 @@ import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Две модели: WikiNEuRal для имён, ruBERT для адресов. */ /** Две модели: WikiNEuRal для имён, ruBERT для адресов. */
class TwoModelCascadeTest { class TwoModelCascadeTest {
private List<Span> find(String text) { private List<Span> find(String text) {
NameCascade cascade = new NameCascade( NameCascade cascade =
new NameCascade(
new NameCascade.EngineConfig( new NameCascade.EngineConfig(
"wikineural", Optional.of("models/wikineural-ner"), "wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"), "rubert", Optional.of("models/rubert-ner"),
"off", Optional.empty()), "off", Optional.empty()),
16, 4); 16,
Pipeline p = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade); 4);
Pipeline p =
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30), cascade);
return p.findPersonalData(text, SystemPolicy.DEFAULT); return p.findPersonalData(text, SystemPolicy.DEFAULT);
} }
@@ -48,7 +50,8 @@ class TwoModelCascadeTest {
for (Span s : spans) { for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
} }
assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)), assertTrue(
spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться"); "известная личность не должна маскироваться");
} }
} }
@@ -0,0 +1,132 @@
package ru.pdguard.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import ru.pdguard.mask.MaskMode;
class SystemsConfigTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void missingFileFallsBackToDefaultPolicy(@TempDir Path dir) {
SystemsConfig config = new SystemsConfig(dir.resolve("absent.json").toString(), mapper);
SystemPolicy policy = config.policyFor("anything");
assertEquals(SystemPolicy.DEFAULT_NAME, policy.name());
assertEquals(MaskMode.MASK, policy.maskMode());
}
@Test
void readsKnownSystemFromFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE"]
}
}
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
SystemPolicy policy = config.policyFor("crm");
assertEquals(MaskMode.TOKEN, policy.maskMode());
assertFalse(policy.demask());
assertTrue(policy.allows("FIO"));
assertFalse(policy.allows("EMAIL"));
}
@Test
void unknownSystemFallsBackToDefault(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertFalse(config.isKnown("ghost"));
assertEquals(SystemPolicy.DEFAULT_NAME, config.policyFor("ghost").name());
}
@Test
void malformedJsonKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertTrue(config.isKnown("crm"));
Files.writeString(file, "{ not valid json", StandardCharsets.UTF_8);
config.reload();
assertTrue(config.isKnown("crm"), "битый файл не должен затирать рабочие настройки");
}
@Test
void unknownMaskModeKeepsPreviousSettings(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "crm": { "maskMode": "TOKEN" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
Files.writeString(
file,
"""
{ "crm": { "maskMode": "NOT_A_MODE" } }
""",
StandardCharsets.UTF_8);
config.reload();
assertEquals(
MaskMode.TOKEN,
config.policyFor("crm").maskMode(),
"неизвестный maskMode не должен принять частично разобранные настройки");
}
@Test
void currentReturnsSortedSnapshot(@TempDir Path dir) throws IOException {
Path file = dir.resolve("systems.json");
Files.writeString(
file,
"""
{ "zzz": { "maskMode": "TOKEN" }, "aaa": { "maskMode": "MASK" } }
""",
StandardCharsets.UTF_8);
SystemsConfig config = new SystemsConfig(file.toString(), mapper);
assertEquals(
java.util.List.of("aaa", "default", "zzz"),
config.current().keySet().stream().sorted().toList());
}
}
@@ -0,0 +1,92 @@
package ru.pdguard.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class AdaptiveConcurrencyLimiterTest {
@Test
void rejectsInvalidBounds() {
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(0, 10, 100));
assertThrows(IllegalArgumentException.class, () -> new AdaptiveConcurrencyLimiter(10, 5, 100));
}
@Test
void acceptsUpToLimitThenRejects() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 2, 1000);
assertTrue(limiter.tryAcquire());
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire(), "предел исчерпан — третий запрос должен быть отклонён");
assertEquals(2, limiter.inFlight());
}
@Test
void releaseFreesSlotForNextRequest() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 1, 1000);
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire());
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertTrue(limiter.tryAcquire(), "после release слот должен освободиться");
}
@Test
void growsLimitWhenLatencyBelowTarget() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertEquals(2, limiter.limit(), "задержка ниже целевой — предел должен вырасти на единицу");
}
@Test
void shrinksLimitWhenLatencyAboveTarget() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 8, 10, window);
// Разгоняем предел до 4, чтобы было куда сжиматься.
for (int i = 0; i < 3; i++) {
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
}
assertEquals(4, limiter.limit());
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
assertEquals(2, limiter.limit(), "задержка выше целевой — предел должен сжаться вдвое");
}
@Test
void limitNeverDropsBelowMin() throws InterruptedException {
long window = TimeUnit.MILLISECONDS.toNanos(1);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(3, 10, 5, window);
limiter.tryAcquire();
Thread.sleep(2);
limiter.release(TimeUnit.MILLISECONDS.toNanos(50));
assertEquals(3, limiter.limit(), "предел не должен опускаться ниже minLimit");
}
@Test
void doesNotAdjustBeforeWindowElapses() {
long window = TimeUnit.SECONDS.toNanos(10);
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 5, 100, window);
limiter.tryAcquire();
limiter.release(1);
assertEquals(1, limiter.limit(), "окно ещё не прошло — предел не должен меняться");
}
}
@@ -0,0 +1,65 @@
package ru.pdguard.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.SecureRandom;
import org.junit.jupiter.api.Test;
class PayloadCipherTest {
private static String randomHexKey() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return java.util.HexFormat.of().formatHex(bytes);
}
@Test
void disabledCipherPassesTextThrough() {
PayloadCipher cipher = PayloadCipher.disabled();
assertFalse(cipher.enabled());
assertEquals("Иванов Иван Иванович", cipher.encrypt("Иванов Иван Иванович"));
assertEquals("Иванов Иван Иванович", cipher.decrypt("Иванов Иван Иванович"));
}
@Test
void enabledCipherRoundTrips() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertTrue(cipher.enabled());
String encrypted = cipher.encrypt("Иванов Иван Иванович, паспорт 4509 123456");
assertNotEquals("Иванов Иван Иванович, паспорт 4509 123456", encrypted);
assertEquals("Иванов Иван Иванович, паспорт 4509 123456", cipher.decrypt(encrypted));
}
@Test
void ciphertextDiffersEachTimeDueToRandomIv() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
String first = cipher.encrypt("тот же текст");
String second = cipher.encrypt("тот же текст");
assertNotEquals(first, second, "разный IV должен давать разный шифротекст на одинаковом входе");
}
@Test
void decryptingGarbageThrows() {
PayloadCipher cipher = new PayloadCipher(randomHexKey());
assertThrows(IllegalStateException.class, () -> cipher.decrypt("не base64!!!"));
}
@Test
void decryptingWithDifferentKeyThrows() {
PayloadCipher first = new PayloadCipher(randomHexKey());
PayloadCipher second = new PayloadCipher(randomHexKey());
String encrypted = first.encrypt("секрет");
assertThrows(IllegalStateException.class, () -> second.decrypt(encrypted));
}
}
@@ -1,16 +1,15 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import org.junit.jupiter.api.AfterEach; import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import static org.junit.jupiter.api.Assertions.assertFalse; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.io.TempDir;
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */ /** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
class NameDictionaryTest { class NameDictionaryTest {
@@ -43,10 +42,9 @@ class NameDictionaryTest {
} }
/** /**
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). * Фамилии, в честь которых чаще всего называют улицы в России (Росреестр). Само по себе «улица
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого * Ленина» никогда не попало бы под ФИО — для этого правила нужны два слова, — но денилист должен
* правила нужны два слова, — но денилист должен покрывать и составные * покрывать и составные названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
* «Буденный»). * «Буденный»).
*/ */
@Test @Test
@@ -59,7 +57,8 @@ class NameDictionaryTest {
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же // «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
// известная граница приёма, родительный падеж («Будённого») им не ловится. // известная граница приёма, родительный падеж («Будённого») им не ловится.
assertTrue(NameDictionary.isWellKnown("улица Будённый")); assertTrue(NameDictionary.isWellKnown("улица Будённый"));
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться"); assertTrue(
NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
assertTrue(NameDictionary.isWellKnown("улица Жукова")); assertTrue(NameDictionary.isWellKnown("улица Жукова"));
assertTrue(NameDictionary.isWellKnown("улица Островского")); assertTrue(NameDictionary.isWellKnown("улица Островского"));
} }
@@ -71,9 +70,11 @@ class NameDictionaryTest {
NameDictionary.useExternalFile(file); NameDictionary.useExternalFile(file);
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"), assertTrue(
NameDictionary.isWellKnown("Интервью Кастомова"),
"дописанное сверху имя должно распознаваться наравне со встроенными"); "дописанное сверху имя должно распознаваться наравне со встроенными");
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"), assertTrue(
NameDictionary.isWellKnown("Стихи Пушкина"),
"встроенный список не должен теряться при дозагрузке"); "встроенный список не должен теряться при дозагрузке");
} }
@@ -1,10 +1,10 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */ /** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
class ToponymDictionaryTest { class ToponymDictionaryTest {
@@ -18,7 +18,8 @@ class ToponymDictionaryTest {
@Test @Test
void recognisesInflectedForms() { void recognisesInflectedForms() {
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную"); assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную"); assertTrue(
ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж"); assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
} }
@@ -41,11 +42,9 @@ class ToponymDictionaryTest {
} }
/** /**
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, * Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки, деревни, хутора, станицы,
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока * аулы, аалы — ровно то, чего не было, пока словарь ограничивался официальными городами. Примеры
* словарь ограничивался официальными городами. Примеры из ТЗ («рп. * из ТЗ («рп. Ильинское», «с. Кукуево») и по одному реальному названию на тип населённого пункта.
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
* населённого пункта.
*/ */
@Test @Test
void recognisesSettlementsFromCensusNotJustOfficialCities() { void recognisesSettlementsFromCensusNotJustOfficialCities() {
@@ -1,10 +1,10 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */ /** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
class ValidatorsTest { class ValidatorsTest {
@@ -41,7 +41,8 @@ class ValidatorsTest {
@Test @Test
void invalidOgrnipChecksumFails() { void invalidOgrnipChecksumFails() {
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится"); assertFalse(
Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
} }
@Test @Test