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:
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Span> findPersonalData(String text, SystemPolicy 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));
|
||||
}
|
||||
@@ -204,8 +206,8 @@ public class Pipeline {
|
||||
*/
|
||||
static List<Span> dropOrganisationNames(String text, List<Span> 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<Span> dropWellKnownNames(String text, List<Span> 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -56,16 +56,16 @@ public class NameCascade {
|
||||
|
||||
/** Метки WikiNEuRal в типы ПД: только PER — имя. Адреса размечает ruBERT. */
|
||||
private static final Map<String, String> NAME_TYPES = Map.of(
|
||||
"PER", RuleRegistry.FIO);
|
||||
"PER", PdTypes.FIO);
|
||||
|
||||
/** Метки ruBERT в типы ПД: детальные составляющие адреса. */
|
||||
private static final Map<String, String> 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<Spa
|
||||
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
|
||||
if (isAccepted(text, candidateStart, candidateEnd, span)) {
|
||||
sink.add(span);
|
||||
if (RuleRegistry.FIO.equals(span.type())) {
|
||||
if (PdTypes.FIO.equals(span.type())) {
|
||||
nameFound = true;
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ private void collect(String text, int candidateStart, int candidateEnd, List<Spa
|
||||
// полное имя при разрешении перекрытий. Убираем такие частичные находки правил,
|
||||
// чтобы полное имя от модели осталось: избыточное покрытие безопаснее утечки ПД.
|
||||
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.priority() > PRIORITY);
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
.distinct()
|
||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||
@@ -51,28 +42,14 @@ public final class NameDictionary {
|
||||
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||
// дописыванием — без отсечения «а» их 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)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private static final AtomicReference<Path> externalFile =
|
||||
new AtomicReference<>(Path.of("config/well-known.txt"));
|
||||
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 ResourceLoader.FileWatchState<Set<String>> 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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
|
||||
*
|
||||
* <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()) {
|
||||
return true;
|
||||
}
|
||||
refreshIfChanged();
|
||||
Set<String> stems = wellKnownStems.get();
|
||||
Set<String> 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<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());
|
||||
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<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);
|
||||
}
|
||||
private static Set<String> currentWellKnownStems() {
|
||||
return ResourceLoader.refreshIfChanged(EXTERNAL_FILE, WELL_KNOWN_STATE,
|
||||
lines -> {
|
||||
Set<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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(
|
||||
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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
|
||||
private static final Set<String> 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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрольная цифра Луна для последовательности цифр: дописывается к телу
|
||||
* номера, чтобы весь номер прошёл проверку {@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);
|
||||
|
||||
@@ -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<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
|
||||
|
||||
private static final Map<String, UnaryOperator<String>> 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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user