feat: detect account number, BIK, card expiry, OGRN(IP), KPP, income, biometrics

Types the spec asks for beyond the payment card itself: settlement
account (20 digits), BIK, card expiry date (kept separate from
birth date), OGRN/OGRNIP (negative lookahead so OGRNIP isn't half-
swallowed by the OGRN rule), KPP, income/salary amounts, and mentions
of biometric enrollment (ЕБС, voice/fingerprint templates).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Максименко Никита Владимирович
2026-09-21 21:29:57 +03:00
co-authored by Claude Sonnet 5
parent e52e9550e6
commit 495cf08a05
3 changed files with 142 additions and 1 deletions
@@ -56,6 +56,16 @@ public class RuleRegistry {
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE"; public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
public static final String MEDICAL_POLICY = "MEDICAL_POLICY"; 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) .groups(1)
.anchoredBy("подразделени", "к/п"), .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) Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
+11 -1
View File
@@ -57,7 +57,17 @@ public class Masker {
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars), Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars), Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
Map.entry(RuleRegistry.BIRTH_PLACE, 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) { public String mask(String type, String value, MaskMode mode, MaskContext context) {
@@ -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("Оформлен слепок голоса для верификации", "слепок голоса");
}
}