package ru.pdguard.detect; import org.springframework.stereotype.Component; import ru.pdguard.config.SystemPolicy; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Реестр правил детекции и сам поиск ПД в тексте. * *

Правила разбиты на три уровня доверия: *

    *
  1. проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;
  2. *
  3. однозначные по формату — email, телефон;
  4. *
  5. требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее, * где сама по себе последовательность знаков ни о чём не говорит.
  6. *
* *

Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен * именно на них. На захватываемое значение регистронезависимость не распространяется: * там, где значение опознаётся по заглавной букве, это существенно. */ @Component public class RuleRegistry { /** * Слово с заглавной буквы; остальные буквы любого регистра, чтобы * «ИВАНОВ» распознавался наравне с «Иванов». */ private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+"; /** * Название улицы: от одного до трёх слов с заглавной буквы либо чисел — * «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно: * без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт * до вечера» оказывался под маской целиком. */ private static final String STREET_NAME = "(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}"; /** * Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян. * Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а. */ private static final String SURNAME = "\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)" + "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}"; /** * Отчество: признак надёжный, ни одно другое слово так не оканчивается. * Основы даны без падежного окончания — Иванович, Ивановича, Ивановне. */ private static final String PATRONYMIC = "\\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}"; private static final String MONTH = "(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*"; /** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */ private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b"; /** «12 мая 1985 г.» */ private static final String DATE_MONTH_WORD = "\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{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|г\\.)"; /** Любая из трёх записей даты; внутри только незахватывающие группы. */ private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")"; /** * Слова, при которых адрес принадлежит организации, а не человеку: * адрес отделения банка персональными данными не является. Части адреса рядом: * улица, упомянутая в рассказе о городе, адресом клиента не является — ровно * как адрес отделения банка из технического задания. Требование стояло только * у постфиксной формы правила, префиксная его не имела. */ public static final String ADDRESS_NEARBY = "(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)"; private static final Pattern ADDRESS_CONTEXT = Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE); /** Адресные типы, которые вне адресного окружения персональными данными не являются. */ private static final java.util.Set ADDRESS_TYPES = java.util.Set.of( PdTypes.ADDRESS_COUNTRY, PdTypes.ADDRESS_REGION, PdTypes.ADDRESS_DISTRICT, PdTypes.ADDRESS_CITY, PdTypes.ADDRESS_STREET, PdTypes.ADDRESS_HOUSE, PdTypes.ADDRESS_FLAT, PdTypes.ADDRESS_POSTCODE); public static boolean isAddressType(String type) { return ADDRESS_TYPES.contains(type); } /** * Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам * второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в * названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом. */ public static boolean hasAddressContext(String text, int start, int end) { return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find(); } private static final String ORGANISATION_NEARBY = "(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)"; private static final List RULES = List.of( // --- Уровень 3: значение опознаётся только рядом с якорным словом --- Rule.of(PdTypes.CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92) .groups(1) .anchoredBy("cvv", "cvc", "код проверки", "защитный код"), Rule.of(PdTypes.PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92) .groups(1) .anchoredBy("пин", "pin"), // «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456» Rule.of(PdTypes.PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?" + "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90) .groups(1) .anchoredBy("паспорт"), // «серия 4509 номер 123456», «серии 45 09 № 123456» // Между серией и номером помещается слово: «серия 4509 номер 123456», // «серии 4509 за номером 123456», «серия 4509 № 123456». Rule.of(PdTypes.PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90) .groups(1, 2) .anchoredBy("сери"), Rule.of(PdTypes.DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b" + "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89) .groups(1) .anchoredBy("водительск", "в/у", "вод.", "ву "), // --- Прочие документы, удостоверяющие личность --- Rule.of(PdTypes.FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*" + "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89) .groups(1) .anchoredBy("загранпаспорт", "заграничн"), Rule.of(PdTypes.MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*" + "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89) .groups(1) .anchoredBy("военн"), Rule.of(PdTypes.BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)" + "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89) .groups(1) .anchoredBy("свидетельств"), Rule.of(PdTypes.MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89) .groups(1) .anchoredBy("полис"), Rule.of(PdTypes.DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88) .groups(1) .anchoredBy("подразделени", "к/п"), // --- Банковские реквизиты сверх карты --- // Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна. Rule.of(PdTypes.ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|" + "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83) .groups(1) .anchoredBy("р/с", "расчетн", "расчётн", "лицев"), Rule.of(PdTypes.BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83) .groups(1) .anchoredBy("бик"), // «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения. Rule.of(PdTypes.CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}" + "(\\d{2}\\s?/\\s?\\d{2})\\b", 83) .groups(1) .anchoredBy("срок действия", "действительн", "exp"), // ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы // ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные // числа рядом со словом — раньше якоря было достаточно самого по себе. Rule.of(PdTypes.OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83) .groups(1) .validatedBy(Validators::ogrnip) .anchoredBy("огрнип"), Rule.of(PdTypes.OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83) .groups(1) .validatedBy(Validators::ogrn) .anchoredBy("огрн"), Rule.of(PdTypes.KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83) .groups(1) .anchoredBy("кпп"), // Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может // стоять слово («доход клиента», «доход за год») — без этого якорь ловил // бы только «доход 85000», вплотную. Rule.of(PdTypes.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(PdTypes.BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)" + "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81) .anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"), // --- Даты с явным якорем --- Rule.of(PdTypes.BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87) .groups(1) .validatedBy(Validators::date) .anchoredBy("рожден"), Rule.of(PdTypes.BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87) .groups(1) .validatedBy(Validators::date) .anchoredBy("родил"), Rule.of(PdTypes.BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87) .groups(1) .validatedBy(Validators::date) .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) .groups(1) .validatedBy(Validators::date) .anchoredBy("выдач"), Rule.of(PdTypes.CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)" + "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86) .groups(1) .anchoredBy("держател", "cardholder", "на имя"), // --- ФИО --- // Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии. // Свободная тройка «любое слово с заглавной + имя + отчество» здесь // сознательно не используется: она захватывает глагол в начале // предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени. // Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову. Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79), // Имя Отчество Фамилия — второй распространённый порядок слов. Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79), // Иванов И.И. и И.И. Иванов Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79), Rule.of(PdTypes.FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79), // Имя Отчество без фамилии Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77), // «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру Rule.of(PdTypes.FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)" + "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77) .groups(1) .anchoredBy("фио", "ф.и.о", "на имя"), // «клиент Иванов Иван», «плательщик Петрова» 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держател)\\p{L}*" + "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77) .groups(1) .anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик", "получател", "абонент", "в лице", "представител", "поручител", "заёмщик", "заемщик", "заявител", "доверител", "вкладчик", "ответственн", "контактное лицо", "исполнител", "держател"), // Фамилия рядом с личным именем из словаря: без словаря правило ловило бы // «Тверская улица» и тому подобное. Имя проверяется по множеству уже // после совпадения — чередование из ста веток в шаблоне обходится дорого. // Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного // якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие // Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс // случайное слово, «Богдана Хмельницкого» — улица в честь исторической // фигуры. Найдено на реальных адресах отделений из реестра ЦБ. Rule.of(PdTypes.FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74) .validatedBy(NameDictionary::containsGivenName) .vetoedBy(ORGANISATION_NEARBY), Rule.of(PdTypes.FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74) .validatedBy(NameDictionary::containsGivenName) .vetoedBy(ORGANISATION_NEARBY), // --- Уровень 1: подтверждается контрольной суммой --- Rule.of(PdTypes.CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85) .validatedBy(Validators::luhn), Rule.of(PdTypes.INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84) .groups(1) .anchoredBy("инн"), Rule.of(PdTypes.SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84) .groups(1) .validatedBy(Validators::snils), // --- Уровень 2: формат однозначен сам по себе --- Rule.of(PdTypes.PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82), Rule.of(PdTypes.EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80) .anchoredBy("@"), // --- Уровень 3: свободный текст после якорного слова --- // «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит, // её забирает отдельное правило. Приоритет выше городского, иначе от органа // осталась бы замаскированной только его часть. Rule.of(PdTypes.PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)" + "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78) .groups(1) .anchoredBy("выдан"), Rule.of(PdTypes.BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76) .groups(1) .anchoredBy("рождения"), Rule.of(PdTypes.BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+" + "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76) .groups(1) .anchoredBy("родил"), Rule.of(PdTypes.CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}" + "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) .groups(1) .anchoredBy("гражданств"), Rule.of(PdTypes.CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+" + "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75) .groups(1) .anchoredBy("граждан"), // --- Адрес: каждая составляющая настраивается отдельно --- Rule.of(PdTypes.ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("индекс"), Rule.of(PdTypes.ADDRESS_POSTCODE, "\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74) .groups(1) .vetoedBy(ORGANISATION_NEARBY), // Не только «г.»: перепись, на которой проверяется словарь, покрывает // сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево» // из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём. Rule.of(PdTypes.ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b" + "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b" + "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73) .groups(1) .validatedBy(ToponymDictionary::isKnownSettlement) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня", "дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"), Rule.of(PdTypes.ADDRESS_STREET, "(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*" + "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)" + "\\W{0,3}(" + STREET_NAME + ")", 73) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .requiringNear(ADDRESS_NEARBY) .anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"), // «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому // принимается только рядом с другими частями адреса: иначе под маску попал бы // любой рассказ про Невский проспект. Rule.of(PdTypes.ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+" + "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .requiringNear(ADDRESS_NEARBY) .anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"), Rule.of(PdTypes.ADDRESS_HOUSE, "(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("д.", "дом"), Rule.of(PdTypes.ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("кв"), Rule.of(PdTypes.ADDRESS_COUNTRY, "(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)" + "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71) .groups(1) .vetoedBy(ORGANISATION_NEARBY) .anchoredBy("стран"), // --- Значения без якоря: принимаются только вместе с другими ПД --- // ИНН физлица без якорного слова — только с верной контрольной суммой. Rule.of(PdTypes.INN, "\\b\\d{12}\\b", 62) .validatedBy(Validators::inn), // Дата без якорного слова персональными данными сама по себе не является: // маскируется, только если в тексте есть ПД другого типа. Rule.of(PdTypes.DATE, DATE_ANY, 58) .validatedBy(Validators::date) ); /** Все типы ПД, которые умеет распознавать сервис. */ public List knownTypes() { return RULES.stream().map(Rule::type).distinct().toList(); } /** * Находит все фрагменты ПД, разрешённые политикой системы. * Перекрытия здесь не разрешаются — это делает вызывающая сторона. */ public List detect(String text, SystemPolicy policy) { List found = new ArrayList<>(); String lowercased = text.toLowerCase(Locale.ROOT); for (Rule rule : RULES) { if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) { continue; } collect(rule, text, found); } return found; } private static void collect(Rule rule, String text, List sink) { Matcher m = rule.pattern().matcher(text); while (m.find()) { for (int group : rule.groups()) { int start = m.start(group); int end = m.end(group); if (isValidGroup(rule, text, start, end)) { sink.add(new Span(start, end, rule.type(), rule.priority())); } } } } /** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */ private static boolean isValidGroup(Rule rule, String text, int start, int end) { if (start < 0 || end <= start) { return false; } if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) { return false; } String surroundings = surroundings(text, start, end); if (rule.veto() != null && rule.veto().matcher(surroundings).find()) { return false; } return rule.context() == null || rule.context().matcher(surroundings).find(); } static String surroundings(String text, int start, int end) { int from = Math.max(0, start - Rule.VETO_LOOKBEHIND); int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD); return text.substring(from, to); } }