Логи и ошибки: не раскрывать лишнего

Логи и сообщения об ошибках — это тоже вывод, и он может выдать слишком много.

Аудит-лог фиксирует значимые для безопасности события (вход, смена прав, доступ к данным). Generic-ошибка — нейтральное сообщение пользователю без внутренних подробностей.

Что нельзя писать в логи

Логи кажутся «внутренними», но они копируются, агрегируются, попадают в системы мониторинга и доступны многим. Поэтому в них не должно быть секретов и чувствительных данных: паролей (даже неверных), токенов, ключей, номеров карт, персональных данных целиком. Утечка лог-файла не должна означать утечку учётных данных.

Чтобы прочувствовать масштаб риска, представьте обычный путь одной строки лога. Сервис приложения пишет её на диск, агент сбора (например, сборщик логов) пересылает её в централизованное хранилище, оттуда она индексируется в поисковом движке, попадает в дашборды дежурной смены, реплицируется в резервные копии и иногда уезжает во внешний SaaS для алертинга. На каждом из этих шагов к строке имеет доступ новый набор людей и систем: дежурные инженеры, аналитики, подрядчики по мониторингу, бэкап-операторы. Один неосторожный log.info с паролем превращается в десяток копий этого пароля в местах, которые вы даже не контролируете напрямую.

Отдельная ловушка — чувствительные данные, которые «не выглядят» секретами. Номер телефона, адрес, дата рождения, номер документа по отдельности кажутся безобидными, но в совокупности это персональные данные, на которые распространяются требования закона о их защите. Сюда же относятся медицинские сведения, геолокация и платёжные реквизиты. Логи с такими полями нужно либо вовсе не писать, либо обезличивать, иначе обычный лог-файл превращается в базу ПДн со всеми вытекающими обязанностями по её охране и срокам хранения.

// Уязвимо: в лог попадает секрет и весь объект запроса
log.info("login attempt " + JSON.stringify(request.body));  // там пароль/токен

// Безопасно: логируем факт и безопасные поля, секреты маскируем
log.info("login attempt", { user: maskEmail(user), ip: ip, result: "fail" });

Полезный приём — централизованная маскировка: фильтр, который вырезает значения чувствительных полей (password, token, authorization) перед записью.

Зачем нужен аудит

Логирование безопасности — не только про «что не писать», но и про «что обязательно записать». Без аудита невозможно расследовать инцидент и нельзя противодействовать repudiation (отрицанию действия) из STRIDE. Логируйте значимые события: входы и выходы, неудачные попытки, изменение прав, доступ к чувствительным данным, административные действия — с кто/что/когда/откуда.

Представьте практическую ситуацию: через месяц после релиза выясняется, что у одного из пользователей внезапно появились права администратора, и никто не помнит, кто их выдал. Если событие «изменение роли» аккуратно записано в аудит с указанием инициатора, цели, старого и нового значения и времени, расследование занимает пять минут — вы открываете журнал и видите цепочку действий. Если же аудита нет, остаётся только гадать: был ли это легитимный администратор, ошибка в коде или взлом. Аудит-лог отвечает не на вопрос «что сломалось», а на вопрос «кто и когда это сделал», и именно поэтому он незаменим при разборе инцидентов и при выполнении требований регуляторов.

Важно отделять аудит от обычного диагностического логирования. Диагностические логи живут ради разработчиков, они шумные, часто их крутят с коротким сроком хранения и спокойно теряют при переполнении. Аудит-лог — это юридически значимая летопись: он должен быть полным, неизменяемым и храниться предсказуемый срок. Поэтому на практике аудит часто выносят в отдельный поток или даже отдельное хранилище, чтобы случайная чистка debug-логов не стёрла записи о смене прав и доступе к данным.

// Аудит значимого события (без секретов)
audit({ event: "role_changed", actor: adminId, target: userId,
        from: "user", to: "admin", at: now(), ip: ip });

Ошибки: generic пользователю, детали — в лог

Подробное сообщение об ошибке с типом исключения, стеком и SQL-фрагментом помогает атакующему понять устройство системы (стек технологий, имена таблиц, пути). Пользователю показывают нейтральное сообщение и идентификатор для поддержки, а подробности пишут в защищённый лог.

// Уязвимо: стектрейс и детали уходят пользователю
catch (e) { return response(500, e.stack + " " + e.sqlMessage); }

// Безопасно: пользователю — generic + id; детали — в лог
catch (e) {
  const id = newErrorId();
  log.error("unhandled", { id, error: e });        // полные детали в логе
  return response(500, { message: "Внутренняя ошибка", ref: id });
}

Здесь же — выключить debug-режим в проде: отладочные страницы фреймворков показывают переменные окружения, исходники и стек всем подряд.

Как работает под капотом: защита самих логов

Логи — ценный актив и для расследования, и для атакующего. Их стоит защищать: ограничить доступ, по возможности делать append-only (чтобы злоумышленник не «подчистил следы»), задать срок хранения по политике ПДн. И помнить про log injection: если в лог пишут пользовательский ввод без нейтрализации переводов строк, можно подделать записи — экранируйте/нейтрализуйте управляющие символы в логируемых значениях.

Разберём механику маскировки чуть глубже. Наивный подход — искать в строке подстроку «password» и заменять её — хрупок: данные приходят в разных регистрах, в разных языках, во вложенных структурах. Надёжнее работать на уровне структурированного логирования: приложение пишет не плоскую строку, а объект с именованными полями, и перед сериализацией этот объект проходит через рекурсивный фильтр, который знает список чувствительных ключей и заменяет их значения на *** на любой глубине вложенности. Такой фильтр ставится один раз централизованно, и тогда даже забывчивый разработчик, передавший в лог весь объект пользователя, не утечёт его пароль — поле будет замаскировано автоматически.

Защита от log injection устроена похоже. Когда логи структурированы (например, в формате JSON, по строке на запись), пользовательский ввод попадает в значение поля, а не в «тело» строки лога, и перевод строки внутри значения уже не создаёт фальшивую запись — он остаётся данными. Это ещё один аргумент в пользу структурированного логирования: оно не только удобнее для поиска, но и устойчивее к подделке журнала. Дополнительно стоит ограничить длину логируемых значений, чтобы атакующий не «забил» хранилище гигантскими строками и не устроил отказ в обслуживании через сами логи.

// Уязвимо: пользовательский ввод склеен в строку лога
log.info("search query: " + query);   // query = "ok
ADMIN logged in" подделает запись

// Безопасно: ввод как отдельное поле структурированной записи
log.info("search", { query: query });  // перевод строки остаётся данными, не новой строкой

Частые ошибки

  • Логировать тело запроса целиком. Туда попадают пароли и токены.
  • Стектрейс пользователю / debug в проде. Раскрывает внутренности.
  • Нет аудита. Инцидент нечем расследовать.
  • Логи без контроля доступа. Файл логов = потенциальная утечка.
  • Аудит вперемешку с debug-логами. Чистка шумных логов стирает юридически значимые записи о смене прав.
  • Логировать пользовательский ввод как часть строки. Перевод строки в значении подделывает записи журнала (log injection).

Итоги

  • Не пишите в логи секреты и чувствительные данные; маскируйте.
  • Ведите аудит значимых событий — иначе нечем расследовать инцидент.
  • Пользователю — generic-ошибка с ref, детали — в защищённый лог; debug в проде выключен.
  • Защищайте сами логи и берегитесь log injection.
Проверьте себя
1. Почему нельзя логировать тело запроса целиком на странице входа?
AЭто занимает много места
BВ тело попадают секреты (пароли, токены), и утечка логов превратится в утечку учётных данных
CЛоги не поддерживают JSON
DЭто замедляет вход
2. Почему пользователю показывают generic-ошибку, а детали пишут в лог?
AЧтобы сэкономить трафик
BПодробный стектрейс раскрывает устройство системы атакующему; детали нужны разработчику и хранятся в защищённом логе
CПотому что пользователи не читают ошибки
DЧтобы ускорить ответ
3. Зачем нужен аудит-лог значимых событий безопасности?
AЧтобы увеличить объём логов
BЧтобы можно было расследовать инциденты и противодействовать отрицанию действий (repudiation)
CЧтобы ускорить приложение
DЧтобы хранить пароли пользователей