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