PD Guard: модуль безопасности персональных данных

This commit is contained in:
Onbehalfofme
2026-09-22 17:43:33 +03:00
commit 41052fac6a
80 changed files with 91230 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Основной вариант развёртывания: прогретая JVM вдвое экономнее native по процессору
# и втрое быстрее по задержке. Холодное окно закрывает прогрев на старте.
# mvn package && docker build -f src/main/docker/Dockerfile -t pd-guard-spring:jvm .
FROM eclipse-temurin:21-jre
WORKDIR /deployments
COPY --chown=1000:1000 target/pd-guard-spring-1.0.0.jar /deployments/app.jar
COPY --chown=1000:1000 config /deployments/config
EXPOSE 8080
USER 1000
ENV JAVA_OPTS="-Dspring.config.additional-location=optional:file:/deployments/config/"
ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /deployments/app.jar"]
@@ -0,0 +1,19 @@
package ru.pdguard;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Точка входа Spring Boot приложения.
*
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
* текст на обратном шаге.
*/
@SpringBootApplication
public class PdGuardApplication {
public static void main(String[] args) {
SpringApplication.run(PdGuardApplication.class, args);
}
}
@@ -0,0 +1,40 @@
package ru.pdguard.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */
@RestController
public class AdminResource {
private final SystemsConfig systems;
private final RuleRegistry registry;
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems;
this.registry = registry;
}
@GetMapping("/admin/config")
public Map<String, SystemPolicy> config() {
return systems.current();
}
@GetMapping("/admin/types")
public List<String> types() {
return registry.knownTypes();
}
@PostMapping("/admin/reload")
public Map<String, SystemPolicy> reload() {
systems.reload();
return systems.current();
}
}
@@ -0,0 +1,14 @@
package ru.pdguard.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** Проба готовности для балансировщика и проверяющей системы. */
@RestController
public class HealthResource {
@GetMapping("/health")
public String health() {
return "OK";
}
}
@@ -0,0 +1,133 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
*/
@RestController
public class ProcessResource {
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
public static final String KEY_HEADER = "X-System-Key";
/** Имя метрики отклонённых запросов и имя её метки причины. */
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
private static final String REASON_TAG = "reason";
/**
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
*/
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
public record ProcessRequest(
@JsonProperty("payload") String payload,
@JsonProperty("payload_id") String payloadId) {
}
public record ProcessResponse(@JsonProperty("result") String result) {
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final AdaptiveConcurrencyLimiter limiter;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
private final Counter failed;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
this.pipeline = pipeline;
this.systems = systems;
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
}
@PostMapping("/process")
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
malformed.increment();
return ResponseEntity.badRequest()
.body(new ProcessResponse("payload и payload_id обязательны"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
forbidden.increment();
LOG.warn("Системе {} отказано: неверный ключ", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Неверный ключ системы"));
}
if (!policy.enabled()) {
forbidden.increment();
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
}
if (!limiter.tryAcquire()) {
rejected.increment();
return ResponseEntity.status(429).header("Retry-After", "1").build();
}
long started = System.nanoTime();
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return ResponseEntity.ok(new ProcessResponse(result));
} catch (RuntimeException e) {
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
// ради чего сервис и существует. Ответ фиксированный: он ничего не
// раскрывает и не выглядит порчей данных.
failed.increment();
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
request.payloadId(), e);
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
} finally {
limiter.release(System.nanoTime() - started);
}
}
}
@@ -0,0 +1,103 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.LlmClient;
import ru.pdguard.core.Pipeline;
import java.util.Map;
/**
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
*
* <pre>
* потребитель → маскирование → LLM → демаскирование → потребитель
* </pre>
*
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
* незамаскированного, а ответ вернулся с восстановленными значениями.
*
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
* применяется обратимая подстановка, независимо от режима маскирования системы.
*
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в
* {@link ProcessResource}.
*/
@RestController
public class ProxyResource {
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
public record ProxyRequest(@JsonProperty("prompt") String prompt) {
}
public record ProxyResponse(
@JsonProperty("prompt_masked") String promptMasked,
@JsonProperty("llm_response_masked") String llmResponseMasked,
@JsonProperty("response") String response,
@JsonProperty("replaced") Map<String, String> replaced,
@JsonProperty("llm") String llm,
@JsonProperty("error") String error) {
}
private final Pipeline pipeline;
private final SystemsConfig systems;
private final LlmClient llm;
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
this.pipeline = pipeline;
this.systems = systems;
this.llm = llm;
}
@PostMapping("/proxy")
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
return ResponseEntity.badRequest()
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.accepts(systemKey)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
}
if (!policy.enabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ProxyResponse(null, null, null, null, null,
"Системе " + systemId + " обращение в модуль запрещено"));
}
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
LlmClient.Answer answer = llm.ask(masked.text());
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
LOG.info("proxy: система={} заменено={} модель={}",
policy.name(), masked.restorations().size(), answer.source());
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored,
masked.restorations(), answer.source(), null));
}
/** Возвращает исходные значения на место подстановок в ответе модели. */
private static String restore(String text, Map<String, String> restorations) {
String result = text;
for (Map.Entry<String, String> entry : restorations.entrySet()) {
result = result.replace(entry.getKey(), entry.getValue());
}
return result;
}
}
@@ -0,0 +1,25 @@
package ru.pdguard.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import ru.pdguard.detect.NameDictionary;
/**
* Конфигурация словарей распознавания ФИО.
*
* <p>{@link NameDictionary} работает через статические методы и не требует
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
* {@code pdguard.well-known-file} и передал его словарю.
*/
@Configuration
public class DictionaryConfiguration {
@Bean
public NameDictionary nameDictionary(
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
NameDictionary.configure(wellKnownFile);
return NameDictionary.create();
}
}
@@ -0,0 +1,80 @@
package ru.pdguard.config;
import ru.pdguard.mask.MaskMode;
import java.util.Set;
/**
* Правила обработки для одной системы-потребителя.
*
* @param name имя системы; им же разделяется хранилище соответствий,
* чтобы одна система не могла достать данные другой
* @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
* совпасть, иначе имя системы можно было бы просто назвать.
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
* и кириллица в ключе до сервиса доедет искажённой
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
* пин-код сам по себе безвреден, пин-код рядом с номером
* карты — уже нет; то же для даты без якорного слова, места
* рождения («Нижний Новгород» в рассказе о городе — не адрес
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
* сами по себе они опознают организацию или счёт, а не человека,
* и в перечне типов из задания их нет. Рядом с именем клиента
* они становятся его данными и маскируются.
* Сюда же доход и биометрия. Сумма заработка без человека —
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
* персональные данные. Биометрия же в тексте не встречается
* вовсе: это шаблон в базе, и правило маскирует лишь само
* упоминание, то есть слово, а не данные. Чувствителен здесь
* факт, что биометрию сдал названный человек, — а он и
* существует только при имени рядом
*/
public record SystemPolicy(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 DEFAULT_NAME = "default";
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
Set.of("CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
"INCOME", "BIOMETRIC"), null);
public SystemPolicy {
types = Set.copyOf(types);
requireCompanion = Set.copyOf(requireCompanion);
}
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(types), DEFAULT.requireCompanion(), null);
}
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
public boolean accepts(String presentedKey) {
if (key == null || key.isBlank()) {
return true;
}
return java.security.MessageDigest.isEqual(
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
public boolean allows(String type) {
return types.contains(ALL) || types.contains(type);
}
public boolean needsCompanion(String type) {
return requireCompanion.contains(type);
}
}
@@ -0,0 +1,136 @@
package ru.pdguard.config;
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.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.Set;
import java.util.TreeMap;
/**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
*
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
* перечитывается сам, когда меняется время его изменения; проверка выполняется
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
*/
@Component
public class SystemsConfig {
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
/** Имя политики, которая применяется к запросам без заголовка системы. */
public static final String DEFAULT_SYSTEM = "default";
private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion, String key) {
}
private final Path file;
private final ObjectMapper mapper;
private final AtomicReference<Map<String, SystemPolicy>> policies =
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
private volatile long fileTimestamp;
private volatile long lastCheck;
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
ObjectMapper mapper) {
this.file = Path.of(path);
this.mapper = mapper;
reload();
}
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
public SystemPolicy policyFor(String systemId) {
refreshIfChanged();
Map<String, SystemPolicy> current = policies.get();
SystemPolicy policy = systemId == null ? null : current.get(systemId);
if (policy != null) {
return policy;
}
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
}
/** Известна ли система по имени. */
public boolean isKnown(String systemId) {
refreshIfChanged();
return systemId != null && policies.get().containsKey(systemId);
}
/** Текущие настройки — для отдачи в административном интерфейсе. */
public Map<String, SystemPolicy> current() {
refreshIfChanged();
return new TreeMap<>(policies.get());
}
/** Перечитать файл настроек немедленно. */
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
fileTimestamp = 0;
return;
}
try {
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
Map<String, SystemPolicy> parsed = new TreeMap<>();
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
policies.set(Map.copyOf(parsed));
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
}
}
private void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(file)) {
return;
}
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
reload();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", file, e);
}
}
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
SystemPolicy base = SystemPolicy.DEFAULT;
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
Set<String> companions = entry.requireCompanion() == null
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
MaskMode mode = entry.maskMode() == null
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
return new SystemPolicy(name,
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions, entry.key());
}
}
@@ -0,0 +1,129 @@
package ru.pdguard.core;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.TimeUnit;
/**
* Предел одновременных запросов, который сам подстраивается под задержку,
* а не задан фиксированным числом. Растёт, пока обработка укладывается в
* целевое время, и сжимается, как только перестаёт — вместо того чтобы
* копить очередь и подходить к таймауту вызывающей стороны.
*
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime.
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно
* показывает все ядра хоста и как источник предела не годится. Задержка —
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
*
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой
* версии предел менялся на каждый завершённый запрос, и на высоком RPS
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась,
* и то же самое повторялось после каждого восстановления. Проверено
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться.
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
* шагом на основе среднего за окно — так скорость регулировки не зависит
* от того, насколько высок входящий RPS.
*
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа
* заявки, и под перегрузкой оно само составляет секунды: предел успевает
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже
* если следующим окном предел тут же обрушить. Проверено нагрузочным
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
* подходить к безопасному уровню, пока заявки продолжают копиться.
*
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе
* окна может добавить образец в уже подводимый итог или отбросить один,
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
* ступени понадобилась отдельная настройка сборки.
*/
public class AdaptiveConcurrencyLimiter {
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicLong windowSumNanos = new AtomicLong();
private final AtomicInteger windowSamples = new AtomicInteger();
private final AtomicLong lastAdjustNanos;
private final int minLimit;
private final int maxLimit;
private final long targetLatencyNanos;
private final long adjustWindowNanos;
private final AtomicInteger limit;
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
}
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
if (minLimit < 1 || maxLimit < minLimit) {
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
}
this.minLimit = minLimit;
this.maxLimit = maxLimit;
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
this.adjustWindowNanos = adjustWindowNanos;
this.limit = new AtomicInteger(minLimit);
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
}
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
public boolean tryAcquire() {
if (inFlight.incrementAndGet() > limit.get()) {
inFlight.decrementAndGet();
return false;
}
return true;
}
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
public void release(long elapsedNanos) {
inFlight.decrementAndGet();
windowSumNanos.addAndGet(elapsedNanos);
windowSamples.incrementAndGet();
long now = System.nanoTime();
long last = lastAdjustNanos.get();
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
adjust();
}
}
private void adjust() {
int samples = windowSamples.getAndSet(0);
long sum = windowSumNanos.getAndSet(0);
if (samples == 0) {
return;
}
long avg = sum / samples;
if (avg < targetLatencyNanos) {
limit.set(Math.min(maxLimit, limit.get() + 1));
} else {
limit.set(Math.max(minLimit, limit.get() / 2));
}
}
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
public int inFlight() {
return inFlight.get();
}
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
public int limit() {
return limit.get();
}
}
@@ -0,0 +1,102 @@
package ru.pdguard.core;
import com.fasterxml.jackson.databind.ObjectMapper;
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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
/**
* Обращение к языковой модели для демонстрационного плеча.
*
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
* восстановленный.
*
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
*/
@Component
public class LlmClient {
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
/** Что вернула модель и кто именно ответил. */
public record Answer(String text, String source) {
}
private final Optional<String> url;
private final Optional<String> apiKey;
private final String model;
private final Duration timeout;
private final HttpClient http;
private final ObjectMapper mapper = new ObjectMapper();
public LlmClient(
@Value("${pdguard.llm.url:}") String url,
@Value("${pdguard.llm.api-key:}") String apiKey,
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
this.model = model;
this.timeout = Duration.ofSeconds(timeoutSeconds);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
if (this.url.isEmpty()) {
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
}
}
public Answer ask(String maskedPrompt) {
if (url.isEmpty()) {
return new Answer(stub(maskedPrompt), "заглушка");
}
try {
return new Answer(call(maskedPrompt), model);
} catch (Exception e) {
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
}
}
/**
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
* подстановки вернулись на свои места при обратном преобразовании.
*/
private static String stub(String maskedPrompt) {
return "Ответ по запросу: " + maskedPrompt;
}
private String call(String maskedPrompt) throws Exception {
ObjectNode body = mapper.createObjectNode();
body.put("model", model);
ObjectNode message = body.putArray("messages").addObject();
message.put("role", "user");
message.put("content", maskedPrompt);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
.timeout(timeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
HttpResponse<String> response = http.send(request.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() / 100 != 2) {
throw new IllegalStateException("модель ответила " + response.statusCode());
}
return mapper.readTree(response.body())
.path("choices").path(0).path("message").path("content").asText();
}
}
@@ -0,0 +1,65 @@
package ru.pdguard.core;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Настройка распределений для метрик времени.
*
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум.
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по
* ним p50, p95 и p99.
*
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
* сочетание меток.
*/
@Configuration
public class MetricsConfiguration {
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
private static final Duration SLA_TARGET = Duration.ofMillis(500);
private static final Duration[] BOUNDARIES = {
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
Duration.ofSeconds(10)
};
@Bean
public MeterFilter histogramsForTimers() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
if (!needsHistogram(id.getName())) {
return config;
}
double[] boundaries = new double[BOUNDARIES.length];
for (int i = 0; i < BOUNDARIES.length; i++) {
boundaries[i] = BOUNDARIES[i].toNanos();
}
return DistributionStatisticConfig.builder()
.percentilesHistogram(false)
.serviceLevelObjectives(boundaries)
.build()
.merge(config);
}
};
}
private static boolean needsHistogram(String name) {
return "pdguard.process".equals(name)
|| "pdguard.ner.duration".equals(name)
|| "http.server.requests".equals(name);
}
}
@@ -0,0 +1,197 @@
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.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicLong;
/**
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
*
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
* страховка на случай, если идентификатор до сервиса не доехал.
*
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
* исходные значения из запроса другого потребителя. Разделение ограничивает это
* пределами одной системы, которая и так видит свои данные.
*
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
*
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
* обходится без обращения по сети.
*/
@Component
public class PayloadStore {
/** Сколько протухших записей просматривается за одну операцию записи. */
private static final int SWEEP_PER_PUT = 4;
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
public record Entry(String system, String original, String masked,
String fingerprint, long expiresAt) {
boolean alive(long now) {
return now < expiresAt;
}
int weight() {
return original.length() + masked.length();
}
}
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
private final AtomicLong charsHeld = new AtomicLong();
private final long maxChars;
private final long ttlMillis;
private final SharedIndex shared;
@Autowired
public PayloadStore(
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
SharedIndex shared) {
this.maxChars = maxChars;
this.ttlMillis = ttlMinutes * 60_000L;
this.shared = shared;
}
/** Конструктор для тестов: только локальная память, общий слой выключен. */
public PayloadStore(long maxChars, int ttlMinutes) {
this(maxChars, ttlMinutes, SharedIndex.disabled());
}
public void put(String system, String payloadId, String original, String masked) {
long now = System.currentTimeMillis();
Entry entry = new Entry(system, original, masked, fingerprint(masked), now + ttlMillis);
String idKey = scoped(system, payloadId);
Entry replaced = byId.put(idKey, entry);
byMaskFingerprint.put(scoped(system, entry.fingerprint()), entry);
insertionOrder.add(idKey);
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
evictWhileOverLimit();
shared.put(system, payloadId, original, masked, entry.fingerprint());
}
public Entry byId(String system, String payloadId) {
String idKey = scoped(system, payloadId);
Entry entry = byId.get(idKey);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry;
}
if (entry != null) {
forget(idKey, entry);
}
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(system, payloadId, fromShared.original(), fromShared.masked());
return byId.get(idKey);
}
/**
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
* данные нельзя.
*/
public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry.original();
}
return shared.originalForFingerprint(system, fingerprint);
}
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
public long charsHeld() {
return charsHeld.get();
}
/**
* Ключ, однозначно разделяющий системы. Длина имени в начале снимает вопрос о
* разделителе: имя системы может содержать любые знаки.
*/
private static String scoped(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry == null) {
insertionOrder.poll();
continue;
}
if (entry.alive(now)) {
return;
}
insertionOrder.poll();
forget(oldest, entry);
}
}
private void evictWhileOverLimit() {
while (charsHeld.get() > maxChars) {
String oldest = insertionOrder.poll();
if (oldest == null) {
return;
}
Entry entry = byId.get(oldest);
if (entry != null) {
// ponytail: если тот же payload_id записали повторно, в очереди остался
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
forget(oldest, entry);
}
}
}
private void forget(String idKey, Entry entry) {
if (byId.remove(idKey, entry)) {
byMaskFingerprint.remove(scoped(entry.system(), entry.fingerprint()), entry);
charsHeld.addAndGet(-entry.weight());
}
}
private static String fingerprint(String value) {
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
}
}
}
+291
View File
@@ -0,0 +1,291 @@
package ru.pdguard.core;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.NameDictionary;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.MaskContext;
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}, а не по содержимому запроса:
* <ul>
* <li>идентификатор неизвестен — маскируем;</li>
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
* </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан
* совпасть с первым, иначе демаскирование по этому элементу развалится.
*/
@Component
public class Pipeline {
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
private static final int CHARS_PER_TOKEN = 4;
private final RuleRegistry registry;
private final Masker masker;
private final PayloadStore store;
private final MeterRegistry meters;
private final NameCascade cascade;
private final Counter tokensProcessed;
@Autowired
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
NameCascade cascade) {
this.registry = registry;
this.masker = masker;
this.store = store;
this.meters = meters;
this.cascade = cascade;
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
.description("Оценка числа обработанных токенов, для расчёта TPS")
.register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
}
/** Конструктор для тестов второй ступени. */
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
}
public String process(String payload, String payloadId, SystemPolicy policy) {
long started = System.nanoTime();
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
if (known != null) {
if (policy.demask() && payload.equals(known.masked())) {
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
recordLatency("unmask", policy.name(), started);
return known.original();
}
if (payload.equals(known.original())) {
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
recordLatency("mask", policy.name(), started);
return known.masked();
}
}
if (policy.demask()) {
String original = store.originalForMask(policy.name(), payload);
if (original != null) {
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
recordLatency("unmask", policy.name(), started);
return original;
}
}
return mask(payload, payloadId, policy, started);
}
/**
* Длительность обработки с разрезом по направлению и системе-потребителю.
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
*/
private void recordLatency(String direction, String system, long startedNanos) {
Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", direction)
.tag("system", system)
.register(meters)
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
*/
public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (policy.allows(RuleRegistry.FIO)) {
// Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
}
spans = dropOrganisationNames(text, spans);
spans = dropWellKnownNames(text, spans);
return dropLonelyCompanions(spans, policy);
}
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
List<Span> spans = findPersonalData(payload, policy);
String masked = apply(payload, spans, policy);
store.put(policy.name(), payloadId, payload, masked);
recordLatency("mask", policy.name(), started);
logFindings(policy.name(), payloadId, payload.length(), spans);
return masked;
}
/**
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
* приоритетный, при равном приоритете — более длинный.
*/
static List<Span> resolveOverlaps(List<Span> spans) {
List<Span> candidates = new ArrayList<>(spans);
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
.thenComparing(Comparator.comparingInt(Span::length).reversed())
.thenComparingInt(Span::start));
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
// фрагментов набираются тысячи.
NavigableMap<Integer, Span> accepted = new TreeMap<>();
for (Span candidate : candidates) {
if (overlapsAccepted(accepted, candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
}
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
if (before != null && before.getValue().overlaps(candidate)) {
return true;
}
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
return after != null && after.getValue().overlaps(candidate);
}
/**
* Убирает имена, стоящие в названиях организаций и объектов на карте:
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
* само по себе.
*/
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|| !NameDictionary.precededByOrganisation(text, span.start()))
.toList();
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
*/
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream()
.anyMatch(span -> !RuleRegistry.FIO.equals(span.type()));
if (otherPersonalDataPresent) {
return spans;
}
return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.toList();
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
*
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
* сравнивалось число различных типов, и два спутника заверяли друг друга:
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
* Сочетание двух несамостоятельных типов самостоятельным не становится.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
for (Span span : spans) {
if (!policy.needsCompanion(span.type())) {
return spans;
}
}
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
return List.of();
}
/** Замаскированный текст вместе с таблицей обратной замены. */
public record Masked(String text, java.util.Map<String, String> restorations) {
}
/**
* Маскирует текст и отдаёт таблицу обратной замены.
*
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
* значениям, — поэтому режим замены здесь всегда обратимый.
*/
public Masked maskWithRestorations(String text, SystemPolicy policy) {
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) {
return new Masked(text, java.util.Map.of());
}
MaskContext context = new MaskContext();
String masked = apply(text, spans, reversible, context);
logFindings(policy.name(), "proxy", text.length(), spans);
return new Masked(masked, context.restorations());
}
private String apply(String text, List<Span> spans, SystemPolicy policy) {
return apply(text, spans, policy, new MaskContext());
}
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
if (spans.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
int cursor = 0;
for (Span span : spans) {
sb.append(text, cursor, span.start());
String value = text.substring(span.start(), span.end());
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
cursor = span.end();
}
sb.append(text, cursor, text.length());
return sb.toString();
}
/**
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
* Сами значения не логируются ни на одном уровне.
*/
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
Map<String, Integer> counts = new LinkedHashMap<>();
for (Span span : spans) {
counts.merge(span.type(), 1, Integer::sum);
}
counts.forEach((type, count) ->
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
}
}
@@ -0,0 +1,79 @@
package ru.pdguard.core;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskMode;
import ru.pdguard.mask.Masker;
import java.util.Set;
/**
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
*
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
* настоящие запросы.
*
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
*
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
* создании своего пула.
*/
@Component
public class PipelineWarmup {
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
private static final String[] SAMPLES = {
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
"Напиши краткое описание продукта для рассылки клиентам банка",
};
private final RuleRegistry registry;
private final Masker masker;
private final int iterations;
public PipelineWarmup(RuleRegistry registry, Masker masker,
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
this.registry = registry;
this.masker = masker;
this.iterations = iterations;
}
@PostConstruct
void warmup() {
if (iterations <= 0) {
LOG.info("Прогрев обработки отключён");
return;
}
long started = System.nanoTime();
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1),
NameCascade.disabled());
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK,
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
for (int i = 0; i < iterations; i++) {
String text = SAMPLES[i % SAMPLES.length];
String id = "warmup-" + i;
String masked = scratch.process(text, id, policy);
scratch.process(masked, id, policy);
}
LOG.info("Прогрев обработки: {} прогонов за {} мс",
iterations, (System.nanoTime() - started) / 1_000_000);
}
}
@@ -0,0 +1,167 @@
package ru.pdguard.core;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
*
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
* Обратное же преобразование требует состояния: если прямой запрос обработал
* один узел, а обратный попал на другой, соответствие должно быть общим.
*
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
*
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
* съедал время ответа, команды ограничены по времени настройкой
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
* временно перестают опрашивать вовсе.
*/
@Component
public class SharedIndex {
private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class);
private static final String KEY_BY_ID = "pdg:id:";
private static final String KEY_BY_MASK = "pdg:mask:";
/** Сколько подряд неудач размыкает предохранитель. */
private static final int FAILURES_TO_OPEN = 3;
/** На сколько общий слой перестают опрашивать после размыкания. */
private static final long OPEN_MILLIS = 5_000;
/** Пара «исходный текст — маска», как она хранится в общем слое. */
public record SharedEntry(String original, String masked) {
}
private final boolean enabled;
private final Duration ttl;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(StringRedisTemplate redis,
@Value("${pdguard.store.backend:memory}") String backend,
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
ObjectMapper mapper) {
this.redis = redis;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
this.mapper = mapper;
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30, new ObjectMapper());
}
public boolean enabled() {
return enabled;
}
public void put(String system, String payloadId, String original, String masked,
String maskFingerprint) {
if (unavailable()) {
return;
}
try {
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
toJson(new SharedEntry(original, masked)), ttl);
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), original, ttl);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
}
}
public SharedEntry byId(String system, String payloadId) {
if (unavailable()) {
return null;
}
try {
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
noteSuccess();
return json == null ? null : fromJson(json);
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
public String originalForFingerprint(String system, String maskFingerprint) {
if (unavailable()) {
return null;
}
try {
String original = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
noteSuccess();
return original;
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
private String toJson(SharedEntry entry) {
try {
return mapper.writeValueAsString(entry);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
}
}
private SharedEntry fromJson(String json) {
try {
return mapper.readValue(json, SharedEntry.class);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
}
}
/** Ключ с разделением по системам: длина имени в начале снимает вопрос о разделителе. */
private static String scoped(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
/** Общий слой выключен или предохранитель разомкнут. */
private boolean unavailable() {
return !enabled || System.currentTimeMillis() < silentUntil;
}
private void noteSuccess() {
if (consecutiveFailures.getAndSet(0) != 0) {
reported = false;
LOG.info("Общий слой снова доступен");
}
}
/**
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
* проверяющая система считает ответ дольше десяти секунд неответом.
*/
private void noteFailure(String action, RuntimeException cause) {
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
}
if (!reported) {
reported = true;
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
}
}
}
@@ -0,0 +1,53 @@
package ru.pdguard.detect;
import java.util.Locale;
/**
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
*
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
* для них сравнение идёт по основе без неё.
*
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
* именительный там не встречается вообще.
*/
final class Declension {
/**
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
*/
private static final String[] ADJECTIVE_ENDINGS = {
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
};
private Declension() {
}
/**
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
* Слова короче четырёх букв не трогает — короткая основа и так шире
* большинства падежных форм.
*/
static String withoutInflectedEnding(String word) {
String lower = word.toLowerCase(Locale.ROOT);
for (String ending : ADJECTIVE_ENDINGS) {
if (lower.length() > ending.length() && lower.endsWith(ending)) {
return lower.substring(0, lower.length() - ending.length() + 2);
}
}
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
return lower.substring(0, lower.length() - 1);
}
return lower;
}
}
@@ -0,0 +1,231 @@
package ru.pdguard.detect;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.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.detect.Span;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Вторая ступень распознавания.
*
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
* микросекунд. Модель нужна там, где они бессильны: имена без русского
* словообразования и нестандартные топонимы.
*
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
* запрос было бы невозможно.
*
* <p>Движок один — модель BERT, размечающая имена и составляющие адреса. Настройка
* {@code pdguard.ner.engine} принимает {@code off} или {@code rubert}; ступень
* выключена, пока движок не задан.
*
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
*/
@Component
public class NameCascade {
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
private static final String OUTCOME_TAG = "outcome";
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE = Pattern.compile(
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
private static final int PRIORITY = 73;
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
private static final int CONTEXT_CHARS = 60;
private final RuBertRecogniser recogniser;
private final Semaphore concurrent;
private final int maxCandidates;
private volatile boolean broken;
/**
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
* посильна ли тяжёлая модель на боевом трафике.
*/
private final Counter engaged;
private final Counter withoutCandidates;
private final Counter busy;
private final Counter candidates;
private final Timer duration;
@Autowired
public NameCascade(
@Value("${pdguard.ner.engine:off}") String engine,
@Value("${pdguard.ner.model:}") String modelPath,
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
@Value("${pdguard.ner.pool-size:16}") int poolSize,
MeterRegistry meters) {
this.maxCandidates = maxCandidates;
this.recogniser = create(engine, Optional.ofNullable(modelPath).filter(p -> !p.isBlank()));
this.concurrent = new Semaphore(Math.max(1, poolSize));
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "engaged").register(meters);
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "no_candidates").register(meters);
this.busy = Counter.builder(NER_REQUESTS_METRIC)
.description(NER_REQUESTS_DESCRIPTION)
.tag(OUTCOME_TAG, "busy").register(meters);
this.candidates = Counter.builder("pdguard.ner.candidates")
.description("Участки текста, отданные модели").register(meters);
this.duration = Timer.builder("pdguard.ner.duration")
.description("Время работы второй ступени").register(meters);
}
/** Конструктор для тестов: метрики никуда не отдаются. */
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
this(engine, modelPath.orElse(""), maxCandidates, poolSize, new SimpleMeterRegistry());
}
/**
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
* что в итоге работает.
*/
private NameCascade() {
this.maxCandidates = 0;
this.recogniser = null;
this.concurrent = new Semaphore(1);
MeterRegistry meters = new SimpleMeterRegistry();
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
this.candidates = meters.counter("pdguard.ner.candidates");
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
}
public static NameCascade disabled() {
return new NameCascade();
}
public boolean enabled() {
return recogniser != null && !broken;
}
/**
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
* трогаются: модель разбирает только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled()) {
return accepted;
}
if (!concurrent.tryAcquire()) {
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
busy.increment();
return accepted;
}
long started = System.nanoTime();
try {
List<Span> found = new ArrayList<>(accepted);
int examined = 0;
Matcher m = CANDIDATE.matcher(text);
while (m.find() && examined < maxCandidates) {
if (coveredBy(accepted, m.start(), m.end())) {
continue;
}
examined++;
collect(text, m.start(), m.end(), found);
}
candidates.increment(examined);
(examined > 0 ? engaged : withoutCandidates).increment();
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return found;
} catch (RuntimeException e) {
broken = true;
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
return accepted;
} finally {
concurrent.release();
}
}
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink) {
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
if (isAccepted(text, candidateStart, candidateEnd, span)) {
sink.add(span);
}
}
}
/**
* Принимает находку модели, если она пересекается с кандидатом и проходит
* те же условия, что и находки правил.
*/
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
// Берём только пересекающееся с кандидатом: контекст добавлен ради
// качества разбора, а не для расширения находки.
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
return false;
}
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
// Вернадского» попадали бы под маску наравне с адресом клиента.
return !RuleRegistry.isAddressType(span.type())
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
}
private static boolean coveredBy(List<Span> accepted, int start, int end) {
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
}
private static RuBertRecogniser create(String engine, Optional<String> modelPath) {
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
if ("off".equals(chosen) || modelPath.isEmpty() || modelPath.get().isBlank()) {
LOG.info("Вторая ступень распознавания выключена");
return null;
}
if (!"rubert".equals(chosen)) {
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
return null;
}
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath.get()), 1);
if (created == null) {
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
}
return created;
}
@PreDestroy
void shutdown() {
if (recogniser != null) {
recogniser.close();
}
}
}
@@ -0,0 +1,272 @@
package ru.pdguard.detect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Словари для распознавания ФИО.
*
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
*
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
* персональными данными не является. Ограничение осознанное: клиент по фамилии
* Пушкин в тексте без других ПД замаскирован не будет.
*
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
* него можно дописать своих публичных лиц без пересборки — файл по пути
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
* изменения, содержимое читается заново только когда оно другое.
*/
public final class NameDictionary {
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
private static final long RECHECK_MILLIS = 1000;
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
.map(Declension::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private static final AtomicReference<Path> externalFile =
new AtomicReference<>(Path.of("config/well-known.txt"));
private static final AtomicReference<Set<String>> wellKnownStems =
new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS);
private static volatile long externalTimestamp;
private static volatile long lastCheck;
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40;
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS =
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
private static final String REGNAL_EPITHETS =
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
/**
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
*/
private static final Pattern REGNAL_NAME = Pattern.compile(
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
/** Не более скольких падежных букв дописывается к основе имени. */
private static final int MAX_INFLECTION = 3;
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
private static final Set<String> SURNAME_SUFFIXES = Set.of(
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
.map(stem -> stem.toLowerCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
private NameDictionary() {
}
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
public static NameDictionary create() {
return new NameDictionary();
}
/**
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
* из конфигурации Spring-бина; статические методы словаря работают без
* экземпляра, поэтому путь хранится в статическом поле.
*/
public static void configure(String wellKnownFile) {
externalFile.set(Path.of(wellKnownFile));
}
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
* приходится не больше четырёх обращений к хеш-таблице.
*/
public static boolean containsGivenName(String value) {
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
// но это имя, а не фамилия.
if (GIVEN_NAMES.contains(lower)) {
return true;
}
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
// человека, а «Марина Шевченко» переставала бы им быть.
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
if (GIVEN_NAMES.contains(lower.substring(0, length))
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
return true;
}
}
}
return false;
}
/**
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
*/
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
/**
* Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху.
*
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
* префиксов у слова — не больше, чем в нём букв.
*/
public static boolean isWellKnown(String value) {
if (REGNAL_NAME.matcher(value.strip()).matches()) {
return true;
}
refreshIfChanged();
Set<String> stems = wellKnownStems.get();
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (stems.contains(lower.substring(0, length))) {
return true;
}
}
}
return false;
}
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
static void useExternalFile(Path path) {
externalFile.set(path);
externalTimestamp = -1;
lastCheck = 0;
}
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(externalFile.get())) {
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
externalFile.get().toAbsolutePath());
}
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS);
externalTimestamp = 0;
return;
}
try {
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed);
}
}
wellKnownStems.set(Set.copyOf(merged));
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e);
}
}
private static void refreshIfChanged() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MILLIS) {
return;
}
lastCheck = now;
try {
if (!Files.isReadable(externalFile.get())) {
if (externalTimestamp != 0) {
reloadExternal();
}
return;
}
if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) {
reloadExternal();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e);
}
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
private static List<String> load(String resource) {
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
}
@@ -0,0 +1,209 @@
package ru.pdguard.detect;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ru.pdguard.detect.Span;
import java.io.IOException;
import java.nio.LongBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
*
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
* нестандартные топонимы. Метки модели ложатся почти один в один
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
* город, улица, дом.
*
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
* десятком кандидатов занял бы все ядра.
*/
final class RuBertRecogniser {
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
private static final int MAX_PIECES = 190;
/** Метки модели в типы ПД сервиса. Имя, отчество и фамилия — один тип. */
private static final Map<String, String> TYPES = Map.of(
"FIRST_NAME", RuleRegistry.FIO,
"MIDDLE_NAME", RuleRegistry.FIO,
"LAST_NAME", RuleRegistry.FIO,
"COUNTRY", RuleRegistry.ADDRESS_COUNTRY,
"REGION", RuleRegistry.ADDRESS_REGION,
"DISTRICT", RuleRegistry.ADDRESS_DISTRICT,
"CITY", RuleRegistry.ADDRESS_CITY,
"STREET", RuleRegistry.ADDRESS_STREET,
"HOUSE", RuleRegistry.ADDRESS_HOUSE);
private final OrtEnvironment environment;
private final OrtSession session;
private final WordPiece tokenizer;
private final String[] labels;
private final Set<String> inputNames;
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
WordPiece tokenizer, String[] labels) throws OrtException {
this.environment = environment;
this.session = session;
this.tokenizer = tokenizer;
this.labels = labels;
this.inputNames = session.getInputNames();
}
/**
* Загружает модель из каталога с файлами {@code model_int8.onnx}, {@code vocab.txt}
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
* и сервис продолжит работать на правилах.
*/
static RuBertRecogniser load(Path directory, int threadsPerCall) {
Path model = directory.resolve("model_int8.onnx");
Path vocabulary = directory.resolve("vocab.txt");
Path config = directory.resolve("config.json");
if (!Files.isReadable(model) || !Files.isReadable(vocabulary) || !Files.isReadable(config)) {
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
return null;
}
try {
OrtEnvironment environment = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions options = new OrtSession.SessionOptions();
options.setIntraOpNumThreads(threadsPerCall);
options.setInterOpNumThreads(1);
OrtSession session = environment.createSession(model.toString(), options);
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
WordPiece.fromVocabulary(vocabulary), readLabels(config));
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
return recogniser;
} catch (OrtException | IOException | RuntimeException e) {
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
return null;
}
}
List<Span> recognise(String text, int from, int to, int priority) {
String region = text.substring(from, to);
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
if (pieces.isEmpty()) {
return List.of();
}
try {
String[] tags = classify(pieces);
return toSpans(pieces, tags, from, priority);
} catch (OrtException e) {
throw new IllegalStateException("Сбой вычисления модели BERT", e);
}
}
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
int length = pieces.size() + 2;
long[] ids = new long[length];
long[] mask = new long[length];
long[] types = new long[length];
ids[0] = tokenizer.classifyId();
for (int i = 0; i < pieces.size(); i++) {
ids[i + 1] = pieces.get(i).id();
}
ids[length - 1] = tokenizer.separatorId();
java.util.Arrays.fill(mask, 1L);
long[] shape = {1, length};
Map<String, OnnxTensor> inputs = new HashMap<>();
try {
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
if (inputNames.contains("token_type_ids")) {
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(types), shape));
}
inputs.keySet().retainAll(inputNames);
try (OrtSession.Result result = session.run(inputs)) {
float[][][] logits = (float[][][]) result.get(0).getValue();
String[] tags = new String[pieces.size()];
for (int i = 0; i < pieces.size(); i++) {
tags[i] = labels[argmax(logits[0][i + 1])];
}
return tags;
}
} finally {
inputs.values().forEach(OnnxTensor::close);
}
}
/**
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
* Схема разметки различает начало, середину, конец и одиночный токен, но для
* сборки достаточно смены типа: границы участков и так проставлены по словам.
*/
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority) {
List<Span> spans = new ArrayList<>();
String currentType = null;
int start = 0;
int end = 0;
for (int i = 0; i < tags.length; i++) {
String type = TYPES.get(entityOf(tags[i]));
if (type != null && type.equals(currentType)) {
end = pieces.get(i).end();
continue;
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
currentType = type;
start = pieces.get(i).start();
end = pieces.get(i).end();
}
if (currentType != null) {
spans.add(new Span(offset + start, offset + end, currentType, priority));
}
return spans;
}
private static String entityOf(String tag) {
int dash = tag.indexOf('-');
return dash < 0 ? tag : tag.substring(dash + 1);
}
private static int argmax(float[] scores) {
int best = 0;
for (int i = 1; i < scores.length; i++) {
if (scores[i] > scores[best]) {
best = i;
}
}
return best;
}
private static String[] readLabels(Path config) throws IOException {
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
String[] labels = new String[node.size()];
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
}
return labels;
}
void close() {
try {
session.close();
} catch (OrtException e) {
LOG.debug("Не удалось закрыть сессию модели", e);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
package ru.pdguard.detect;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
/**
* Одно правило детекции персональных данных.
*
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
* {@link RuleRegistry}; менять остальной код не требуется.
*
* @param type тип ПД, который распознаёт правило
* @param pattern регулярное выражение
* @param priority приоритет при разрешении перекрытий
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком.
* Несколько групп нужны, когда значение разорвано словами:
* «серия 4509 номер 123456»
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат);
* {@code null} — проверка не нужна
* @param veto шаблон окружения, при котором совпадение персональными данными не считается:
* адрес отделения банка не является ПД, хотя выглядит как адрес
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там,
* где форма совпадения сама по себе слишком общая: «Невский проспект»
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте.
* Проверка через {@code indexOf} на порядок дешевле запуска
* регулярного выражения и отсекает большинство правил на коротком
* запросе. Пустой список — правило запускается всегда
*/
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
/**
* Флаги компиляции для всех правил.
*
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
* {@code (?i)} корректным для кириллицы.
*/
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
/**
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
*
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
* вето не срабатывало на части из них.
*/
public static final int VETO_LOOKBEHIND = 150;
public static final int VETO_LOOKAHEAD = 40;
/** Правило без проверок, маскируется всё совпадение. */
public static Rule of(String type, String regex, int priority) {
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
}
/** Маскировать только перечисленные группы, а не всё совпадение. */
public Rule groups(Integer... indexes) {
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
}
/** Принять совпадение, только если значение прошло проверку. */
public Rule validatedBy(Predicate<String> check) {
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
}
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
public Rule anchoredBy(String... required) {
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
}
/** Есть ли в тексте хоть один из якорей правила. */
public boolean mayMatch(String lowercasedText) {
if (anchors.isEmpty()) {
return true;
}
for (String anchor : anchors) {
if (lowercasedText.contains(anchor)) {
return true;
}
}
return false;
}
/** Принять совпадение, только если рядом встретилось указанное слово. */
public Rule requiringNear(String regex) {
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
}
/** Отбросить совпадение, если рядом встретилось указанное слово. */
public Rule vetoedBy(String regex) {
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
}
}
@@ -0,0 +1,522 @@
package ru.pdguard.detect;
import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.Span;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Реестр правил детекции и сам поиск ПД в тексте.
*
* <p>Правила разбиты на три уровня доверия:
* <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
* <li>однозначные по формату — email, телефон;</li>
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
* где сама по себе последовательность знаков ни о чём не говорит.</li>
* </ol>
*
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
* именно на них. На захватываемое значение регистронезависимость не распространяется:
* там, где значение опознаётся по заглавной букве, это существенно.
*/
@Component
public class RuleRegistry {
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Регион и район размечает только модель второй ступени: правил под них нет. */
public static final String ADDRESS_REGION = "ADDRESS_REGION";
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
/** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME";
public static final String OGRN = "OGRN";
public static final String OGRNIP = "OGRNIP";
public static final String KPP = "KPP";
public static final String BIOMETRIC = "BIOMETRIC";
/**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
* «ИВАНОВ» распознавался наравне с «Иванов».
*/
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
/**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
* до вечера» оказывался под маской целиком.
*/
private static final String STREET_NAME =
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
/**
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
*/
private static final String SURNAME =
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
/**
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
*/
private static final String PATRONYMIC =
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}";
private static final String MONTH =
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
/** «12 мая 1985 г.» */
private static final String DATE_MONTH_WORD =
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
private static final String DATE_WORDS =
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
+ "(?iu:ьего|ого|его)\\s+" + MONTH
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)";
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
/**
* Слова, при которых адрес принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является. Части адреса рядом:
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
* как адрес отделения банка из технического задания. Требование стояло только
* у постфиксной формы правила, префиксная его не имела.
*/
public static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
private static final Pattern ADDRESS_CONTEXT =
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
ADDRESS_COUNTRY, ADDRESS_REGION, ADDRESS_DISTRICT, ADDRESS_CITY,
ADDRESS_STREET, ADDRESS_HOUSE, ADDRESS_FLAT, ADDRESS_POSTCODE);
public static boolean isAddressType(String type) {
return ADDRESS_TYPES.contains(type);
}
/**
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
*/
public static boolean hasAddressContext(String text, int start, int end) {
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
}
private static final String ORGANISATION_NEARBY =
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
private static final List<Rule> RULES = List.of(
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
.groups(1)
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
.groups(1)
.anchoredBy("пин", "pin"),
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
.groups(1)
.anchoredBy("паспорт"),
// «серия 4509 номер 123456», «серии 45 09 № 123456»
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
// «серии 4509 за номером 123456», «серия 4509 № 123456».
Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
.groups(1, 2)
.anchoredBy("сери"),
Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
.groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "),
// --- Прочие документы, удостоверяющие личность ---
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("загранпаспорт", "заграничн"),
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
.groups(1)
.anchoredBy("военн"),
Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
.groups(1)
.anchoredBy("свидетельств"),
Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1)
.anchoredBy("полис"),
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
.groups(1)
.anchoredBy("подразделени", "к/п"),
// --- Банковские реквизиты сверх карты ---
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
.groups(1)
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
Rule.of(BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("бик"),
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
Rule.of(CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
.groups(1)
.anchoredBy("срок действия", "действительн", "exp"),
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
// числа рядом со словом — раньше якоря было достаточно самого по себе.
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"),
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1)
.validatedBy(Validators::ogrn)
.anchoredBy("огрн"),
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
.groups(1)
.anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную.
Rule.of(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)
.anchoredBy("доход", "заработн", "зарплат"),
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
// значения для захвата нет: маскируется якорная фраза целиком.
Rule.of(BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
// --- Даты с явным якорем ---
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("рожден"),
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("родил"),
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1)
.validatedBy(Validators::date)
.anchoredBy("выдач"),
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
.groups(1)
.anchoredBy("держател", "cardholder", "на имя"),
// --- ФИО ---
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
// сознательно не используется: она захватывает глагол в начале
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
// Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
// Иванов И.И. и И.И. Иванов
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
// Имя Отчество без фамилии
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
.groups(1)
.anchoredBy("фио", "ф.и.о", "на имя"),
// «клиент Иванов Иван», «плательщик Петрова»
Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
.groups(1)
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
"контактное лицо", "исполнител", "держател"),
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY),
// --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
.validatedBy(Validators::luhn),
Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн"),
Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
.groups(1)
.validatedBy(Validators::snils),
// --- Уровень 2: формат однозначен сам по себе ---
Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"),
// --- Уровень 3: свободный текст после якорного слова ---
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть.
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
.groups(1)
.anchoredBy("выдан"),
Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("рождения"),
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1)
.anchoredBy("родил"),
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("гражданств"),
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
.anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),
Rule.of(ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY),
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\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)
.groups(1)
.validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
// принимается только рядом с другими частями адреса: иначе под маску попал бы
// любой рассказ про Невский проспект.
Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY)
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
Rule.of(ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"),
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"),
Rule.of(ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
.groups(1)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("стран"),
// --- Значения без якоря: принимаются только вместе с другими ПД ---
// ИНН физлица без якорного слова — только с верной контрольной суммой.
Rule.of(INN, "\\b\\d{12}\\b", 62)
.validatedBy(Validators::inn),
// Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа.
Rule.of(DATE, DATE_ANY, 58)
.validatedBy(Validators::date)
);
/** Все типы ПД, которые умеет распознавать сервис. */
public List<String> knownTypes() {
return RULES.stream().map(Rule::type).distinct().toList();
}
/**
* Находит все фрагменты ПД, разрешённые политикой системы.
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
*/
public List<Span> detect(String text, SystemPolicy policy) {
List<Span> found = new ArrayList<>();
String lowercased = text.toLowerCase(Locale.ROOT);
for (Rule rule : RULES) {
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
continue;
}
collect(rule, text, found);
}
return found;
}
private static void collect(Rule rule, String text, List<Span> sink) {
Matcher m = rule.pattern().matcher(text);
while (m.find()) {
for (int group : rule.groups()) {
int start = m.start(group);
int end = m.end(group);
if (isValidGroup(rule, text, start, end)) {
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
}
}
}
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
if (start < 0 || end <= start) {
return false;
}
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
return false;
}
String surroundings = surroundings(text, start, end);
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
return false;
}
return rule.context() == null || rule.context().matcher(surroundings).find();
}
static String surroundings(String text, int start, int end) {
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
return text.substring(from, to);
}
}
+26
View File
@@ -0,0 +1,26 @@
package ru.pdguard.detect;
/**
* Найденный фрагмент персональных данных в исходном тексте.
*
* @param start индекс первого символа (включительно)
* @param end индекс за последним символом (исключительно)
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
* @param priority приоритет при разрешении перекрытий: больше — важнее
*/
public record Span(int start, int end, String type, int priority) {
public Span {
if (start < 0 || end <= start) {
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
}
}
public int length() {
return end - start;
}
public boolean overlaps(Span other) {
return start < other.end && other.start < end;
}
}
@@ -0,0 +1,77 @@
package ru.pdguard.detect;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Словарь населённых пунктов России — проверка того, что значение, пойманное
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
* село, посёлок или другой населённый пункт, а не на произвольное слово с
* заглавной буквы после якоря.
*
* <p>Не только официальные города (~1100 по классификатору): перепись
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
* а не этот словарь — он только подтверждает, что название реальное.
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
* морфологии, как и у известных людей в {@link NameDictionary}.
*/
public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
private ToponymDictionary() {
}
/**
* Похоже ли значение на название населённого пункта из словаря в любом
* падеже.
*
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
* основ по значению: перебор списка на каждое совпадение правила был бы
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
*/
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
return true;
}
}
return false;
}
private static Set<String> load(String resource) {
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.collect(Collectors.toUnmodifiableSet());
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
}
@@ -0,0 +1,162 @@
package ru.pdguard.detect;
/**
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
* которые по форме похожи на ПД, но ими не являются.
*/
public final class Validators {
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
private Validators() {
}
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
public static boolean luhn(String value) {
int sum = 0;
int digits = 0;
boolean doubled = false;
for (int i = value.length() - 1; i >= 0; i--) {
char c = value.charAt(i);
if (!Character.isDigit(c)) {
continue;
}
int d = c - '0';
digits++;
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
return digits >= 13 && digits <= 19 && sum % 10 == 0;
}
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
public static boolean inn(String value) {
int[] d = digits(value);
if (d.length == 10) {
return d[9] == checksum(d, INN_10);
}
if (d.length == 12) {
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
}
return false;
}
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
public static boolean snils(String value) {
int[] d = digits(value);
if (d.length != 11) {
return false;
}
int sum = 0;
for (int i = 0; i < 9; i++) {
sum += d[i] * (9 - i);
}
int control = snilsControl(sum);
return control == d[9] * 10 + d[10];
}
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
private static int snilsControl(int sum) {
if (sum < 100) {
return sum;
}
if (sum == 100 || sum == 101) {
return 0;
}
return sum % 101 % 100;
}
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
public static boolean ogrn(String value) {
int[] d = digits(value);
return d.length == 13 && d[12] == modReduce(d, 12, 11);
}
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
public static boolean ogrnip(String value) {
int[] d = digits(value);
return d.length == 15 && d[14] == modReduce(d, 14, 13);
}
/**
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
*/
private static int modReduce(int[] d, int count, int divisor) {
long remainder = 0;
for (int i = 0; i < count; i++) {
remainder = (remainder * 10 + d[i]) % divisor;
}
return (int) (remainder % 10);
}
/**
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
* последовательности вроде {@code 192.168.1}.
*/
public static boolean date(String value) {
// Запись с названием месяца словом в дополнительной проверке не нуждается:
// «мая» само по себе однозначно указывает на дату.
for (int i = 0; i < value.length(); i++) {
if (Character.isLetter(value.charAt(i))) {
return true;
}
}
String[] parts = value.split("[.\\-/]");
if (parts.length != 3) {
return false;
}
int[] n = new int[3];
for (int i = 0; i < 3; i++) {
if (parts[i].isEmpty() || parts[i].length() > 4) {
return false;
}
n[i] = Integer.parseInt(parts[i]);
}
for (int y = 0; y < 3; y++) {
if (parts[y].length() == 4) {
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
}
}
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
}
/** Пара чисел похожа на «день и месяц» в любом порядке. */
private static boolean dayAndMonth(int a, int b) {
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
}
private static int checksum(int[] d, int[] weights) {
int sum = 0;
for (int i = 0; i < weights.length; i++) {
sum += d[i] * weights[i];
}
return sum % 11 % 10;
}
private static int[] digits(String value) {
int[] out = new int[value.length()];
int n = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isDigit(c)) {
out[n++] = c - '0';
}
}
int[] trimmed = new int[n];
System.arraycopy(out, 0, trimmed, 0, n);
return trimmed;
}
}
@@ -0,0 +1,159 @@
package ru.pdguard.detect;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Разбиение текста на подслова так, как это делает токенизатор BERT.
*
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
*
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
* модели не перенести обратно на строку.
*/
final class WordPiece {
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
private static final int MAX_WORD_CHARS = 100;
private static final String CONTINUATION = "##";
/** Подслово и его границы в исходном тексте. */
record Piece(int id, int start, int end) {
}
private final Map<String, Integer> vocabulary;
private final int unknownId;
private final int classifyId;
private final int separatorId;
private WordPiece(Map<String, Integer> vocabulary) {
this.vocabulary = vocabulary;
this.unknownId = required(vocabulary, "[UNK]");
this.classifyId = required(vocabulary, "[CLS]");
this.separatorId = required(vocabulary, "[SEP]");
}
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
String line;
int index = 0;
while ((line = reader.readLine()) != null) {
vocabulary.putIfAbsent(line.strip(), index++);
}
}
return new WordPiece(vocabulary);
}
int classifyId() {
return classifyId;
}
int separatorId() {
return separatorId;
}
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
List<Piece> split(String text, int maxPieces) {
List<Piece> pieces = new ArrayList<>();
for (int[] word : words(text)) {
if (pieces.size() >= maxPieces) {
break;
}
splitWord(text, word[0], word[1], pieces, maxPieces);
}
return pieces;
}
/**
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
* причём знак препинания сам становится отдельным словом.
*/
private static List<int[]> words(String text) {
List<int[]> result = new ArrayList<>();
int start = -1;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
if (separator) {
if (start >= 0) {
result.add(new int[]{start, i});
start = -1;
}
if (isPunctuation(c)) {
result.add(new int[]{i, i + 1});
}
} else if (start < 0) {
start = i;
}
}
if (start >= 0) {
result.add(new int[]{start, text.length()});
}
return result;
}
private static boolean isPunctuation(char c) {
if (Character.isLetterOrDigit(c)) {
return false;
}
return !Character.isWhitespace(c);
}
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
if (to - from > MAX_WORD_CHARS) {
sink.add(new Piece(unknownId, from, to));
return;
}
int cursor = from;
List<Piece> ofThisWord = new ArrayList<>();
while (cursor < to) {
int end = to;
Integer id = null;
while (end > cursor) {
String candidate = text.substring(cursor, end);
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
id = vocabulary.get(lookup);
if (id != null) {
break;
}
end--;
}
if (id == null) {
// Ни одна часть слова не нашлась — слово целиком неизвестно.
sink.add(new Piece(unknownId, from, to));
return;
}
ofThisWord.add(new Piece(id, cursor, end));
cursor = end;
}
for (Piece piece : ofThisWord) {
if (sink.size() >= maxPieces) {
return;
}
sink.add(piece);
}
}
private static int required(Map<String, Integer> vocabulary, String token) {
Integer id = vocabulary.get(token);
if (id == null) {
throw new IllegalStateException("В словаре нет служебного токена " + token);
}
return id;
}
}
@@ -0,0 +1,46 @@
package ru.pdguard.mask;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiFunction;
/**
* Состояние одной операции маскирования.
*
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
* запроса для модели сохранится.
*
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
*/
public final class MaskContext {
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
private static final char SEPARATOR = '#';
private final Map<String, String> assigned = new HashMap<>();
private final Map<String, Integer> counters = new HashMap<>();
private final Map<String, String> restorations = new LinkedHashMap<>();
/**
* Замена для значения; при повторе возвращается ранее выданная.
*
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
restorations.put(replacement, value);
return replacement;
});
}
/**
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
*/
public Map<String, String> restorations() {
return Map.copyOf(restorations);
}
}
@@ -0,0 +1,14 @@
package ru.pdguard.mask;
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
}
+82
View File
@@ -0,0 +1,82 @@
package ru.pdguard.mask;
import org.springframework.stereotype.Component;
import ru.pdguard.detect.RuleRegistry;
import java.util.Map;
import java.util.function.UnaryOperator;
/**
* Превращает найденное значение в замену согласно настройкам системы.
*
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
* безопасное поведение по умолчанию для вновь добавленных правил.
*/
@Component
public class Masker {
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
Map.entry(RuleRegistry.EMAIL, Strategies::email),
Map.entry(RuleRegistry.PHONE, EDGES),
Map.entry(RuleRegistry.CARD, EDGES),
Map.entry(RuleRegistry.INN, EDGES),
Map.entry(RuleRegistry.SNILS, EDGES),
Map.entry(RuleRegistry.PASSPORT, EDGES),
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES),
Map.entry(RuleRegistry.DEPT_CODE, EDGES),
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
// только последние. У паспорта РФ и водительского удостоверения серия
// из четырёх знаков, там открывается половина.
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES),
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES),
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials),
Map.entry(RuleRegistry.FIO, Strategies::initials),
// Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один.
Map.entry(RuleRegistry.CVV, Strategies::stars),
Map.entry(RuleRegistry.PIN, Strategies::stars),
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars),
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars),
Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars),
Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars),
Map.entry(RuleRegistry.ACCOUNT_NUMBER, EDGES),
Map.entry(RuleRegistry.OGRN, EDGES),
Map.entry(RuleRegistry.OGRNIP, EDGES),
Map.entry(RuleRegistry.KPP, EDGES),
// Срок действия карты — разделитель виден, сам месяц/год нет.
Map.entry(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.BIK, Strategies::stars),
Map.entry(RuleRegistry.INCOME, Strategies::stars),
Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars)
);
public String mask(String type, String value, MaskMode mode, MaskContext context) {
return switch (mode) {
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
};
}
}
@@ -0,0 +1,115 @@
package ru.pdguard.mask;
/**
* Способы преобразования найденного значения в маску.
*
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
* замаскированный текст остаётся читаемым для LLM и минимально отличается
* от эталона при посимвольном сравнении.
*/
public final class Strategies {
private static final char MASK = '*';
private Strategies() {
}
/** Каждый непробельный символ заменяется на «*». */
public static String stars(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isWhitespace(c) ? c : MASK);
}
return sb.toString();
}
/**
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
* само значение — нет.
*/
public static String starsKeepingPunctuation(String value) {
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
}
return sb.toString();
}
/**
* Оставляет первые и последние значащие символы, остальные скрывает,
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
*/
public static String keepEdges(String value, int head, int tail) {
int significant = 0;
for (int i = 0; i < value.length(); i++) {
if (Character.isLetterOrDigit(value.charAt(i))) {
significant++;
}
}
if (significant <= head + tail) {
return stars(value);
}
StringBuilder sb = new StringBuilder(value.length());
int seen = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (!Character.isLetterOrDigit(c)) {
sb.append(c);
continue;
}
boolean visible = seen < head || seen >= significant - tail;
sb.append(visible ? c : MASK);
seen++;
}
return sb.toString();
}
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
public static String initials(String value) {
StringBuilder sb = new StringBuilder();
boolean wordStart = true;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isLetter(c)) {
if (wordStart) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(Character.toUpperCase(c)).append('.');
wordStart = false;
}
} else {
wordStart = true;
}
}
return sb.isEmpty() ? stars(value) : sb.toString();
}
/**
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
*/
public static String email(String value) {
int at = value.lastIndexOf('@');
if (at <= 0 || at == value.length() - 1) {
return stars(value);
}
String local = value.substring(0, at);
String domain = value.substring(at + 1);
int dot = domain.lastIndexOf('.');
if (dot <= 0) {
return hideTail(local) + '@' + hideTail(domain);
}
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
}
private static String hideTail(String part) {
if (part.length() <= 1) {
return part;
}
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
}
}
@@ -0,0 +1,97 @@
package ru.pdguard.mask;
import ru.pdguard.detect.RuleRegistry;
/**
* Правдоподобные подставные значения вместо настоящих.
*
* <p>Модель получает текст, который выглядит естественно, и качество ответа
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
* исходное значение всегда даёт одну и ту же подстановку.
*/
final class Synthetic {
private static final String[] SURNAMES =
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
private static final String[] PATRONYMICS =
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
private Synthetic() {
}
static String forType(String type, String value, int ordinal) {
int seed = value.hashCode() & Integer.MAX_VALUE;
return switch (type) {
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
+ " " + pick(PATRONYMICS, seed >> 6);
case RuleRegistry.CARDHOLDER -> "IVAN PETROV";
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
case RuleRegistry.CARD -> luhnCard(seed);
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT,
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
case RuleRegistry.INN -> digits(seed, 12);
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16);
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed);
case RuleRegistry.ADDRESS_CITY -> "Зареченск";
case RuleRegistry.ADDRESS_STREET -> "Сосновая";
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6);
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье";
case RuleRegistry.ADDRESS_REGION -> "Заречная область";
case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район";
case RuleRegistry.CVV -> digits(seed, 3);
case RuleRegistry.PIN -> digits(seed, 4);
// Для остальных типов правдоподобной замены нет — отдаём токен.
default -> "[" + type + "_" + ordinal + "]";
};
}
private static String pick(String[] options, int seed) {
return options[Math.floorMod(seed, options.length)];
}
private static String syntheticDate(int seed) {
int day = 1 + Math.floorMod(seed, 28);
int month = 1 + Math.floorMod(seed >> 5, 12);
int year = 1960 + Math.floorMod(seed >> 9, 45);
return String.format("%02d.%02d.%d", day, month, year);
}
private static String digits(int seed, int count) {
StringBuilder sb = new StringBuilder(count);
int value = Math.abs(seed);
for (int i = 0; i < count; i++) {
sb.append((char) ('0' + Math.floorMod(value, 10)));
value = value / 10 + (i + 1) * 7;
}
return sb.toString();
}
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
int sum = 0;
boolean doubled = true;
for (int i = body.length() - 1; i >= 0; i--) {
int d = body.charAt(i) - '0';
if (doubled) {
d *= 2;
if (d > 9) {
d -= 9;
}
}
sum += d;
doubled = !doubled;
}
body.append((10 - sum % 10) % 10);
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
+ body.substring(8, 12) + " " + body.substring(12);
}
}
+52
View File
@@ -0,0 +1,52 @@
server:
port: 8080
address: 0.0.0.0
max-http-request-header-size: 16KB
spring:
application:
name: pd-guard-spring
jackson:
default-property-inclusion: non_null
data:
redis:
host: localhost
port: 6379
timeout: 200ms
connect-timeout: 200ms
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
endpoint:
health:
probes:
enabled: true
prometheus:
metrics:
export:
enabled: true
# Список систем-потребителей. Файла нет — работают настройки по умолчанию.
pdguard:
systems-file: config/systems.json
store:
backend: memory
max-chars: 134217728
ttl-minutes: 30
min-concurrent: 8
max-concurrent: 2000
target-latency-ms: 200
warmup-iterations: 2000
ner:
engine: off
model: models/rubert-ner
max-candidates: 16
pool-size: 16
llm:
url:
api-key:
model: gpt-4o-mini
timeout-seconds: 20
+167
View File
@@ -0,0 +1,167 @@
# Основы русских личных имён. Правило дописывает до трёх строчных букв,
# поэтому падежные формы (Ивану, Иваном, Ивана) покрываются основой.
# Строка — одна основа; строки с # игнорируются.
Александр
Алексей
Анатолий
Андрей
Антон
Аркадий
Арсений
Артём
Артем
Артур
Богдан
Борис
Вадим
Валентин
Валерий
Василий
Виктор
Виталий
Владимир
Владислав
Вячеслав
Геннадий
Георгий
Герман
Глеб
Григорий
Даниил
Данил
Денис
Дмитрий
Евгений
Егор
Иван
Игорь
Илья
Кирилл
Константин
Леонид
Максим
Марк
Матвей
Михаил
Никита
Николай
Олег
Павел
Пётр
Петр
Роман
Руслан
Семён
Семен
Сергей
Станислав
Степан
Тимофей
Тимур
Фёдор
Федор
Эдуард
Юрий
Ярослав
Алёна
Алена
Алина
Алла
Анастасия
Ангелина
Анна
Антонина
Валентина
Валерия
Варвара
Вероника
Виктория
Галина
Дарья
Диана
Евгения
Екатерина
Елена
Елизавета
Жанна
Зинаида
Инна
Ирина
Карина
Кристина
Ксения
Лариса
Лидия
Любовь
Людмила
Маргарита
Марина
Мария
Надежда
Наталья
Наталия
Нина
Оксана
Ольга
Полина
Раиса
Регина
Светлана
София
Софья
Тамара
Татьяна
Ульяна
Юлия
Лев
Яков
Ян
Захар
Тарас
Савва
Мирон
Демид
Клим
Влас
Родион
Святослав
Всеволод
Игнат
Филипп
Лука
Назар
Платон
Прохор
Трофим
Фома
Эмиль
Юлиан
Тихон
Гавриил
Давид
Марат
Рустам
Яна
Алиса
Василиса
Агата
Злата
Милана
Дарина
Есения
Таисия
Инга
Вера
Эмма
Нелли
Алевтина
Клавдия
Лилия
Римма
Элина
Ева
Аделина
Амина
Динара
Лейла
Сабина
@@ -0,0 +1,79 @@
# Слова, после которых идущее следом имя принадлежит организации, учреждению или
# объекту на карте, а не человеку: «Институт Склифосовского», «Музей Тропинина»,
# «улица Королёва», «Премия имени Ломоносова».
#
# Здесь не названия, а маркеры. Слово засчитывается только вплотную перед именем,
# поэтому «Больница приняла Иванова Ивана» под правило не попадает.
#
# «ИП» сюда сознательно не входит: имя индивидуального предпринимателя —
# это персональные данные.
институт
университет
академия
школа
гимназия
лицей
училище
колледж
музей
театр
галерея
библиотека
филармония
консерватория
больница
поликлиника
клиника
госпиталь
диспансер
санаторий
фонд
премия
стипендия
стадион
клуб
общество
союз
ассоциация
федерация
комитет
министерство
ведомство
департамент
управление
агентство
бюро
корпорация
холдинг
компания
завод
комбинат
фабрика
верфь
аэропорт
вокзал
станция
порт
парк
сквер
площадь
проспект
улица
переулок
бульвар
шоссе
набережная
проезд
тупик
мост
тоннель
храм
собор
монастырь
часовня
кладбище
мемориал
памятник
монумент
центр
имени
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
# Известные исторические и культурные фигуры. Упоминание такого имени
# персональными данными не является — если рядом нет ПД другого типа.
# Сравнение идёт по началу слова, поэтому падежи покрываются основой.
Пушкин
Лермонтов
Толстой
Достоевский
Гоголь
Чехов
Тургенев
Некрасов
Есенин
Маяковский
Ахматова
Цветаева
Булгаков
Пастернак
Чайковский
Ломоносов
Менделеев
Гагарин
Королёв
Суворов
Кутузов
Шекспир
Эйнштейн
Ньютон
Моцарт
Бетховен
Рахманинов
Репин
Айвазовский
Циолковский
Онегин
Печорин
Раскольников
Обломов
Чичиков
Базаров
Болконский
Каренин
Чацкий
Мцыри
Хлестаков
Митрофанушка
# Действующие публичные фигуры — упоминание в новостном/служебном контексте
# («Президент подписал закон», «ЦБ во главе с Набиуллиной повысил ставку»)
# персональными данными клиента не является.
Путин
Мишустин
Набиуллина
Греф
Костин
Тиньков
Дуров
Мордашов
Потанин
Дерипаска
Абрамович
Усманов
# Фамилии, в честь которых чаще всего называют улицы в России (Росреестр,
# Яндекс.Исследования). Упоминание «улица Ленина» само по себе не задевает
# распознавание ФИО — для него нужны два слова, — но составные названия
# («Феликса Дзержинского», «Александра Матросова») попадают под то же
# правило, что и «Богдана Хмельницкого»: в честь человека, а не клиент.
Ленин
Киров
Свердлов
Дзержинский
Фрунзе
Куйбышев
Ворошилов
Будённый
Буденный
Чапаев
Жуков
Чкалов
Терешкова
Мичурин
Шевченко
Орджоникидзе
Калинин
Энгельс
Маркс
Островский
Матросов
Волошина
Сусанин
Димитров
Донской
+27
View File
@@ -0,0 +1,27 @@
{
"default": {
"enabled": true,
"demask": true,
"maskMode": "MASK",
"types": ["*"],
"requireCompanion": ["CVV", "PIN", "DATE"]
},
"crm": {
"enabled": true,
"demask": false,
"maskMode": "TOKEN",
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
},
"analytics": {
"enabled": true,
"demask": false,
"maskMode": "SYNTHETIC",
"types": ["*"]
},
"legacy-billing": {
"enabled": false,
"demask": false,
"maskMode": "MASK",
"types": ["*"]
}
}