Как персональные данные утекают из Doctrine в Monolog, очереди ошибок Messenger, трейсы профилировщика и отчеты об исключениях — и как паттерн белых списков решает эту проблему.
Инженерный анализ в контексте GDPR. Не является юридической консультацией.
Приходит тикет в саппорт: клиент хочет знать, почему не прошла оплата. Вы открываете Kibana, ищете по ID заказа и находите его — но не только ID. Там лежит весь payload запроса, красиво отформатированный обработчиком исключений: имя, email, имя владельца карты, адрес доставки. В базе данных колонка с email этого клиента зашифрована. А в строке лога тремя вкладками правее в вашем браузере лежат открытые данные, которые хранятся девяносто дней, доступны любому инженеру с доступом к логам и находятся вне всех настроенных для базы данных прав доступа.
Никто не добавлял их туда намеренно. Exception listener сериализовал упавшую команду для отладки. Этот слушатель ничего не знает о «персональных данных». Он просто умеет вызывать json_encode для любого переданного ему объекта.
Популярное решение и почему оно закрывает лишь половину дыры
Обычно первой реакцией становится черный список: процессор Monolog, который маскирует ключи вроде email, password, ssn. Он перехватывает очевидный кейс — плоский массив с ключом email — и полезен сам по себе. Но он не ловит то, из-за чего логи переполнились персональными данными в примере выше: полное имя клиента внутри текста сообщения исключения («Payment declined for Jane Doe, card ending 4242»), вложенный DTO с полем contactName или конверт Messenger, сериализованное тело которого содержит весь объект команды в виде сплошной строки. Черный список по именам полей не заглянет в произвольный текст, во вложенные структуры, о которых ему не сообщили, или в блоб, который он даже не парсит.
Проблема носит архитектурный характер, и простым добавлением ключей в черный список ее не решить. Как только персональным данным разрешают попадать в логи по принципу «всё, что случайно оказалось в объекте», черный список превращается в попытку догнать бесконечное множество.
Где на самом деле протекает приложение на Symfony
Персональные данные попадают в хранилище логов и систему наблюдаемости гораздо большим количеством путей, чем представляет большинство команд:
- Автоматическая сериализация команд, событий и DTO. Exception subscriber, debug processor или интеграция с APM, вызывающая json_encode или Symfony Serializer для «обрабатываемого объекта», чтобы сделать ошибки воспроизводимыми. Если этот объект — команда с контактными данными клиента, они окажутся и в сериализованном дампе.
- Массивы контекста Monolog и сообщения исключений. Вызов $logger->error('Failed to update customer', ['customer' => $customer]) выглядит безобидно, пока процессор или форматтер Monolog не превратит $customer в строку. Текстовые сообщения исключений со строковой интерполяцией еще опаснее — персональные данные оказываются внутри текста, а не в структурированном поле, поэтому ни один фильтр по ключам их не найдет.
- Повторы Symfony Messenger и транспорты ошибок (failure transports). Асинхронное сообщение сериализуется целиком для отправки. Если обработка падает, Messenger может повторить ее; после превышения лимита попыток он может отправить сериализованное сообщение в failure transport, если он настроен. Этот транспорт может работать поверх Doctrine, Redis, AMQP или любого другого решения, и у него свои правила хранения и контроля доступа — обычно отличные от основной базы данных.
- Symfony Profiler и логирование запросов/ответов. Profiler может сохранять данные запроса и другой отладочный контекст — что именно попадет в лог, зависит от активных сборщиков данных (data collectors). В стандартном приложении на Flex это инструмент разработки, и Symfony прямо предостерегает от его включения в проде. Однако «включить профилировщик в проде на полчаса, чтобы разобрать инцидент» — классическое решение под давлением обстоятельств, которое молча открывает эту брешь на все время работы.
- Трейсинг, APM и системы сбора ошибок. Спаны распределенного трейсинга и SDK сбора ошибок (breadcrumbs в Sentry, теги запросов в APM) часто по умолчанию перехватывают тела запросов/ответов или контекст исключений, так как именно это помогает в отладке. Эти настройки по умолчанию никто не адаптировал под ваши правила работы с персональными данными.
- Логи веб-сервера, реверс-прокси и шлюзов. Персональные данные, переданные в query string, пути URL или определенных заголовках, могут быть залогированы еще до того, как запрос дойдет до Symfony — на уровне nginx, балансировщика нагрузки, API gateway или CDN. Никакой процессор Monolog не сможет замаскировать строку лога, записанную выше по стеку.
- Персональные данные в именах файлов, ключах кэша, лейблах и метриках. Ключ кэша вида "customer_export_{$email}" или лейбл метрики с ID клиента оседают в инфраструктуре — дашбордах кэша, бэкендах метрик, — которая изначально вообще не предназначалась для хранения персональных данных.
Ни в одном из этих сценариев разработчик не вписывает email клиента в лог намеренно. В каждом случае это стандартный, разумный компонент инфраструктуры, делающий ровно то, ради чего создан: собирающий контекст для отладки сбоя. Просто в этот контекст случайно попадают персональные данные.
Белый список вместо черного
Решение, закрывающее брешь, инвертирует поведение по умолчанию. Вместо попыток перечислить все поля, сообщения и структуры объектов, которые могут содержать персональные данные, чтобы их вырезать, определите, что логировать безопасно, а всё остальное считайте небезопасным по умолчанию.
final readonly class SafeLogContext
{
private function __construct(
private array $fields,
) {
}
public static function forOrder(
OrderId $orderId,
CustomerId $customerId,
string $status,
): self {
return new self([
'order_id' => $orderId->toString(),
'customer_id' => $customerId->toString(),
'status' => $status,
]);
}
public function toArray(): array
{
return $this->fields;
}
}У SafeLogContext нет конструктора, принимающего сырую сущность, сырую команду или произвольную строку. Каждое попадающее внутрь поле выбрано явно, по имени, разработчиком, написавшим конкретный именованный фабричный метод для этого места логирования. Универсальной лазейки вроде fromEntity() нет — ее появление вернуло бы ту самую уязвимость, ради устранения которой создавался класс.
Вторая часть решения — процессор Monolog, служащий страховкой для мест, куда SafeLogContext не дотягивается: сторонних бандлов, логов уровня фреймворка и всего, что еще не успели переписать:
final readonly class PersonalDataRedactionProcessor
{
private const array DENYLISTED_KEYS = ['email', 'phone', 'fullName', 'address', 'cardHolder'];
public function __invoke(LogRecord $record): LogRecord
{
$record->extra['context_redacted'] = $this->redactRecursively($record->context) !== $record->context;
return $record->with(context: $this->redactRecursively($record->context));
}
private function redactRecursively(array $context): array
{
$redacted = [];
foreach ($context as $key => $value) {
$redacted[$key] = match (true) {
in_array((string) $key, self::DENYLISTED_KEYS, strict: true) => '[REDACTED]',
is_array($value) => $this->redactRecursively($value),
is_object($value) => '[object:' . $value::class . ']',
default => $value,
};
}
return $redacted;
}
}Порядок ветвей match имеет значение. Маскирование по ключам идет первым, поэтому поле из черного списка удаляется независимо от того, является ли значение скаляром, массивом или объектом: ['email' => ['value' => 'alice@example.com']] превращается в ['email' => '[REDACTED]'], вместо того чтобы рекурсивно обходиться в поисках неизвестного ключа. Для остальных полей массивы обходятся рекурсивно, а любой объект заменяется на имя его класса вместо сериализации — именно это закрывает дыру с «вложенным DTO с неожиданным именем поля», которую оставляет чистый черный список. Процессор никогда не вызывает json_encode или Serializer для объектов приложения, исключая утечку новых свойств через рефлексию. В Monolog 3 базовые поля записи, такие как context, являются readonly и заменяются через LogRecord::with(), тогда как extra специально оставлен изменяемым для процессоров — поэтому маркер маскирования пишется напрямую в extra. В связке с SafeLogContext как основным путем и этим процессором в роли страховки имя класса в логах становится сигналом обхода белого списка — поводом для алерта, а не тихой нормой.
Та же дисциплина белых списков применима к идентификаторам вне массива контекста лога. Фингерпринту или техническому идентификатору — ID заказа, захэшированному correlation ID — самое место в строке лога, имени файла или ключе кэша. Исходному значению, которое скрывается за фингерпринтом, там не место. Если две строки лога нужно связать с одним клиентом без раскрытия его личности, эту связь обеспечивает стабильный псевдонимизированный идентификатор; в третьей статье серии мы строим похожую структуру для другой задачи — слепого индекса (blind index), позволяющего делать поиск без раскрытия исходного значения.
Три сценария, где этот подход дает сбой
Процессор срабатывает после утечки, а не до нее. Процессор Monolog маскирует запись до ее отправки в хэндлер. Но если сторонний бандл пишет напрямую в файл, syslog или внешний SDK в обход логгера Symfony (многие интеграции с APM и системами сбора ошибок регистрируют собственные хуки параллельно с Monolog, а не через него), процессор этих данных не увидит. Белый список на основном пути — это реальный контроль; процессор — лишь страховка для того, что проходит через Monolog, а не универсальный фильтр.
Failure transport в Messenger требует отдельного решения. Маскирование данных в логе упавшего сообщения не маскирует само сообщение, хранящееся в failure transport, если он настроен. Это отдельная зона хранения и контроля доступа со своими требованиями (ограниченный срок хранения, урезанный доступ или нормализатор, убирающий или хэширующий поля с персональными данными до попадания команды в шину). Заблуждение «раз мы маскируем логи, значит, всё в порядке» при сохранении полных команд в очереди ошибок на 30 дней создает ложное чувство безопасности.
Profiler и инструменты отладки — это риск конфигурации окружения, а не кода. Никакая дисциплина SafeLogContext в коде приложения не спасет от того, что перехватят включенный Profiler и его активные сборщики данных. Если при экстренной отладке его включают на проде, относитесь к собранным профилям как к еще одному потенциально чувствительному хранилищу данных и проверяйте активные сборщики. Это зона ответственности деплоя и конфигурации: убедитесь, что framework.profiler.enabled выставлен в false для прод-окружения, и проверьте регламенты аварийной отладки, ведь именно в таких ситуациях этот контроль отключают под давлением.
Что этот подход гарантирует, а что нет
Логирование на основе белых списков задает тестируемую границу: контекст лога, записанный этим кодом, содержит только поля, явно указанные разработчиком. Однако оно не дает:
- Универсальной очистки любой строки, попадающей в Monolog. Сырое сообщение исключения со строковой интерполяцией всё еще может содержать персональные данные в тексте; здесь поможет только дисциплина формирования сообщений исключений, а не фильтр, разбирающий естественный язык.
- Защиты инструментов, работающих в обход Monolog. Сторонние SDK с собственным логированием или сбором breadcrumbs требуют индивидуальной настройки с оглядкой на документацию вендора.
- Гарантий для уже записанных логов. Решение предотвращает утечки в будущем; данные, записанные старыми небезопасными вызовами, остаются в хранилище логов и требуют отдельного решения по срокам хранения или удалению.
Ментальная модель, объединяющая всё это: логи — это вторая база данных, которую вы специально не проектировали, где нет настроенных для первой базы прав доступа, шифрования или политик хранения. Поэтому туда должно попадать только то, что вы осознанно решили туда отправить, а не то, что решил включить универсальный сериализатор.
Простой и дешевый регрессионный тест: заполните фикстуру приметной, заведомо фейковой строкой-маркером в поле с персональными данными (вроде zz-pii-marker-do-not-log-zz), прогоните ее через сценарии с наибольшим риском утечки (падающая команда, обработчик исключений, сбой в Messenger) и выполните grep полученных логов на наличие этого маркера. Он не поймает все описанные утечки, но превратит фразу «мы вроде бы маскируем данные» в тест, который упадет в день, когда кто-то добавит новый debug processor, дампящий весь объект команды.
Коротко о главном
- Шифрование колонок в базе данных не мешает тем же значениям попадать в открытом виде в логи, трейсы, failure transport в Messenger или Profiler — это разные хранилища с собственными политиками хранения и доступа.
- Черный список по именам полей (email, phone) пропускает персональные данные в тексте сообщений исключений, во вложенных объектах с нестандартными именами свойств и во всем, что отладочные инструменты сериализуют целиком.
- Инвертируйте логику: формируйте контекст логов по именованному белому списку (SafeLogContext), где каждое поле выбрано осознанно, а процессор Monolog, маскирующий по ключам и заменяющий случайные объекты на имена их классов, используйте как страховку, а не основной барьер.
- Используйте для корреляции в логах, ключах кэша и именах файлов фингерпринты или технические ID, а не исходные персональные данные.
- Failure transport в Messenger, Profiler на проде, сторонние SDK трейсинга/сбора ошибок, а также логи веб-серверов, прокси и шлюзов выше по стеку находятся вне зоны досягаемости процессора Monolog — для каждого из них нужно явное решение.
- Тест, подставляющий уникальный маркер персональных данных и проверяющий логи грепом после упавшего запроса — дешевая и надежная регрессионная проверка: она не покроет всё, но перехватит случайный дамп объекта целиком.
- Цель — не универсальный санитайзер, а минимизация данных: в лог попадает только то, что разработчик поле за полем решил там сохранить.
Случалось ли, что инструменты логирования или трейсинга перехватывали больше, чем вы ожидали — и находили ли вы это грепом по логам или об этом сообщал аудит безопасности?
Комментарии (0)
Пока нет комментариев — будьте первым.