Pull to refresh
1291.01
МТС
Про жизнь и развитие в IT

Сокрытие конфиденциальных данных при логировании

Reading time6 min
Views2.3K

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

Заключение

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

Tags:
Hubs:
+7
Comments11

Useful links

Отправка уведомлений по таймеру в Apache Flink

Reading time15 min
Views604
Total votes 3: ↑3 and ↓0+6
Comments1

Работа с кодом на C++ в Swift

Reading time9 min
Views1.4K
Total votes 9: ↑9 and ↓0+14
Comments4

Пайплайн распознавания транспортных средств: как это работает

Reading time12 min
Views3.2K
Total votes 9: ↑9 and ↓0+14
Comments7

Information

Website
www.mts.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия