{ "index": 283, "slug": "editorial-2020-02-field-configs-secrets", "title": "Токен попал в лог: как провести ротацию и закрыть путь утечки", "excerpt": "Удалить строку из кода недостаточно, если credential уже попал в лог, образ или историю Git. Разбираем учебный incident: ограничиваем распространение, меняем потребителей, отзываем старое значение и проверяем, что ошибка не вернулась.", "contentHtml": "
Сервис отвечает ошибкой 500, а в централизованном логе рядом с request ID виден заголовок Authorization. Поиск по логам находит ту же строку ещё в нескольких записях. Это не просто неудачный формат диагностики. Пока credential действует, читатель лога может использовать его как доступ к внешней системе. Цена ошибки — отзыв ключа, переключение всех потребителей, разбор копий в CI и backup, а иногда и вынужденное окно простоя.
Первый импульс обычно неверен: удалить поле из логгера, стереть найденную запись и закрыть задачу. Эти действия убирают симптом, но не меняют уже выданное значение. Секрет мог попасть в другой лог, error-reporting, Docker-образ, артефакт сборки или историю Git. Значит, incident закрывается не после commit, а после проверки границ распространения и отзыва старого credential. В этом учебном разборе я не объявляю утечку закрытой по одному исправленному месту: ниже каждый вывод привязан к наблюдаемой проверке.
\nНастройка описывает поведение приложения: имя окружения, URL зависимости, уровень логирования, таймаут. Credential даёт право действовать от имени приложения. У этих данных разные требования к хранению, доставке и журналированию. Шаблон конфигурации можно положить рядом с кодом. Значение токена должно приходить через защищённый канал запуска и не должно попадать в image, commit или диагностический объект.
\nРотация решает другой вопрос: какое значение сейчас может принимать провайдер. Redaction решает вопрос вывода: какие поля можно показать оператору. Cleanup решает вопрос доступных копий. Нельзя подменять одно другим. Маска не отзывает токен. Удаление файла не очищает backup. Новый commit не делает историческое значение недействительным.
\nВ приложении есть обычный путь запроса и путь ошибки. Обычный путь передаёт заголовки HTTP-клиенту. Путь ошибки добавляет request context в JSON для лога. Если сериализатор не знает, какие поля чувствительны, он копирует объект целиком. Так credential покидает границу процесса и начинает жить в системах, которые команда могла не учитывать.
\nМеханизм легко проверить на учебном значении. Функция должна принимать структуру заголовков, заменять чувствительные поля и сохранять безопасный request ID. В коде ниже нет реального доступа и нет настоящего токена. Строка DEMO_NOT_A_REAL_TOKEN ограничивает пример: она проверяет форму результата, но не доказывает безопасность production-логгера.
function redactHeaders(headers) {\n const result = {};\n\n for (const [name, value] of Object.entries(headers)) {\n const sensitive = /authorization|cookie|token|secret|password/i.test(name);\n result[name] = sensitive ? '[REDACTED]' : value;\n }\n\n return result;\n}\n\nconst sample = {\n authorization: 'Bearer DEMO_NOT_A_REAL_TOKEN',\n 'x-request-id': 'sample-2020-02'\n};\n\nconst redacted = redactHeaders(sample);\nconst serialized = JSON.stringify(redacted);\n\nif (\n redacted.authorization !== '[REDACTED]' ||\n redacted['x-request-id'] !== 'sample-2020-02' ||\n serialized.includes('DEMO_NOT_A_REAL_TOKEN')\n) {\n throw new Error('redaction check failed');\n}\n\nconsole.log(redacted);\n// { authorization: '[REDACTED]', 'x-request-id': 'sample-2020-02' }\nСохраните этот блок как redact-headers.mjs и запустите node redact-headers.mjs: при нарушении отрицательной проверки процесс завершится с ошибкой. Тест должен убедиться, что исходная строка отсутствует в сериализованном результате, а request ID остался. Тест не должен печатать вход до redaction: иначе сам тест создаёт новую копию утечки. В реальном приложении нужно проверить все error paths и все сериализаторы, а не только функцию из одного модуля.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
В логе виден Authorization | Ошибка сериализует headers без redaction | Воспроизвести только на фиктивном значении и проверить весь error path | Остановить новый вывод, добавить маску и ограничить доступ к найденным записям |
| Строку удалили, но credential всё ещё принимается | Cleanup перепутали с отзывом | Получить у провайдера статус старой пары через разрешённый канал | Выпустить replacement, переключить consumers и отозвать старую пару |
| После deploy один worker получает 401 | Consumer не получил новую версию конфигурации | Проверить имя версии и redacted startup record у каждого owner | Остановить revoke для неизвестного consumer, доставить новую конфигурацию и повторить проверку |
| Токен найден в image или artifact | Секрет вошёл в build context, ENV, ARG или файл результата | Проверить manifest и слои только в разрешённом контуре, не копируя значение в issue | Удалить путь доставки, заменить credential и отдельно оценить retention образа или artifact |
| Команда говорит «утечки больше нет» | Не определены носители и граница доказательства | Сопоставить список известных носителей, consumers и время revoke | Оставить неизвестные копии открытым риском с владельцем и не объявлять incident закрытым |
Матрица нужна до изменения конфигурации. Она не требует собирать секрет в одном месте. В карточке incident достаточно имени переменной, типа credential, времени обнаружения, носителя, request ID и ссылки на закрытый канал владельца. Само значение, его полный hash и частичные фрагменты не стоит копировать в чат или issue: каждая новая копия получает отдельный срок хранения и круг читателей.
\nВладелец сервиса и владелец credential могут быть разными людьми. Дежурный разработчик видит запись, но не всегда имеет право менять ключ у провайдера. Worker может запускаться редко и не попасть в быстрый smoke test. Поэтому список consumers строят по имени переменной и контракту доставки: web-процесс, worker, cron, локальная инструкция, CI job и тестовый контур. Неизвестный consumer — это причина остановиться, а не повод предположить, что он неважен.
\nПервое техническое изменение должно остановить появление новых копий. Уберите сериализацию заголовков из общего error path или направьте её через redaction. Ограничьте доступ к конкретному поисковому запросу и сохраните только безопасные поля: request ID, timestamp, версию сервиса и тип ошибки. Не удаляйте все логи вслепую. Они нужны для определения масштаба, но расследование должно проходить в разрешённом контуре.
\nПосле этого проверьте путь доставки. Credential не должен находиться в Dockerfile, tracked .env, build artifact, публичном config endpoint или переменной, которую приложение возвращает в debug-ответе. Для Docker важно различать этап сборки и запуск: ENV сохраняется в конфигурации образа, а ARG может попасть в историю сборки и provenance. Поэтому ни один из них не подходит для секретов. Если credential нужен только во время сборки, используйте временный BuildKit secret mount. Учебный шаблон может выглядеть так:
# config.example.env — шаблон без действующих значений\nAPP_ENV=development\nPAYMENTS_API_URL=https://gateway.invalid\nPAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET\nLOG_LEVEL=info\n\n# В запуске PAYMENTS_TOKEN приходит отдельным защищённым каналом.\nПример не задаёт способ хранения для конкретной платформы. В одном контуре это secret store, в другом — защищённая переменная job или механизм оркестратора. Важно наблюдаемое свойство: образ и репозиторий содержат имя настройки и безопасный placeholder, а runtime получает значение отдельно. Проверка должна смотреть не только исходный файл, но и итоговый image, artifact и логи сборки.
\nЕсли провайдер поддерживает две активные пары, безопасный порядок выглядит так: создать новую пару, доставить её всем consumers, проверить каждый процесс, затем отозвать старую. Проверка не должна печатать token. Достаточно ID версии, успешного разрешённого запроса и redacted startup record. После revoke повторно проверьте старый путь: запрос с прежней парой должен быть отклонён провайдером. Это проверка состояния credential, а не доказательство отсутствия всех копий.
\nЕсли провайдер не допускает overlap, сначала согласуйте окно переключения. Остановите consumers, замените значение, запустите узкую функциональную проверку и зафиксируйте длительность простоя. Не обещайте бесшовную ротацию там, где API провайдера допускает только одну активную пару. Если потребитель не может подтвердить новую конфигурацию, отложите revoke и передайте риск владельцу. Молчаливый отзыв создаст отказ, который сложнее отличить от исходного incident.
\nНовая пара должна иметь собственный идентификатор и владельца. В записи не нужен secret value. Нужны version ID, список consumers, момент доставки, результат проверки и момент revoke. Так команда может доказать порядок действий, не создавая ещё один защищаемый документ с credential.
\nЭта схема не выполняет ротацию реального сервиса, не открывает provider portal и не подтверждает состояние production. Код использует фиктивную строку. Иллюстрация показывает порядок, а не успешный результат конкретной команды. Документы провайдера могут задавать другой срок действия, лимит активных пар или порядок отзыва; эти условия нужно проверить до изменения.
\nСхема также не обещает найти все копии. Она помогает назвать известные носители и неизвестность. Логи, backups, error-reporting и старые images могут иметь отдельные retention policy. Нельзя удалять их без владельца и согласованного способа восстановления. Нельзя считать зелёный тест доказательством, что все consumers обновились. Нельзя считать новый commit доказательством, что старый credential больше не действует.
\nЕсли после исправления один worker получает 401, путь не продолжается автоматически. Остановите revoke для оставшихся consumers, проверьте версию конфигурации и owner, затем повторите проверку. Если провайдер не подтверждает revoke, incident остаётся открытым. Если найден новый носитель, расширьте карту распространения и отдельно оцените его доступ. Отрицательный путь должен быть таким же конкретным, как успешный.
\nИнцидент можно считать технически закрытым, когда старая пара отозвана провайдером и каждый известный consumer подтвердил новую версию безопасным результатом. Error path не выдаёт чувствительные поля, а список проверенных носителей и остаточных неизвестных записан с владельцами. Если хотя бы одно условие не выполнено, статус должен оставаться открытым или ограниченным. Следующая проверка должна иметь конкретного владельца.
\nТакой критерий не говорит, что утечки не было и что все копии уничтожены. Он фиксирует только проверяемое состояние: старый доступ больше не принимается, новая поставка работает у известных потребителей, диагностический путь не повторяет ошибку, а неизвестность не скрыта за словом «готово».
\nARG и переменные окружения не подходят для секретов сборки, а secret mount временно предоставляет значение инструкции без встраивания в итоговый образ.