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
@@ -50,6 +50,9 @@ public class ProxyResource {
@JsonProperty("replaced") Map<String, String> replaced,
@JsonProperty("llm") String llm,
@JsonProperty("error") String error) {
public ProxyResponse {
replaced = replaced == null ? null : Map.copyOf(replaced);
}
}
private final Pipeline pipeline;
@@ -39,6 +39,10 @@ public class SystemsConfig {
/** Описание одной системы в файле настроек. */
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
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;
@@ -7,6 +7,7 @@ import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
@@ -62,7 +63,7 @@ public class PayloadCipher {
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Не удалось зашифровать персональные данные", e);
}
}
@@ -80,7 +81,7 @@ public class PayloadCipher {
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("Не удалось расшифровать персональные данные", e);
}
}
+23 -4
View File
@@ -224,11 +224,30 @@ public class Pipeline {
return spans;
}
return spans.stream()
.filter(span -> !PdTypes.FIO.equals(span.type())
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span))
.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());
List<Span> spans = findPersonalData(text, reversible);
if (spans.isEmpty()) {
return new Masked(text, java.util.Map.of());
return new Masked(text, Map.of());
}
MaskContext context = new MaskContext();
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;
}
/**
* Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова
* или соседнего личного имени рядом, самое слабое основание для ФИО. Точное
* совпадение с личным именем принимается в любом регистре («иван» тоже имя),
* а вот словообразовательная эвристика (фамилия/отчество по окончанию) —
* только с заглавной буквы: без этого «законов», «домов», «холодов» —
* обычные родительные падежи, а не фамилии — ложно матчились бы.
*/
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) {
return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$");
@@ -90,6 +90,8 @@ final class ResourceLoader {
}
} catch (IOException e) {
// Битый файл не должен ронять работу: остаётся прежнее значение.
java.util.logging.Logger.getLogger(ResourceLoader.class.getName())
.fine("Не удалось перечитать файл " + path + ": " + e.getMessage());
}
return state.current;
}
@@ -36,6 +36,18 @@ public class RuleRegistry {
/** Якорное слово-основа: держатель карты, держателем и т.п. */
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 Марта». Ограничение по форме обязательно:
@@ -61,8 +73,13 @@ public class RuleRegistry {
"\\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}";
/**
* Серия и номер: «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 \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)
.anchoredBy("сери"),
// Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890».
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)
.anchoredBy("водительск", "в/у", "вод.", "ву "),
@@ -206,10 +236,19 @@ public class RuleRegistry {
.groups(1)
.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)
.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 цифр после якоря, группировка пробелами не важна.
@@ -335,7 +374,10 @@ public class RuleRegistry {
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\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)
.groups(1)
.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)
.validatedBy(NameDictionary::containsNamePart),
.validatedBy(NameDictionary::isStandaloneNameCandidate),
// --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
.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)
.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)
.groups(1)
.validatedBy(Validators::snils),
// --- Уровень 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)
.anchoredBy("@"),
@@ -393,10 +443,27 @@ public class RuleRegistry {
// «выдан ОУФМС России по г. Москве 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)
.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)
.groups(1)
@@ -407,19 +474,26 @@ public class RuleRegistry {
.groups(1)
.anchoredBy("родил"),
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1)
// ROLE_GAP, не \W{0,5}: «Гражданство бенефициара по договору страхования: Х» —
// между якорем и значением бывает несколько слов, не только пунктуация.
// Список через запятую/слэш — вторая опциональная группа тем же шаблоном.
Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*" + CITIZENSHIP_GAP
+ "(" + CITIZENSHIP_VALUE + ")(?:\\s*[,/]\\s*(" + CITIZENSHIP_VALUE + "))?", 80)
.groups(1, 2)
.validatedBy(CountryDictionary::isKnownCountry)
.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("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно ---
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)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"),