feat: улучшение regexp + деплой одного инстанса
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -11,10 +11,3 @@ scrape_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("индекс"),
|
||||||
|
|||||||
@@ -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) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user