DarkRiDDeR14 мин

Разбор журнала: redaction и кардинальность до первой аварии

BackendБезопасностьРазбор

Проблема заметна поздно: нужное событие найдено, но рядом лежит целый объект запроса, длинный URL и строка, похожая на credential. Другой поиск не работает, потому что имя события каждый раз включает номер заказа или текст внешней ошибки. Цена — не только неудобная диагностика. Журнал становится местом, куда утекают лишние данные, а одинаковые случаи невозможно посчитать или сравнить без ручной очистки.

Лечить это после первого серьёзного сбоя дорого: код уже привык писать «всё, что есть под рукой», а доступ к журналу обычно шире доступа к исходному запросу. Поэтому правила redaction и кардинальности нужны в точке формирования события. В этой статье нет настоящих инцидентов, секретов или production-логов: только синтетическая запись и локальный fixture. Задача — научиться отличать поле, нужное для ответа, от поля, которое журналу не принадлежит.

Разделяем данные на три класса до вызова логгера

Первый класс — безопасный операционный контекст. Это время, уровень, сервис, имя события, шаблон маршрута, код ответа и request_id. Они помогают связать одну историю и не требуют выгружать тело запроса. Второй класс — ограниченный контекст: технический код внешней ошибки, нормализованное состояние, короткое имя адаптера. Его добавляют, если есть точный вопрос диагностики и форма значения известна. Третий класс — запрещённый или redacted: учётные данные, cookies, заголовок authorization, пароль, полный body и произвольный объект пользователя.

Разделение не означает, что второй класс всегда безопасен. Даже поле error.message может включить в себя входные данные внешней системы. Поэтому практический контракт предпочитает error.code и собственное имя события, а текст оставляет коротким и контролируемым. Если подробность действительно нужна, её нельзя добавлять в общий JSON по умолчанию: сначала определяется доступ, срок хранения и отдельный путь проверки. В этой минимальной схеме подробность отсутствует, а не прячется в красивом имени поля.

Политика полей для учебного JSON-события
КлассПримерыЗачем они нужныДействие перед записью
Базовыйtimestamp, service, event, request_idсвязать и прочитать один запросписать в устойчивом формате
Ограниченныйadapter.name, error.code, http.status_codeотделить известную ветвь отказадобавлять только владельцем операции
Redactauthorization, cookie, password, tokenне нужны для поиска событиязаменить до JSON или не передавать объект
Не логироватьbody, полный URL, целый объект пользователяслучайная форма и лишние данныезаменить на шаблон маршрута или нормализованный код
Вертикальное дерево решения для учебного поля: сначала проверяется диагностический вопрос, затем выбирается безопасное обязательное поле, ограниченный нормализованный контекст, redaction чувствительного значения или полный отказ от записи; внизу показан поиск одного синтетического request_id
Поле проходит через вопрос «зачем оно нужно?». Если ответ не приводит к проверяемому действию, значение не попадает в событие. Redaction — защита для известных путей, а не разрешение логировать весь объект.

Redaction должен работать с объектом, а не с готовой строкой

Ошибка redaction часто начинается с позднего шага: код уже сделал JSON.stringify(req), затем регулярное выражение пытается вычеркнуть секрет из текста. Вложенный ключ, другой регистр или неожиданная форма легко проходят мимо такой маски. Надёжнее принять маленький объект контекста, пройти его по известным ключам и только потом сериализовать. Документация Pino описывает redaction путями полей; это тот же принцип: правило должно знать структуру, а не угадывать фрагмент строки.

const sensitiveKeys = new Set(['authorization', 'cookie', 'password', 'token', 'secret']);

function redact(value, key = '') {
  if (sensitiveKeys.has(key.toLowerCase())) return '[REDACTED]';
  if (Array.isArray(value)) return value.map((item) => redact(item));
  if (value && typeof value === 'object') {
    return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, redact(item, name)]));
  }
  return value;
}

