From aa4c926a8fd74fd7c58b4f7fda07515d69d7197c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=92=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Tue, 22 Sep 2026 22:39:16 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=83=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=D1=80=D1=83=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20DRY,=20SRP,=20KISS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DRY: вынести scoped() в общий ScopedKey (был дублирован в PayloadStore и SharedIndex) - DRY: вынести чтение словарей и перечитывание файлов в ResourceLoader (было дублировано в NameDictionary, ToponymDictionary, SystemsConfig) - DRY: вынести алгоритм Луна в Validators.luhnCheckDigit (Synthetic переиспользует) - SRP: вынести константы типов ПД из RuleRegistry в PdTypes - SRP: вынести проверку организаций из NameDictionary в OrganisationDetector - KISS: словари оставлены статическими (неизменяемые, детерминированные) Поведение не изменилось: 1840 тестов проходят, NodeLogsLeakTest по-прежнему падает на тех же 55 задокументированных утечках. --- .../java/ru/pdguard/config/SystemPolicy.java | 7 +- .../java/ru/pdguard/core/PayloadStore.java | 22 +-- src/main/java/ru/pdguard/core/Pipeline.java | 12 +- src/main/java/ru/pdguard/core/ScopedKey.java | 20 +++ .../java/ru/pdguard/core/SharedIndex.java | 14 +- .../java/ru/pdguard/detect/NameCascade.java | 18 +-- .../ru/pdguard/detect/NameDictionary.java | 147 +++++------------ .../pdguard/detect/OrganisationDetector.java | 35 ++++ src/main/java/ru/pdguard/detect/PdTypes.java | 58 +++++++ .../ru/pdguard/detect/ResourceLoader.java | 116 ++++++++++++++ .../java/ru/pdguard/detect/RuleRegistry.java | 150 +++++++----------- .../ru/pdguard/detect/ToponymDictionary.java | 29 +--- .../java/ru/pdguard/detect/Validators.java | 22 +++ src/main/java/ru/pdguard/mask/Masker.java | 78 ++++----- src/main/java/ru/pdguard/mask/Synthetic.java | 60 +++---- src/test/java/ru/pdguard/BenchmarkTest.java | 7 +- .../java/ru/pdguard/DateAndAddressTest.java | 3 +- src/test/java/ru/pdguard/PipelineTest.java | 3 +- .../java/ru/pdguard/TwoModelCascadeTest.java | 5 +- 19 files changed, 457 insertions(+), 349 deletions(-) create mode 100644 src/main/java/ru/pdguard/core/ScopedKey.java create mode 100644 src/main/java/ru/pdguard/detect/OrganisationDetector.java create mode 100644 src/main/java/ru/pdguard/detect/PdTypes.java create mode 100644 src/main/java/ru/pdguard/detect/ResourceLoader.java diff --git a/src/main/java/ru/pdguard/config/SystemPolicy.java b/src/main/java/ru/pdguard/config/SystemPolicy.java index 5ea7fd8..575f2ff 100644 --- a/src/main/java/ru/pdguard/config/SystemPolicy.java +++ b/src/main/java/ru/pdguard/config/SystemPolicy.java @@ -1,5 +1,6 @@ package ru.pdguard.config; +import ru.pdguard.detect.PdTypes; import ru.pdguard.mask.MaskMode; 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( DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL), - Set.of("CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY", - "ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP", - "INCOME", "BIOMETRIC"), null); + Set.of(PdTypes.CVV, PdTypes.PIN, PdTypes.DATE, PdTypes.BIRTH_PLACE, PdTypes.ADDRESS_COUNTRY, + PdTypes.ACCOUNT_NUMBER, PdTypes.BIK, PdTypes.OGRN, PdTypes.OGRNIP, PdTypes.KPP, + PdTypes.INCOME, PdTypes.BIOMETRIC), null); public SystemPolicy { types = Set.copyOf(types); diff --git a/src/main/java/ru/pdguard/core/PayloadStore.java b/src/main/java/ru/pdguard/core/PayloadStore.java index 514e49e..f7546ea 100644 --- a/src/main/java/ru/pdguard/core/PayloadStore.java +++ b/src/main/java/ru/pdguard/core/PayloadStore.java @@ -87,10 +87,10 @@ public class PayloadStore { long now = System.currentTimeMillis(); String encrypted = cipher.encrypt(original); 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); - byMaskFingerprint.put(scoped(system, entry.fingerprint()), entry); + byMaskFingerprint.put(ScopedKey.of(system, entry.fingerprint()), entry); insertionOrder.add(idKey); charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight())); @@ -101,7 +101,7 @@ public class PayloadStore { } public Entry byId(String system, String payloadId) { - String idKey = scoped(system, payloadId); + String idKey = ScopedKey.of(system, payloadId); Entry entry = byId.get(idKey); if (entry != null && entry.alive(System.currentTimeMillis())) { return decrypt(entry); @@ -126,7 +126,7 @@ public class PayloadStore { */ public String originalForMask(String system, String 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())) { return cipher.decrypt(entry.original()); } @@ -137,17 +137,9 @@ public class PayloadStore { public long charsHeld() { 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) { for (int i = 0; i < SWEEP_PER_PUT; i++) { String oldest = insertionOrder.peek(); @@ -185,7 +177,7 @@ public class PayloadStore { private void forget(String idKey, Entry 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()); } } diff --git a/src/main/java/ru/pdguard/core/Pipeline.java b/src/main/java/ru/pdguard/core/Pipeline.java index fef7845..fc1f901 100644 --- a/src/main/java/ru/pdguard/core/Pipeline.java +++ b/src/main/java/ru/pdguard/core/Pipeline.java @@ -11,6 +11,8 @@ import org.springframework.stereotype.Component; import ru.pdguard.config.SystemPolicy; import ru.pdguard.detect.NameCascade; import ru.pdguard.detect.NameDictionary; +import ru.pdguard.detect.OrganisationDetector; +import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; import ru.pdguard.mask.MaskContext; @@ -139,7 +141,7 @@ public class Pipeline { */ public List findPersonalData(String text, SystemPolicy policy) { List spans = resolveOverlaps(registry.detect(text, policy)); - if (policy.allows(RuleRegistry.FIO)) { + if (policy.allows(PdTypes.FIO)) { // Вторая ступень разбирает только то, что не покрыла первая. spans = resolveOverlaps(cascade.addMissedNames(text, spans)); } @@ -204,8 +206,8 @@ public class Pipeline { */ static List dropOrganisationNames(String text, List spans) { return spans.stream() - .filter(span -> !RuleRegistry.FIO.equals(span.type()) - || !NameDictionary.precededByOrganisation(text, span.start())) + .filter(span -> !PdTypes.FIO.equals(span.type()) + || !OrganisationDetector.precededByOrganisation(text, span.start())) .toList(); } @@ -217,12 +219,12 @@ public class Pipeline { */ static List dropWellKnownNames(String text, List spans) { boolean otherPersonalDataPresent = spans.stream() - .anyMatch(span -> !RuleRegistry.FIO.equals(span.type())); + .anyMatch(span -> !PdTypes.FIO.equals(span.type())); if (otherPersonalDataPresent) { return spans; } 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()))) .toList(); } diff --git a/src/main/java/ru/pdguard/core/ScopedKey.java b/src/main/java/ru/pdguard/core/ScopedKey.java new file mode 100644 index 0000000..c843a57 --- /dev/null +++ b/src/main/java/ru/pdguard/core/ScopedKey.java @@ -0,0 +1,20 @@ +package ru.pdguard.core; + +/** + * Ключ, однозначно разделяющий системы-потребители. + * + *

Длина имени в начале снимает вопрос о разделителе: имя системы может + * содержать любые знаки, и без длины «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; + } +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/core/SharedIndex.java b/src/main/java/ru/pdguard/core/SharedIndex.java index 939a3a7..1db06f7 100644 --- a/src/main/java/ru/pdguard/core/SharedIndex.java +++ b/src/main/java/ru/pdguard/core/SharedIndex.java @@ -83,9 +83,9 @@ public void put(String system, String payloadId, String original, String masked, } try { 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); - redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), encrypted, ttl); + redis.opsForValue().set(ScopedKey.of(system, maskFingerprint), encrypted, ttl); noteSuccess(); } catch (RuntimeException e) { noteFailure("записать", e); @@ -97,7 +97,7 @@ public void put(String system, String payloadId, String original, String masked, return null; } try { - String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId)); + String json = redis.opsForValue().get(ScopedKey.of(system, payloadId)); noteSuccess(); SharedEntry entry = json == null ? null : fromJson(json); 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; } try { - String encrypted = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint)); + String encrypted = redis.opsForValue().get(ScopedKey.of(system, maskFingerprint)); noteSuccess(); return encrypted == null ? null : cipher.decrypt(encrypted); } 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() { return !enabled || System.currentTimeMillis() < silentUntil; diff --git a/src/main/java/ru/pdguard/detect/NameCascade.java b/src/main/java/ru/pdguard/detect/NameCascade.java index c625cc5..117051f 100644 --- a/src/main/java/ru/pdguard/detect/NameCascade.java +++ b/src/main/java/ru/pdguard/detect/NameCascade.java @@ -56,16 +56,16 @@ public class NameCascade { /** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */ private static final Map NAME_TYPES = Map.of( - "PER", RuleRegistry.FIO); + "PER", PdTypes.FIO); /** Метки ruBERT в типы ПД: детальные составляющие адреса. */ private static final Map ADDRESS_TYPES = Map.of( - "COUNTRY", RuleRegistry.ADDRESS_COUNTRY, - "REGION", RuleRegistry.ADDRESS_REGION, - "DISTRICT", RuleRegistry.ADDRESS_DISTRICT, - "CITY", RuleRegistry.ADDRESS_CITY, - "STREET", RuleRegistry.ADDRESS_STREET, - "HOUSE", RuleRegistry.ADDRESS_HOUSE); + "COUNTRY", PdTypes.ADDRESS_COUNTRY, + "REGION", PdTypes.ADDRESS_REGION, + "DISTRICT", PdTypes.ADDRESS_DISTRICT, + "CITY", PdTypes.ADDRESS_CITY, + "STREET", PdTypes.ADDRESS_STREET, + "HOUSE", PdTypes.ADDRESS_HOUSE); /** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */ private static final Pattern CANDIDATE = Pattern.compile( @@ -221,7 +221,7 @@ private void collect(String text, int candidateStart, int candidateEnd, List RuleRegistry.FIO.equals(span.type()) + sink.removeIf(span -> PdTypes.FIO.equals(span.type()) && span.start() < candidateEnd && candidateStart < span.end() && span.priority() > PRIORITY); } diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index 81f12a8..aab79e0 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -3,20 +3,12 @@ package ru.pdguard.detect; import org.slf4j.Logger; 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.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -40,9 +32,8 @@ import java.util.stream.Collectors; public final class NameDictionary { private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class); - private static final long RECHECK_MILLIS = 1000; - private static final List GIVEN_NAME_STEMS = load("/names/given-names.txt").stream() + private static final List GIVEN_NAME_STEMS = ResourceLoader.lines("/names/given-names.txt", true).stream() .map(Declension::withoutInflectedEnding) .distinct() .sorted(Comparator.comparingInt(String::length).reversed()) @@ -51,28 +42,14 @@ public final class NameDictionary { // творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не // дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём, // что и для личных имён. - private static final Set BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream() + private static final Set BUNDLED_WELL_KNOWN_STEMS = ResourceLoader.set("/names/well-known.txt").stream() .map(Declension::withoutInflectedEnding) .collect(Collectors.toUnmodifiableSet()); - private static final AtomicReference externalFile = - new AtomicReference<>(Path.of("config/well-known.txt")); - private static final AtomicReference> wellKnownStems = - new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS); - private static volatile long externalTimestamp; - private static volatile long lastCheck; + private static final ResourceLoader.FileWatchState> WELL_KNOWN_STATE = + new ResourceLoader.FileWatchState<>(BUNDLED_WELL_KNOWN_STEMS); - /** - * Маркер организации вплотную перед именем. Слово может стоять в любом падеже, - * между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова». - */ - 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 Path EXTERNAL_FILE = Path.of("config/well-known.txt"); /** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */ private static final String REGNAL_ORDINALS = @@ -117,7 +94,13 @@ public final class NameDictionary { * экземпляра, поэтому путь хранится в статическом поле. */ 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; } - - /** - * Стоит ли перед именем слово, относящее его к организации или объекту на карте. - * - *

«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена - * в названиях, а не персональные данные. Отличие от списка известных людей в том, - * что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв - * защиту не теряет, а улица Королёва под маску не попадает. - */ - 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()) { return true; } - refreshIfChanged(); - Set stems = wellKnownStems.get(); + Set stems = currentWellKnownStems(); for (String word : value.split("\\P{L}+")) { String lower = word.toLowerCase(Locale.ROOT); for (int length = lower.length(); length > 0; length--) { @@ -191,82 +159,55 @@ public final class NameDictionary { /** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */ static void useExternalFile(Path path) { - externalFile.set(path); - externalTimestamp = -1; - lastCheck = 0; + WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; + WELL_KNOWN_STATE.mtime = -1; + WELL_KNOWN_STATE.lastCheck = 0; + // Перечитываем немедленно, минуя секундный троттлинг. + reloadExternal(path); } /** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */ - static synchronized void reloadExternal() { - lastCheck = System.currentTimeMillis(); - if (!Files.isReadable(externalFile.get())) { - if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) { + static synchronized void reloadExternal(Path path) { + WELL_KNOWN_STATE.lastCheck = System.currentTimeMillis(); + if (!java.nio.file.Files.isReadable(path)) { + if (WELL_KNOWN_STATE.current != BUNDLED_WELL_KNOWN_STEMS) { LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список", - externalFile.get().toAbsolutePath()); + path.toAbsolutePath()); } - wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS); - externalTimestamp = 0; + WELL_KNOWN_STATE.current = BUNDLED_WELL_KNOWN_STEMS; + WELL_KNOWN_STATE.mtime = 0; return; } try { - externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis(); + WELL_KNOWN_STATE.mtime = java.nio.file.Files.getLastModifiedTime(path).toMillis(); Set 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()); if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { merged.add(trimmed); } } - wellKnownStems.set(Set.copyOf(merged)); + WELL_KNOWN_STATE.current = Set.copyOf(merged); LOG.info("Денилист дополнен из {}: {} имён сверх встроенных", - externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); - } catch (IOException e) { + path.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size()); + } catch (java.io.IOException e) { // Битый файл не должен ронять маскирование: остаётся прежний список. - LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e); + LOG.error("Не удалось прочитать {}, денилист не изменён", path.toAbsolutePath(), e); } } - private static void refreshIfChanged() { - long now = System.currentTimeMillis(); - if (now - lastCheck < RECHECK_MILLIS) { - return; - } - lastCheck = now; - try { - if (!Files.isReadable(externalFile.get())) { - if (externalTimestamp != 0) { - reloadExternal(); - } - return; - } - if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) { - reloadExternal(); - } - } catch (IOException e) { - LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e); - } - } - - /** - * Основы сортируются от длинных к коротким: в чередовании регулярного - * выражения побеждает первая подошедшая ветка, и короткая основа не должна - * перехватывать совпадение у длинной. - */ - private static List 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); - } + private static Set currentWellKnownStems() { + return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE, + lines -> { + Set merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS); + for (String line : lines) { + String trimmed = Declension.withoutInflectedEnding(line); + if (!trimmed.isEmpty()) { + merged.add(trimmed); + } + } + return Set.copyOf(merged); + }, + BUNDLED_WELL_KNOWN_STEMS); } } \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/OrganisationDetector.java b/src/main/java/ru/pdguard/detect/OrganisationDetector.java new file mode 100644 index 0000000..e2eabe7 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/OrganisationDetector.java @@ -0,0 +1,35 @@ +package ru.pdguard.detect; + +import java.util.regex.Pattern; + +/** + * Проверка, стоит ли перед именем слово, относящее его к организации или + * объекту на карте. + * + *

«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена + * в названиях, а не персональные данные. Отличие от списка известных людей в том, + * что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв + * защиту не теряет, а улица Королёва под маску не попадает. + */ +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(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/PdTypes.java b/src/main/java/ru/pdguard/detect/PdTypes.java new file mode 100644 index 0000000..88089f0 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/PdTypes.java @@ -0,0 +1,58 @@ +package ru.pdguard.detect; + +/** + * Имена типов персональных данных, которые умеет распознавать сервис. + * + *

Вынесены из {@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"; +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/ResourceLoader.java b/src/main/java/ru/pdguard/detect/ResourceLoader.java new file mode 100644 index 0000000..bd42c61 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/ResourceLoader.java @@ -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; + +/** + * Общие приёмы чтения словарей и внешних файлов. + * + *

Словари лежат в сборке как ресурсы и читаются одинаково: строки обрезаются, + * пустые и комментарии отбрасываются. Внешние файлы (денилист, настройки систем) + * перечитываются, когда меняется время их изменения, и не чаще раза в секунду — + * чтобы не ходить в файловую систему на каждом запросе. Обе задачи вынесены сюда, + * чтобы не дублировать их в каждом словаре. + */ +final class ResourceLoader { + + private ResourceLoader() { + } + + /** + * Читает строки ресурса, отбрасывая пустые и комментарии. + * + * @param resource путь к ресурсу в classpath + * @param sortByLength сортировать ли от длинных к коротким (нужно для чередований) + */ + static List 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 set(String resource) { + return lines(resource, false).stream().collect(Collectors.toUnmodifiableSet()); + } + + /** + * Перечитывает внешний файл, когда меняется время его изменения, не чаще раза + * в секунду. Возвращает текущее содержимое; при недоступности файла — прежнее. + * + * @param path путь к файлу + * @param state состояние проверки (время последней проверки и mtime файла) + * @param reader как превратить строки файла в итоговое значение + * @param fallback значение, если файл недоступен + */ + static T refreshIfChanged(Path path, FileWatchState state, + Function, 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 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 { + 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index f5e0088..fa8165b 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -27,48 +27,6 @@ import java.util.regex.Pattern; @Component 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 ADDRESS_TYPES = java.util.Set.of( - ADDRESS_COUNTRY, ADDRESS_REGION, ADDRESS_DISTRICT, ADDRESS_CITY, - ADDRESS_STREET, ADDRESS_HOUSE, ADDRESS_FLAT, ADDRESS_POSTCODE); + PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY, + PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE); public static boolean isAddressType(String type) { return ADDRESS_TYPES.contains(type); @@ -162,16 +120,16 @@ public class RuleRegistry { // --- Уровень 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) .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) .anchoredBy("пин", "pin"), // «паспорт 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) .groups(1) .anchoredBy("паспорт"), @@ -179,54 +137,54 @@ public class RuleRegistry { // «серия 4509 номер 123456», «серии 45 09 № 123456» // Между серией и номером помещается слово: «серия 4509 номер 123456», // «серии 4509 за номером 123456», «серия 4509 № 123456». - Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90) + 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) .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) .groups(1) .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) .groups(1) .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) .groups(1) .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) .groups(1) .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) .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) .anchoredBy("подразделени", "к/п"), // --- Банковские реквизиты сверх карты --- // Расчётный счёт — ровно 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) .groups(1) .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) .anchoredBy("бик"), // «действительна до 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) .groups(1) .anchoredBy("срок действия", "действительн", "exp"), @@ -234,58 +192,58 @@ public class RuleRegistry { // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 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) .validatedBy(Validators::ogrnip) .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) .validatedBy(Validators::ogrn) .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) .anchoredBy("кпп"), // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может // стоять слово («доход клиента», «доход за год») — без этого якорь ловил // бы только «доход 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) .groups(1) .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) .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) .validatedBy(Validators::date) .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) .validatedBy(Validators::date) .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) .validatedBy(Validators::date) .anchoredBy("г.р", "г/р", "года рождения"), // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» - Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87) + Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87) .groups(1) .validatedBy(Validators::date) .anchoredBy("выдач"), - Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" + Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) .groups(1) .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(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79), + Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 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) .groups(1) .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заёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + "|\\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) .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) .vetoedBy(ORGANISATION_NEARBY), // --- Уровень 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), - 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) .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) .validatedBy(Validators::snils), // --- Уровень 2: формат однозначен сам по себе --- - Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), + Rule.of(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("@"), // --- Уровень 3: свободный текст после якорного слова --- @@ -367,38 +325,38 @@ public class RuleRegistry { // «выдан ОУФМС России по г. Москве 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) .groups(1) .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) .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) .groups(1) .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) .groups(1) .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) .groups(1) .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) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("индекс"), - Rule.of(ADDRESS_POSTCODE, + Rule.of(PdTypes.ADDRESS_POSTCODE, "\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74) .groups(1) .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)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73) .groups(1) @@ -415,7 +373,7 @@ public class RuleRegistry { .anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", "дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"), - Rule.of(ADDRESS_STREET, + Rule.of(PdTypes.ADDRESS_STREET, "(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*" + "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)" + "\\W{0,3}(" + STREET_NAME + ")", 73) @@ -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) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .requiringNear(ADDRESS_NEARBY) .anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"), - Rule.of(ADDRESS_HOUSE, + Rule.of(PdTypes.ADDRESS_HOUSE, "(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("д.", "дом"), - Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) + Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("кв"), - Rule.of(ADDRESS_COUNTRY, + Rule.of(PdTypes.ADDRESS_COUNTRY, "(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)" + "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71) .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), // Дата без якорного слова персональными данными сама по себе не является: // маскируется, только если в тексте есть ПД другого типа. - Rule.of(DATE, DATE_ANY, 58) + Rule.of(PdTypes.DATE, DATE_ANY, 58) .validatedBy(Validators::date) ); diff --git a/src/main/java/ru/pdguard/detect/ToponymDictionary.java b/src/main/java/ru/pdguard/detect/ToponymDictionary.java index 3ef67a5..1bdb8f4 100644 --- a/src/main/java/ru/pdguard/detect/ToponymDictionary.java +++ b/src/main/java/ru/pdguard/detect/ToponymDictionary.java @@ -1,14 +1,7 @@ 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.Set; -import java.util.stream.Collectors; /** * Словарь населённых пунктов России — проверка того, что значение, пойманное @@ -28,9 +21,9 @@ import java.util.stream.Collectors; */ public final class ToponymDictionary { - private static final Set SETTLEMENT_STEMS = load("/names/settlements.txt").stream() + private static final Set SETTLEMENT_STEMS = ResourceLoader.set("/names/settlements.txt").stream() .map(Declension::withoutInflectedEnding) - .collect(Collectors.toUnmodifiableSet()); + .collect(java.util.stream.Collectors.toUnmodifiableSet()); private ToponymDictionary() { } @@ -58,20 +51,4 @@ public final class ToponymDictionary { } return false; } - - private static Set 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); - } - } -} +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/Validators.java b/src/main/java/ru/pdguard/detect/Validators.java index 2e8c829..48f6679 100644 --- a/src/main/java/ru/pdguard/detect/Validators.java +++ b/src/main/java/ru/pdguard/detect/Validators.java @@ -37,6 +37,28 @@ public final class Validators { 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 у физлица. */ public static boolean inn(String value) { int[] d = digits(value); diff --git a/src/main/java/ru/pdguard/mask/Masker.java b/src/main/java/ru/pdguard/mask/Masker.java index 3a4c8bc..27d21d3 100644 --- a/src/main/java/ru/pdguard/mask/Masker.java +++ b/src/main/java/ru/pdguard/mask/Masker.java @@ -1,7 +1,7 @@ package ru.pdguard.mask; import org.springframework.stereotype.Component; -import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.detect.PdTypes; import java.util.Map; import java.util.function.UnaryOperator; @@ -19,57 +19,57 @@ public class Masker { private static final UnaryOperator SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2); private static final Map> BY_TYPE = Map.ofEntries( - Map.entry(RuleRegistry.EMAIL, Strategies::email), - Map.entry(RuleRegistry.PHONE, EDGES), - Map.entry(RuleRegistry.CARD, EDGES), - Map.entry(RuleRegistry.INN, EDGES), - Map.entry(RuleRegistry.SNILS, EDGES), - Map.entry(RuleRegistry.PASSPORT, EDGES), - Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES), - Map.entry(RuleRegistry.DEPT_CODE, EDGES), + Map.entry(PdTypes.EMAIL, Strategies::email), + Map.entry(PdTypes.PHONE, EDGES), + Map.entry(PdTypes.CARD, EDGES), + Map.entry(PdTypes.INN, EDGES), + Map.entry(PdTypes.SNILS, EDGES), + Map.entry(PdTypes.PASSPORT, EDGES), + Map.entry(PdTypes.DRIVER_LICENSE, EDGES), + Map.entry(PdTypes.DEPT_CODE, EDGES), // У этих документов серия короткая — две цифры или две буквы. Оставь мы // первые два знака, серия оказалась бы открыта целиком, поэтому видны // только последние. У паспорта РФ и водительского удостоверения серия // из четырёх знаков, там открывается половина. - Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES), - Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES), - Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES), - Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES), - Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials), - Map.entry(RuleRegistry.FIO, Strategies::initials), + Map.entry(PdTypes.FOREIGN_PASSPORT, SHORT_SERIES), + Map.entry(PdTypes.MILITARY_ID, SHORT_SERIES), + Map.entry(PdTypes.BIRTH_CERTIFICATE, SHORT_SERIES), + Map.entry(PdTypes.MEDICAL_POLICY, EDGES), + Map.entry(PdTypes.CARDHOLDER, Strategies::initials), + Map.entry(PdTypes.FIO, Strategies::initials), // Код проверки и пин-код не показываем даже частично: у них слишком // мало знаков, чтобы открывать хотя бы один. - Map.entry(RuleRegistry.CVV, Strategies::stars), - Map.entry(RuleRegistry.PIN, Strategies::stars), + Map.entry(PdTypes.CVV, 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(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation), - Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation), + Map.entry(PdTypes.BIRTH_DATE, Strategies::starsKeepingPunctuation), + Map.entry(PdTypes.PASSPORT_DATE, Strategies::starsKeepingPunctuation), + Map.entry(PdTypes.DATE, Strategies::starsKeepingPunctuation), - Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars), - Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars), - Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars), - Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars), + Map.entry(PdTypes.ADDRESS_COUNTRY, Strategies::stars), + Map.entry(PdTypes.ADDRESS_POSTCODE, Strategies::stars), + Map.entry(PdTypes.ADDRESS_CITY, Strategies::stars), + Map.entry(PdTypes.ADDRESS_STREET, Strategies::stars), + Map.entry(PdTypes.ADDRESS_HOUSE, Strategies::stars), + Map.entry(PdTypes.ADDRESS_FLAT, Strategies::stars), + Map.entry(PdTypes.ADDRESS_REGION, Strategies::stars), + Map.entry(PdTypes.ADDRESS_DISTRICT, Strategies::stars), + Map.entry(PdTypes.BIRTH_PLACE, Strategies::stars), + Map.entry(PdTypes.CITIZENSHIP, Strategies::stars), - Map.entry(RuleRegistry.ACCOUNT_NUMBER, EDGES), - Map.entry(RuleRegistry.OGRN, EDGES), - Map.entry(RuleRegistry.OGRNIP, EDGES), - Map.entry(RuleRegistry.KPP, EDGES), + Map.entry(PdTypes.ACCOUNT_NUMBER, EDGES), + Map.entry(PdTypes.OGRN, EDGES), + Map.entry(PdTypes.OGRNIP, EDGES), + Map.entry(PdTypes.KPP, EDGES), // Срок действия карты — разделитель виден, сам месяц/год нет. - Map.entry(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation), - Map.entry(RuleRegistry.BIK, Strategies::stars), - Map.entry(RuleRegistry.INCOME, Strategies::stars), - Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars) + Map.entry(PdTypes.CARD_EXPIRY, Strategies::starsKeepingPunctuation), + Map.entry(PdTypes.BIK, Strategies::stars), + Map.entry(PdTypes.INCOME, Strategies::stars), + Map.entry(PdTypes.BIOMETRIC, Strategies::stars) ); public String mask(String type, String value, MaskMode mode, MaskContext context) { diff --git a/src/main/java/ru/pdguard/mask/Synthetic.java b/src/main/java/ru/pdguard/mask/Synthetic.java index b0167a3..9112ccc 100644 --- a/src/main/java/ru/pdguard/mask/Synthetic.java +++ b/src/main/java/ru/pdguard/mask/Synthetic.java @@ -1,6 +1,7 @@ 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) { int seed = value.hashCode() & Integer.MAX_VALUE; 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); - case RuleRegistry.CARDHOLDER -> "IVAN PETROV"; - case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); - case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) + case PdTypes.CARDHOLDER -> "IVAN PETROV"; + case PdTypes.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed); + case PdTypes.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3) + "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2); - case RuleRegistry.CARD -> luhnCard(seed); - case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT, - RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); - case RuleRegistry.INN -> digits(seed, 12); - case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16); - case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) + case PdTypes.CARD -> luhnCard(seed); + case PdTypes.PASSPORT, PdTypes.DRIVER_LICENSE, PdTypes.FOREIGN_PASSPORT, + PdTypes.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6); + case PdTypes.INN -> digits(seed, 12); + case PdTypes.MEDICAL_POLICY -> digits(seed, 16); + case PdTypes.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3) + "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2); - case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed); - case RuleRegistry.ADDRESS_CITY -> "Зареченск"; - case RuleRegistry.ADDRESS_STREET -> "Сосновая"; - case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90)); - case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200)); - case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6); - case RuleRegistry.ADDRESS_COUNTRY -> "Заречье"; - case RuleRegistry.ADDRESS_REGION -> "Заречная область"; - case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район"; - case RuleRegistry.CVV -> digits(seed, 3); - case RuleRegistry.PIN -> digits(seed, 4); + case PdTypes.BIRTH_DATE, PdTypes.PASSPORT_DATE, PdTypes.DATE -> syntheticDate(seed); + case PdTypes.ADDRESS_CITY -> "Зареченск"; + case PdTypes.ADDRESS_STREET -> "Сосновая"; + case PdTypes.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90)); + case PdTypes.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200)); + case PdTypes.ADDRESS_POSTCODE -> digits(seed, 6); + case PdTypes.ADDRESS_COUNTRY -> "Заречье"; + case PdTypes.ADDRESS_REGION -> "Заречная область"; + case PdTypes.ADDRESS_DISTRICT -> "Сосновый район"; + case PdTypes.CVV -> digits(seed, 3); + case PdTypes.PIN -> digits(seed, 4); // Для остальных типов правдоподобной замены нет — отдаём токен. default -> "[" + type + "_" + ordinal + "]"; }; @@ -77,20 +78,7 @@ final class Synthetic { /** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */ private static String luhnCard(int seed) { StringBuilder body = new StringBuilder("4").append(digits(seed, 14)); - int sum = 0; - boolean doubled = true; - for (int i = body.length() - 1; i >= 0; i--) { - int d = body.charAt(i) - '0'; - if (doubled) { - d *= 2; - if (d > 9) { - d -= 9; - } - } - sum += d; - doubled = !doubled; - } - body.append((10 - sum % 10) % 10); + body.append(Validators.luhnCheckDigit(body.toString())); return body.substring(0, 4) + " " + body.substring(4, 8) + " " + body.substring(8, 12) + " " + body.substring(12); } diff --git a/src/test/java/ru/pdguard/BenchmarkTest.java b/src/test/java/ru/pdguard/BenchmarkTest.java index 23a1e39..88a794f 100644 --- a/src/test/java/ru/pdguard/BenchmarkTest.java +++ b/src/test/java/ru/pdguard/BenchmarkTest.java @@ -6,6 +6,7 @@ import ru.pdguard.core.PayloadStore; import ru.pdguard.core.Pipeline; import ru.pdguard.detect.Span; import ru.pdguard.detect.NameCascade; +import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.mask.Masker; @@ -246,7 +247,7 @@ class BenchmarkTest { } for (Span gold : sample.gold()) { - if (!RuleRegistry.FIO.equals(gold.type())) { + if (!PdTypes.FIO.equals(gold.type())) { continue; } goldFioSpans++; @@ -261,7 +262,7 @@ class BenchmarkTest { report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans, 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; return new Result(fio.f1(), anyType.precision(), anyType.recall(), falsePositiveRate, foundFioSpans, goldFioSpans); @@ -304,7 +305,7 @@ class BenchmarkTest { private static boolean overlappedByFio(Span gold, List found) { 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) { diff --git a/src/test/java/ru/pdguard/DateAndAddressTest.java b/src/test/java/ru/pdguard/DateAndAddressTest.java index 1a2d089..719178c 100644 --- a/src/test/java/ru/pdguard/DateAndAddressTest.java +++ b/src/test/java/ru/pdguard/DateAndAddressTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.mask.Masker; @@ -123,7 +124,7 @@ class DateAndAddressTest { @Test void addressTypesAreConfigurableSeparately() { - SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY); + SystemPolicy onlyCity = SystemPolicy.forTypes(PdTypes.ADDRESS_CITY); String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity); assertFalse(masked.contains("Москва"), masked); diff --git a/src/test/java/ru/pdguard/PipelineTest.java b/src/test/java/ru/pdguard/PipelineTest.java index bc92b21..b5f55c1 100644 --- a/src/test/java/ru/pdguard/PipelineTest.java +++ b/src/test/java/ru/pdguard/PipelineTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; import ru.pdguard.core.Pipeline; +import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.mask.Masker; @@ -114,7 +115,7 @@ class PipelineTest { @Test void systemPolicyDisablesSelectedTypes() { 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); assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked); diff --git a/src/test/java/ru/pdguard/TwoModelCascadeTest.java b/src/test/java/ru/pdguard/TwoModelCascadeTest.java index 389386c..2f674ef 100644 --- a/src/test/java/ru/pdguard/TwoModelCascadeTest.java +++ b/src/test/java/ru/pdguard/TwoModelCascadeTest.java @@ -5,6 +5,7 @@ import ru.pdguard.config.SystemPolicy; import ru.pdguard.core.PayloadStore; import ru.pdguard.core.Pipeline; import ru.pdguard.detect.NameCascade; +import ru.pdguard.detect.PdTypes; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; import ru.pdguard.mask.Masker; @@ -34,7 +35,7 @@ class TwoModelCascadeTest { for (Span s : spans) { 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 @@ -45,7 +46,7 @@ class TwoModelCascadeTest { for (Span s : spans) { 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)), "известная личность не должна маскироваться"); } }