feat: улучшение regexp + деплой одного инстанса

This commit is contained in:
Максименко Никита Владимирович
2026-09-23 13:26:14 +03:00
parent f0e9c6c60c
commit 6ac5bb7819
12 changed files with 472 additions and 35 deletions
+20
View File
@@ -27,3 +27,23 @@ services:
interval: 10s interval: 10s
timeout: 2s timeout: 2s
retries: 3 retries: 3
prometheus:
image: prom/prometheus:v2.55.1
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
grafana:
image: grafana/grafana:11.3.0
depends_on:
- prometheus
ports:
- "3000:3000"
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+1 -8
View File
@@ -10,11 +10,4 @@ scrape_configs:
static_configs: static_configs:
- targets: ["pd-guard:8080"] - targets: ["pd-guard:8080"]
labels: labels:
instance: "один узел" instance: "один узел"
# Узлы кластерного профиля. Пока он не поднят, цели просто числятся
# недоступными и на остальной сбор это не влияет.
- job_name: pd-guard-cluster
metrics_path: /actuator/prometheus
static_configs:
- targets: ["node-a:8080", "node-b:8080"]
@@ -50,6 +50,9 @@ public class ProxyResource {
@JsonProperty("replaced") Map<String, String> replaced, @JsonProperty("replaced") Map<String, String> replaced,
@JsonProperty("llm") String llm, @JsonProperty("llm") String llm,
@JsonProperty("error") String error) { @JsonProperty("error") String error) {
public ProxyResponse {
replaced = replaced == null ? null : Map.copyOf(replaced);
}
} }
private final Pipeline pipeline; private final Pipeline pipeline;
@@ -39,6 +39,10 @@ public class SystemsConfig {
/** Описание одной системы в файле настроек. */ /** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode, public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
List<String> types, List<String> requireCompanion, String key) { List<String> types, List<String> requireCompanion, String key) {
public SystemEntry {
types = types == null ? null : List.copyOf(types);
requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion);
}
} }
private final Path file; private final Path file;
@@ -7,6 +7,7 @@ import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
import java.util.HexFormat; import java.util.HexFormat;
@@ -62,7 +63,7 @@ public class PayloadCipher {
System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined); return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) { } catch (GeneralSecurityException e) {
throw new IllegalStateException("Не удалось зашифровать персональные данные", e); throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
} }
} }
@@ -80,7 +81,7 @@ public class PayloadCipher {
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length); byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
return new String(decrypted, StandardCharsets.UTF_8); return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) { } catch (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("Не удалось расшифровать персональные данные", e); throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
} }
} }
+23 -4
View File
@@ -224,11 +224,30 @@ public class Pipeline {
return spans; return spans;
} }
return spans.stream() return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type()) .filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.toList(); .toList();
} }
/**
* Известный человек по самому спану («Пушкина») или по спану вместе со следующим
* словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от
* прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком.
*/
private static boolean isWellKnownHere(String text, Span span) {
if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) {
return true;
}
int wordStart = span.end();
while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) {
wordStart++;
}
int wordEnd = wordStart;
while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) {
wordEnd++;
}
return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd));
}
/** /**
* Убирает типы, которые опасны только в сочетании с другими ПД. * Убирает типы, которые опасны только в сочетании с другими ПД.
* Пин-код в отрыве от номера карты не является персональными данными, * Пин-код в отрыве от номера карты не является персональными данными,
@@ -251,7 +270,7 @@ public class Pipeline {
} }
/** Замаскированный текст вместе с таблицей обратной замены. */ /** Замаскированный текст вместе с таблицей обратной замены. */
public record Masked(String text, java.util.Map<String, String> restorations) { public record Masked(String text, Map<String, String> restorations) {
} }
/** /**
@@ -267,7 +286,7 @@ public class Pipeline {
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key()); ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
List<Span> spans = findPersonalData(text, reversible); List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) { if (spans.isEmpty()) {
return new Masked(text, java.util.Map.of()); return new Masked(text, Map.of());
} }
MaskContext context = new MaskContext(); MaskContext context = new MaskContext();
String masked = apply(text, spans, reversible, context); String masked = apply(text, spans, reversible, context);
@@ -0,0 +1,38 @@
package ru.pdguard.detect;
import java.util.Locale;
import java.util.Set;
/**
* Словарь названий стран — проверка того, что значение, пойманное правилом
* {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное
* слово с заглавной буквы после якоря «гражданство».
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в России», «из Казахстана») и формы прилагательных («российская»,
* «российское») тем самым покрываются без отдельного разбора морфологии.
* Основа «российск» покрывает и «Российская», и «российская», и «российское».
*/
public final class CountryDictionary {
private static final Set<String> COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt");
private CountryDictionary() {
}
/**
* Похоже ли значение на название страны из словаря в любом падеже и регистре.
*
* <p>Проверяются префиксы значения по множеству, а не каждая основа по
* значению: префиксов у слова не больше, чем в нём букв.
*/
public static boolean isKnownCountry(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (COUNTRY_STEMS.contains(lower.substring(0, length))) {
return true;
}
}
return false;
}
}
@@ -153,6 +153,25 @@ public final class NameDictionary {
return false; return false;
} }
/**
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное
* совпадение с личным именем принимается в любом регистре («иван» тоже имя),
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) —
* только с заглавной буквы: без этого «законов», «домов», «холодов» —
* обычные родительные падежи, а не фамилии — ложно матчились бы.
*/
public static boolean isStandaloneNameCandidate(String word) {
String lower = word.toLowerCase(Locale.ROOT);
if (GIVEN_NAMES.contains(lower)) {
return true;
}
if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) {
return false;
}
return isPatronymic(lower) || isSurname(lower);
}
/** Отчество: Иванович, Петровна, Сидоровна. */ /** Отчество: Иванович, Петровна, Сидоровна. */
private static boolean isPatronymic(String lower) { private static boolean isPatronymic(String lower) {
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$"); return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
@@ -90,6 +90,8 @@ final class ResourceLoader {
} }
} catch (IOException e) { } catch (IOException e) {
// Битый файл не должен ронять работу: остаётся прежнее значение. // Битый файл не должен ронять работу: остаётся прежнее значение.
java.util.logging.Logger.getLogger(ResourceLoader.class.getName())
.fine("Не удалось перечитать файл " + path + ": " + e.getMessage());
} }
return state.current; return state.current;
} }
@@ -36,6 +36,18 @@ public class RuleRegistry {
/** Якорное слово-основа: держатель карты, держателем и т.п. */ /** Якорное слово-основа: держатель карты, держателем и т.п. */
private static final String HOLDER_STEM = "держател"; private static final String HOLDER_STEM = "держател";
/** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */
private static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}";
/**
* То же самое, но только строчные слова-филлеры: ролевые слова перед значением
* гражданства всегда строчные («бенефициара», «поручителя»), а само значение —
* с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно
* поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя
* CITIZENSHIP_VALUE только хвост («Республики Беларусь» → «Беларусь»).
*/
private static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}";
/** /**
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел — * Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно: * «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
@@ -61,8 +73,13 @@ public class RuleRegistry {
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич" "\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}"; + "|овн|евн|иничн|ичн)\\p{L}{0,2}";
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */ /**
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}"; * Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456»,
* а также с произвольным числом пробелов и словом «номер» между частями —
* «12 34 номер 567890» (реальный кейс из бланка).
*/
private static final String SERIES_AND_NUMBER =
"\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}";
/** /**
* Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер * Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер
@@ -107,9 +124,21 @@ public class RuleRegistry {
* «день и месяц» — это слова, а не дата. Ветка со словами требует пробела * «день и месяц» — это слова, а не дата. Ветка со словами требует пробела
* перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, * перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W},
* которая тоже матчит пробелы, — это приводило к катастрофическому * которая тоже матчит пробелы, — это приводило к катастрофическому
* возврату на длинных текстах. * возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных
* слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится
* веткой {@code \W}, но следующие за ним буквы без пробела перед ними не
* покрывала ни одна ветка.
*/ */
private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|\\W){0,30}"; private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}";
/**
* Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/
* «республики X» — частые формы отдельным списком; последняя ветка — страна из
* 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост
* идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт.
*/
private static final String CITIZENSHIP_VALUE =
"\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}";
/** /**
* Слова, при которых адрес принадлежит организации, а не человеку: * Слова, при которых адрес принадлежит организации, а не человеку:
@@ -180,8 +209,9 @@ public class RuleRegistry {
.groups(1, 2) .groups(1, 2)
.anchoredBy("сери"), .anchoredBy("сери"),
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89) + "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89)
.groups(1) .groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "), .anchoredBy("водительск", "в/у", "вод.", "ву "),
@@ -206,10 +236,19 @@ public class RuleRegistry {
.groups(1) .groups(1)
.anchoredBy("полис"), .anchoredBy("полис"),
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) // ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и
// значением есть слово («стоит»/«объекта»), не только пунктуация.
Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP
+ "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
.groups(1) .groups(1)
.anchoredBy("подразделени", "к/п"), .anchoredBy("подразделени", "к/п"),
// «770-001 — таков код подразделения» — значение перед якорем.
Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}"
+ "(?iu:код\\w*\\s+подразделения)", 88)
.groups(1)
.anchoredBy("подразделени"),
// --- Банковские реквизиты сверх карты --- // --- Банковские реквизиты сверх карты ---
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
@@ -335,7 +374,10 @@ public class RuleRegistry {
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател" + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател"
+ "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик" + "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик"
+ "|\\bпоручител)\\p{L}*" // \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял
// движку «отдать» уже съеденное падежное окончание ролевого слова и
// захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов».
+ "|\\bпоручител)\\p{L}*+"
+ "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77) + "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77)
.groups(1) .groups(1)
.validatedBy(NameDictionary::containsNamePart) .validatedBy(NameDictionary::containsNamePart)
@@ -363,27 +405,35 @@ public class RuleRegistry {
// Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович». // Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович».
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары // Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары
// слов, — поэтому приоритет ниже и проверка по словарю обязательна. // слов, — поэтому приоритет ниже и проверка по словарю обязательна.
// Словарь отсекает «сочи», «казань» и прочие не-имена. // Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста
Rule.of(PdTypes.FIO, "\\b(\\p{L}{2,})\\b", 70) // не рассматривается: заглавная буква там от начала предложения, а не от
// имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел».
Rule.of(PdTypes.FIO, "(?<!^)\\b(\\p{L}{2,})\\b", 70)
.groups(1) .groups(1)
.validatedBy(NameDictionary::containsNamePart), .validatedBy(NameDictionary::isStandaloneNameCandidate),
// --- Уровень 1: подтверждается контрольной суммой --- // --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85) Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
.validatedBy(Validators::luhn), .validatedBy(Validators::luhn),
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84) Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b" + ROLE_GAP + "(\\d{12}|\\d{10})\\b", 84)
.groups(1) .groups(1)
.anchoredBy("инн"), .anchoredBy("инн"),
// «ИНН/КПП 7712345671/771201001» — ИНН юрлица перед КПП через слэш.
Rule.of(PdTypes.INN, "(?iu)\\bИНН\\s*/\\s*КПП\\b\\W{0,5}(\\d{10})\\b", 84)
.groups(1)
.anchoredBy("инн/кпп"),
Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84) Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
.groups(1) .groups(1)
.validatedBy(Validators::snils), .validatedBy(Validators::snils),
// --- Уровень 2: формат однозначен сам по себе --- // --- Уровень 2: формат однозначен сам по себе ---
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), // \b7, не только +7: номер без плюса («79031119955») тоже встречается.
Rule.of(PdTypes.PHONE, "(?:\\+7|\\b7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80) Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"), .anchoredBy("@"),
@@ -393,10 +443,27 @@ public class RuleRegistry {
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит, // «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа // её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть. // осталась бы замаскированной только его часть.
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)" // Перечень форм, не голая основа «выда»: она зацепила бы и «выдающийся»
// (обычное слово, не про выдачу документа).
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан|выдал[аио]?|выдали|выдач[аи]|выдаче)"
+ "\\W{0,3}([^,;\\n]{3,90}?)"
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78) + "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
.groups(1) .groups(1)
.anchoredBy("выдан"), .anchoredBy("выдан", "выдал", "выдач"),
// «совпадает с указанным в анкете: X» — второе упоминание органа выдачи
// под собственным якорем, без бэкреференса на первое.
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:указанн\\w*\\s+в\\s+анкете)\\W{0,5}([^,;.\\n]{3,90}?)"
+ "(?=[,;.\\n]|\\s*$)", 78)
.groups(1)
.anchoredBy("указанн"),
// «Орган выдачи УФМС России по Республике Татарстан» — орган после якоря,
// до слова «совпадает» или конца фразы.
Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:орган\\s+выдачи)\\W{0,5}([^,;:\\n]{3,90}?)"
+ "(?=\\s*(?iu:совпадает|указанн)|[,;:\\n]|\\s*$)", 78)
.groups(1)
.anchoredBy("орган выдачи"),
Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76) Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1) .groups(1)
@@ -407,19 +474,26 @@ public class RuleRegistry {
.groups(1) .groups(1)
.anchoredBy("родил"), .anchoredBy("родил"),
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}" // ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) // между якорем и значением бывает несколько слов, не только пунктуация.
.groups(1) // Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80)
.groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("гражданств"), .anchoredBy("гражданств"),
Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+" // ин/ка/ина/ки — именительный/родительный; ином/кой — творительный
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) // («гражданином», «гражданкой»).
.groups(1) Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки|ином|кой))\\b\\s+"
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 75)
.groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry)
.anchoredBy("граждан"), .anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно --- // --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74) Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\w*" + ROLE_GAP + "(\\d{6})\\b", 74)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"), .anchoredBy("индекс"),
+143
View File
@@ -0,0 +1,143 @@
# Основы названий стран для распознавания гражданства.
# В нижнем регистре, без падежных окончаний (Declension::withoutInflectedEnding).
# Сравнение идёт по началу слова, поэтому «Российская»/«российской» покрываются
# основой «российск», «Федерация»/«федерации» — «федераци».
российск
федераци
республик
соединенн
штат
америк
армени
казахстан
белорус
украин
грузи
азербайджан
узбекистан
таджикистан
туркменистан
киргиз
молдов
молдав
литв
латви
эстони
польш
германи
франци
итали
испани
португали
нидерланд
бельги
швейцари
австри
чехи
словаки
венгри
румыни
болгари
серби
хорвати
словени
босни
македони
черногори
греци
турци
кипр
израил
иордани
ливан
сири
ирак
иран
афганистан
пакистан
инди
кита
япони
коре
монголи
вьетнам
таиланд
индонези
малайзи
сингапур
филиппин
австрали
новозеланд
канад
мексик
бразили
аргентин
чили
перу
колумби
венесуэл
эквадор
уругва
парагва
боливи
куб
доминикан
гаити
ямайк
египет
алжир
марокко
тунис
ливи
судан
эфиопи
кени
нигери
ган
юар
ангол
мозамбик
танзани
уганд
замби
зимбабве
ботсван
намиби
сенегал
кот
д'ивуар
камерун
конго
габон
экваториальн
мадагаскар
маврики
сейшельск
мальдив
шри
ланк
непал
бутан
бангладеш
мьянм
камбодж
лаос
финлянди
швеци
норвеги
дани
исланди
ирланди
великобритан
британ
англи
шотланд
уэльс
люксембург
мальт
андорр
монако
сан
марин
ватикан
лихтенштейн
@@ -0,0 +1,121 @@
package ru.pdguard;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
/**
* Датасет из 44 сгенерированных строк — не новые ТИПЫ ПДН, а новые РАЗМЕЩЕНИЯ уже
* известных типов относительно якорного слова: расстояние до якоря, обратный порядок
* (значение перед якорем), падеж/число анкера, структурированные форматы
* (JSON/CSV/markdown-таблица/XML/key=value), несколько значений одного типа в одном
* поле, значение в кавычках/скобках. Гипотезы построены на 55 утечках из
* {@link NodeLogsDatasetTest} и обобщают их корневые причины на другие типы и формы.
* Разбор по категориям — в отчёте, приложенном к задаче.
*
* <p>Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки
* (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные
* позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт,
* значит новое размещение сломало то, что раньше работало.
*/
class PlacementVariantsTest {
private static final RuleRegistry REGISTRY = new RuleRegistry();
private static final Masker MASKER = new Masker();
private static final List<BenchmarkFixtures.Sample> DATASET = BenchmarkFixtures.load("/dataset-placements.txt");
private static final int LEAK_CHECK_MIN_LENGTH = 3;
private static final NameCascade CASCADE = modelsPresent()
? new NameCascade("wikineural", Optional.of("models/wikineural-ner"),
"rubert", Optional.of("models/rubert-ner"), 16, 4)
: NameCascade.disabled();
private static boolean modelsPresent() {
return Files.isReadable(Path.of("models/wikineural-ner/model.onnx"))
&& Files.isReadable(Path.of("models/rubert-ner/model.onnx"));
}
@TestFactory
Stream<DynamicTest> placementDataset() {
List<DynamicTest> cases = new ArrayList<>(DATASET.size());
for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i);
int index = i;
cases.add(dynamicTest(
String.format("#%02d: %s", index, preview(sample.text())),
() -> runRoundTrip(sample, index)));
}
return cases.stream();
}
/** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */
private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE);
String payloadId = "placement-" + index;
String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT);
String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT);
assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст");
}
/**
* Сводка утечек одним прогоном — печатает список по типам и падает, только если
* утечек стало больше 11, то есть если что-то из уже маскируемого сегодня
* размещения перестало маскироваться. Было 20 при составлении датасета, после
* точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный
* порядок для дат/гражданства/кода подразделения) осталось 11.
*/
@Test
void leakSummary() {
Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(10_000_000L, 30), CASCADE);
int leaked = 0;
int checked = 0;
java.util.Map<String, Integer> byType = new java.util.LinkedHashMap<>();
StringBuilder report = new StringBuilder("\n=== Утечки по dataset-placements.txt ===\n");
for (int i = 0; i < DATASET.size(); i++) {
BenchmarkFixtures.Sample sample = DATASET.get(i);
String masked = pipeline.process(sample.text(), "leak-scan-" + i, SystemPolicy.DEFAULT);
for (Span gold : sample.gold()) {
String value = sample.text().substring(gold.start(), gold.end());
if (value.length() < LEAK_CHECK_MIN_LENGTH) {
continue;
}
checked++;
if (masked.contains(value)) {
leaked++;
byType.merge(gold.type(), 1, Integer::sum);
report.append(String.format(" [%s] %s%n", gold.type(), value));
}
}
}
report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked));
byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count)));
System.out.println(report);
assertTrue(leaked <= 9,
"утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + leaked);
}
private static String preview(String text) {
return text.length() <= 50 ? text : text.substring(0, 50) + "...";
}
}