PD Guard: модуль безопасности персональных данных
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Состояние одной операции маскирования.
|
||||
*
|
||||
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
|
||||
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
|
||||
* запроса для модели сохранится.
|
||||
*
|
||||
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
|
||||
*/
|
||||
public final class MaskContext {
|
||||
|
||||
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
|
||||
private static final char SEPARATOR = '#';
|
||||
|
||||
private final Map<String, String> assigned = new HashMap<>();
|
||||
private final Map<String, Integer> counters = new HashMap<>();
|
||||
private final Map<String, String> restorations = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* Замена для значения; при повторе возвращается ранее выданная.
|
||||
*
|
||||
* @param factory получает тип ПД и порядковый номер значения этого типа
|
||||
*/
|
||||
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
|
||||
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
|
||||
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
|
||||
restorations.put(replacement, value);
|
||||
return replacement;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
|
||||
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
|
||||
*/
|
||||
public Map<String, String> restorations() {
|
||||
return Map.copyOf(restorations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
|
||||
public enum MaskMode {
|
||||
|
||||
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
|
||||
MASK,
|
||||
|
||||
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
|
||||
TOKEN,
|
||||
|
||||
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
|
||||
SYNTHETIC
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Превращает найденное значение в замену согласно настройкам системы.
|
||||
*
|
||||
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
|
||||
* безопасное поведение по умолчанию для вновь добавленных правил.
|
||||
*/
|
||||
@Component
|
||||
public class Masker {
|
||||
|
||||
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
|
||||
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
|
||||
|
||||
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
|
||||
Map.entry(RuleRegistry.EMAIL, Strategies::email),
|
||||
Map.entry(RuleRegistry.PHONE, EDGES),
|
||||
Map.entry(RuleRegistry.CARD, EDGES),
|
||||
Map.entry(RuleRegistry.INN, EDGES),
|
||||
Map.entry(RuleRegistry.SNILS, EDGES),
|
||||
Map.entry(RuleRegistry.PASSPORT, EDGES),
|
||||
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES),
|
||||
Map.entry(RuleRegistry.DEPT_CODE, EDGES),
|
||||
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
|
||||
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
|
||||
// только последние. У паспорта РФ и водительского удостоверения серия
|
||||
// из четырёх знаков, там открывается половина.
|
||||
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES),
|
||||
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES),
|
||||
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES),
|
||||
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES),
|
||||
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials),
|
||||
Map.entry(RuleRegistry.FIO, Strategies::initials),
|
||||
|
||||
// Код проверки и пин-код не показываем даже частично: у них слишком
|
||||
// мало знаков, чтобы открывать хотя бы один.
|
||||
Map.entry(RuleRegistry.CVV, Strategies::stars),
|
||||
Map.entry(RuleRegistry.PIN, Strategies::stars),
|
||||
|
||||
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars),
|
||||
|
||||
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
|
||||
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation),
|
||||
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
|
||||
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation),
|
||||
|
||||
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars),
|
||||
Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars),
|
||||
Map.entry(RuleRegistry.BIRTH_PLACE, 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) {
|
||||
return switch (mode) {
|
||||
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
|
||||
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
|
||||
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
/**
|
||||
* Способы преобразования найденного значения в маску.
|
||||
*
|
||||
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
|
||||
* замаскированный текст остаётся читаемым для LLM и минимально отличается
|
||||
* от эталона при посимвольном сравнении.
|
||||
*/
|
||||
public final class Strategies {
|
||||
|
||||
private static final char MASK = '*';
|
||||
|
||||
private Strategies() {
|
||||
}
|
||||
|
||||
/** Каждый непробельный символ заменяется на «*». */
|
||||
public static String stars(String value) {
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
sb.append(Character.isWhitespace(c) ? c : MASK);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
|
||||
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
|
||||
* само значение — нет.
|
||||
*/
|
||||
public static String starsKeepingPunctuation(String value) {
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Оставляет первые и последние значащие символы, остальные скрывает,
|
||||
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
|
||||
*/
|
||||
public static String keepEdges(String value, int head, int tail) {
|
||||
int significant = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (Character.isLetterOrDigit(value.charAt(i))) {
|
||||
significant++;
|
||||
}
|
||||
}
|
||||
if (significant <= head + tail) {
|
||||
return stars(value);
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
int seen = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (!Character.isLetterOrDigit(c)) {
|
||||
sb.append(c);
|
||||
continue;
|
||||
}
|
||||
boolean visible = seen < head || seen >= significant - tail;
|
||||
sb.append(visible ? c : MASK);
|
||||
seen++;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
|
||||
public static String initials(String value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean wordStart = true;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (Character.isLetter(c)) {
|
||||
if (wordStart) {
|
||||
if (!sb.isEmpty()) {
|
||||
sb.append(' ');
|
||||
}
|
||||
sb.append(Character.toUpperCase(c)).append('.');
|
||||
wordStart = false;
|
||||
}
|
||||
} else {
|
||||
wordStart = true;
|
||||
}
|
||||
}
|
||||
return sb.isEmpty() ? stars(value) : sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
|
||||
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
|
||||
*/
|
||||
public static String email(String value) {
|
||||
int at = value.lastIndexOf('@');
|
||||
if (at <= 0 || at == value.length() - 1) {
|
||||
return stars(value);
|
||||
}
|
||||
String local = value.substring(0, at);
|
||||
String domain = value.substring(at + 1);
|
||||
int dot = domain.lastIndexOf('.');
|
||||
if (dot <= 0) {
|
||||
return hideTail(local) + '@' + hideTail(domain);
|
||||
}
|
||||
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
|
||||
}
|
||||
|
||||
private static String hideTail(String part) {
|
||||
if (part.length() <= 1) {
|
||||
return part;
|
||||
}
|
||||
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package ru.pdguard.mask;
|
||||
|
||||
import ru.pdguard.detect.RuleRegistry;
|
||||
|
||||
/**
|
||||
* Правдоподобные подставные значения вместо настоящих.
|
||||
*
|
||||
* <p>Модель получает текст, который выглядит естественно, и качество ответа
|
||||
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
|
||||
* исходное значение всегда даёт одну и ту же подстановку.
|
||||
*/
|
||||
final class Synthetic {
|
||||
|
||||
private static final String[] SURNAMES =
|
||||
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
|
||||
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
|
||||
private static final String[] PATRONYMICS =
|
||||
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
|
||||
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
|
||||
|
||||
private Synthetic() {
|
||||
}
|
||||
|
||||
static String forType(String type, String value, int ordinal) {
|
||||
int seed = value.hashCode() & Integer.MAX_VALUE;
|
||||
return switch (type) {
|
||||
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
|
||||
+ " " + pick(PATRONYMICS, seed >> 6);
|
||||
case RuleRegistry.CARDHOLDER -> "IVAN PETROV";
|
||||
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
|
||||
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
|
||||
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
|
||||
case RuleRegistry.CARD -> luhnCard(seed);
|
||||
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT,
|
||||
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
|
||||
case RuleRegistry.INN -> digits(seed, 12);
|
||||
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16);
|
||||
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
|
||||
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
|
||||
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed);
|
||||
case RuleRegistry.ADDRESS_CITY -> "Зареченск";
|
||||
case RuleRegistry.ADDRESS_STREET -> "Сосновая";
|
||||
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
|
||||
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
|
||||
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6);
|
||||
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье";
|
||||
case RuleRegistry.ADDRESS_REGION -> "Заречная область";
|
||||
case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район";
|
||||
case RuleRegistry.CVV -> digits(seed, 3);
|
||||
case RuleRegistry.PIN -> digits(seed, 4);
|
||||
// Для остальных типов правдоподобной замены нет — отдаём токен.
|
||||
default -> "[" + type + "_" + ordinal + "]";
|
||||
};
|
||||
}
|
||||
|
||||
private static String pick(String[] options, int seed) {
|
||||
return options[Math.floorMod(seed, options.length)];
|
||||
}
|
||||
|
||||
private static String syntheticDate(int seed) {
|
||||
int day = 1 + Math.floorMod(seed, 28);
|
||||
int month = 1 + Math.floorMod(seed >> 5, 12);
|
||||
int year = 1960 + Math.floorMod(seed >> 9, 45);
|
||||
return String.format("%02d.%02d.%d", day, month, year);
|
||||
}
|
||||
|
||||
private static String digits(int seed, int count) {
|
||||
StringBuilder sb = new StringBuilder(count);
|
||||
int value = Math.abs(seed);
|
||||
for (int i = 0; i < count; i++) {
|
||||
sb.append((char) ('0' + Math.floorMod(value, 10)));
|
||||
value = value / 10 + (i + 1) * 7;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
|
||||
private static String luhnCard(int seed) {
|
||||
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
|
||||
int sum = 0;
|
||||
boolean doubled = true;
|
||||
for (int i = body.length() - 1; i >= 0; i--) {
|
||||
int d = body.charAt(i) - '0';
|
||||
if (doubled) {
|
||||
d *= 2;
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
sum += d;
|
||||
doubled = !doubled;
|
||||
}
|
||||
body.append((10 - sum % 10) % 10);
|
||||
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
|
||||
+ body.substring(8, 12) + " " + body.substring(12);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user