const trainingContext = {
  request_id: 'req-demo-20200714-01',
  event: 'http.request.completed',
  headers: { authorization: 'synthetic-placeholder-not-a-secret' },
  http: { route: '/training/orders/:orderId', status_code: 202 }
};
process.stdout.write(JSON.stringify(redact(trainingContext)) + "\n");
// authorization is emitted as [REDACTED]; no real credential is present in this fixture.

Этот пример не обещает поймать каждый секрет во всех структурах. Он фиксирует минимум: известные чувствительные ключи не должны дойти до stdout в исходном виде. В реальном проекте список расширяется по фактическим форматам интеграций, а тесты добавляют каждый уже известный путь. Если библиотека поддерживает redaction конфигурацией, правило всё равно следует проверять на том JSON, который действительно выходит из процесса. Название опции — не доказательство результата.

Есть и более простая защита: не давать логгеру сырой объект. Контроллер сам выбирает method, route и status_code; адаптер сам выбирает adapter.name и error.code. Тогда redaction остаётся страховочной сеткой, а не единственным барьером. Чем меньше произвольных объектов пересекает границу логирования, тем легче редактору и ревьюеру увидеть, откуда появилось поле.

Кардинальность — это форма будущего поиска

Кардинальность показывает, сколько разных значений может иметь поле. У event она должна быть низкой: десятки устойчивых имён, а не одна новая строка на исключение. У route тоже низкая: шаблон /training/orders/:orderId, а не конкретный путь. У request_id она намеренно высокая, потому что его задача — найти одну историю. Ошибка начинается, когда все эти поля используют одинаково — например, группируют по request_id или добавляют номер заказа в имя события.

Не надо превращать эту заметку в рассказ о зрелой платформе метрик. Здесь достаточно заранее записать, какие поля допускаются как фильтр одного случая, а какие могут быть группой для короткого локального отчёта. Даже если команда пока читает JSONL через jq, это решение уже меняет качество диагностики: следующий разработчик не создаст сто вариантов события ради удобства одного сообщения.

Кардинальность и назначение поля
ПолеОжидаемая кардинальностьРазрешённое использованиеАнтипаттерн
eventнизкаяфильтр и небольшая группаorder.7421.failed
serviceнизкаяграница владельцаимя с номером pod или локальным путём
routeнизкаясравнить обработчикиполный URL с id и query string
request_idвысокаянайти одну историюиспользовать как измерение агрегата
error.codeограниченнаяразделить известные причиныписать произвольный текст исключения

Разбираем один синтетический сбой без имитации инцидента

Все идентификаторы, маршруты и значения в примерах ниже учебные и синтетические. Это не журнал реального пользователя и не отчёт о production-инциденте.

{"timestamp":"2020-07-14T09:30:11.001Z","level":"info","service":"demo-gateway","environment":"training","event":"http.request.received","request_id":"req-demo-20200714-01","message":"Synthetic request accepted","http":{"route":"/training/orders/:orderId"}}
{"timestamp":"2020-07-14T09:30:11.024Z","level":"warn","service":"demo-catalog-api","environment":"training","event":"adapter.response.rejected","request_id":"req-demo-20200714-01","message":"Synthetic adapter response rejected","adapter":{"name":"training-inventory","error_code":"TRAINING_SCHEMA_MISMATCH"}}
{"timestamp":"2020-07-14T09:30:11.042Z","level":"info","service":"demo-gateway","environment":"training","event":"http.request.completed","request_id":"req-demo-20200714-01","message":"Synthetic request completed","http":{"status_code":502}}

# Учебная проверка: поле request_id связывает записи; error_code называет нормализованную причину.
jq -c 'select(.request_id == "req-demo-20200714-01")' training.jsonl

Диагноз здесь не «адаптер плохой». Проверяемая цепочка короче: gateway принял учебный запрос, API записал нормализованный TRAINING_SCHEMA_MISMATCH, gateway завершил его 502. Если нужен следующий шаг, он относится к контракту учебного адаптера, а не к поиску несуществующего токена в журнале. Если в ответе появляется полное тело внешней системы, это новый дефект контракта логирования, а не удобный контекст.

Проверяем redaction на форме, которая реально уходит в stdout

Маска полезна только там, где объект окончательно превращается в JSON. Поэтому fixture должен проверять не внутренний JavaScript-объект, а результат сериализации: в нём есть обязательные безопасные поля, а вместо учебного placeholder в headers.authorization стоит ровно [REDACTED]. Такая проверка ловит порядок операций. Если разработчик случайно записал исходный контекст раньше, чем вызвал redaction, тест видит запрещённое значение в строке и не даёт считать код защищённым только по названию функции.

У redaction есть предел. Он не исправит поле, которое автор назвал access вместо token, и не решит, что делать с вложенной строкой, куда внешний сервис уже склеил данные. Поэтому список ключей — не замена ревью. Ревью задаёт два вопроса: откуда взялось значение и можно ли ответить на диагностический вопрос без него? Если второй ответ «да», поле удаляется. Если «нет», для него фиксируется нормализованная форма, путь redaction и учебный пример, который проверяет именно этот путь.

Практический порядок важнее количества масок. Сначала не передавать объект запроса. Затем выбрать короткий контекст. Затем заменить известные чувствительные узлы. Наконец, проверить сериализованный результат. При таком порядке новая интеграция не получает неявное право приносить любой JSON. Она должна добавить поле к контракту и объяснить его цену, иначе требование «сохранить для отладки» станет бесконечным исключением.

Кардинальность проверяется в именах до первого запроса

Высокая кардинальность редко выглядит угрозой в одном примере. Один номер заказа, одна строка внешней ошибки и один полный URL кажутся безобидными. Проблема появляется, когда они становятся формой поля: через неделю журнал содержит множество вариантов, где одинаковое событие нельзя отличить от нового. Поэтому правило проверяют не количеством строк, а источником значения. Если оно рождается из идентификатора, текста пользователя, query string или свободного исключения, ему не место в event, route или имени сервиса.

Нормализация не обязана скрывать причину. Вместо текста можно выбрать error.code из маленького списка, вместо полного URL — route template, вместо динамического события — пару «устойчивое имя события + локальный технический код». Тогда человек всё ещё видит, куда идти: adapter.response.rejected и TRAINING_SCHEMA_MISMATCH ведут к адаптеру и его контракту. Но поиск не создаёт отдельную категорию на каждый учебный заказ или случайную фразу.

Маршрут ревью для поля и записи

  1. Назвать диагностический вопрос, который должно закрыть новое поле; без вопроса поле не добавлять.
  2. Отнести поле к базовому, ограниченному, redact или запрещённому классу.
  3. Для строк с множеством значений выбрать нормализованный код, шаблон маршрута или словарное имя события.
  4. Перед сериализацией прогнать синтетический объект через redaction и проверить точный JSON-результат.
  5. По одному учебному request_id убедиться, что запись отвечает на вопрос без полного запроса, пользователя или секрета.
  6. На ревью спросить, не используется ли высококардинальное поле как группа и не скрывается ли необязательная информация во вложенном объекте.

Такой разбор связывает безопасность и диагностику без громких обещаний. В журнале остаётся ровно столько контекста, чтобы объяснить одну известную ветвь, а не восстановить целое production-событие. Следующее улучшение может потребовать отдельного формата ошибок, доступа к хранилищу или модели трассировки. Но до него полезно закрепить базовую дисциплину: событие имеет ограниченный словарь, один request_id ищется отдельно, а чувствительное значение не должно быть принято в журнал даже «временно».

Проверяемые источники

  • RFC 5424: The Syslog Protocol — задаёт отдельные поля заголовка и формат STRUCTURED-DATA; это полезный контрпример строке, которую затем приходится разбирать регулярным выражением
  • RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format — фиксирует правила JSON-значений и строк; логгер должен выдавать валидную запись, а не склеивать псевдо-JSON вручную
  • Pino: API documentation — документирует дочерние логгеры и параметр redact; перед применением нужно сверить эти возможности с версией, установленной в конкретном проекте
  • OWASP Logging Cheat Sheet — перечисляет данные, которые не следует писать в журнал, и предлагает проверять событие до записи