PD Guard: модуль безопасности персональных данных
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Словари для распознавания ФИО.
|
||||
*
|
||||
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
|
||||
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
|
||||
*
|
||||
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
|
||||
* персональными данными не является. Ограничение осознанное: клиент по фамилии
|
||||
* Пушкин в тексте без других ПД замаскирован не будет.
|
||||
*
|
||||
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
|
||||
* него можно дописать своих публичных лиц без пересборки — файл по пути
|
||||
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
|
||||
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
|
||||
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
|
||||
* изменения, содержимое читается заново только когда оно другое.
|
||||
*/
|
||||
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()
|
||||
.map(Declension::withoutInflectedEnding)
|
||||
.distinct()
|
||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||
.toList();
|
||||
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
|
||||
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
||||
// что и для личных имён.
|
||||
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/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 Pattern ORGANISATION_BEFORE = Pattern.compile(
|
||||
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
|
||||
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
|
||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||
|
||||
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
|
||||
private static final int ORGANISATION_LOOKBEHIND = 40;
|
||||
|
||||
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
|
||||
private static final String REGNAL_ORDINALS =
|
||||
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
|
||||
|
||||
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
|
||||
private static final String REGNAL_EPITHETS =
|
||||
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
|
||||
|
||||
/**
|
||||
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
|
||||
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
|
||||
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
|
||||
*/
|
||||
private static final Pattern REGNAL_NAME = Pattern.compile(
|
||||
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
|
||||
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
|
||||
|
||||
/** Не более скольких падежных букв дописывается к основе имени. */
|
||||
private static final int MAX_INFLECTION = 3;
|
||||
|
||||
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
||||
private static final Set<String> SURNAME_SUFFIXES = Set.of(
|
||||
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
|
||||
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
|
||||
|
||||
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
|
||||
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
private NameDictionary() {
|
||||
}
|
||||
|
||||
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
|
||||
public static NameDictionary create() {
|
||||
return new NameDictionary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
|
||||
* из конфигурации Spring-бина; статические методы словаря работают без
|
||||
* экземпляра, поэтому путь хранится в статическом поле.
|
||||
*/
|
||||
public static void configure(String wellKnownFile) {
|
||||
externalFile.set(Path.of(wellKnownFile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||
*
|
||||
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
|
||||
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
|
||||
* приходится не больше четырёх обращений к хеш-таблице.
|
||||
*/
|
||||
public static boolean containsGivenName(String value) {
|
||||
for (String word : value.split("\\P{L}+")) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
|
||||
// но это имя, а не фамилия.
|
||||
if (GIVEN_NAMES.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
|
||||
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
|
||||
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
|
||||
// человека, а «Марина Шевченко» переставала бы им быть.
|
||||
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
|
||||
if (GIVEN_NAMES.contains(lower.substring(0, length))
|
||||
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Содержит ли текст упоминание известного человека — из сборки или дописанных
|
||||
* сверху.
|
||||
*
|
||||
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
|
||||
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
|
||||
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
|
||||
* префиксов у слова — не больше, чем в нём букв.
|
||||
*/
|
||||
public static boolean isWellKnown(String value) {
|
||||
if (REGNAL_NAME.matcher(value.strip()).matches()) {
|
||||
return true;
|
||||
}
|
||||
refreshIfChanged();
|
||||
Set<String> stems = wellKnownStems.get();
|
||||
for (String word : value.split("\\P{L}+")) {
|
||||
String lower = word.toLowerCase(Locale.ROOT);
|
||||
for (int length = lower.length(); length > 0; length--) {
|
||||
if (stems.contains(lower.substring(0, length))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
|
||||
static void useExternalFile(Path path) {
|
||||
externalFile.set(path);
|
||||
externalTimestamp = -1;
|
||||
lastCheck = 0;
|
||||
}
|
||||
|
||||
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
|
||||
static synchronized void reloadExternal() {
|
||||
lastCheck = System.currentTimeMillis();
|
||||
if (!Files.isReadable(externalFile.get())) {
|
||||
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) {
|
||||
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
|
||||
externalFile.get().toAbsolutePath());
|
||||
}
|
||||
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS);
|
||||
externalTimestamp = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis();
|
||||
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) {
|
||||
String trimmed = Declension.withoutInflectedEnding(line.trim());
|
||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||
merged.add(trimmed);
|
||||
}
|
||||
}
|
||||
wellKnownStems.set(Set.copyOf(merged));
|
||||
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
|
||||
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
||||
} catch (IOException e) {
|
||||
// Битый файл не должен ронять маскирование: остаётся прежний список.
|
||||
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user