![](https://habrastorage.org/getpro/habr/upload_files/df5/8d2/732/df58d2732c126030061754dcf1e318c2.jpg)
Всем привет! Меня зовут Сергей Соловых, я Java-разработчик в команде МТС Digital. В этой статье я расскажу, как скрыть личные данные пользователей при организации логирования.
Такая необходимость возникает при отслеживании запросов, анализе ошибок и диагностике проблем. Однако в процессе обработки персональных данных пользователей (паспортных данных, ИНН, СНИЛС и прочих документов, удостоверяющих личность) нужно учитывать, что их содержимое не подлежит разглашению. Это серьезный вопрос, который затрагивает множество аспектов: репутацию компании, доверие потребителей, законодательство. Так что задача разработчика не только связать логами всю цепочку прохождения запроса, но и исключить из них те данные, что не подлежат раскрытию.
Сегодня мы не будем сильно погружаться в детали работы той или иной технологии, а просто рассмотрим несколько доступных решений.
И все же немного теории
Логирование — это процесс фиксации событий и их сохранения в журнал. Событие в логе представляют собой настраиваемую текстовую запись, которая обычно содержит уровень важности события, временную метку, источник и самое главное — основное сообщение.
Именно его разработчик описывает в нужном участке кода: передает stackTrace отловленного исключения, параметры метода или фиксирует начало какого-либо процесса. Затем строка сообщения попадает в логгер, который форматирует ее, дополняет указанными выше метаданными и публикует в журнал. Давайте рассмотрим несколько вариантов, где мы можем вмешаться в данный процесс и предотвратить утечку данных.
Пример
Ставить наши эксперименты мы будем в проекте на Spring Boot на «землекопе» в мире программирования — классе пользователя. Будем считать фамилию, пароль, мобильный номер и даже возраст конфиденциальными данными:
@AllArgsConstructor
@Data
public class User {
private String name;
private String surname;
private String password;
private Long mobileNumber;
private int age;
}
Переопределение метода toString()
Самый простой и очевидный способ — вмешаться в создание лога на этапе формирования сообщения. Вариант указывать каждое поле вручную мы сразу отбрасываем по очевидным причинам, поэтому будем логировать сразу весь объект:
log.info("User = {}", user);
Для объекта user подкапотно вызывается метод toString(), который возвращает строковое представление объекта. Так что первый способ избежать утечки данных — написать свою реализацию этого метода:
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", surname='*****'" +
", password='*****'" +
", mobileNumber=#####" +
", age=##" +
'}';
}
После запуска кода в консоли увидим строку:
2024-04-16 12:02:11.454 INFO 48615 --- [main] dev.riccio.LogProcessor: User = User{name='Alex', surname='*****', password='*****', mobileNumber=#####, age=##}
К недостаткам такого решения можно отнести недостаточную гибкость: придется отказаться от реализации метода toString() через аннотации проекта Lombok и вносить все правки вручную в случае изменение класса. Кроме того, это ухудшит читаемость кода, особенно в случае классов с большим количеством полей. В общем, хардкод — не наш метод, тем более что душа тянется к чему-то светлому и декларативному.
Светлое и декларативное
А почему бы не ставить над полем класса аннотацию, чтобы при вызове метода toString() значение этого поля автоматически менялось на заданный шаблон? Давайте так и поступим. Создаем аннотацию, которой будем отмечать конфиденциальные данные:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Confidentially {
}
Размечаем поля класса:
@AllArgsConstructor
@Data
public class User {
private String name;
@Confidentially
private String surname;
@Confidentially
private String password;
@Confidentially
private Long mobileNumber;
@Confidentially
private int age;
}
Давайте посмотрим, как выглядит метод toString(), полученный с помощью Lombok:
public String toString() {
return "User(name=" + this.getName() +
", surname=" + this.getSurname() +
", password=" + this.getPassword() +
", mobileNumber=" + this.getMobileNumber() +
", age=" + this.getAge() +
")";
}
Стандартно он использует геттеры, поэтому нам останется реализовать аспект, который будет во время исполнения метода toString() перехватывать обращение к геттерам и, если запрашиваемое поле содержит созданную нами аннотацию, возвращать шаблонное значение. Для этого нужен pointcut типа cflow.
Cflow (control flow) — это одна из функций AspectJ, которая позволяет определять точки соединения (join points) на основе потока управления. Однако, как гласит документация Spring, реализовывать данную функцию в Spring AOP пока не спешат:
![](https://habrastorage.org/getpro/habr/upload_files/581/ea8/932/581ea8932da50b1e9938a24e40507952.png)
Применяем AspectJ
Что ж, будем использовать древнюю магию. Добавляем в gradle плагин, позволяющий запускать ajc после компилятора Java:
id "io.freefair.aspectj.post-compile-weaving" version "8.6"
Зависимость:
implementation "org.aspectj:aspectjrt:1.9.21.1"
И создаем наш аспект:
@Aspect
public class ConfidentialDataAspect {
@Around("cflow(execution(public String *.toString(..))) && get(@Confidentially * *)")
public Object processConfidentialData(ProceedingJoinPoint jp) throws Throwable {
final var obj = jp.proceed();
final Object result;
if (obj instanceof String) {
result = "*****";
} else {
result = null;
}
return result;
}
}
Запускаем код:
2024-04-16 12:12:01.454 INFO 48615 --- [main] dev.riccio.LogProcessor: User = User(name=Alex, surname=*****, password=*****, mobileNumber=null, age=0)
Не так красиво, как раньше. Раз мы перехватываем геттеры, то возвращать должны те же типы, что и поля класса. А значит, мы не можем заменить числовые значения красивыми строками типа "######". В данном случае можно маскировать лишь строковые данные — оболочечные типы получат значение null, а примитивы будут равны нулю. Можно посмотреть, как происходит обработка примитивов на примере int в методе org.aspectj.runtime.internal.Conversions#intValue:
public static int intValue(Object o) {
if (o == null) {
return 0;
} else if (o instanceof Number) {
return ((Number)o).intValue();
} else {
throw new ClassCastException(o.getClass().getName() + " can not be converted to int");
}
}
Замена реальных значений на значения по умолчанию скроет данные пользователя, но также может внести путаницу при разборе инцидента. Предположим, мы таким приемом скрыли номер телефона — в логе отобразился ноль и сразу же возникнут вопросы: «Был ли передан мобильный номер? Или он отсутствовал в запросе, и из-за этого случился сбой? Может, надо поискать проблему в другом месте?»
Можно доработать логику и маскировать лишь часть числового значения: например, в мобильном номере 71112223344 заменять на нули лишь несколько цифр после кода мобильного оператора,например: 71110000044, но такой подход уже теряет универсальность и требует привязки к предметной области.
Реализовать подобное решение можно и в maven с использованием aspectj-maven-plugin.
Корректировка сообщения на уровне логгера
Еще один вариант — это реализовать свой конвертер на уровне логгера. Никаких чудес тут не будет: строка сообщения перед публикацией будет попадать в созданный нами класс и там анализироваться по некоторым признакам.
Определим их в лоб, добавив каждому полю суффикс Confidetial:
@AllArgsConstructor
@Data
public class User {
private String name;
private String surnameConfidetial;
private String passwordConfidetial;
private Long mobileNumberConfidetial;
private int ageConfidetial;
}
В данном проекте я использую logback, так что создам следующую конфигурацию в logback-spring.xml, указав класс конвертера:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<contextName>logback</contextName>
<conversionRule conversionWord="mask" converterClass="config.logging.converter.LogConverter"/>
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{3}: %mask(%msg%n)</pattern>
<charset>utf-8</charset>
</encoder>
</appender>
<root level="info">
<appender-ref ref="console"/>
</root>
</configuration>
И, собственно, сам конвертер:
public class LogConverter extends CompositeConverter<ILoggingEvent> {
public String transform(ILoggingEvent event, String in) {
final String result;
if (Objects.nonNull(in) && in.contains("Confidetial")) {
result = Arrays.stream((in).split(", "))
.map(it -> {
if (it.contains("Confidetial")) {
final var start = it.substring(0, it.lastIndexOf("Confidetial") );
return start + ": \"***\"";
} else {
return it;
}
})
.collect(Collectors.joining(", ", "", ")" + System.lineSeparator()));
} else {
result = in;
}
return result;
}
}
Запустим код:
2024-04-16 12:34:35.199 [main] INFO d.r.LogProcessor: User = User(name=Alex, surname: "***", password: "***", mobileNumber: "***", age: "***")
Каждая строка лога перед публикацией проверяется на вхождение ключевого слова, при необходимости разделяется, модифицируется и собирается заново. Это выглядит ужасно, но это работает.
Такой способ несет дополнительные накладные расходы, но иногда это единственный возможный выход, например, если вы используете protobuf или avro, а входящий запрос должен быть тут же залогирован. Если, конечно, не обращаться к темной стороне и не использовать Reflection API, JavaParser или ASM. Эти инструменты — тема для отдельной статьи, так как «с большой силой приходит большая ответственность».
Заключение
Я рассмотрел несколько вариантов, начиная с базового, требующего ручного управления формированием сообщения, замену значений с помощью аспектов и правку строки на уровне логгера. Среди них нет «серебряной пули» — уникального решения, которое подошло бы во всех случаях, встречаемых на практике. Управлять логами вручную слишком хлопотно и несет риск человеческой ошибки. Обработка аннотаций с помощью аспектов выглядит неплохо, но этот вариант не подходит для сгенерированного кода. Вариант с анализом сформированной строки лога требует дополнительных ресурсов, и чем больше у вас логов — тем больше на это будут расходоваться ресурсы. Каждую ситуацию нужно анализировать и подбирать под нее собственное решение — я описал самые очевидные, с которыми сталкивался сам. Если вы тоже решали похожую задачу, то пишите в комментариях, обсудим вместе.