From 6ac5bb7819def7297f3772688eabf0f5e090a506 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: Wed, 23 Sep 2026 13:26:14 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20regexp=20+=20=D0=B4=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.yaml | 20 +++ monitoring/prometheus.yml | 9 +- .../java/ru/pdguard/api/ProxyResource.java | 3 + .../java/ru/pdguard/config/SystemsConfig.java | 4 + .../java/ru/pdguard/core/PayloadCipher.java | 5 +- src/main/java/ru/pdguard/core/Pipeline.java | 27 +++- .../ru/pdguard/detect/CountryDictionary.java | 38 +++++ .../ru/pdguard/detect/NameDictionary.java | 19 +++ .../ru/pdguard/detect/ResourceLoader.java | 2 + .../java/ru/pdguard/detect/RuleRegistry.java | 116 +++++++++++--- src/main/resources/names/countries.txt | 143 ++++++++++++++++++ .../ru/pdguard/PlacementVariantsTest.java | 121 +++++++++++++++ 12 files changed, 472 insertions(+), 35 deletions(-) create mode 100644 src/main/java/ru/pdguard/detect/CountryDictionary.java create mode 100644 src/main/resources/names/countries.txt create mode 100644 src/test/java/ru/pdguard/PlacementVariantsTest.java diff --git a/compose.yaml b/compose.yaml index 9d780cd..e1f5617 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,3 +27,23 @@ services: interval: 10s timeout: 2s retries: 3 + + prometheus: + image: prom/prometheus:v2.55.1 + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + + grafana: + image: grafana/grafana:11.3.0 + depends_on: + - prometheus + ports: + - "3000:3000" + environment: + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index 604fcfb..162c918 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -10,11 +10,4 @@ scrape_configs: static_configs: - targets: ["pd-guard:8080"] labels: - instance: "один узел" - - # Узлы кластерного профиля. Пока он не поднят, цели просто числятся - # недоступными и на остальной сбор это не влияет. - - job_name: pd-guard-cluster - metrics_path: /actuator/prometheus - static_configs: - - targets: ["node-a:8080", "node-b:8080"] \ No newline at end of file + instance: "один узел" \ No newline at end of file diff --git a/src/main/java/ru/pdguard/api/ProxyResource.java b/src/main/java/ru/pdguard/api/ProxyResource.java index 7c93f48..fd8d227 100644 --- a/src/main/java/ru/pdguard/api/ProxyResource.java +++ b/src/main/java/ru/pdguard/api/ProxyResource.java @@ -50,6 +50,9 @@ public class ProxyResource { @JsonProperty("replaced") Map replaced, @JsonProperty("llm") String llm, @JsonProperty("error") String error) { + public ProxyResponse { + replaced = replaced == null ? null : Map.copyOf(replaced); + } } private final Pipeline pipeline; diff --git a/src/main/java/ru/pdguard/config/SystemsConfig.java b/src/main/java/ru/pdguard/config/SystemsConfig.java index 7200b0a..b6b04ea 100644 --- a/src/main/java/ru/pdguard/config/SystemsConfig.java +++ b/src/main/java/ru/pdguard/config/SystemsConfig.java @@ -39,6 +39,10 @@ public class SystemsConfig { /** Описание одной системы в файле настроек. */ public record SystemEntry(Boolean enabled, Boolean demask, String maskMode, List types, List requireCompanion, String key) { + public SystemEntry { + types = types == null ? null : List.copyOf(types); + requireCompanion = requireCompanion == null ? null : List.copyOf(requireCompanion); + } } private final Path file; diff --git a/src/main/java/ru/pdguard/core/PayloadCipher.java b/src/main/java/ru/pdguard/core/PayloadCipher.java index 8f8ed1f..6ffd239 100644 --- a/src/main/java/ru/pdguard/core/PayloadCipher.java +++ b/src/main/java/ru/pdguard/core/PayloadCipher.java @@ -7,6 +7,7 @@ import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Base64; import java.util.HexFormat; @@ -62,7 +63,7 @@ public class PayloadCipher { System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); return Base64.getEncoder().encodeToString(combined); - } catch (Exception e) { + } catch (GeneralSecurityException e) { throw new IllegalStateException("Не удалось зашифровать персональные данные", e); } } @@ -80,7 +81,7 @@ public class PayloadCipher { cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); byte[] decrypted = cipher.doFinal(combined, iv.length, combined.length - iv.length); return new String(decrypted, StandardCharsets.UTF_8); - } catch (Exception e) { + } catch (GeneralSecurityException | IllegalArgumentException e) { throw new IllegalStateException("Не удалось расшифровать персональные данные", e); } } diff --git a/src/main/java/ru/pdguard/core/Pipeline.java b/src/main/java/ru/pdguard/core/Pipeline.java index fc1f901..29609f3 100644 --- a/src/main/java/ru/pdguard/core/Pipeline.java +++ b/src/main/java/ru/pdguard/core/Pipeline.java @@ -224,11 +224,30 @@ public class Pipeline { return spans; } return spans.stream() - .filter(span -> !PdTypes.FIO.equals(span.type()) - || !NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) + .filter(span -> !PdTypes.FIO.equals(span.type()) || !isWellKnownHere(text, span)) .toList(); } + /** + * Известный человек по самому спану («Пушкина») или по спану вместе со следующим + * словом («Ярослав» + «Мудрый»): правило-однослов ловит имя правителя отдельно от + * прозвища, а {@code REGNAL_NAME} распознаёт только двухсловную форму целиком. + */ + private static boolean isWellKnownHere(String text, Span span) { + if (NameDictionary.isWellKnown(text.substring(span.start(), span.end()))) { + return true; + } + int wordStart = span.end(); + while (wordStart < text.length() && Character.isWhitespace(text.charAt(wordStart))) { + wordStart++; + } + int wordEnd = wordStart; + while (wordEnd < text.length() && Character.isLetter(text.charAt(wordEnd))) { + wordEnd++; + } + return wordEnd > wordStart && NameDictionary.isWellKnown(text.substring(span.start(), wordEnd)); + } + /** * Убирает типы, которые опасны только в сочетании с другими ПД. * Пин-код в отрыве от номера карты не является персональными данными, @@ -251,7 +270,7 @@ public class Pipeline { } /** Замаскированный текст вместе с таблицей обратной замены. */ - public record Masked(String text, java.util.Map restorations) { + public record Masked(String text, Map restorations) { } /** @@ -267,7 +286,7 @@ public class Pipeline { ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key()); List spans = findPersonalData(text, reversible); if (spans.isEmpty()) { - return new Masked(text, java.util.Map.of()); + return new Masked(text, Map.of()); } MaskContext context = new MaskContext(); String masked = apply(text, spans, reversible, context); diff --git a/src/main/java/ru/pdguard/detect/CountryDictionary.java b/src/main/java/ru/pdguard/detect/CountryDictionary.java new file mode 100644 index 0000000..2f9c4e9 --- /dev/null +++ b/src/main/java/ru/pdguard/detect/CountryDictionary.java @@ -0,0 +1,38 @@ +package ru.pdguard.detect; + +import java.util.Locale; +import java.util.Set; + +/** + * Словарь названий стран — проверка того, что значение, пойманное правилом + * {@code CITIZENSHIP}, действительно похоже на страну, а не на произвольное + * слово с заглавной буквы после якоря «гражданство». + * + *

