This commit is contained in:
dakocha3
2026-09-21 17:40:25 +03:00
commit 309188d191
52 changed files with 5124 additions and 0 deletions
@@ -0,0 +1,48 @@
package ru.pdguard.api;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.detect.RuleRegistry;
import java.util.List;
import java.util.Map;
/** Просмотр действующих настроек и принудительное их перечитывание. */
@Path("/admin")
public class AdminResource {
private final SystemsConfig systems;
private final RuleRegistry registry;
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
this.systems = systems;
this.registry = registry;
}
@GET
@Path("/config")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, SystemPolicy> config() {
return systems.current();
}
@GET
@Path("/types")
@Produces(MediaType.APPLICATION_JSON)
public List<String> types() {
return registry.knownTypes();
}
@POST
@Path("/reload")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, SystemPolicy> reload() {
systems.reload();
return systems.current();
}
}
@@ -0,0 +1,17 @@
package ru.pdguard.api;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
/** Проба готовности для балансировщика и проверяющей системы. */
@Path("/health")
public class HealthResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String health() {
return "OK";
}
}
@@ -0,0 +1,107 @@
package ru.pdguard.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.smallrye.common.annotation.Blocking;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.Pipeline;
import java.util.concurrent.Semaphore;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
*
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
* нет или система неизвестна — применяются настройки {@code default}, поэтому
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After} вместо того,
* чтобы копить запросы и упереться в таймаут вызывающей стороны.
*/
@Path("/process")
public class ProcessResource {
private static final Logger LOG = Logger.getLogger(ProcessResource.class);
/** Заголовок, которым система-потребитель себя называет. */
public static final String SYSTEM_HEADER = "X-System-Id";
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 Semaphore permits;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
@ConfigProperty(name = "pdguard.max-concurrent", defaultValue = "2000")
int maxConcurrent) {
this.pipeline = pipeline;
this.systems = systems;
this.permits = new Semaphore(maxConcurrent);
this.rejected = meters.counter("pdguard.requests.rejected", "reason", "overload");
this.malformed = meters.counter("pdguard.requests.rejected", "reason", "malformed");
this.forbidden = meters.counter("pdguard.requests.rejected", "reason", "system_disabled");
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Blocking
public Response process(ProcessRequest request, @HeaderParam(SYSTEM_HEADER) String systemId) {
if (request == null || request.payload() == null
|| request.payloadId() == null || request.payloadId().isBlank()) {
malformed.increment();
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ProcessResponse("payload и payload_id обязательны"))
.build();
}
SystemPolicy policy = systems.policyFor(systemId);
if (!policy.enabled()) {
forbidden.increment();
LOG.warnf("Системе %s обращение в модуль запрещено настройками", systemId);
return Response.status(Response.Status.FORBIDDEN)
.entity(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"))
.build();
}
if (!permits.tryAcquire()) {
rejected.increment();
return Response.status(429).header("Retry-After", "1").build();
}
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return Response.ok(new ProcessResponse(result)).build();
} catch (RuntimeException e) {
// Пять подряд невалидных ответов останавливают проверку, поэтому при
// внутреннем сбое возвращаем текст без изменений, а не 5xx.
LOG.errorf(e, "payload_id=%s обработка не удалась, текст возвращён без изменений",
request.payloadId());
return Response.ok(new ProcessResponse(request.payload())).build();
} finally {
permits.release();
}
}
}
@@ -0,0 +1,44 @@
package ru.pdguard.config;
import ru.pdguard.mask.MaskMode;
import java.util.Set;
/**
* Правила обработки для одной системы-потребителя.
*
* @param enabled разрешено ли системе обращаться в модуль
* @param demask выполняется ли для системы обратное преобразование
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
* @param types типы ПД к маскированию; {@code "*"} — все известные
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
* пин-код сам по себе безвреден, пин-код рядом с номером
* карты — уже нет; то же для даты без якорного слова
*/
public record SystemPolicy(boolean enabled, boolean demask, MaskMode maskMode,
Set<String> types, Set<String> requireCompanion) {
public static final String ALL = "*";
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy(
true, true, MaskMode.MASK, Set.of(ALL), Set.of("CVV", "PIN", "DATE"));
public SystemPolicy {
types = Set.copyOf(types);
requireCompanion = Set.copyOf(requireCompanion);
}
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
public static SystemPolicy forTypes(String... types) {
return new SystemPolicy(true, true, MaskMode.MASK, Set.of(types), DEFAULT.requireCompanion());
}
public boolean allows(String type) {
return types.contains(ALL) || types.contains(type);
}
public boolean needsCompanion(String type) {
return requireCompanion.contains(type);
}
}
@@ -0,0 +1,137 @@
package ru.pdguard.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.runtime.annotations.RegisterForReflection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
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.Set;
import java.util.TreeMap;
/**
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
*
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
* перечитывается сам, когда меняется время его изменения; проверка выполняется
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
*/
@ApplicationScoped
public class SystemsConfig {
private static final Logger LOG = Logger.getLogger(SystemsConfig.class);
/** Имя политики, которая применяется к запросам без заголовка системы. */
public static final String DEFAULT_SYSTEM = "default";
private static final long RECHECK_MILLIS = 1000;
/** Описание одной системы в файле настроек. */
@RegisterForReflection
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion) {
}
private final Path file;
private final ObjectMapper mapper;
private volatile Map<String, SystemPolicy> policies = Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
private volatile long fileTimestamp;
private volatile long lastCheck;
@Inject
public SystemsConfig(@ConfigProperty(name = "pdguard.systems-file", defaultValue = "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;
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.containsKey(systemId);
}
/** Текущие настройки — для отдачи в административном интерфейсе. */
public Map<String, SystemPolicy> current() {
refreshIfChanged();
return new TreeMap<>(policies);
}
/** Перечитать файл настроек немедленно. */
public final synchronized void reload() {
lastCheck = System.currentTimeMillis();
if (!Files.isReadable(file)) {
LOG.infof("Файл настроек %s не найден, применяются настройки по умолчанию", file.toAbsolutePath());
policies = 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(entry)));
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
policies = Map.copyOf(parsed);
LOG.infof("Настройки систем перечитаны из %s: %s", file.toAbsolutePath(), parsed.keySet());
} catch (IOException | IllegalArgumentException e) {
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
LOG.errorf(e, "Не удалось прочитать %s, продолжаем с прежними настройками", file.toAbsolutePath());
}
}
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.debugf(e, "Не удалось проверить время изменения %s", file);
}
}
private static SystemPolicy toPolicy(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(
entry.enabled() == null || entry.enabled(),
entry.demask() == null || entry.demask(),
mode, types, companions);
}
}
@@ -0,0 +1,174 @@
package ru.pdguard.core;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
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>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
*
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
* обходится без обращения по сети.
*/
@ApplicationScoped
public class PayloadStore {
/** Сколько протухших записей просматривается за одну операцию записи. */
private static final int SWEEP_PER_PUT = 4;
/** Пара «исходный текст — маска» с отпечатком и сроком жизни. */
public record Entry(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;
@Inject
public PayloadStore(
@ConfigProperty(name = "pdguard.store.max-chars", defaultValue = "134217728") long maxChars,
@ConfigProperty(name = "pdguard.store.ttl-minutes", defaultValue = "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 payloadId, String original, String masked) {
long now = System.currentTimeMillis();
Entry entry = new Entry(original, masked, fingerprint(masked), now + ttlMillis);
Entry replaced = byId.put(payloadId, entry);
byMaskFingerprint.put(entry.fingerprint(), entry);
insertionOrder.add(payloadId);
charsHeld.addAndGet(entry.weight() - (replaced == null ? 0 : replaced.weight()));
sweepExpired(now);
evictWhileOverLimit();
shared.put(payloadId, original, masked, entry.fingerprint());
}
public Entry byId(String payloadId) {
Entry entry = byId.get(payloadId);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry;
}
if (entry != null) {
forget(payloadId, entry);
}
SharedIndex.SharedEntry fromShared = shared.byId(payloadId);
if (fromShared == null) {
return null;
}
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
// чтобы повторное обращение обошлось без сети.
put(payloadId, fromShared.original(), fromShared.masked());
return byId.get(payloadId);
}
/** Исходный текст по самой маске — когда {@code payload_id} не совпал. */
public String originalForMask(String masked) {
String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(fingerprint);
if (entry != null && entry.alive(System.currentTimeMillis())) {
return entry.original();
}
return shared.originalForFingerprint(fingerprint);
}
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
public long charsHeld() {
return charsHeld.get();
}
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
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 payloadId, Entry entry) {
if (byId.remove(payloadId, entry)) {
byMaskFingerprint.remove(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);
}
}
}
+233
View File
@@ -0,0 +1,233 @@
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 jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.jboss.logging.Logger;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.NameDictionary;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.MaskContext;
import ru.pdguard.mask.Masker;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
/**
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
*
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
* <ul>
* <li>идентификатор неизвестен — маскируем;</li>
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
* </ul>
* Последний случай — повторная попытка проверяющей системы: ответ обязан
* совпасть с первым, иначе демаскирование по этому элементу развалится.
*/
@ApplicationScoped
public class Pipeline {
private static final Logger LOG = Logger.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 Timer maskTimer;
private final Timer unmaskTimer;
private final Counter tokensProcessed;
@Inject
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;
this.maskTimer = Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", "mask")
.register(meters);
this.unmaskTimer = Timer.builder("pdguard.process")
.description("Длительность обработки обращения")
.tag("direction", "unmask")
.register(meters);
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(payloadId);
if (known != null) {
if (policy.demask() && payload.equals(known.masked())) {
LOG.debugf("payload_id=%s обратное преобразование по идентификатору", payloadId);
unmaskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return known.original();
}
if (payload.equals(known.original())) {
LOG.debugf("payload_id=%s повторная попытка, отдаём прежнюю маску", payloadId);
maskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return known.masked();
}
}
if (policy.demask()) {
String original = store.originalForMask(payload);
if (original != null) {
LOG.debugf("payload_id=%s обратное преобразование по отпечатку маски", payloadId);
unmaskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
return original;
}
}
return mask(payload, payloadId, policy, started);
}
/**
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
* можно было измерить, не разбирая замаскированный текст обратно.
*/
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 = 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(payloadId, payload, masked);
maskTimer.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
logFindings(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) {
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
if (before != null && before.getValue().overlaps(candidate)) {
continue;
}
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
if (after != null && after.getValue().overlaps(candidate)) {
continue;
}
accepted.put(candidate.start(), candidate);
}
return List.copyOf(accepted.values());
}
/**
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
* конкретном человеке, и имя остаётся замаскированным — однофамилец
* исторической фигуры защиту не теряет.
*/
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();
}
/**
* Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными,
* рядом с номером карты — является.
*/
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
Set<String> present = new HashSet<>();
for (Span span : spans) {
present.add(span.type());
}
if (present.size() > 1) {
return spans;
}
return spans.stream().filter(span -> !policy.needsCompanion(span.type())).toList();
}
private String apply(String text, List<Span> spans, SystemPolicy policy) {
if (spans.isEmpty()) {
return text;
}
MaskContext context = new MaskContext();
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 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).increment(count));
LOG.infof("payload_id=%s символов=%d найдено=%s", payloadId, length, counts);
}
}
@@ -0,0 +1,167 @@
package ru.pdguard.core;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.value.SetArgs;
import io.quarkus.redis.datasource.value.ValueCommands;
import io.quarkus.runtime.annotations.RegisterForReflection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
*
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
* Обратное же преобразование требует состояния: если прямой запрос обработал
* один узел, а обратный попал на другой, соответствие должно быть общим.
*
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
*
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
* съедал время ответа, команды ограничены по времени настройкой
* {@code quarkus.redis.timeout}, а после нескольких подряд неудач общий слой
* временно перестают опрашивать вовсе.
*/
@ApplicationScoped
public class SharedIndex {
private static final Logger LOG = Logger.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;
/** Пара «исходный текст — маска», как она хранится в общем слое. */
@RegisterForReflection
public record SharedEntry(String original, String masked) {
}
private final boolean enabled;
private final Duration ttl;
private final Instance<RedisDataSource> redisSource;
private volatile ValueCommands<String, SharedEntry> pairs;
private volatile ValueCommands<String, String> originals;
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private volatile long silentUntil;
private volatile boolean reported;
public SharedIndex(Instance<RedisDataSource> redisSource,
@ConfigProperty(name = "pdguard.store.backend", defaultValue = "memory") String backend,
@ConfigProperty(name = "pdguard.store.ttl-minutes", defaultValue = "30") int ttlMinutes) {
this.redisSource = redisSource;
this.enabled = "redis".equalsIgnoreCase(backend);
this.ttl = Duration.ofMinutes(ttlMinutes);
}
/** Выключенный слой — для тестов и для сборки без Redis. */
public static SharedIndex disabled() {
return new SharedIndex(null, "memory", 30);
}
public boolean enabled() {
return enabled;
}
public void put(String payloadId, String original, String masked, String maskFingerprint) {
if (unavailable()) {
return;
}
try {
SetArgs expiry = new SetArgs().ex(ttl);
commands().set(KEY_BY_ID + payloadId, new SharedEntry(original, masked), expiry);
originalCommands().set(KEY_BY_MASK + maskFingerprint, original, expiry);
noteSuccess();
} catch (RuntimeException e) {
noteFailure("записать", e);
}
}
public SharedEntry byId(String payloadId) {
if (unavailable()) {
return null;
}
try {
SharedEntry entry = commands().get(KEY_BY_ID + payloadId);
noteSuccess();
return entry;
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
public String originalForFingerprint(String maskFingerprint) {
if (unavailable()) {
return null;
}
try {
String original = originalCommands().get(KEY_BY_MASK + maskFingerprint);
noteSuccess();
return original;
} catch (RuntimeException e) {
noteFailure("прочитать", e);
return null;
}
}
/**
* Команды создаются при первом обращении: пока общий слой выключен,
* клиент Redis не создаётся и подключение не устанавливается.
*/
private ValueCommands<String, SharedEntry> commands() {
ValueCommands<String, SharedEntry> local = pairs;
if (local == null) {
local = redisSource.get().value(SharedEntry.class);
pairs = local;
}
return local;
}
private ValueCommands<String, String> originalCommands() {
ValueCommands<String, String> local = originals;
if (local == null) {
local = redisSource.get().value(String.class);
originals = local;
}
return local;
}
/** Общий слой выключен или предохранитель разомкнут. */
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.errorf(cause, "Не удалось %s соответствие в общий слой, узел работает на своей памяти", action);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package ru.pdguard.core;
/**
* Найденный фрагмент персональных данных в исходном тексте.
*
* @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,205 @@
package ru.pdguard.detect;
import io.quarkus.runtime.Startup;
import jakarta.enterprise.context.ApplicationScoped;
import opennlp.tools.namefind.NameFinderME;
import opennlp.tools.namefind.TokenNameFinderModel;
import opennlp.tools.tokenize.SimpleTokenizer;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import ru.pdguard.core.Span;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Вторая ступень распознавания имён.
*
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
* микросекунд. Модель нужна там, где они бессильны: имена без русского
* словообразования и без отчества — «Нгуен Ван Ань», «Ким Сон Хо».
*
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в
* обычном запросе единицы, и на задержку это почти не влияет.
*
* <p>Модели нет — ступень выключена и поведение сервиса не меняется. Путь к файлу
* задаётся свойством {@code pdguard.ner.model}.
*
* <p>Сбой второй ступени не должен отражаться на первой: ошибка перехватывается
* здесь, ступень выключается насовсем, и дальше работают правила. Иначе одно
* исключение обнуляло бы маскирование целиком.
*/
@Startup
@ApplicationScoped
public class NameCascade {
private static final Logger LOG = Logger.getLogger(NameCascade.class);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться именем. */
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 static final long BORROW_TIMEOUT_MILLIS = 50;
/** Текст для прогрева: важно не что в нём, а что модель отработала хотя бы раз. */
private static final String[] WARMUP_WORDS =
{"Клиент", "Иванов", "Иван", "Иванович", "обратился", "в", "отделение"};
private final BlockingQueue<NameFinderME> pool;
private final int maxCandidates;
private final boolean enabled;
private volatile boolean broken;
public NameCascade(
@ConfigProperty(name = "pdguard.ner.model") Optional<String> modelPath,
@ConfigProperty(name = "pdguard.ner.max-candidates", defaultValue = "16") int maxCandidates,
@ConfigProperty(name = "pdguard.ner.pool-size", defaultValue = "16") int poolSize) {
this.maxCandidates = maxCandidates;
TokenNameFinderModel model = load(modelPath);
this.enabled = model != null;
this.pool = enabled ? warmedPool(model, Math.max(1, poolSize)) : null;
}
/** Выключенная ступень — для тестов и для сборок без модели. */
public static NameCascade disabled() {
return new NameCascade(Optional.empty(), 0, 1);
}
public boolean enabled() {
return enabled;
}
/**
* Добавляет имена, которые не нашла первая ступень. Уже принятые фрагменты
* не трогаются: модель разбирает только непокрытые участки.
*/
public List<Span> addMissedNames(String text, List<Span> accepted) {
if (!enabled || broken) {
return accepted;
}
NameFinderME finder = borrow();
if (finder == null) {
// Все распознаватели заняты: отвечаем по правилам, а не копим очередь.
return accepted;
}
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++;
recognise(finder, text, m.start(), m.end(), found);
}
return found;
} catch (RuntimeException e) {
broken = true;
LOG.errorf(e, "Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам");
return accepted;
} finally {
finder.clearAdaptiveData();
pool.offer(finder);
}
}
private NameFinderME borrow() {
try {
return pool.poll(BORROW_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
private static void recognise(NameFinderME finder, 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);
String region = text.substring(from, to);
opennlp.tools.util.Span[] tokens = SimpleTokenizer.INSTANCE.tokenizePos(region);
String[] words = new String[tokens.length];
for (int i = 0; i < tokens.length; i++) {
words[i] = region.substring(tokens[i].getStart(), tokens[i].getEnd());
}
for (opennlp.tools.util.Span name : finder.find(words)) {
int start = from + tokens[name.getStart()].getStart();
int end = from + tokens[name.getEnd() - 1].getEnd();
// Берём только то, что пересекается с кандидатом: контекст добавлен
// ради качества разбора, а не для расширения находки.
if (start < candidateEnd && candidateStart < end) {
sink.add(new Span(start, end, RuleRegistry.FIO, PRIORITY));
}
}
}
private static boolean coveredBy(List<Span> accepted, int start, int end) {
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
}
/**
* Готовые к работе распознаватели создаются на старте и сразу прогоняют текст.
*
* <p>{@link NameFinderME} хранит состояние между вызовами, поэтому одним
* экземпляром на несколько потоков пользоваться нельзя. Создание экземпляра
* вместе с первым разбором стоит сотни миллисекунд, и при создании по
* требованию эта цена доставалась первому запросу каждого рабочего потока.
* Пул снимает и то, и другое: к первому обращению всё создано и прогрето.
*/
private static BlockingQueue<NameFinderME> warmedPool(TokenNameFinderModel model, int size) {
long started = System.nanoTime();
BlockingQueue<NameFinderME> ready = new ArrayBlockingQueue<>(size);
for (int i = 0; i < size; i++) {
NameFinderME finder = new NameFinderME(model);
finder.find(WARMUP_WORDS);
finder.clearAdaptiveData();
ready.add(finder);
}
LOG.infof("Прогрев второй ступени: %d распознавателей за %d мс",
size, (System.nanoTime() - started) / 1_000_000);
return ready;
}
private TokenNameFinderModel load(Optional<String> modelPath) {
if (modelPath.isEmpty() || modelPath.get().isBlank()) {
LOG.info("Вторая ступень распознавания имён выключена: модель не задана");
return null;
}
Path file = Path.of(modelPath.get());
if (!Files.isReadable(file)) {
LOG.warnf("Модель %s недоступна, вторая ступень выключена", file.toAbsolutePath());
return null;
}
try (InputStream in = Files.newInputStream(file)) {
TokenNameFinderModel model = new TokenNameFinderModel(in);
LOG.infof("Вторая ступень распознавания имён включена, модель %s", file.toAbsolutePath());
return model;
} catch (IOException | RuntimeException e) {
// Испорченная модель не должна мешать сервису подняться: работают правила.
LOG.errorf(e, "Не удалось загрузить модель %s, вторая ступень выключена", file.toAbsolutePath());
return null;
}
}
}
@@ -0,0 +1,124 @@
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.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Словари для распознавания ФИО.
*
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
*
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
* персональными данными не является. Ограничение осознанное: клиент по фамилии
* Пушкин в тексте без других ПД замаскирован не будет.
*/
public final class NameDictionary {
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
.map(NameDictionary::withoutInflectedEnding)
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
private static final List<String> WELL_KNOWN_STEMS = load("/names/well-known.txt");
/** Не более скольких падежных букв дописывается к основе имени. */
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() {
}
/**
* Отбрасывает у основы конечную гласную, которая меняется по падежам:
* Ольга → Ольг (Ольги, Ольге, Ольгой), Николай → Никола (Николая, Николаю).
*/
private static String withoutInflectedEnding(String stem) {
if (stem.length() >= 4 && "аяйь".indexOf(stem.charAt(stem.length() - 1)) >= 0) {
return stem.substring(0, stem.length() - 1);
}
return stem;
}
/**
* Есть ли среди слов личное имя из словаря в любом падеже.
*
* <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;
}
/** Содержит ли текст упоминание известного человека. */
public static boolean isWellKnown(String value) {
for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT);
for (String stem : WELL_KNOWN_STEMS) {
if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) {
return true;
}
}
}
return false;
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
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,45 @@
package ru.pdguard.detect;
import io.quarkus.runtime.annotations.RegisterForReflection;
/**
* Классы, которые OpenNLP создаёт по имени, разбирая описание признаков внутри модели.
*
* <p>В обычной сборке это работает само, в native-образе — нет: класс, не упомянутый
* в коде, туда просто не попадает. Без регистрации загрузка модели проходит, а
* создание распознавателя падает с {@code ClassNotFoundException} на первом запросе.
*
* <p>Перечислены фабрики целиком, а не только те, что встречаются в текущей модели:
* набор признаков задаётся при обучении и может измениться без правки кода.
*/
@RegisterForReflection(classNames = {
"opennlp.tools.namefind.TokenNameFinderFactory",
"opennlp.tools.namefind.BioCodec",
"opennlp.tools.util.featuregen.AggregatedFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BigramNameFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BrownClusterBigramFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BrownClusterTokenClassFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.BrownClusterTokenFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.CachedFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.CharacterNgramFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.DefinitionFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.DictionaryFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.DocumentBeginFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.POSTaggerNameFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.PosTaggerFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.PrefixFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.PreviousMapFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.SentenceFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.SuffixFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TokenClassFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TokenFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TokenPatternFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.TrigramNameFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.WindowFeatureGeneratorFactory",
"opennlp.tools.util.featuregen.WordClusterFeatureGeneratorFactory"
})
final class OpenNlpReflection {
private OpenNlpReflection() {
}
}
+90
View File
@@ -0,0 +1,90 @@
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;
/** Сколько символов слева и справа от совпадения просматривает вето-шаблон. */
public static final int VETO_LOOKBEHIND = 80;
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,421 @@
package ru.pdguard.detect;
import jakarta.enterprise.context.ApplicationScoped;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.Span;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
/**
* Реестр правил детекции и сам поиск ПД в тексте.
*
* <p>Правила разбиты на три уровня доверия:
* <ol>
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
* <li>однозначные по формату — email, телефон;</li>
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
* где сама по себе последовательность знаков ни о чём не говорит.</li>
* </ol>
*
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
* именно на них. На захватываемое значение регистронезависимость не распространяется:
* там, где значение опознаётся по заглавной букве, это существенно.
*/
@ApplicationScoped
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 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";
/**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
* «ИВАНОВ» распознавался наравне с «Иванов».
*/
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 + ")";
/**
* Слова, при которых адрес принадлежит организации, а не человеку:
* адрес отделения банка персональными данными не является.
*/
/**
* Части адреса рядом. Улица, упомянутая в рассказе о городе, адресом клиента не
* является — ровно как адрес отделения банка из технического задания. Требование
* стояло только у постфиксной формы правила, префиксная его не имела.
*/
private static final String ADDRESS_NEARBY =
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
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("подразделени", "к/п"),
// --- Даты с явным якорем ---
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),
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName),
// --- Уровень 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город)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
.groups(1)
.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 (start < 0 || end <= start) {
continue;
}
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
continue;
}
if (rule.veto() != null && rule.veto().matcher(surroundings(text, start, end)).find()) {
continue;
}
if (rule.context() != null && !rule.context().matcher(surroundings(text, start, end)).find()) {
continue;
}
sink.add(new Span(start, end, rule.type(), rule.priority()));
}
}
}
private 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);
}
}
@@ -0,0 +1,125 @@
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 = sum < 100 ? sum : (sum == 100 || sum == 101 ? 0 : sum % 101 % 100);
return control == d[9] * 10 + d[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,33 @@
package ru.pdguard.mask;
import java.util.HashMap;
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<>();
/**
* Замена для значения; при повторе возвращается ранее выданная.
*
* @param factory получает тип ПД и порядковый номер значения этого типа
*/
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
return assigned.computeIfAbsent(type + SEPARATOR + value,
key -> factory.apply(type, counters.merge(type, 1, Integer::sum)));
}
}
@@ -0,0 +1,14 @@
package ru.pdguard.mask;
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
public enum MaskMode {
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
MASK,
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
TOKEN,
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
SYNTHETIC
}
+70
View File
@@ -0,0 +1,70 @@
package ru.pdguard.mask;
import jakarta.enterprise.context.ApplicationScoped;
import ru.pdguard.detect.RuleRegistry;
import java.util.Map;
import java.util.function.UnaryOperator;
/**
* Превращает найденное значение в замену согласно настройкам системы.
*
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
* безопасное поведение по умолчанию для вновь добавленных правил.
*/
@ApplicationScoped
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.BIRTH_PLACE, Strategies::stars),
Map.entry(RuleRegistry.CITIZENSHIP, 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,95 @@
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 = Math.abs(value.hashCode());
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.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);
}
}