Всем привет! Меня зовут Сергей Соловых, я 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 пока не спешат:

Применяем 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. Эти инструменты — тема для отдельной статьи, так как «с большой силой приходит большая ответственность».

Заключение

Я рассмотрел несколько вариантов, начиная с базового, требующего ручного управления формированием сообщения, замену значений с помощью аспектов и правку строки на уровне логгера. Среди них нет «серебряной пули» — уникального решения, которое подошло бы во всех случаях, встречаемых на практике. Управлять логами вручную слишком хлопотно и несет риск человеческой ошибки. Обработка аннотаций с помощью аспектов выглядит неплохо, но этот вариант не подходит для сгенерированного кода. Вариант с анализом сформированной строки лога требует дополнительных ресурсов, и чем больше у вас логов — тем больше на это будут расходоваться ресурсы. Каждую ситуацию нужно анализировать и подбирать под нее собственное решение — я описал самые очевидные, с которыми сталкивался сам. Если вы тоже решали похожую задачу, то пишите в комментариях, обсудим вместе.

Комментарии (8)


  1. gleb_l
    23.08.2024 12:15

    «..скрываем данные о фактических зарплатах руководителей путем деления исходных значений на 10..»

    Если серьезно - забыли аннотацию - данные ушли в лог в небезопасном виде. Уж лучше тогда сделать интерфейс ILoggable, в нем декларировать метод toLogString(), и разрешать логировать только классы, имплементирующие этот интерфейс


    1. riccio Автор
      23.08.2024 12:15

      Идея ограничить возможность логировать объекты выглядит неплохо, но не везде применима: вспомним хотя бы protobuf или avro. И реализация интерфейса ILoggable все равно приводит нас к методу, который будет возвращать строковое представление объекта.


  1. tuxi
    23.08.2024 12:15

    Как по мне, первый способ со своей реализацией toString() наиболее правильный по двум причинам

    1. он быстрый, если у вас хайлоад, это на мой взгляд важно.

    2. существует не так много мест где надо скрывать номер телефона, фио, пароль и тд. По пальцам обычно можно пересчитать варианты этих User.

      Пароль кстати мне не понятно зачем выводить скрытым, так как настоящий все равно хэширован, а то что ввел пользователь, наоборот надо выводить, это будет одной из зацепок при разборе инцидентов в случае бутфорса например


    1. Batalmv
      23.08.2024 12:15
      +1

      Как по мне, первый способ со своей реализацией toString() наиболее правильный по двум причинам

      А если toString() еще где-то используется, и там, в другом месте надо отдавать незамаскированнеы данные

      Я вот думаю, может проще реализовать отдельный метод, который конвертит объект в строку для логирования. По умолчанию пусть под капотом будет toString, а в классах с "чуствительными" данными маскировать эти поля

      Конечно в каждой строчек с логами надо будет его явно указывать, но скорость будет та же, зато нет риска словить сайд эффект

      --------

      Но я уже давно не программирую, поэтому возможно фигню сморозил


    1. breninsul
      23.08.2024 12:15
      +1

      ну обычно очень полезно логгировать jsonчики нам приходящие, что-бы анализировать что где пошло не так.

      Но это не так важно. Важно что везде, кроме как маскирования в логгере, у вас пароль, к что хуже пан карты будет логгировать на любой чих.

      Включили дебаг уровень, и не знали что ваш вреймворк начал логгировать аргументы метода? Привет PCI DSS.

      Ненене, проходили. Все конфиденциальное имеет спец. структуру(или префикс/постфикс) и логгируется на уровне логгера.


      1. riccio Автор
        23.08.2024 12:15

        Еще один подход к обработке логируемой строки, в том числе входящего JSON, в использовании PatternLayout.


  1. OlegZH
    23.08.2024 12:15

    Было бы любопытно потом разобраться во всех тонкостях приведённого кода. Можно будет задать вопросы в личку?

    И ещё один вопрос. А могут ли поля класса сами быть экземплярами других классов? В этом случае, необходимость ведения лога включило бы на этапе конструктора объекта объемлющего класса некую разделяемую различными полями сущность, ответственную за формирование лога.


    1. riccio Автор
      23.08.2024 12:15

      Давайте попробуем разобраться, что именно происходит. Напишите ваши вопросы личным сообщением, постараюсь вам помочь.

      Если использовать в виде поля класса объект другого класса: например:

      public class User {
      //...
      @Confidentially
      private Address address;
      }

      то в консоли будет выведено null