diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index 123fc1b..2cc0271 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -56,6 +56,16 @@ public class RuleRegistry { 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"; + /** * Слово с заглавной буквы; остальные буквы любого регистра, чтобы * «ИВАНОВ» распознавался наравне с «Иванов». @@ -181,6 +191,52 @@ public class RuleRegistry { .groups(1) .anchoredBy("подразделени", "к/п"), + // --- Банковские реквизиты сверх карты --- + + // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. + Rule.of(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) + .groups(1) + .anchoredBy("бик"), + + // «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения. + Rule.of(CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}" + + "(\\d{2}\\s?/\\s?\\d{2})\\b", 83) + .groups(1) + .anchoredBy("срок действия", "действительн", "exp"), + + // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы + // ещё и правилом ОГРН. + Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) + .groups(1) + .anchoredBy("огрнип"), + + Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) + .groups(1) + .anchoredBy("огрн"), + + Rule.of(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}?" + + "\\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*)" + + "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81) + .anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"), + // --- Даты с явным якорем --- Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87) diff --git a/src/main/java/ru/pdguard/mask/Masker.java b/src/main/java/ru/pdguard/mask/Masker.java index abb4236..debb85a 100644 --- a/src/main/java/ru/pdguard/mask/Masker.java +++ b/src/main/java/ru/pdguard/mask/Masker.java @@ -57,7 +57,17 @@ public class Masker { Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars), Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars), Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars), - Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars) + Map.entry(RuleRegistry.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(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation), + Map.entry(RuleRegistry.BIK, Strategies::stars), + Map.entry(RuleRegistry.INCOME, Strategies::stars), + Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars) ); public String mask(String type, String value, MaskMode mode, MaskContext context) { diff --git a/src/test/java/ru/pdguard/BankTypesTest.java b/src/test/java/ru/pdguard/BankTypesTest.java new file mode 100644 index 0000000..6fa09a4 --- /dev/null +++ b/src/test/java/ru/pdguard/BankTypesTest.java @@ -0,0 +1,75 @@ +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 BankTypesTest { + + 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 + "»"); + } + + @Test + void masksAccountNumber() { + assertHidden("Расчётный счёт 40702810500000001234 открыт вчера", "40702810500000001234"); + assertHidden("р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234"); + } + + @Test + void masksBik() { + assertHidden("БИК 044525593 банка-получателя", "044525593"); + } + + @Test + void masksCardExpiryButNotCardNumber() { + String masked = pipeline.process( + "Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT); + assertFalse(masked.contains("09/27"), masked); + assertEquals("Карта 41** **** **** **11, срок действия **/**", masked); + } + + @Test + void masksOgrnAndOgrnipDifferently() { + assertHidden("ОГРН 1027700132195 организации", "1027700132195"); + assertHidden("ОГРНИП 304500116000157 предпринимателя", "304500116000157"); + } + + /** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */ + @Test + void ogrnDoesNotSwallowOgrnip() { + String masked = pipeline.process("ОГРНИП 304500116000157", "ogrnip-1", SystemPolicy.DEFAULT); + assertFalse(masked.contains("304500116000157"), masked); + assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked); + } + + @Test + void masksKpp() { + assertHidden("КПП 770101001 указан в реквизитах", "770101001"); + } + + @Test + void masksIncomeAmount() { + assertHidden("Доход клиента 85 000 руб. в месяц", "85 000"); + assertHidden("Заработная плата 120000 в месяц", "120000"); + } + + @Test + void masksBiometricMention() { + assertHidden("Клиент сдал биометрические данные в отделении", "биометрические данные"); + assertHidden("Оформлен слепок голоса для верификации", "слепок голоса"); + } +}