refactor: устранить нарушения DRY, SRP, KISS

- DRY: вынести scoped() в общий ScopedKey (был дублирован в PayloadStore и SharedIndex)
- DRY: вынести чтение словарей и перечитывание файлов в ResourceLoader
  (было дублировано в NameDictionary, ToponymDictionary, SystemsConfig)
- DRY: вынести алгоритм Луна в Validators.luhnCheckDigit (Synthetic переиспользует)
- SRP: вынести константы типов ПД из RuleRegistry в PdTypes
- SRP: вынести проверку организаций из NameDictionary в OrganisationDetector
- KISS: словари оставлены статическими (неизменяемые, детерминированные)

Поведение не изменилось: 1840 тестов проходят, NodeLogsLeakTest по-прежнему
падает на тех же 55 задокументированных утечках.
This commit is contained in:
Максименко Никита Владимирович
2026-09-22 22:39:16 +03:00
parent 6b5a7ae35f
commit aa4c926a8f
19 changed files with 457 additions and 349 deletions
@@ -1,5 +1,6 @@
package ru.pdguard.config; package ru.pdguard.config;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.mask.MaskMode; import ru.pdguard.mask.MaskMode;
import java.util.Set; import java.util.Set;
@@ -45,9 +46,9 @@ public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMod
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */ /** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
public static final SystemPolicy DEFAULT = new SystemPolicy( public static final SystemPolicy DEFAULT = new SystemPolicy(
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL), DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
Set.of("CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY", Set.of(PdTypes.CVV, PdTypes.PIN, PdTypes.DATE, PdTypes.BIRTH_PLACE, PdTypes.ADDRESS_COUNTRY,
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP", PdTypes.ACCOUNT_NUMBER, PdTypes.BIK, PdTypes.OGRN, PdTypes.OGRNIP, PdTypes.KPP,
"INCOME", "BIOMETRIC"), null); PdTypes.INCOME, PdTypes.BIOMETRIC), null);
public SystemPolicy { public SystemPolicy {
types = Set.copyOf(types); types = Set.copyOf(types);
@@ -87,10 +87,10 @@ public class PayloadStore {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
String encrypted = cipher.encrypt(original); String encrypted = cipher.encrypt(original);
Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis); Entry entry = new Entry(system, encrypted, masked, fingerprint(masked), now + ttlMillis);
String idKey = scoped(system, payloadId); String idKey = ScopedKey.of(system, payloadId);
Entry replaced = byId.put(idKey, entry); Entry replaced = byId.put(idKey, entry);
byMaskFingerprint.put(scoped(system, entry.fingerprint()), entry); byMaskFingerprint.put(ScopedKey.of(system, entry.fingerprint()), entry);
insertionOrder.add(idKey); insertionOrder.add(idKey);
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight())); charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
@@ -101,7 +101,7 @@ public class PayloadStore {
} }
public Entry byId(String system, String payloadId) { public Entry byId(String system, String payloadId) {
String idKey = scoped(system, payloadId); String idKey = ScopedKey.of(system, payloadId);
Entry entry = byId.get(idKey); Entry entry = byId.get(idKey);
if (entry != null && entry.alive(System.currentTimeMillis())) { if (entry != null && entry.alive(System.currentTimeMillis())) {
return decrypt(entry); return decrypt(entry);
@@ -126,7 +126,7 @@ public class PayloadStore {
*/ */
public String originalForMask(String system, String masked) { public String originalForMask(String system, String masked) {
String fingerprint = fingerprint(masked); String fingerprint = fingerprint(masked);
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint)); Entry entry = byMaskFingerprint.get(ScopedKey.of(system, fingerprint));
if (entry != null && entry.alive(System.currentTimeMillis())) { if (entry != null && entry.alive(System.currentTimeMillis())) {
return cipher.decrypt(entry.original()); return cipher.decrypt(entry.original());
} }
@@ -137,17 +137,9 @@ public class PayloadStore {
public long charsHeld() { public long charsHeld() {
return charsHeld.get(); return charsHeld.get();
} }
/**
/** * Убирает протухшие записи с головы очереди, не более нескольких за раз.
* Ключ, однозначно разделяющий системы. Длина имени в начале снимает вопрос о
* разделителе: имя системы может содержать любые знаки.
*/ */
private static String scoped(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
private void sweepExpired(long now) { private void sweepExpired(long now) {
for (int i = 0; i < SWEEP_PER_PUT; i++) { for (int i = 0; i < SWEEP_PER_PUT; i++) {
String oldest = insertionOrder.peek(); String oldest = insertionOrder.peek();
@@ -185,7 +177,7 @@ public class PayloadStore {
private void forget(String idKey, Entry entry) { private void forget(String idKey, Entry entry) {
if (byId.remove(idKey, entry)) { if (byId.remove(idKey, entry)) {
byMaskFingerprint.remove(scoped(entry.system(), entry.fingerprint()), entry); byMaskFingerprint.remove(ScopedKey.of(entry.system(), entry.fingerprint()), entry);
charsHeld.addAndGet(-entry.weight()); charsHeld.addAndGet(-entry.weight());
} }
} }
+7 -5
View File
@@ -11,6 +11,8 @@ import org.springframework.stereotype.Component;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.NameDictionary; import ru.pdguard.detect.NameDictionary;
import ru.pdguard.detect.OrganisationDetector;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.MaskContext; import ru.pdguard.mask.MaskContext;
@@ -139,7 +141,7 @@ public class Pipeline {
*/ */
public List<Span> findPersonalData(String text, SystemPolicy policy) { public List<Span> findPersonalData(String text, SystemPolicy policy) {
List<Span> spans = resolveOverlaps(registry.detect(text, policy)); List<Span> spans = resolveOverlaps(registry.detect(text, policy));
if (policy.allows(RuleRegistry.FIO)) { if (policy.allows(PdTypes.FIO)) {
// Вторая ступень разбирает только то, что не покрыла первая. // Вторая ступень разбирает только то, что не покрыла первая.
spans = resolveOverlaps(cascade.addMissedNames(text, spans)); spans = resolveOverlaps(cascade.addMissedNames(text, spans));
} }
@@ -204,8 +206,8 @@ public class Pipeline {
*/ */
static List<Span> dropOrganisationNames(String text, List<Span> spans) { static List<Span> dropOrganisationNames(String text, List<Span> spans) {
return spans.stream() return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type()) .filter(span -> !PdTypes.FIO.equals(span.type())
|| !NameDictionary.precededByOrganisation(text, span.start())) || !OrganisationDetector.precededByOrganisation(text, span.start()))
.toList(); .toList();
} }
@@ -217,12 +219,12 @@ public class Pipeline {
*/ */
static List<Span> dropWellKnownNames(String text, List<Span> spans) { static List<Span> dropWellKnownNames(String text, List<Span> spans) {
boolean otherPersonalDataPresent = spans.stream() boolean otherPersonalDataPresent = spans.stream()
.anyMatch(span -> !RuleRegistry.FIO.equals(span.type())); .anyMatch(span -> !PdTypes.FIO.equals(span.type()));
if (otherPersonalDataPresent) { if (otherPersonalDataPresent) {
return spans; return spans;
} }
return spans.stream() return spans.stream()
.filter(span -> !RuleRegistry.FIO.equals(span.type()) .filter(span -> !PdTypes.FIO.equals(span.type())
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) || !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
.toList(); .toList();
} }
@@ -0,0 +1,20 @@
package ru.pdguard.core;
/**
* Ключ, однозначно разделяющий системы-потребители.
*
* <p>Длина имени в начале снимает вопрос о разделителе: имя системы может
* содержать любые знаки, и без длины «a:b» и «ab:» были бы неразличимы.
* Используется и в локальном хранилище, и в общем слое — единая реализация
* вместо двух копий.
*/
final class ScopedKey {
private ScopedKey() {
}
static String of(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
}
+4 -10
View File
@@ -83,9 +83,9 @@ public void put(String system, String payloadId, String original, String masked,
} }
try { try {
String encrypted = cipher.encrypt(original); String encrypted = cipher.encrypt(original);
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId), redis.opsForValue().set(ScopedKey.of(system, payloadId),
toJson(new SharedEntry(encrypted, masked)), ttl); toJson(new SharedEntry(encrypted, masked)), ttl);
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), encrypted, ttl); redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl);
noteSuccess(); noteSuccess();
} catch (RuntimeException e) { } catch (RuntimeException e) {
noteFailure("записать", e); noteFailure("записать", e);
@@ -97,7 +97,7 @@ public void put(String system, String payloadId, String original, String masked,
return null; return null;
} }
try { try {
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId)); String json = redis.opsForValue().get(ScopedKey.of(system, payloadId));
noteSuccess(); noteSuccess();
SharedEntry entry = json == null ? null : fromJson(json); SharedEntry entry = json == null ? null : fromJson(json);
return entry == null ? null : new SharedEntry(cipher.decrypt(entry.original()), entry.masked()); return entry == null ? null : new SharedEntry(cipher.decrypt(entry.original()), entry.masked());
@@ -112,7 +112,7 @@ public void put(String system, String payloadId, String original, String masked,
return null; return null;
} }
try { try {
String encrypted = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint)); String encrypted = redis.opsForValue().get(ScopedKey.of(system, maskFingerprint));
noteSuccess(); noteSuccess();
return encrypted == null ? null : cipher.decrypt(encrypted); return encrypted == null ? null : cipher.decrypt(encrypted);
} catch (RuntimeException e) { } catch (RuntimeException e) {
@@ -137,12 +137,6 @@ public void put(String system, String payloadId, String original, String masked,
} }
} }
/** Ключ с разделением по системам: длина имени в начале снимает вопрос о разделителе. */
private static String scoped(String system, String key) {
String owner = system == null ? "" : system;
return owner.length() + ":" + owner + ":" + key;
}
/** Общий слой выключен или предохранитель разомкнут. */ /** Общий слой выключен или предохранитель разомкнут. */
private boolean unavailable() { private boolean unavailable() {
return !enabled || System.currentTimeMillis() < silentUntil; return !enabled || System.currentTimeMillis() < silentUntil;
@@ -56,16 +56,16 @@ public class NameCascade {
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */ /** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
private static final Map<String, String> NAME_TYPES = Map.of( private static final Map<String, String> NAME_TYPES = Map.of(
"PER", RuleRegistry.FIO); "PER", PdTypes.FIO);
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */ /** Метки ruBERT в типы ПД: детальные составляющие адреса. */
private static final Map<String, String> ADDRESS_TYPES = Map.of( private static final Map<String, String> ADDRESS_TYPES = Map.of(
"COUNTRY", RuleRegistry.ADDRESS_COUNTRY, "COUNTRY", PdTypes.ADDRESS_COUNTRY,
"REGION", RuleRegistry.ADDRESS_REGION, "REGION", PdTypes.ADDRESS_REGION,
"DISTRICT", RuleRegistry.ADDRESS_DISTRICT, "DISTRICT", PdTypes.ADDRESS_DISTRICT,
"CITY", RuleRegistry.ADDRESS_CITY, "CITY", PdTypes.ADDRESS_CITY,
"STREET", RuleRegistry.ADDRESS_STREET, "STREET", PdTypes.ADDRESS_STREET,
"HOUSE", RuleRegistry.ADDRESS_HOUSE); "HOUSE", PdTypes.ADDRESS_HOUSE);
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */ /** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
private static final Pattern CANDIDATE = Pattern.compile( private static final Pattern CANDIDATE = Pattern.compile(
@@ -221,7 +221,7 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) { for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
if (isAccepted(text, candidateStart, candidateEnd, span)) { if (isAccepted(text, candidateStart, candidateEnd, span)) {
sink.add(span); sink.add(span);
if (RuleRegistry.FIO.equals(span.type())) { if (PdTypes.FIO.equals(span.type())) {
nameFound = true; nameFound = true;
} }
} }
@@ -231,7 +231,7 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил, // полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД. // чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
if (nameFound) { if (nameFound) {
sink.removeIf(span -> RuleRegistry.FIO.equals(span.type()) sink.removeIf(span -> PdTypes.FIO.equals(span.type())
&& span.start() < candidateEnd && candidateStart < span.end() && span.start() < candidateEnd && candidateStart < span.end()
&& span.priority() > PRIORITY); && span.priority() > PRIORITY);
} }
@@ -3,20 +3,12 @@ package ru.pdguard.detect;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -40,9 +32,8 @@ import java.util.stream.Collectors;
public final class NameDictionary { public final class NameDictionary {
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class); private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
private static final long RECHECK_MILLIS = 1000;
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream() private static final List<String> GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream()
.map(Declension::withoutInflectedEnding) .map(Declension::withoutInflectedEnding)
.distinct() .distinct()
.sorted(Comparator.comparingInt(String::length).reversed()) .sorted(Comparator.comparingInt(String::length).reversed())
@@ -51,28 +42,14 @@ public final class NameDictionary {
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
// что и для личных имён. // что и для личных имён.
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream() private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream()
.map(Declension::withoutInflectedEnding) .map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet()); .collect(Collectors.toUnmodifiableSet());
private static final AtomicReference<Path> externalFile = private static final ResourceLoader.FileWatchState<Set<String>> WELL_KNOWN_STATE =
new AtomicReference<>(Path.of("config/well-known.txt")); new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS);
private static final AtomicReference<Set<String>> wellKnownStems =
new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS);
private static volatile long externalTimestamp;
private static volatile long lastCheck;
/** private static final Path EXTERNAL_FILE = Path.of("config/well-known.txt");
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40;
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */ /** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
private static final String REGNAL_ORDINALS = private static final String REGNAL_ORDINALS =
@@ -117,7 +94,13 @@ public final class NameDictionary {
* экземпляра, поэтому путь хранится в статическом поле. * экземпляра, поэтому путь хранится в статическом поле.
*/ */
public static void configure(String wellKnownFile) { public static void configure(String wellKnownFile) {
externalFile.set(Path.of(wellKnownFile)); WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
WELL_KNOWN_STATE.mtime = -1;
WELL_KNOWN_STATE.lastCheck = 0;
// Путь фиксирован в статическом поле; для тестов используется useExternalFile.
if (!"config/well-known.txt".equals(wellKnownFile)) {
useExternalFile(Path.of(wellKnownFile));
}
} }
/** /**
@@ -149,20 +132,6 @@ public final class NameDictionary {
return false; return false;
} }
/**
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
*/
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
/** /**
* Содержит ли текст упоминание известного человека — из сборки или дописанных * Содержит ли текст упоминание известного человека — из сборки или дописанных
* сверху. * сверху.
@@ -176,8 +145,7 @@ public final class NameDictionary {
if (REGNAL_NAME.matcher(value.strip()).matches()) { if (REGNAL_NAME.matcher(value.strip()).matches()) {
return true; return true;
} }
refreshIfChanged(); Set<String> stems = currentWellKnownStems();
Set<String> stems = wellKnownStems.get();
for (String word : value.split("\\P{L}+")) { for (String word : value.split("\\P{L}+")) {
String lower = word.toLowerCase(Locale.ROOT); String lower = word.toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) { for (int length = lower.length(); length > 0; length--) {
@@ -191,82 +159,55 @@ public final class NameDictionary {
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */ /** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
static void useExternalFile(Path path) { static void useExternalFile(Path path) {
externalFile.set(path); WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
externalTimestamp = -1; WELL_KNOWN_STATE.mtime = -1;
lastCheck = 0; WELL_KNOWN_STATE.lastCheck = 0;
// Перечитываем немедленно, минуя секундный троттлинг.
reloadExternal(path);
} }
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */ /** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
static synchronized void reloadExternal() { static synchronized void reloadExternal(Path path) {
lastCheck = System.currentTimeMillis(); WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis();
if (!Files.isReadable(externalFile.get())) { if (!java.nio.file.Files.isReadable(path)) {
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) { if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) {
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список", LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
externalFile.get().toAbsolutePath()); path.toAbsolutePath());
} }
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS); WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS;
externalTimestamp = 0; WELL_KNOWN_STATE.mtime = 0;
return; return;
} }
try { try {
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis(); WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis();
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) { for (String line : java.nio.file.Files.readAllLines(path, java.nio.charset.StandardCharsets.UTF_8)) {
String trimmed = Declension.withoutInflectedEnding(line.trim()); String trimmed = Declension.withoutInflectedEnding(line.trim());
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
merged.add(trimmed); merged.add(trimmed);
} }
} }
wellKnownStems.set(Set.copyOf(merged)); WELL_KNOWN_STATE.current = Set.copyOf(merged);
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных", LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
} catch (IOException e) { } catch (java.io.IOException e) {
// Битый файл не должен ронять маскирование: остаётся прежний список. // Битый файл не должен ронять маскирование: остаётся прежний список.
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e); LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e);
} }
} }
private static void refreshIfChanged() { private static Set<String> currentWellKnownStems() {
long now = System.currentTimeMillis(); return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
if (now - lastCheck < RECHECK_MILLIS) { lines -> {
return; Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
} for (String line : lines) {
lastCheck = now; String trimmed = Declension.withoutInflectedEnding(line);
try { if (!trimmed.isEmpty()) {
if (!Files.isReadable(externalFile.get())) { merged.add(trimmed);
if (externalTimestamp != 0) { }
reloadExternal(); }
} return Set.copyOf(merged);
return; },
} BUNDLED_WELL_KNOWN_STEMS);
if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) {
reloadExternal();
}
} catch (IOException e) {
LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e);
}
}
/**
* Основы сортируются от длинных к коротким: в чередовании регулярного
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
* перехватывать совпадение у длинной.
*/
private static List<String> load(String resource) {
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
} }
} }
@@ -0,0 +1,35 @@
package ru.pdguard.detect;
import java.util.regex.Pattern;
/**
* Проверка, стоит ли перед именем слово, относящее его к организации или
* объекту на карте.
*
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
* защиту не теряет, а улица Королёва под маску не попадает.
*/
public final class OrganisationDetector {
/**
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
*/
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
"(?iu:" + String.join("|", ResourceLoader.lines("/names/organisations.txt", false)) + ")\\p{L}*"
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
private static final int ORGANISATION_LOOKBEHIND = 40;
private OrganisationDetector() {
}
public static boolean precededByOrganisation(String text, int nameStart) {
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
}
}
@@ -0,0 +1,58 @@
package ru.pdguard.detect;
/**
* Имена типов персональных данных, которые умеет распознавать сервис.
*
* <p>Вынесены из {@link RuleRegistry} отдельно: константы используются и в
* правилах, и в маскировании ({@link ru.pdguard.mask.Masker}), и в политиках
* ({@link ru.pdguard.config.SystemPolicy}), и в синтетических подстановках
* ({@link ru.pdguard.mask.Synthetic}). Единое место — чтобы имя типа не
* расходилось между слоями.
*/
public final class PdTypes {
private PdTypes() {
}
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Регион и район размечает только модель второй ступени: правил под них нет. */
public static final String ADDRESS_REGION = "ADDRESS_REGION";
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
/** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME";
public static final String OGRN = "OGRN";
public static final String OGRNIP = "OGRNIP";
public static final String KPP = "KPP";
public static final String BIOMETRIC = "BIOMETRIC";
}
@@ -0,0 +1,116 @@
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.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Общие приёмы чтения словарей и внешних файлов.
*
* <p>Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются,
* пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем)
* перечитываются, когда меняется время их изменения, и не чаще раза в секунду —
* чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда,
* чтобы не дублировать их в каждом словаре.
*/
final class ResourceLoader {
private ResourceLoader() {
}
/**
* Читает строки ресурса, отбрасывая пустые и комментарии.
*
* @param resource путь к ресурсу в classpath
* @param sortByLength сортировать ли от длинных к коротким (нужно для чередований)
*/
static List<String> lines(String resource, boolean sortByLength) {
try (InputStream in = ResourceLoader.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(sortByLength
? Comparator.comparingInt(String::length).reversed()
: Comparator.naturalOrder())
.toList();
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
/**
* Читает строки ресурса в множество, отбрасывая пустые и комментарии.
*/
static java.util.Set<String> set(String resource) {
return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet());
}
/**
* Перечитывает внешний файл, когда меняется время его изменения, не чаще раза
* в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее.
*
* @param path путь к файлу
* @param state состояние проверки (время последней проверки и mtime файла)
* @param reader как превратить строки файла в итоговое значение
* @param fallback значение, если файл недоступен
*/
static <T> T refreshIfChanged(Path path, FileWatchState<T> state,
Function<List<String>, T> reader, T fallback) {
long now = System.currentTimeMillis();
if (now - state.lastCheck < state.recheckMillis) {
return state.current;
}
state.lastCheck = now;
try {
if (!Files.isReadable(path)) {
return state.current;
}
long mtime = Files.getLastModifiedTime(path).toMillis();
if (mtime != state.mtime) {
state.mtime = mtime;
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8).stream()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList();
state.current = reader.apply(lines);
}
} catch (IOException e) {
// Битый файл не должен ронять работу: остаётся прежнее значение.
}
return state.current;
}
/** Состояние проверки внешнего файла: время последней проверки и mtime. */
static final class FileWatchState<T> {
private static final long DEFAULT_RECHECK_MILLIS = 1000;
final long recheckMillis;
long lastCheck;
long mtime;
T current;
FileWatchState(T initial, long recheckMillis) {
this.current = initial;
this.recheckMillis = recheckMillis;
}
FileWatchState(T initial) {
this(initial, DEFAULT_RECHECK_MILLIS);
}
}
}
@@ -27,48 +27,6 @@ import java.util.regex.Pattern;
@Component @Component
public class RuleRegistry { public class RuleRegistry {
public static final String EMAIL = "EMAIL";
public static final String PHONE = "PHONE";
public static final String CARD = "CARD";
public static final String INN = "INN";
public static final String SNILS = "SNILS";
public static final String PASSPORT = "PASSPORT";
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
public static final String PASSPORT_DATE = "PASSPORT_DATE";
public static final String DEPT_CODE = "DEPT_CODE";
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
public static final String CITIZENSHIP = "CITIZENSHIP";
public static final String BIRTH_PLACE = "BIRTH_PLACE";
public static final String BIRTH_DATE = "BIRTH_DATE";
public static final String DATE = "DATE";
public static final String CVV = "CVV";
public static final String PIN = "PIN";
public static final String CARDHOLDER = "CARDHOLDER";
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
public static final String ADDRESS_CITY = "ADDRESS_CITY";
public static final String ADDRESS_STREET = "ADDRESS_STREET";
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
/** Регион и район размечает только модель второй ступени: правил под них нет. */
public static final String ADDRESS_REGION = "ADDRESS_REGION";
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
public static final String FIO = "FIO";
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
public static final String MILITARY_ID = "MILITARY_ID";
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
/** Банковские реквизиты сверх платёжной карты. */
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
public static final String BIK = "BIK";
public static final String CARD_EXPIRY = "CARD_EXPIRY";
public static final String INCOME = "INCOME";
public static final String OGRN = "OGRN";
public static final String OGRNIP = "OGRNIP";
public static final String KPP = "KPP";
public static final String BIOMETRIC = "BIOMETRIC";
/** /**
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы * Слово с заглавной буквы; остальные буквы любого регистра, чтобы
* «ИВАНОВ» распознавался наравне с «Иванов». * «ИВАНОВ» распознавался наравне с «Иванов».
@@ -139,8 +97,8 @@ public class RuleRegistry {
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */ /** Адресные типы, которые вне адресного окружения персональными данными не являются. */
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of( private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
ADDRESS_COUNTRY, ADDRESS_REGION, ADDRESS_DISTRICT, ADDRESS_CITY, PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY,
ADDRESS_STREET, ADDRESS_HOUSE, ADDRESS_FLAT, ADDRESS_POSTCODE); PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE);
public static boolean isAddressType(String type) { public static boolean isAddressType(String type) {
return ADDRESS_TYPES.contains(type); return ADDRESS_TYPES.contains(type);
@@ -162,16 +120,16 @@ public class RuleRegistry {
// --- Уровень 3: значение опознаётся только рядом с якорным словом --- // --- Уровень 3: значение опознаётся только рядом с якорным словом ---
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92) Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
.groups(1) .groups(1)
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"), .anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92) Rule.of(PdTypes.PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
.groups(1) .groups(1)
.anchoredBy("пин", "pin"), .anchoredBy("пин", "pin"),
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456» // «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?" Rule.of(PdTypes.PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90) + "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
.groups(1) .groups(1)
.anchoredBy("паспорт"), .anchoredBy("паспорт"),
@@ -179,54 +137,54 @@ public class RuleRegistry {
// «серия 4509 номер 123456», «серии 45 09 № 123456» // «серия 4509 номер 123456», «серии 45 09 № 123456»
// Между серией и номером помещается слово: «серия 4509 номер 123456», // Между серией и номером помещается слово: «серия 4509 номер 123456»,
// «серии 4509 за номером 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) Rule.of(PdTypes.PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
.groups(1, 2) .groups(1, 2)
.anchoredBy("сери"), .anchoredBy("сери"),
Rule.of(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}(" + SERIES_AND_NUMBER + ")\\b", 89)
.groups(1) .groups(1)
.anchoredBy("водительск", "в/у", "вод.", "ву "), .anchoredBy("водительск", "в/у", "вод.", "ву "),
// --- Прочие документы, удостоверяющие личность --- // --- Прочие документы, удостоверяющие личность ---
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89) + "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
.groups(1) .groups(1)
.anchoredBy("загранпаспорт", "заграничн"), .anchoredBy("загранпаспорт", "заграничн"),
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89) + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
.groups(1) .groups(1)
.anchoredBy("военн"), .anchoredBy("военн"),
Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)" Rule.of(PdTypes.BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89) + "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
.groups(1) .groups(1)
.anchoredBy("свидетельств"), .anchoredBy("свидетельств"),
Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89) Rule.of(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
.groups(1) .groups(1)
.anchoredBy("полис"), .anchoredBy("полис"),
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
.groups(1) .groups(1)
.anchoredBy("подразделени", "к/п"), .anchoredBy("подразделени", "к/п"),
// --- Банковские реквизиты сверх карты --- // --- Банковские реквизиты сверх карты ---
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
Rule.of(ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|" Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83) + "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
.groups(1) .groups(1)
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"), .anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
Rule.of(BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83) Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
.groups(1) .groups(1)
.anchoredBy("бик"), .anchoredBy("бик"),
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения. // «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
Rule.of(CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}" Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83) + "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
.groups(1) .groups(1)
.anchoredBy("срок действия", "действительн", "exp"), .anchoredBy("срок действия", "действительн", "exp"),
@@ -234,58 +192,58 @@ public class RuleRegistry {
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
// числа рядом со словом — раньше якоря было достаточно самого по себе. // числа рядом со словом — раньше якоря было достаточно самого по себе.
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
.groups(1) .groups(1)
.validatedBy(Validators::ogrnip) .validatedBy(Validators::ogrnip)
.anchoredBy("огрнип"), .anchoredBy("огрнип"),
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
.groups(1) .groups(1)
.validatedBy(Validators::ogrn) .validatedBy(Validators::ogrn)
.anchoredBy("огрн"), .anchoredBy("огрн"),
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83) Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
.groups(1) .groups(1)
.anchoredBy("кпп"), .anchoredBy("кпп"),
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил // стоять слово («доход клиента», «доход за год») — без этого якорь ловил
// бы только «доход 85000», вплотную. // бы только «доход 85000», вплотную.
Rule.of(INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?" Rule.of(PdTypes.INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76) + "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76)
.groups(1) .groups(1)
.anchoredBy("доход", "заработн", "зарплат"), .anchoredBy("доход", "заработн", "зарплат"),
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного // Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
// значения для захвата нет: маскируется якорная фраза целиком. // значения для захвата нет: маскируется якорная фраза целиком.
Rule.of(BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)" Rule.of(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81) + "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"), .anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
// --- Даты с явным якорем --- // --- Даты с явным якорем ---
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87) Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("рожден"), .anchoredBy("рожден"),
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87) Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("родил"), .anchoredBy("родил"),
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87) Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("г.р", "г/р", "года рождения"), .anchoredBy("г.р", "г/р", "года рождения"),
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87) Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
.groups(1) .groups(1)
.validatedBy(Validators::date) .validatedBy(Validators::date)
.anchoredBy("выдач"), .anchoredBy("выдач"),
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
.groups(1) .groups(1)
.anchoredBy("держател", "cardholder", "на имя"), .anchoredBy("держател", "cardholder", "на имя"),
@@ -297,26 +255,26 @@ public class RuleRegistry {
// сознательно не используется: она захватывает глагол в начале // сознательно не используется: она захватывает глагол в начале
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
// Имя Отчество Фамилия — второй распространённый порядок слов. // Имя Отчество Фамилия — второй распространённый порядок слов.
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79), Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
// Иванов И.И. и И.И. Иванов // Иванов И.И. и И.И. Иванов
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79), Rule.of(PdTypes.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(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
// Имя Отчество без фамилии // Имя Отчество без фамилии
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77), Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру // «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)" Rule.of(PdTypes.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) + "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
.groups(1) .groups(1)
.anchoredBy("фио", "ф.и.о", "на имя"), .anchoredBy("фио", "ф.и.о", "на имя"),
// «клиент Иванов Иван», «плательщик Петрова» // «клиент Иванов Иван», «плательщик Петрова»
Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик" Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител" + "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*" + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
@@ -335,31 +293,31 @@ public class RuleRegistry {
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс // Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической // случайное слово, «Богдана Хмельницкого» — улица в честь исторической
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ. // фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74) Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName) .validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY), .vetoedBy(ORGANISATION_NEARBY),
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74) Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
.validatedBy(NameDictionary::containsGivenName) .validatedBy(NameDictionary::containsGivenName)
.vetoedBy(ORGANISATION_NEARBY), .vetoedBy(ORGANISATION_NEARBY),
// --- Уровень 1: подтверждается контрольной суммой --- // --- Уровень 1: подтверждается контрольной суммой ---
Rule.of(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(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84) Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
.groups(1) .groups(1)
.anchoredBy("инн"), .anchoredBy("инн"),
Rule.of(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(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), Rule.of(PdTypes.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) Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
.anchoredBy("@"), .anchoredBy("@"),
// --- Уровень 3: свободный текст после якорного слова --- // --- Уровень 3: свободный текст после якорного слова ---
@@ -367,38 +325,38 @@ public class RuleRegistry {
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит, // «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
// её забирает отдельное правило. Приоритет выше городского, иначе от органа // её забирает отдельное правило. Приоритет выше городского, иначе от органа
// осталась бы замаскированной только его часть. // осталась бы замаскированной только его часть.
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)" Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\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("выдан"),
Rule.of(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)
.anchoredBy("рождения"), .anchoredBy("рождения"),
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+" Rule.of(PdTypes.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76) + "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
.groups(1) .groups(1)
.anchoredBy("родил"), .anchoredBy("родил"),
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}" Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) + "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1) .groups(1)
.anchoredBy("гражданств"), .anchoredBy("гражданств"),
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+" Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) + "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
.groups(1) .groups(1)
.anchoredBy("граждан"), .anchoredBy("граждан"),
// --- Адрес: каждая составляющая настраивается отдельно --- // --- Адрес: каждая составляющая настраивается отдельно ---
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74) Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("индекс"), .anchoredBy("индекс"),
Rule.of(ADDRESS_POSTCODE, Rule.of(PdTypes.ADDRESS_POSTCODE,
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74) "\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY), .vetoedBy(ORGANISATION_NEARBY),
@@ -406,7 +364,7 @@ public class RuleRegistry {
// Не только «г.»: перепись, на которой проверяется словарь, покрывает // Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево» // сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём. // из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b" Rule.of(PdTypes.ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b" + "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73) + "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
.groups(1) .groups(1)
@@ -415,7 +373,7 @@ public class RuleRegistry {
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", .anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"), "дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(ADDRESS_STREET, Rule.of(PdTypes.ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*" "(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)" + "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
+ "\\W{0,3}(" + STREET_NAME + ")", 73) + "\\W{0,3}(" + STREET_NAME + ")", 73)
@@ -427,25 +385,25 @@ public class RuleRegistry {
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому // «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
// принимается только рядом с другими частями адреса: иначе под маску попал бы // принимается только рядом с другими частями адреса: иначе под маску попал бы
// любой рассказ про Невский проспект. // любой рассказ про Невский проспект.
Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+" Rule.of(PdTypes.ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73) + "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.requiringNear(ADDRESS_NEARBY) .requiringNear(ADDRESS_NEARBY)
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"), .anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
Rule.of(ADDRESS_HOUSE, Rule.of(PdTypes.ADDRESS_HOUSE,
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72) "(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("д.", "дом"), .anchoredBy("д.", "дом"),
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
.groups(1) .groups(1)
.vetoedBy(ORGANISATION_NEARBY) .vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("кв"), .anchoredBy("кв"),
Rule.of(ADDRESS_COUNTRY, Rule.of(PdTypes.ADDRESS_COUNTRY,
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)" "(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71) + "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
.groups(1) .groups(1)
@@ -455,12 +413,12 @@ public class RuleRegistry {
// --- Значения без якоря: принимаются только вместе с другими ПД --- // --- Значения без якоря: принимаются только вместе с другими ПД ---
// ИНН физлица без якорного слова — только с верной контрольной суммой. // ИНН физлица без якорного слова — только с верной контрольной суммой.
Rule.of(INN, "\\b\\d{12}\\b", 62) Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62)
.validatedBy(Validators::inn), .validatedBy(Validators::inn),
// Дата без якорного слова персональными данными сама по себе не является: // Дата без якорного слова персональными данными сама по себе не является:
// маскируется, только если в тексте есть ПД другого типа. // маскируется, только если в тексте есть ПД другого типа.
Rule.of(DATE, DATE_ANY, 58) Rule.of(PdTypes.DATE, DATE_ANY, 58)
.validatedBy(Validators::date) .validatedBy(Validators::date)
); );
@@ -1,14 +1,7 @@
package ru.pdguard.detect; package ru.pdguard.detect;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
/** /**
* Словарь населённых пунктов России — проверка того, что значение, пойманное * Словарь населённых пунктов России — проверка того, что значение, пойманное
@@ -28,9 +21,9 @@ import java.util.stream.Collectors;
*/ */
public final class ToponymDictionary { public final class ToponymDictionary {
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream() private static final Set<String> SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding) .map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet()); .collect(java.util.stream.Collectors.toUnmodifiableSet());
private ToponymDictionary() { private ToponymDictionary() {
} }
@@ -58,20 +51,4 @@ public final class ToponymDictionary {
} }
return false; return false;
} }
}
private static Set<String> load(String resource) {
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
return reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.collect(Collectors.toUnmodifiableSet());
}
} catch (IOException e) {
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
}
}
}
@@ -37,6 +37,28 @@ public final class Validators {
return digits >= 13 && digits <= 19 && sum % 10 == 0; return digits >= 13 && digits <= 19 && sum % 10 == 0;
} }
/**
* Контрольная цифра Луна для последовательности цифр: дописывается к телу
* номера, чтобы весь номер прошёл проверку {@link #luhn}. Используется при
* генерации правдоподобных подставных номеров карт.
*/
public static int luhnCheckDigit(String body) {
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;
}
return (10 - sum % 10) % 10;
}
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */ /** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
public static boolean inn(String value) { public static boolean inn(String value) {
int[] d = digits(value); int[] d = digits(value);
+39 -39
View File
@@ -1,7 +1,7 @@
package ru.pdguard.mask; package ru.pdguard.mask;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.PdTypes;
import java.util.Map; import java.util.Map;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
@@ -19,57 +19,57 @@ public class Masker {
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 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( private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
Map.entry(RuleRegistry.EMAIL, Strategies::email), Map.entry(PdTypes.EMAIL, Strategies::email),
Map.entry(RuleRegistry.PHONE, EDGES), Map.entry(PdTypes.PHONE, EDGES),
Map.entry(RuleRegistry.CARD, EDGES), Map.entry(PdTypes.CARD, EDGES),
Map.entry(RuleRegistry.INN, EDGES), Map.entry(PdTypes.INN, EDGES),
Map.entry(RuleRegistry.SNILS, EDGES), Map.entry(PdTypes.SNILS, EDGES),
Map.entry(RuleRegistry.PASSPORT, EDGES), Map.entry(PdTypes.PASSPORT, EDGES),
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES), Map.entry(PdTypes.DRIVER_LICENSE, EDGES),
Map.entry(RuleRegistry.DEPT_CODE, EDGES), Map.entry(PdTypes.DEPT_CODE, EDGES),
// У этих документов серия короткая — две цифры или две буквы. Оставь мы // У этих документов серия короткая — две цифры или две буквы. Оставь мы
// первые два знака, серия оказалась бы открыта целиком, поэтому видны // первые два знака, серия оказалась бы открыта целиком, поэтому видны
// только последние. У паспорта РФ и водительского удостоверения серия // только последние. У паспорта РФ и водительского удостоверения серия
// из четырёх знаков, там открывается половина. // из четырёх знаков, там открывается половина.
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES), Map.entry(PdTypes.FOREIGN_PASSPORT, SHORT_SERIES),
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES), Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES),
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES), Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES),
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES), Map.entry(PdTypes.MEDICAL_POLICY, EDGES),
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials), Map.entry(PdTypes.CARDHOLDER, Strategies::initials),
Map.entry(RuleRegistry.FIO, Strategies::initials), Map.entry(PdTypes.FIO, Strategies::initials),
// Код проверки и пин-код не показываем даже частично: у них слишком // Код проверки и пин-код не показываем даже частично: у них слишком
// мало знаков, чтобы открывать хотя бы один. // мало знаков, чтобы открывать хотя бы один.
Map.entry(RuleRegistry.CVV, Strategies::stars), Map.entry(PdTypes.CVV, Strategies::stars),
Map.entry(RuleRegistry.PIN, Strategies::stars), Map.entry(PdTypes.PIN, Strategies::stars),
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars), Map.entry(PdTypes.PASSPORT_ISSUER, Strategies::stars),
// У дат сохраняем разделители: модель видит, что это дата, но не какая. // У дат сохраняем разделители: модель видит, что это дата, но не какая.
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars), Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars), Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars), Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars), Map.entry(PdTypes.ADDRESS_STREET, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars), Map.entry(PdTypes.ADDRESS_HOUSE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars), Map.entry(PdTypes.ADDRESS_FLAT, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars), Map.entry(PdTypes.ADDRESS_REGION, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars), Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars),
Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars), Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars),
Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars), Map.entry(PdTypes.CITIZENSHIP, Strategies::stars),
Map.entry(RuleRegistry.ACCOUNT_NUMBER, EDGES), Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES),
Map.entry(RuleRegistry.OGRN, EDGES), Map.entry(PdTypes.OGRN, EDGES),
Map.entry(RuleRegistry.OGRNIP, EDGES), Map.entry(PdTypes.OGRNIP, EDGES),
Map.entry(RuleRegistry.KPP, EDGES), Map.entry(PdTypes.KPP, EDGES),
// Срок действия карты — разделитель виден, сам месяц/год нет. // Срок действия карты — разделитель виден, сам месяц/год нет.
Map.entry(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation), Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
Map.entry(RuleRegistry.BIK, Strategies::stars), Map.entry(PdTypes.BIK, Strategies::stars),
Map.entry(RuleRegistry.INCOME, Strategies::stars), Map.entry(PdTypes.INCOME, Strategies::stars),
Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars) Map.entry(PdTypes.BIOMETRIC, Strategies::stars)
); );
public String mask(String type, String value, MaskMode mode, MaskContext context) { public String mask(String type, String value, MaskMode mode, MaskContext context) {
+24 -36
View File
@@ -1,6 +1,7 @@
package ru.pdguard.mask; package ru.pdguard.mask;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.Validators;
/** /**
* Правдоподобные подставные значения вместо настоящих. * Правдоподобные подставные значения вместо настоящих.
@@ -24,30 +25,30 @@ final class Synthetic {
static String forType(String type, String value, int ordinal) { static String forType(String type, String value, int ordinal) {
int seed = value.hashCode() & Integer.MAX_VALUE; int seed = value.hashCode() & Integer.MAX_VALUE;
return switch (type) { return switch (type) {
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3) case PdTypes.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
+ " " + pick(PATRONYMICS, seed >> 6); + " " + pick(PATRONYMICS, seed >> 6);
case RuleRegistry.CARDHOLDER -> "IVAN PETROV"; case PdTypes.CARDHOLDER -> "IVAN PETROV";
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2); + "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
case RuleRegistry.CARD -> luhnCard(seed); case PdTypes.CARD -> luhnCard(seed);
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT, case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT,
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
case RuleRegistry.INN -> digits(seed, 12); case PdTypes.INN -> digits(seed, 12);
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16); case PdTypes.MEDICAL_POLICY -> digits(seed, 16);
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2); + "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed); case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed);
case RuleRegistry.ADDRESS_CITY -> "Зареченск"; case PdTypes.ADDRESS_CITY -> "Зареченск";
case RuleRegistry.ADDRESS_STREET -> "Сосновая"; case PdTypes.ADDRESS_STREET -> "Сосновая";
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90)); case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200)); case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6); case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6);
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье"; case PdTypes.ADDRESS_COUNTRY -> "Заречье";
case RuleRegistry.ADDRESS_REGION -> "Заречная область"; case PdTypes.ADDRESS_REGION -> "Заречная область";
case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район"; case PdTypes.ADDRESS_DISTRICT -> "Сосновый район";
case RuleRegistry.CVV -> digits(seed, 3); case PdTypes.CVV -> digits(seed, 3);
case RuleRegistry.PIN -> digits(seed, 4); case PdTypes.PIN -> digits(seed, 4);
// Для остальных типов правдоподобной замены нет — отдаём токен. // Для остальных типов правдоподобной замены нет — отдаём токен.
default -> "[" + type + "_" + ordinal + "]"; default -> "[" + type + "_" + ordinal + "]";
}; };
@@ -77,20 +78,7 @@ final class Synthetic {
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */ /** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
private static String luhnCard(int seed) { private static String luhnCard(int seed) {
StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
int sum = 0; body.append(Validators.luhnCheckDigit(body.toString()));
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) + " " return body.substring(0, 4) + " " + body.substring(4, 8) + " "
+ body.substring(8, 12) + " " + body.substring(12); + body.substring(8, 12) + " " + body.substring(12);
} }
+4 -3
View File
@@ -6,6 +6,7 @@ import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
@@ -246,7 +247,7 @@ class BenchmarkTest {
} }
for (Span gold : sample.gold()) { for (Span gold : sample.gold()) {
if (!RuleRegistry.FIO.equals(gold.type())) { if (!PdTypes.FIO.equals(gold.type())) {
continue; continue;
} }
goldFioSpans++; goldFioSpans++;
@@ -261,7 +262,7 @@ class BenchmarkTest {
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans, report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked); cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
Score fio = byType.getOrDefault(RuleRegistry.FIO, new Score()); Score fio = byType.getOrDefault(PdTypes.FIO, new Score());
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts; double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
return new Result(fio.f1(), anyType.precision(), anyType.recall(), return new Result(fio.f1(), anyType.precision(), anyType.recall(),
falsePositiveRate, foundFioSpans, goldFioSpans); falsePositiveRate, foundFioSpans, goldFioSpans);
@@ -304,7 +305,7 @@ class BenchmarkTest {
private static boolean overlappedByFio(Span gold, List<Span> found) { private static boolean overlappedByFio(Span gold, List<Span> found) {
return found.stream() return found.stream()
.anyMatch(span -> RuleRegistry.FIO.equals(span.type()) && span.overlaps(gold)); .anyMatch(span -> PdTypes.FIO.equals(span.type()) && span.overlaps(gold));
} }
private static String fragment(String text, Span span) { private static String fragment(String text, Span span) {
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
@@ -123,7 +124,7 @@ class DateAndAddressTest {
@Test @Test
void addressTypesAreConfigurableSeparately() { void addressTypesAreConfigurableSeparately() {
SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY); SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY);
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity); String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
assertFalse(masked.contains("Москва"), masked); assertFalse(masked.contains("Москва"), masked);
+2 -1
View File
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import ru.pdguard.config.SystemPolicy; import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
@@ -114,7 +115,7 @@ class PipelineTest {
@Test @Test
void systemPolicyDisablesSelectedTypes() { void systemPolicyDisablesSelectedTypes() {
Pipeline pipeline = pipeline(); Pipeline pipeline = pipeline();
SystemPolicy onlyEmail = SystemPolicy.forTypes(RuleRegistry.EMAIL); SystemPolicy onlyEmail = SystemPolicy.forTypes(PdTypes.EMAIL);
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail); String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked); assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
@@ -5,6 +5,7 @@ import ru.pdguard.config.SystemPolicy;
import ru.pdguard.core.PayloadStore; import ru.pdguard.core.PayloadStore;
import ru.pdguard.core.Pipeline; import ru.pdguard.core.Pipeline;
import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.NameCascade;
import ru.pdguard.detect.PdTypes;
import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.RuleRegistry;
import ru.pdguard.detect.Span; import ru.pdguard.detect.Span;
import ru.pdguard.mask.Masker; import ru.pdguard.mask.Masker;
@@ -34,7 +35,7 @@ class TwoModelCascadeTest {
for (Span s : spans) { for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
} }
assertTrue(spans.stream().anyMatch(s -> s.type().equals(RuleRegistry.FIO)), "должно найти ФИО"); assertTrue(spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO)), "должно найти ФИО");
} }
@Test @Test
@@ -45,7 +46,7 @@ class TwoModelCascadeTest {
for (Span s : spans) { for (Span s : spans) {
System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]"); System.out.println(" -> " + s.type() + " [" + text.substring(s.start(), s.end()) + "]");
} }
assertTrue(spans.stream().noneMatch(s -> s.type().equals(RuleRegistry.FIO)), assertTrue(spans.stream().noneMatch(s -> s.type().equals(PdTypes.FIO)),
"известная личность не должна маскироваться"); "известная личность не должна маскироваться");
} }
} }