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

от автора

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

Заключение

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


ссылка на оригинал статьи https://habr.com/ru/articles/838086/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *