Init
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.core.Span;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Замер качества детекции на размеченных наборах.
|
||||
*
|
||||
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
|
||||
* его оценка завышена и годится только как защита от ухудшений.
|
||||
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
|
||||
* настраивались — именно он показывает настоящее качество.
|
||||
*
|
||||
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
|
||||
* поставило границу совпадения, и напрямую соотносятся с посимвольным
|
||||
* сравнением замаскированного текста с эталоном.
|
||||
*
|
||||
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
|
||||
* а расхождение в названии типа (скажем, место рождения против города) на
|
||||
* качество маскирования не влияет.
|
||||
*/
|
||||
class BenchmarkTest {
|
||||
|
||||
/** Модель второй ступени; собирается отдельно, см. README. */
|
||||
private static final String MODEL_PATH = "models/ru-ner-person.bin";
|
||||
|
||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||
|
||||
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
||||
private record Sample(String text, List<Span> gold) {
|
||||
}
|
||||
|
||||
/** Итог замера по одному набору. */
|
||||
private record Result(double fioF1, double overallPrecision, double overallRecall,
|
||||
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
|
||||
}
|
||||
|
||||
/** Накопитель посимвольных совпадений по одному типу. */
|
||||
private static final class Score {
|
||||
private int truePositive;
|
||||
private int falsePositive;
|
||||
private int falseNegative;
|
||||
|
||||
private int gold() {
|
||||
return truePositive + falseNegative;
|
||||
}
|
||||
|
||||
private double precision() {
|
||||
int found = truePositive + falsePositive;
|
||||
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||
}
|
||||
|
||||
private double recall() {
|
||||
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||
}
|
||||
|
||||
private double f1() {
|
||||
double p = precision();
|
||||
double r = recall();
|
||||
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||
}
|
||||
}
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||
|
||||
/**
|
||||
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
|
||||
* означает, что сломалось то, что раньше работало.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnTuningSet() {
|
||||
Result result = measure("/benchmark.txt", "набор отладки");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.95,
|
||||
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.95,
|
||||
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
|
||||
assertTrue(result.falsePositiveRate() <= 0.05,
|
||||
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
|
||||
* отражают измеренное на нём качество, а не желаемое.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnHoldoutSet() {
|
||||
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.75,
|
||||
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.75,
|
||||
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
assertTrue(result.falsePositiveRate() <= 0.15,
|
||||
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
|
||||
* показывать качество на данных, которых разработка не видела. Пороги здесь
|
||||
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
|
||||
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityOnSecondHoldoutSet() {
|
||||
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||
new NameCascade(Optional.of(MODEL_PATH), 16, 4))
|
||||
: pipeline;
|
||||
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.70,
|
||||
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
|
||||
assertTrue(result.overallRecall() >= 0.70,
|
||||
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
|
||||
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
|
||||
*/
|
||||
@Test
|
||||
void detectionQualityWithNameCascade() {
|
||||
Path model = Path.of(MODEL_PATH);
|
||||
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||
|
||||
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(10_000_000L, 30), new NameCascade(Optional.of(MODEL_PATH), 16, 4));
|
||||
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
|
||||
|
||||
assertTrue(result.fioF1() >= 0.75,
|
||||
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
|
||||
}
|
||||
|
||||
private Result measure(String resource, String title) {
|
||||
return measure(pipeline, resource, title);
|
||||
}
|
||||
|
||||
private Result measure(Pipeline stage, String resource, String title) {
|
||||
List<Sample> samples = load(resource);
|
||||
Map<String, Score> byType = new LinkedHashMap<>();
|
||||
Score anyType = new Score();
|
||||
|
||||
int cleanTexts = 0;
|
||||
int cleanTextsWithFalseHit = 0;
|
||||
int goldFioSpans = 0;
|
||||
int foundFioSpans = 0;
|
||||
List<String> falseHits = new ArrayList<>();
|
||||
List<String> missedFio = new ArrayList<>();
|
||||
List<String> overMasked = new ArrayList<>();
|
||||
|
||||
for (Sample sample : samples) {
|
||||
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||
|
||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||
String[] foundChars = paint(sample.text().length(), found);
|
||||
|
||||
for (int i = 0; i < sample.text().length(); i++) {
|
||||
account(byType, goldChars[i], foundChars[i]);
|
||||
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
|
||||
}
|
||||
|
||||
if (sample.gold().isEmpty()) {
|
||||
cleanTexts++;
|
||||
if (!found.isEmpty()) {
|
||||
cleanTextsWithFalseHit++;
|
||||
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
|
||||
}
|
||||
} else {
|
||||
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
|
||||
}
|
||||
|
||||
for (Span gold : sample.gold()) {
|
||||
if (!RuleRegistry.FIO.equals(gold.type())) {
|
||||
continue;
|
||||
}
|
||||
goldFioSpans++;
|
||||
if (overlappedByFio(gold, found)) {
|
||||
foundFioSpans++;
|
||||
} else {
|
||||
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
|
||||
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
|
||||
|
||||
Score fio = byType.getOrDefault(RuleRegistry.FIO, new Score());
|
||||
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
|
||||
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
|
||||
falsePositiveRate, foundFioSpans, goldFioSpans);
|
||||
}
|
||||
|
||||
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||
private static String[] paint(int length, List<Span> spans) {
|
||||
String[] painted = new String[length];
|
||||
for (Span span : spans) {
|
||||
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||
painted[i] = span.type();
|
||||
}
|
||||
}
|
||||
return painted;
|
||||
}
|
||||
|
||||
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||
if (gold != null) {
|
||||
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||
if (gold.equals(found)) {
|
||||
score.truePositive++;
|
||||
} else {
|
||||
score.falseNegative++;
|
||||
}
|
||||
}
|
||||
if (found != null && !found.equals(gold)) {
|
||||
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void accountAnyType(Score score, boolean gold, boolean found) {
|
||||
if (gold && found) {
|
||||
score.truePositive++;
|
||||
} else if (gold) {
|
||||
score.falseNegative++;
|
||||
} else if (found) {
|
||||
score.falsePositive++;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean overlappedByFio(Span gold, List<Span> found) {
|
||||
return found.stream()
|
||||
.anyMatch(span -> RuleRegistry.FIO.equals(span.type()) && span.overlaps(gold));
|
||||
}
|
||||
|
||||
private static String fragment(String text, Span span) {
|
||||
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
|
||||
}
|
||||
|
||||
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
|
||||
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
|
||||
int from = -1;
|
||||
for (int i = 0; i <= text.length(); i++) {
|
||||
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
|
||||
if (extra && from < 0) {
|
||||
from = i;
|
||||
} else if (!extra && from >= 0) {
|
||||
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
|
||||
from = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
|
||||
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
|
||||
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
|
||||
StringBuilder out = new StringBuilder(4096);
|
||||
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
|
||||
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||
|
||||
byType.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
||||
e.getValue().recall(), e.getValue().f1())));
|
||||
|
||||
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
|
||||
anyType.precision(), anyType.recall(), anyType.f1()));
|
||||
|
||||
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
|
||||
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
|
||||
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
|
||||
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
|
||||
|
||||
appendList(out, "\nНе найденные ФИО:", missedFio);
|
||||
appendList(out, "\nЛожные срабатывания:", falseHits);
|
||||
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
|
||||
|
||||
System.out.println(out);
|
||||
}
|
||||
|
||||
private static void appendList(StringBuilder out, String title, List<String> lines) {
|
||||
if (lines.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
out.append(title).append('\n');
|
||||
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||
}
|
||||
|
||||
private static List<Sample> load(String resource) {
|
||||
List<Sample> samples = new ArrayList<>();
|
||||
try (InputStream in = BenchmarkTest.class.getResourceAsStream(resource);
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||
samples.add(parse(trimmed));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
||||
private static Sample parse(String line) {
|
||||
StringBuilder text = new StringBuilder(line.length());
|
||||
List<Span> gold = new ArrayList<>();
|
||||
Matcher m = MARKUP.matcher(line);
|
||||
int cursor = 0;
|
||||
while (m.find()) {
|
||||
text.append(line, cursor, m.start());
|
||||
int start = text.length();
|
||||
text.append(m.group(2));
|
||||
gold.add(new Span(start, text.length(), m.group(1), 0));
|
||||
cursor = m.end();
|
||||
}
|
||||
text.append(line, cursor, line.length());
|
||||
return new Sample(text.toString(), gold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Типы ПД, которые опознаются только рядом с якорным словом. */
|
||||
class ContextDetectionTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPassportInEveryNotation() {
|
||||
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
|
||||
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
|
||||
assertHidden("ПАСПОРТ 4509123456", "4509123456");
|
||||
assertHidden("Серия 4509 номер 123456", "4509");
|
||||
assertHidden("серии 45 09 № 123456", "123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPassportSeriesAndNumberSplitByWords() {
|
||||
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
|
||||
assertTrue(masked.contains("серия "), masked);
|
||||
assertTrue(masked.contains("номер "), masked);
|
||||
assertFalse(masked.contains("4509"), masked);
|
||||
assertFalse(masked.contains("123456"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksDepartmentCode() {
|
||||
assertHidden("Код подразделения 770-001", "770-001");
|
||||
assertHidden("к/п 770001", "770001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksIssuingAuthorityButNotTheDateAfterIt() {
|
||||
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
|
||||
assertFalse(masked.contains("ОУФМС"), masked);
|
||||
assertFalse(masked.contains("12.05.2015"), masked);
|
||||
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksDriverLicense() {
|
||||
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
|
||||
assertHidden("в/у 99 02 123456", "99 02 123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCitizenship() {
|
||||
assertHidden("Гражданство: РФ", "РФ");
|
||||
assertHidden("гражданство Республики Беларусь", "Беларусь");
|
||||
assertHidden("Гражданин России обратился", "России");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthPlace() {
|
||||
assertHidden("Место рождения: город Тверь, проживает в Москве", "город Тверь");
|
||||
assertHidden("Родился в Нижнем Новгороде", "Нижнем Новгороде");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCardholderName() {
|
||||
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
|
||||
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSecurityCodeAndPinCompletely() {
|
||||
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
|
||||
assertFalse(masked.contains("123,"), masked);
|
||||
assertFalse(masked.contains("4321"), masked);
|
||||
assertTrue(masked.contains("***"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskPinWithoutAnyOtherPersonalData() {
|
||||
String text = "Пин-код 1234 введён неверно";
|
||||
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPinWhenCardNumberIsAlsoPresent() {
|
||||
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorWordsAreCaseInsensitive() {
|
||||
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
|
||||
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void complexSentenceKeepsSurroundingWords() {
|
||||
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
|
||||
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
|
||||
String masked = mask(original);
|
||||
|
||||
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
|
||||
assertTrue(masked.contains("код подразделения"), masked);
|
||||
assertTrue(masked.contains("телефон"), masked);
|
||||
assertFalse(masked.contains("4509 123456"), masked);
|
||||
assertFalse(masked.contains("770301234550"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresComplexSentence() {
|
||||
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
|
||||
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
|
||||
String id = "complex-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("4509 123456"), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Даты во всех вариантах записи и составляющие адреса. */
|
||||
class DateAndAddressTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthDateInAnyPartOrder() {
|
||||
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
|
||||
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
|
||||
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
|
||||
assertHidden("Родился 12-05-1985", "12-05-1985");
|
||||
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthDateWrittenWithWords() {
|
||||
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
|
||||
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
|
||||
"двенадцатого мая");
|
||||
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSeparatorsInMaskedDate() {
|
||||
String masked = mask("Дата рождения 12.05.1985");
|
||||
assertTrue(masked.endsWith("**.**.****"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPassportIssueDate() {
|
||||
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskDateWithoutAnyOtherPersonalData() {
|
||||
String text = "Встреча перенесена на 12.05.2025, подтвердите";
|
||||
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBareDateWhenOtherPersonalDataIsPresent() {
|
||||
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
|
||||
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
|
||||
assertEquals(text, mask(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksAddressComponentsSeparately() {
|
||||
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
|
||||
assertFalse(masked.contains("125009"), masked);
|
||||
assertFalse(masked.contains("Москва"), masked);
|
||||
assertFalse(masked.contains("Тверская"), masked);
|
||||
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
|
||||
assertTrue(masked.contains("ул. "), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void streetNameDoesNotSwallowTheRestOfTheSentence() {
|
||||
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
|
||||
assertTrue(masked.contains("перекрыта из-за ремонта"),
|
||||
"название улицы это одно-три слова, а не остаток предложения: " + masked);
|
||||
assertFalse(masked.contains("Сосновая"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskStreetMentionedOutsideAnAddress() {
|
||||
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
|
||||
assertEquals("Улица Весенняя названа в честь праздника",
|
||||
mask("Улица Весенняя названа в честь праздника"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksMultiWordStreetName() {
|
||||
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
|
||||
assertFalse(masked.contains("Малая Никитская"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksIndexByAnchorWord() {
|
||||
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskBankBranchAddress() {
|
||||
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
|
||||
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskOfficeAddress() {
|
||||
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
|
||||
assertEquals(text, mask(text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addressTypesAreConfigurableSeparately() {
|
||||
SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY);
|
||||
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
|
||||
|
||||
assertFalse(masked.contains("Москва"), masked);
|
||||
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresTextWithDateAndAddress() {
|
||||
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
|
||||
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
|
||||
String id = "date-addr-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("12.05.1985"), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** ФИО и защита от ложных срабатываний. */
|
||||
class FioTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private String mask(String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = mask(text);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
private void assertUnchanged(String text) {
|
||||
assertEquals(text, mask(text), "ложное срабатывание");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFullNameAsInitials() {
|
||||
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksNameAndPatronymicWithoutSurname() {
|
||||
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksFemalePatronymic() {
|
||||
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
|
||||
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSurnameWithInitialsInBothOrders() {
|
||||
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
|
||||
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSurnameNextToKnownGivenName() {
|
||||
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
|
||||
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
|
||||
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksLowercaseNameAfterExplicitAnchor() {
|
||||
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
|
||||
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksNameAfterRoleAnchor() {
|
||||
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
|
||||
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskWellKnownPerson() {
|
||||
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
|
||||
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
|
||||
assertUnchanged("Когда Гагарин полетел в космос");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
|
||||
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
|
||||
assertUnchanged("Московский Кремль открыт для посещения");
|
||||
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
|
||||
assertUnchanged("Смоленская площадь закрыта на ремонт");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMaskOrdinaryCapitalisedWords() {
|
||||
assertUnchanged("Банк Открытие подтвердил лимит");
|
||||
assertUnchanged("В Понедельник Отдел Согласует Договор");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identificationIgnoresCase() {
|
||||
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
|
||||
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresNames() {
|
||||
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
|
||||
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
|
||||
String id = "fio-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
|
||||
assertTrue(masked.contains("И. И. И."), masked);
|
||||
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesStayFastOnLargeText() {
|
||||
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
|
||||
String large = block.repeat(4000);
|
||||
|
||||
long started = System.nanoTime();
|
||||
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"));
|
||||
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
|
||||
class IdentityDocumentTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private void assertHidden(String text, String secret) {
|
||||
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||
}
|
||||
|
||||
private void assertMasked(String text, String payloadId, String expected) {
|
||||
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksForeignPassport() {
|
||||
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksMilitaryId() {
|
||||
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksBirthCertificate() {
|
||||
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksMedicalPolicy() {
|
||||
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
|
||||
}
|
||||
|
||||
/**
|
||||
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
|
||||
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
|
||||
* целиком, поэтому у этих документов открыты только последние знаки номера.
|
||||
*/
|
||||
@Test
|
||||
void hidesShortDocumentSeriesCompletely() {
|
||||
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
|
||||
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
|
||||
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
|
||||
"Свидетельство о рождении **-** № ****56");
|
||||
}
|
||||
|
||||
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
|
||||
@Test
|
||||
void keepsHalfOfFourCharacterSeries() {
|
||||
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
|
||||
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
|
||||
"Водительское удостоверение 99** ****56");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.detect.Validators;
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Виды замены: звёздочки, токены, правдоподобные значения. */
|
||||
class MaskModeTest {
|
||||
|
||||
private final Pipeline pipeline =
|
||||
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
|
||||
private SystemPolicy policy(MaskMode mode) {
|
||||
return new SystemPolicy(true, true, mode, Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion());
|
||||
}
|
||||
|
||||
private String mask(MaskMode mode, String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeNumbersEachType() {
|
||||
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
|
||||
assertTrue(masked.contains("[FIO_1]"), masked);
|
||||
assertTrue(masked.contains("[EMAIL_1]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameValueGetsSameTokenWithinRequest() {
|
||||
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
|
||||
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
|
||||
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntheticModeProducesPlausibleValues() {
|
||||
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
|
||||
|
||||
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
|
||||
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
|
||||
|
||||
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
|
||||
assertTrue(card.find(), masked);
|
||||
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntheticValuesAreStable() {
|
||||
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
|
||||
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingWorksInEveryMode() {
|
||||
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
|
||||
for (MaskMode mode : MaskMode.values()) {
|
||||
String id = "mode-" + mode;
|
||||
String masked = pipeline.process(original, id, policy(mode));
|
||||
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
|
||||
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
|
||||
}
|
||||
}
|
||||
|
||||
private static int count(String text, String fragment) {
|
||||
int n = 0;
|
||||
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.NameCascade;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/** Вторая ступень не должна вредить первой. */
|
||||
class NameCascadeTest {
|
||||
|
||||
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||
|
||||
private String mask(NameCascade cascade, String payloadId) {
|
||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||
new PayloadStore(1_000_000L, 30), cascade);
|
||||
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutModelTheStageIsOff() {
|
||||
NameCascade cascade = NameCascade.disabled();
|
||||
assertFalse(cascade.enabled());
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
|
||||
NameCascade cascade = new NameCascade(Optional.of(dir.resolve("нет-модели.bin").toString()), 16, 4);
|
||||
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
|
||||
Path broken = dir.resolve("испорченная.bin");
|
||||
Files.writeString(broken, "это не модель", StandardCharsets.UTF_8);
|
||||
|
||||
NameCascade cascade = new NameCascade(Optional.of(broken.toString()), 16, 4);
|
||||
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
|
||||
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
|
||||
"маскирование по правилам обязано работать и без второй ступени");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Ограничения хранилища соответствий: объём и срок жизни. */
|
||||
class PayloadStoreTest {
|
||||
|
||||
@Test
|
||||
void returnsWhatWasStored() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
store.put("id", "исходный текст", "маска");
|
||||
|
||||
PayloadStore.Entry entry = store.byId("id");
|
||||
assertNotNull(entry);
|
||||
assertEquals("исходный текст", entry.original());
|
||||
assertEquals("маска", entry.masked());
|
||||
assertEquals("исходный текст", store.originalForMask("маска"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgetsEntriesAfterTheirLifetime() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 0);
|
||||
store.put("id", "исходный текст", "маска");
|
||||
|
||||
assertNull(store.byId("id"), "запись с истёкшим сроком жизни не должна отдаваться");
|
||||
assertNull(store.originalForMask("маска"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictsOldestWhenOverSizeLimit() {
|
||||
PayloadStore store = new PayloadStore(100L, 30);
|
||||
for (int i = 0; i < 50; i++) {
|
||||
store.put("id" + i, "текст номер " + i, "маска номер " + i);
|
||||
}
|
||||
|
||||
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
|
||||
assertNull(store.byId("id0"), "самая старая запись должна быть вытеснена");
|
||||
assertNotNull(store.byId("id49"), "последняя запись должна остаться");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKeysReturnNothing() {
|
||||
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||
assertNull(store.byId("нет такого"));
|
||||
assertNull(store.originalForMask("нет такой маски"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.core.PayloadStore;
|
||||
import ru.pdguard.core.Pipeline;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
import ru.pdguard.mask.Masker;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
|
||||
class PipelineTest {
|
||||
|
||||
private static final String VALID_CARD = "4111 1111 1111 1111";
|
||||
private static final String VALID_INN_12 = "770301234550";
|
||||
private static final String VALID_SNILS = "112-233-445 95";
|
||||
|
||||
private Pipeline pipeline() {
|
||||
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||
}
|
||||
|
||||
private String mask(Pipeline pipeline, String text) {
|
||||
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksCardNumber() {
|
||||
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
|
||||
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
|
||||
assertTrue(masked.contains("41** **** **** **11"), masked);
|
||||
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNumberThatFailsLuhn() {
|
||||
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
||||
assertEquals(text, mask(pipeline(), text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksEmailKeepingTopLevelDomain() {
|
||||
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
|
||||
assertEquals("Почта i**********@m***.ru для связи", masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksPhoneInAnyNotation() {
|
||||
Pipeline pipeline = pipeline();
|
||||
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
|
||||
String masked = mask(pipeline, "Телефон " + phone);
|
||||
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
|
||||
assertTrue(masked.endsWith("67"), masked);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksInnByContextAndByChecksum() {
|
||||
Pipeline pipeline = pipeline();
|
||||
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
|
||||
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
|
||||
}
|
||||
|
||||
@Test
|
||||
void masksSnils() {
|
||||
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
|
||||
assertFalse(masked.contains(VALID_SNILS), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmaskingRestoresOriginalText() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
|
||||
String id = "pair-1";
|
||||
|
||||
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertNotEquals(original, masked);
|
||||
|
||||
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||
assertEquals(original, restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryReturnsSameMask() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Карта " + VALID_CARD;
|
||||
String id = "retry-1";
|
||||
|
||||
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmasksWhenPayloadIdIsUnknown() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String original = "Почта ivan@mail.ru";
|
||||
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
|
||||
|
||||
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void textWithoutPersonalDataIsUnchanged() {
|
||||
String text = "Расскажи о погоде в Москве завтра";
|
||||
assertEquals(text, mask(pipeline(), text));
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemPolicyDisablesSelectedTypes() {
|
||||
Pipeline pipeline = pipeline();
|
||||
SystemPolicy onlyEmail = SystemPolicy.forTypes(RuleRegistry.EMAIL);
|
||||
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
|
||||
|
||||
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
|
||||
assertFalse(masked.contains("ivan@mail.ru"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesLargeText() {
|
||||
Pipeline pipeline = pipeline();
|
||||
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
|
||||
String large = block.repeat(4000);
|
||||
|
||||
long started = System.nanoTime();
|
||||
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
|
||||
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||
|
||||
assertFalse(masked.contains("ivan@mail.ru"));
|
||||
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
|
||||
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
|
||||
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
|
||||
@QuarkusTest
|
||||
class ProcessResourceTest {
|
||||
|
||||
private String post(String payload, String payloadId) {
|
||||
return given()
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", payload, "payload_id", payloadId))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
}
|
||||
|
||||
@Test
|
||||
void maskAndUnmaskPair() {
|
||||
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
|
||||
String id = "8a77d363c7c044b49b41d7b8a448243a";
|
||||
|
||||
String masked = post(original, id);
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRequestWithoutRequiredFields() {
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "текст"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthProbeResponds() {
|
||||
given().when().get("/health").then().statusCode(200).body(equalTo("OK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledSystemIsRefused() {
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "disabled")
|
||||
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemPolicySelectsMaskMode() {
|
||||
String masked = given()
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "crm")
|
||||
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.extract().path("result");
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSystemFallsBackToDefaultPolicy() {
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.header("X-System-Id", "неизвестная-система")
|
||||
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.body("result", equalTo("почта i***@m***.ru"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsExposeLatencyAndTokenCounters() {
|
||||
post("Клиент Иванов Иван Иванович", "metrics-1");
|
||||
|
||||
String body = given().when().get("/metrics").then().statusCode(200).extract().asString();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsDoNotLeakPersonalData() {
|
||||
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
|
||||
|
||||
String body = given().when().get("/metrics").then().statusCode(200).extract().asString();
|
||||
org.junit.jupiter.api.Assertions.assertFalse(body.contains("Иванов"), "ПД попали в метрики");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(body.contains("4111"), "ПД попали в метрики");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminShowsSystemsAndTypes() {
|
||||
given().when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
|
||||
given().when().get("/admin/types").then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void textWithoutPersonalDataIsReturnedAsIs() {
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
|
||||
.when().post("/process")
|
||||
.then().statusCode(200)
|
||||
.body("result", equalTo("тестовая строка"))
|
||||
.body("result", not(equalTo("")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ru.pdguard;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import ru.pdguard.config.SystemPolicy;
|
||||
import ru.pdguard.config.SystemsConfig;
|
||||
import ru.pdguard.mask.MaskMode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Чтение и горячая перезагрузка списка систем. */
|
||||
class SystemsConfigTest {
|
||||
|
||||
private static final String CONTENT = """
|
||||
{
|
||||
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
|
||||
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
|
||||
"old": { "enabled": false }
|
||||
}
|
||||
""";
|
||||
|
||||
private SystemsConfig configAt(Path file) {
|
||||
return new SystemsConfig(file.toString(), new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
|
||||
SystemsConfig config = configAt(file);
|
||||
SystemPolicy crm = config.policyFor("crm");
|
||||
|
||||
assertEquals(MaskMode.TOKEN, crm.maskMode());
|
||||
assertFalse(crm.demask());
|
||||
assertTrue(crm.allows("FIO"));
|
||||
assertFalse(crm.allows("CARD"));
|
||||
assertFalse(config.policyFor("old").enabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
|
||||
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
|
||||
assertTrue(policy.enabled());
|
||||
assertTrue(policy.allows("CARD"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void worksWithoutConfigFile(@TempDir Path dir) {
|
||||
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
|
||||
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
SystemsConfig config = configAt(file);
|
||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
|
||||
|
||||
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
|
||||
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("systems.json");
|
||||
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||
SystemsConfig config = configAt(file);
|
||||
|
||||
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
|
||||
config.reload();
|
||||
|
||||
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
|
||||
"сломанный файл не должен ронять работающий сервис");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ru.pdguard.tools;
|
||||
|
||||
import opennlp.tools.namefind.NameFinderME;
|
||||
import opennlp.tools.namefind.NameSample;
|
||||
import opennlp.tools.namefind.NameSampleDataStream;
|
||||
import opennlp.tools.namefind.TokenNameFinderFactory;
|
||||
import opennlp.tools.namefind.TokenNameFinderModel;
|
||||
import opennlp.tools.util.InputStreamFactory;
|
||||
import opennlp.tools.util.ObjectStream;
|
||||
import opennlp.tools.util.PlainTextByLineStream;
|
||||
import opennlp.tools.util.TrainingParameters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Обучение модели для второй ступени распознавания имён.
|
||||
*
|
||||
* <p>Не часть сервиса: инструмент запускается вручную, результат кладётся рядом
|
||||
* с приложением и подключается свойством {@code pdguard.ner.model}. Подготовка
|
||||
* обучающего набора — в {@code tools/prepare-ner-corpus.py}.
|
||||
*
|
||||
* <pre>
|
||||
* mvn -q test-compile
|
||||
* java -cp target/test-classes:$(cat cp.txt) ru.pdguard.tools.NerTrainer train.txt models/ru-ner-person.bin
|
||||
* </pre>
|
||||
*/
|
||||
public final class NerTrainer {
|
||||
|
||||
private static final int ITERATIONS = Integer.getInteger("ner.iterations", 300);
|
||||
private static final int CUTOFF = Integer.getInteger("ner.cutoff", 1);
|
||||
|
||||
private NerTrainer() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
if (args.length < 2) {
|
||||
throw new IllegalArgumentException("Нужны два аргумента: обучающий набор и путь к модели");
|
||||
}
|
||||
Path data = Path.of(args[0]);
|
||||
Path modelFile = Path.of(args[1]);
|
||||
|
||||
TrainingParameters parameters = new TrainingParameters();
|
||||
parameters.put(TrainingParameters.ITERATIONS_PARAM, ITERATIONS);
|
||||
parameters.put(TrainingParameters.CUTOFF_PARAM, CUTOFF);
|
||||
parameters.put(TrainingParameters.ALGORITHM_PARAM,
|
||||
System.getProperty("ner.algorithm", "MAXENT"));
|
||||
|
||||
InputStreamFactory source = () -> Files.newInputStream(data);
|
||||
TokenNameFinderModel model;
|
||||
try (ObjectStream<String> lines = new PlainTextByLineStream(source, StandardCharsets.UTF_8);
|
||||
ObjectStream<NameSample> samples = new NameSampleDataStream(lines)) {
|
||||
model = NameFinderME.train("ru", "person", samples, parameters, new TokenNameFinderFactory());
|
||||
}
|
||||
|
||||
Files.createDirectories(modelFile.toAbsolutePath().getParent());
|
||||
try (OutputStream out = Files.newOutputStream(modelFile)) {
|
||||
model.serialize(out);
|
||||
}
|
||||
System.out.printf("Модель сохранена: %s, %d КБ%n",
|
||||
modelFile.toAbsolutePath(), Files.size(modelFile) / 1024);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user