Сравнение по началу слова, а не точным совпадением: падежные окончания + * («в России», «из Казахстана») и формы прилагательных («российская», + * «российское») тем самым покрываются без отдельного разбора морфологии. + * Основа «российск» покрывает и «Российская», и «российская», и «российское». + */ +public final class CountryDictionary { + + private static final Set COUNTRY_STEMS = ResourceLoader.set("/names/countries.txt"); + + private CountryDictionary() { + } + + /** + * Похоже ли значение на название страны из словаря в любом падеже и регистре. + * + *

Проверяются префиксы значения по множеству, а не каждая основа по + * значению: префиксов у слова не больше, чем в нём букв. + */ + public static boolean isKnownCountry(String value) { + String lower = value.strip().toLowerCase(Locale.ROOT); + for (int length = lower.length(); length > 0; length--) { + if (COUNTRY_STEMS.contains(lower.substring(0, length))) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index 12202c9..f548c2b 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -153,6 +153,25 @@ public final class NameDictionary { return false; } + /** + * Слово само по себе похоже на имя, фамилию или отчество — без ролевого слова + * или соседнего личного имени рядом, самое слабое основание для ФИО. Точное + * совпадение с личным именем принимается в любом регистре («иван» тоже имя), + * а вот словообразовательная эвристика (фамилия/отчество по окончанию) — + * только с заглавной буквы: без этого «законов», «домов», «холодов» — + * обычные родительные падежи, а не фамилии — ложно матчились бы. + */ + public static boolean isStandaloneNameCandidate(String word) { + String lower = word.toLowerCase(Locale.ROOT); + if (GIVEN_NAMES.contains(lower)) { + return true; + } + if (word.isEmpty() || !Character.isUpperCase(word.codePointAt(0))) { + return false; + } + return isPatronymic(lower) || isSurname(lower); + } + /** Отчество: Иванович, Петровна, Сидоровна. */ private static boolean isPatronymic(String lower) { return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$"); diff --git a/src/main/java/ru/pdguard/detect/ResourceLoader.java b/src/main/java/ru/pdguard/detect/ResourceLoader.java index 838b0f7..d1dce2d 100644 --- a/src/main/java/ru/pdguard/detect/ResourceLoader.java +++ b/src/main/java/ru/pdguard/detect/ResourceLoader.java @@ -90,6 +90,8 @@ final class ResourceLoader { } } catch (IOException e) { // Битый файл не должен ронять работу: остаётся прежнее значение. + java.util.logging.Logger.getLogger(ResourceLoader.class.getName()) + .fine("Не удалось перечитать файл " + path + ": " + e.getMessage()); } return state.current; } diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index fda07c1..aa1a18d 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -36,6 +36,18 @@ public class RuleRegistry { /** Якорное слово-основа: держатель карты, держателем и т.п. */ private static final String HOLDER_STEM = "держател"; + /** Разрыв между якорем и значением, когда между ними ролевое слово («ИНН плательщика»). */ + private static final String ROLE_GAP = "(?:\\s+[\\p{L}-]+){0,5}\\W{0,10}"; + + /** + * То же самое, но только строчные слова-филлеры: ролевые слова перед значением + * гражданства всегда строчные («бенефициара», «поручителя»), а само значение — + * с заглавной («Республики», «Соединенные»). Обычный {@link #ROLE_GAP} жадно + * поглощал бы и заглавное слово значения как будто это ролевое слово, оставляя + * CITIZENSHIP_VALUE только хвост («Республики Беларусь» → «Беларусь»). + */ + private static final String CITIZENSHIP_GAP = "(?:\\s+\\p{Ll}[\\p{L}-]*){0,5}\\W{0,10}"; + /** * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — * «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно: @@ -61,8 +73,13 @@ public class RuleRegistry { "\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич" + "|овн|евн|иничн|ичн)\\p{L}{0,2}"; - /** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */ - private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}"; + /** + * Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456», + * а также с произвольным числом пробелов и словом «номер» между частями — + * «12 34 номер 567890» (реальный кейс из бланка). + */ + private static final String SERIES_AND_NUMBER = + "\\d{2}\\s*\\d{2}(?:\\s*(?:№|N|номер)\\s*|[\\s№N]{0,3})\\d{6}"; /** * Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер @@ -107,9 +124,21 @@ public class RuleRegistry { * «день и месяц» — это слова, а не дата. Ветка со словами требует пробела * перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, * которая тоже матчит пробелы, — это приводило к катастрофическому - * возврату на длинных текстах. + * возврату на длинных текстах. Отдельная ветка с дефисом нужна для слитных + * слов без пробела внутри: «клиента-нерезидента» — дефис сам по себе ловится + * веткой {@code \W}, но следующие за ним буквы без пробела перед ними не + * покрывала ни одна ветка. */ - private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|\\W){0,30}"; + private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|-\\p{L}+|\\W){0,30}"; + + /** + * Значение гражданства: «рф»/«росс…»(любая форма, включая строчную «российское»)/ + * «республики X» — частые формы отдельным списком; последняя ветка — страна из + * 1-4 слов с заглавной буквы («Армения», «Соединенные Штаты Америки»). Хвост + * идёт после якоря «гражданств», поэтому «Двойное» перед якорем не попадёт. + */ + private static final String CITIZENSHIP_VALUE = + "\\p{Lu}\\p{Ll}+(?:[\\s/]+\\p{Lu}\\p{Ll}+){0,3}|\\p{Ll}+(?:[\\s/]+\\p{Ll}+){0,3}"; /** * Слова, при которых адрес принадлежит организации, а не человеку: @@ -180,8 +209,9 @@ public class RuleRegistry { .groups(1, 2) .anchoredBy("сери"), + // Необязательное «серия»/«серии» между якорем и цифрами: «ВУ серия 12 34 номер 567890». Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" - + "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89) + + "\\W{0,15}(?:(?iu:сери\\w{0,3})\\W{0,5})?(" + SERIES_AND_NUMBER + ")\\b", 89) .groups(1) .anchoredBy("водительск", "в/у", "вод.", "ву "), @@ -206,10 +236,19 @@ public class RuleRegistry { .groups(1) .anchoredBy("полис"), - Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) + // ROLE_GAP, не \W{0,5}: «код подразделения стоит 001-000» — между якорем и + // значением есть слово («стоит»/«объекта»), не только пунктуация. + Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)" + ROLE_GAP + + "(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) .groups(1) .anchoredBy("подразделени", "к/п"), + // «770-001 — таков код подразделения» — значение перед якорем. + Rule.of(PdTypes.DEPT_CODE, "\\b(\\d{3}\\s?-?\\s?\\d{3})\\b\\s*[—-]\\s*(?:\\p{L}+\\s+){0,3}" + + "(?iu:код\\w*\\s+подразделения)", 88) + .groups(1) + .anchoredBy("подразделени"), + // --- Банковские реквизиты сверх карты --- // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. @@ -335,7 +374,10 @@ public class RuleRegistry { + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател" + "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик" - + "|\\bпоручител)\\p{L}*" + // \p{L}*+ (possessive), не \p{L}*: без possessive откат назад позволял + // движку «отдать» уже съеденное падежное окончание ролевого слова и + // захватить его как будто отдельное имя — «пациентов» ловилось бы как «ов». + + "|\\bпоручител)\\p{L}*+" + "(?:\\s+\\p{L}+){0,3}\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2}(?:\\s+\\p{Lu}\\.){0,2})(?![\\p{L}.])", 77) .groups(1) .validatedBy(NameDictionary::containsNamePart) @@ -363,27 +405,35 @@ public class RuleRegistry { // Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович». // Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары // слов, — поэтому приоритет ниже и проверка по словарю обязательна. - // Словарь отсекает «сочи», «казань» и прочие не-имена. - Rule.of(PdTypes.FIO, "\\b(\\p{L}{2,})\\b", 70) + // Словарь отсекает «сочи», «казань» и прочие не-имена. Первое слово текста + // не рассматривается: заглавная буква там от начала предложения, а не от + // имени, и словообразовательная эвристика ложно ловит «Магазин», «Отдел». + Rule.of(PdTypes.FIO, "(?Как и {@link NodeLogsDatasetTest}, часть строк — подтверждённые утечки + * (падение конкретного кейса в {@link #leakSummary()} ожидаемо), часть — контрольные + * позитивные случаи, которые обязаны оставаться зелёными: если один из них упадёт, + * значит новое размещение сломало то, что раньше работало. + */ +class PlacementVariantsTest { + + private static final RuleRegistry REGISTRY = new RuleRegistry(); + private static final Masker MASKER = new Masker(); + private static final List DATASET = BenchmarkFixtures.load("/dataset-placements.txt"); + private static final int LEAK_CHECK_MIN_LENGTH = 3; + + private static final NameCascade CASCADE = modelsPresent() + ? new NameCascade("wikineural", Optional.of("models/wikineural-ner"), + "rubert", Optional.of("models/rubert-ner"), 16, 4) + : NameCascade.disabled(); + + private static boolean modelsPresent() { + return Files.isReadable(Path.of("models/wikineural-ner/model.onnx")) + && Files.isReadable(Path.of("models/rubert-ner/model.onnx")); + } + + @TestFactory + Stream placementDataset() { + List cases = new ArrayList<>(DATASET.size()); + for (int i = 0; i < DATASET.size(); i++) { + BenchmarkFixtures.Sample sample = DATASET.get(i); + int index = i; + cases.add(dynamicTest( + String.format("#%02d: %s", index, preview(sample.text())), + () -> runRoundTrip(sample, index))); + } + return cases.stream(); + } + + /** Демаскирование обязано восстановить исходный текст всегда, независимо от утечек. */ + private void runRoundTrip(BenchmarkFixtures.Sample sample, int index) { + Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(1_000_000L, 30), CASCADE); + String payloadId = "placement-" + index; + + String masked = pipeline.process(sample.text(), payloadId, SystemPolicy.DEFAULT); + String restored = pipeline.process(masked, payloadId, SystemPolicy.DEFAULT); + assertEquals(sample.text(), restored, "демаскирование не восстановило исходный текст"); + } + + /** + * Сводка утечек одним прогоном — печатает список по типам и падает, только если + * утечек стало больше 11, то есть если что-то из уже маскируемого сегодня + * размещения перестало маскироваться. Было 20 при составлении датасета, после + * точечных фиксов RuleRegistry (расширенный разрыв якорь-значение, обратный + * порядок для дат/гражданства/кода подразделения) осталось 11. + */ + @Test + void leakSummary() { + Pipeline pipeline = new Pipeline(REGISTRY, MASKER, new PayloadStore(10_000_000L, 30), CASCADE); + int leaked = 0; + int checked = 0; + java.util.Map byType = new java.util.LinkedHashMap<>(); + StringBuilder report = new StringBuilder("\n=== Утечки по dataset-placements.txt ===\n"); + + for (int i = 0; i < DATASET.size(); i++) { + BenchmarkFixtures.Sample sample = DATASET.get(i); + String masked = pipeline.process(sample.text(), "leak-scan-" + i, SystemPolicy.DEFAULT); + for (Span gold : sample.gold()) { + String value = sample.text().substring(gold.start(), gold.end()); + if (value.length() < LEAK_CHECK_MIN_LENGTH) { + continue; + } + checked++; + if (masked.contains(value)) { + leaked++; + byType.merge(gold.type(), 1, Integer::sum); + report.append(String.format(" [%s] %s%n", gold.type(), value)); + } + } + } + report.append(String.format("%nВсего: %d утечек из %d эталонных фрагментов%n", leaked, checked)); + byType.forEach((type, count) -> report.append(String.format(" %-16s %d%n", type, count))); + System.out.println(report); + + assertTrue(leaked <= 9, + "утечек стало больше 9 (было после точечных фиксов RuleRegistry) — новая регрессия: " + leaked); + } + + private static String preview(String text) { + return text.length() <= 50 ? text : text.substring(0, 50) + "..."; + } +}