diff --git a/src/main/java/ru/pdguard/detect/NameDictionary.java b/src/main/java/ru/pdguard/detect/NameDictionary.java index aab79e0..789a481 100644 --- a/src/main/java/ru/pdguard/detect/NameDictionary.java +++ b/src/main/java/ru/pdguard/detect/NameDictionary.java @@ -132,6 +132,34 @@ public final class NameDictionary { return false; } + /** + * Проверяет, что фрагмент — имя, отчество или фамилия человека. Используется + * для строчных имён после ролевого слова («клиент иван иванов»), где регистр + * не подсказывает, что перед нами имя. + */ + public static boolean containsNamePart(String value) { + for (String word : value.split("\\P{L}+")) { + String lower = word.toLowerCase(Locale.ROOT); + if (GIVEN_NAMES.contains(lower)) { + return true; + } + if (isPatronymic(lower) || isSurname(lower)) { + return true; + } + } + return false; + } + + /** Отчество: Иванович, Петровна, Сидоровна. */ + private static boolean isPatronymic(String lower) { + return lower.matches(".*(?:ович|евич|овна|евна|ична|ичн)$"); + } + + /** Фамилия по словообразованию: Иванов, Петрова, Троицкий, Шевченко. */ + private static boolean isSurname(String lower) { + return lower.matches(".*(?:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)|енко|ко|ук|юк|ян|швили|дзе)$"); + } + /** * Содержит ли текст упоминание известного человека — из сборки или дописанных * сверху. diff --git a/src/main/java/ru/pdguard/detect/RuleRegistry.java b/src/main/java/ru/pdguard/detect/RuleRegistry.java index fa8165b..6313374 100644 --- a/src/main/java/ru/pdguard/detect/RuleRegistry.java +++ b/src/main/java/ru/pdguard/detect/RuleRegistry.java @@ -61,26 +61,52 @@ public class RuleRegistry { /** Серия и номер: «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}"; + /** + * Название месяца: полная форма («январь»), сокращение («янв») и плейсхолдер + * «ммм» (в логах встречается и латинская «M»). Сокращения нужны, потому что + * в датах вида «15 ЯНВ 10» месяц записан тремя буквами. + */ private static final String MONTH = - "(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*"; + "(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр" + + "|янв|фев|мар|апр|авг|сен|окт|ноя|дек|[МM]мм)\\p{L}*"; /** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */ private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b"; - /** «12 мая 1985 г.» */ + /** «15 03 1990», «15 03 10» — числовая дата с пробелами вместо разделителей. */ + private static final String DATE_DIGITS_SPACE = "\\b\\d{1,2}\\s+\\d{1,2}\\s+\\d{2,4}\\b"; + + /** «15 03», «15/03» — день и месяц без года. */ + private static final String DATE_DAY_MONTH = "\\b\\d{1,2}\\s*[-/.]?\\s*\\d{1,2}\\b"; + + /** «12 мая 1985 г.», «15-ЯНВ-10», «15 января» — месяц словом, год 2-4 цифры или без года. */ private static final String DATE_MONTH_WORD = - "\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?"; + "\\b\\d{1,2}\\s*[-/.]?\\s*" + MONTH + "\\s*[-/.]?\\s*(?:\\d{2,4})?\\b" + + "(?:\\s*(?iu:года|г\\.|г\\b))?"; /** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */ private static final String DATE_WORDS = "\\b(?:(?iu:двадцать|тридцать)\\s+)?" + "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат" + "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)" - + "(?iu:ьего|ого|его)\\s+" + MONTH - + "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)"; + + "(?iu:ьего|ого|его|ое)\\s+" + MONTH + + "\\s+(?:\\d{4}|(?iu:тысяча)(?:\\s+\\p{L}+){1,8})\\s*(?iu:года|год\\b|г\\.)"; - /** Любая из трёх записей даты; внутри только незахватывающие группы. */ - private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")"; + /** Любая из записей даты; внутри только незахватывающие группы. */ + private static final String DATE_ANY = + "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + "|" + + DATE_DIGITS_SPACE + "|" + DATE_DAY_MONTH + ")"; + + /** + * Промежуток между якорем даты («дата рождения») и самой датой: слова, + * скобочные группы («(день и месяц)») и знаки препинания. Без скобочной + * ветки «Дата рождения клиента (день и месяц): 15 января» не находилась бы: + * «день и месяц» — это слова, а не дата. Ветка со словами требует пробела + * перед словом ({@code \s+}), иначе она неоднозначна с веткой {@code \W}, + * которая тоже матчит пробелы, — это приводило к катастрофическому + * возврату на длинных текстах. + */ + private static final String DATE_GAP = "(?:\\s+\\([^)]*\\)|\\s+\\p{L}+|\\W){0,30}"; /** * Слова, при которых адрес принадлежит организации, а не человеку: @@ -120,11 +146,21 @@ public class RuleRegistry { // --- Уровень 3: значение опознаётся только рядом с якорным словом --- - Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92) + // CVV: латиница, кириллическая транслитерация («цвв», «сививи») и + // описательные якоря («код на обороте карты»). Между якорем и числом + // допускаются слова («CVV код 321», «CVV указан код 123») и длинные + // разделители («код на обороте карты 789»). + Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|цвв|сививи|код\\p{L}*\\s+на\\s+обороте\\s+карты" + + "|код\\s+проверки|защитный\\s+код)\\b)" + + "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{3,4})\\b", 92) .groups(1) - .anchoredBy("cvv", "cvc", "код проверки", "защитный код"), + .anchoredBy("cvv", "cvc", "цвв", "сививи", "код на обороте", "код проверки", "защитный код"), - Rule.of(PdTypes.PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92) + // PIN: «пин-код», «пин код», «ПИН:», «пин 3456». Между якорем и числом + // допускаются слова («ПИН-код карты 2468») и длинные разделители + // («Пин Код: 1234»). + Rule.of(PdTypes.PIN, "(?iu:\\b(?:пин[\\s-]?кода?|pin[\\s-]?code|пин|pin)\\b)" + + "(?:\\s+\\p{L}+){0,2}\\W{0,30}(\\d{4,6})\\b", 92) .groups(1) .anchoredBy("пин", "pin"), @@ -222,12 +258,14 @@ public class RuleRegistry { // --- Даты с явным якорем --- - Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87) + Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)" + + DATE_GAP + "(" + DATE_ANY + ")", 87) .groups(1) .validatedBy(Validators::date) .anchoredBy("рожден"), - Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87) + Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))" + + DATE_GAP + "(" + DATE_ANY + ")", 87) .groups(1) .validatedBy(Validators::date) .anchoredBy("родил"), @@ -238,7 +276,8 @@ public class RuleRegistry { .anchoredBy("г.р", "г/р", "года рождения"), // «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015» - Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87) + Rule.of(PdTypes.PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)" + + DATE_GAP + "(" + DATE_ANY + ")", 87) .groups(1) .validatedBy(Validators::date) .anchoredBy("выдач"), @@ -285,6 +324,24 @@ public class RuleRegistry { "заемщик", "заявител", "доверител", "вкладчик", "ответственн", "контактное лицо", "исполнител", "держател"), + // «клиент иван иванов», «поручитель петрович» — строчные имена после + // ролевого слова. Регистр снимает требование к заглавной букве, а словарь + // имён отсекает «клиент пришёл в офис». + Rule.of(PdTypes.FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик" + + "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител" + + "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител" + + "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател" + + "|\\bотправител|\\bбенефициар|\\bдоверенное\\s+лицо|\\bнаследник|\\bсозаемщик" + + "|\\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) + .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", + "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", + "заемщик", "заявител", "доверител", "вкладчик", "ответственн", + "контактное лицо", "исполнител", "держател", "отправител", "бенефициар", + "доверенное лицо", "наследник", "созаемщик"), + // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы // «Тверская улица» и тому подобное. Имя проверяется по множеству уже // после совпадения — чередование из ста веток в шаблоне обходится дорого. @@ -300,6 +357,14 @@ public class RuleRegistry { .validatedBy(NameDictionary::containsGivenName) .vetoedBy(ORGANISATION_NEARBY), + // Одиночное имя, фамилия или отчество: «Иванов», «иван», «петрович». + // Самое слабое основание среди правил ФИО — ни ролевого слова, ни пары + // слов, — поэтому приоритет ниже и проверка по словарю обязательна. + // Словарь отсекает «сочи», «казань» и прочие не-имена. + Rule.of(PdTypes.FIO, "\\b(\\p{L}{2,})\\b", 70) + .groups(1) + .validatedBy(NameDictionary::containsNamePart), + // --- Уровень 1: подтверждается контрольной суммой --- Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85) diff --git a/src/test/java/ru/pdguard/LeakDiagTest.java b/src/test/java/ru/pdguard/LeakDiagTest.java index 8a98a3d..d4699fb 100644 --- a/src/test/java/ru/pdguard/LeakDiagTest.java +++ b/src/test/java/ru/pdguard/LeakDiagTest.java @@ -4,6 +4,7 @@ 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.PdTypes; import ru.pdguard.detect.RuleRegistry; import ru.pdguard.detect.Span; import ru.pdguard.mask.Masker; @@ -39,12 +40,12 @@ class LeakDiagTest { List remaining = new ArrayList<>(); for (String raw : leaks) { List spans = p.findPersonalData(raw, SystemPolicy.DEFAULT); - boolean found = spans.stream().anyMatch(s -> s.type().equals(RuleRegistry.FIO) - || s.type().equals(RuleRegistry.BIRTH_DATE) || s.type().equals(RuleRegistry.PASSPORT_DATE) - || s.type().equals(RuleRegistry.CVV) || s.type().equals(RuleRegistry.PIN) - || s.type().equals(RuleRegistry.INN) || s.type().equals(RuleRegistry.PHONE) - || s.type().equals(RuleRegistry.CARD) || s.type().equals(RuleRegistry.DRIVER_LICENSE) - || s.type().equals(RuleRegistry.CITIZENSHIP) || s.type().equals(RuleRegistry.BIRTH_PLACE)); + boolean found = spans.stream().anyMatch(s -> s.type().equals(PdTypes.FIO) + || s.type().equals(PdTypes.BIRTH_DATE) || s.type().equals(PdTypes.PASSPORT_DATE) + || s.type().equals(PdTypes.CVV) || s.type().equals(PdTypes.PIN) + || s.type().equals(PdTypes.INN) || s.type().equals(PdTypes.PHONE) + || s.type().equals(PdTypes.CARD) || s.type().equals(PdTypes.DRIVER_LICENSE) + || s.type().equals(PdTypes.CITIZENSHIP) || s.type().equals(PdTypes.BIRTH_PLACE)); if (found) { fixed++; } else {