From e06ea4a5d8d0b7274a664a98e1b361352e073150 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 22:41:34 +0300 Subject: [PATCH] editorial: refine secret rotation article 283 --- editorial/agent-rewrites/283.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/283.json b/editorial/agent-rewrites/283.json index 06cd215..690aa18 100644 --- a/editorial/agent-rewrites/283.json +++ b/editorial/agent-rewrites/283.json @@ -3,5 +3,5 @@ "slug": "editorial-2020-02-field-configs-secrets", "title": "Токен попал в лог: как провести ротацию и закрыть путь утечки", "excerpt": "Удалить строку из кода недостаточно, если credential уже попал в лог, образ или историю Git. Разбираем учебный incident: ограничиваем распространение, меняем потребителей, отзываем старое значение и проверяем, что ошибка не вернулась.", - "contentHtml": "

Сервис отвечает ошибкой 500, а в централизованном логе рядом с request ID виден заголовок Authorization. Поиск по логам находит ту же строку ещё в нескольких записях. Это не просто неудачный формат диагностики. Пока credential действует, читатель лога может использовать его как доступ к внешней системе. Цена ошибки — отзыв ключа, переключение всех потребителей, разбор копий в CI и backup, а иногда и вынужденное окно простоя.

\n

Первый импульс обычно неверен: удалить поле из логгера, стереть найденную запись и закрыть задачу. Эти действия убирают симптом, но не меняют уже выданное значение. Секрет мог попасть в другой лог, error-reporting, Docker-образ, артефакт сборки или историю Git. Значит, incident закрывается не после commit, а после проверки границ распространения и отзыва старого credential.

\n

Тезис: конфигурация и credential живут в разных контурах

\n

Настройка описывает поведение приложения: имя окружения, URL зависимости, уровень логирования, таймаут. Credential даёт право действовать от имени приложения. У этих данных разные требования к хранению, доставке и журналированию. Шаблон конфигурации можно положить рядом с кодом. Значение токена должно приходить через защищённый канал запуска и не должно попадать в image, commit или диагностический объект.

\n

Ротация решает другой вопрос: какое значение сейчас может принимать провайдер. Redaction решает вопрос вывода: какие поля можно показать оператору. Cleanup решает вопрос доступных копий. Нельзя подменять одно другим. Маска не отзывает токен. Удаление файла не очищает backup. Новый commit не делает историческое значение недействительным.

\n

Как возникает утечка

\n

В приложении есть обычный путь запроса и путь ошибки. Обычный путь передаёт заголовки HTTP-клиенту. Путь ошибки добавляет request context в JSON для лога. Если сериализатор не знает, какие поля чувствительны, он копирует объект целиком. Так credential покидает границу процесса и начинает жить в системах, которые команда могла не учитывать.

\n

Механизм легко проверить на учебном значении. Функция должна принимать структуру заголовков, заменять чувствительные поля и сохранять безопасный request ID. В коде ниже нет реального доступа и нет настоящего токена. Строка DEMO_NOT_A_REAL_TOKEN ограничивает пример: она проверяет форму результата, но не доказывает безопасность production-логгера.

\n
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\nconsole.log(redactHeaders(sample));\n// authorization: '[REDACTED]'\n// x-request-id: 'sample-2020-02'
\n

Отрицательная проверка важнее красивого positive case. Тест должен убедиться, что исходная строка отсутствует в сериализованном результате, а request ID остался. Тест не должен печатать вход до redaction: иначе сам тест создаёт новую копию утечки. В реальном приложении нужно проверить все error paths и все сериализаторы, а не только функцию из одного модуля.

\n

Симптом → причина → проверка → действие

\n
Учебная матрица первичной диагностики
СимптомПричинаПроверкаДействие
В логе виден AuthorizationОшибка сериализует headers без redactionВоспроизвести только на фиктивном значении и проверить весь error pathОстановить новый вывод, добавить маску и ограничить доступ к найденным записям
Строку удалили, но credential всё ещё принимаетсяCleanup перепутали с отзывомПолучить у провайдера статус старой пары через разрешённый каналВыпустить replacement, переключить consumers и отозвать старую пару
После deploy один worker получает 401Consumer не получил новую версию конфигурацииПроверить имя версии и redacted startup record у каждого ownerОстановить revoke для неизвестного consumer, доставить новую конфигурацию и повторить проверку
Токен найден в image или artifactСекрет вошёл в build context, ENV, ARG или файл результатаПроверить manifest и слои только в разрешённом контуре, не копируя значение в issueУдалить путь доставки, заменить credential и отдельно оценить retention образа или artifact
Команда говорит «утечки больше нет»Не определены носители и граница доказательстваСопоставить список известных носителей, consumers и время revokeОставить неизвестные копии открытым риском с владельцем и не объявлять incident закрытым
\n

Матрица нужна до изменения конфигурации. Она не требует собирать секрет в одном месте. В карточке incident достаточно имени переменной, типа credential, времени обнаружения, носителя, request ID и ссылки на закрытый канал владельца. Само значение, его полный hash и частичные фрагменты не стоит копировать в чат или issue: каждая новая копия получает отдельный срок хранения и круг читателей.

\n

Иллюстрация границы

\n
\"Схема
Ротация — последовательность зависимых действий. Старый credential отзывают после проверки новой поставки, а cleanup следов ведут отдельным контролируемым шагом.
\n

Владелец сервиса и владелец credential могут быть разными людьми. Дежурный разработчик видит запись, но не всегда имеет право менять ключ у провайдера. Worker может запускаться редко и не попасть в быстрый smoke test. Поэтому список consumers строят по имени переменной и контракту доставки: web-процесс, worker, cron, локальная инструкция, CI job и тестовый контур. Неизвестный consumer — это причина остановиться, а не повод предположить, что он неважен.

\n

Сначала закрываем новый поток, затем меняем значение

\n

Первое техническое изменение должно остановить появление новых копий. Уберите сериализацию заголовков из общего error path или направьте её через redaction. Ограничьте доступ к конкретному поисковому запросу и сохраните только безопасные поля: request ID, timestamp, версию сервиса и тип ошибки. Не удаляйте все логи вслепую. Они нужны для определения масштаба, но расследование должно проходить в разрешённом контуре.

\n

После этого проверьте путь доставки. Credential не должен находиться в Dockerfile, tracked .env, build artifact, публичном config endpoint или переменной, которую приложение возвращает в debug-ответе. Учебный шаблон может выглядеть так:

\n
# 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

Ротация с двумя активными парами

\n

Если провайдер поддерживает две активные пары, безопасный порядок выглядит так: создать новую пару, доставить её всем consumers, проверить каждый процесс, затем отозвать старую. Проверка не должна печатать token. Достаточно ID версии, успешного разрешённого запроса и redacted startup record. После revoke повторно проверьте старый путь: запрос с прежней парой должен быть отклонён провайдером. Это проверка состояния credential, а не доказательство отсутствия всех копий.

\n

Если провайдер не допускает overlap, сначала согласуйте окно переключения. Остановите consumers, замените значение, запустите узкую функциональную проверку и зафиксируйте длительность простоя. Не обещайте бесшовную ротацию там, где API провайдера допускает только одну активную пару. Если потребитель не может подтвердить новую конфигурацию, отложите revoke и передайте риск владельцу. Молчаливый отзыв создаст отказ, который сложнее отличить от исходного incident.

\n

Новая пара должна иметь собственный идентификатор и владельца. В записи не нужен secret value. Нужны version ID, список consumers, момент доставки, результат проверки и момент revoke. Так команда может доказать порядок действий, не создавая ещё один защищаемый документ с credential.

\n

Порядок действий

\n
  1. Создайте закрытую запись incident: имя credential, время, носитель, request ID, сервис и владельцы. Значение не копируйте.
  2. Остановите новый поток: исправьте serializer или логгер, ограничьте доступ к найденному логу и оставьте безопасные диагностические поля.
  3. Составьте список consumers по имени переменной и назначьте owner каждому процессу: web, worker, cron, CI и тестовый контур.
  4. Проверьте способ замены у провайдера. Выберите две активные пары или согласованное окно простоя.
  5. Создайте replacement через разрешённый канал. Не записывайте значение в commit, issue, image, artifact или общий чат.
  6. Доставьте новую конфигурацию каждому consumer и выполните его узкую функциональную проверку. Сохраните только ID версии и результат.
  7. Отзовите старый credential после проверки всех известных consumers. Если owner отсутствует, остановитесь и эскалируйте риск.
  8. Проверьте repository, image, CI log, error-reporting и logging path на следы старой схемы. Cleanup retention выполняйте отдельной согласованной процедурой.
  9. Добавьте отрицательный тест redaction и проверку, что диагностический результат не содержит исходного значения. Закройте incident только после проверки revoke и списка остаточных рисков.
\n

Ограничения и отрицательный путь

\n

Эта схема не выполняет ротацию реального сервиса, не открывает provider portal и не подтверждает состояние production. Код использует фиктивную строку. Иллюстрация показывает порядок, а не успешный результат конкретной команды. Документы провайдера могут задавать другой срок действия, лимит активных пар или порядок отзыва; эти условия нужно проверить до изменения.

\n

Схема также не обещает найти все копии. Она помогает назвать известные носители и неизвестность. Логи, backups, error-reporting и старые images могут иметь отдельные retention policy. Нельзя удалять их без владельца и согласованного способа восстановления. Нельзя считать зелёный тест доказательством, что все consumers обновились. Нельзя считать новый commit доказательством, что старый credential больше не действует.

\n

Если после исправления один worker получает 401, путь не продолжается автоматически. Остановите revoke для оставшихся consumers, проверьте версию конфигурации и owner, затем повторите проверку. Если провайдер не подтверждает revoke, incident остаётся открытым. Если найден новый носитель, расширьте карту распространения и отдельно оцените его доступ. Отрицательный путь должен быть таким же конкретным, как успешный.

\n

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

\n

Инцидент можно считать технически закрытым, когда выполнены все четыре условия: старая пара отозвана провайдером; каждый известный consumer подтвердил новую версию безопасным результатом; error path не выдаёт чувствительные поля; список проверенных носителей и остаточных неизвестных записан с владельцами. Если хотя бы одно условие не выполнено, статус должен оставаться открытым или ограниченным, а следующая проверка должна иметь конкретного владельца.

\n

Такой критерий не говорит, что утечки не было и что все копии уничтожены. Он фиксирует только проверяемое состояние: старый доступ больше не принимается, новая поставка работает у известных потребителей, диагностический путь не повторяет ошибку, а неизвестность не скрыта за словом «готово».

\n

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

\n" + "contentHtml": "

Сервис отвечает ошибкой 500, а в централизованном логе рядом с request ID виден заголовок Authorization. Поиск по логам находит ту же строку ещё в нескольких записях. Это не просто неудачный формат диагностики. Пока credential действует, читатель лога может использовать его как доступ к внешней системе. Цена ошибки — отзыв ключа, переключение всех потребителей, разбор копий в CI и backup, а иногда и вынужденное окно простоя.

\n

Первый импульс обычно неверен: удалить поле из логгера, стереть найденную запись и закрыть задачу. Эти действия убирают симптом, но не меняют уже выданное значение. Секрет мог попасть в другой лог, error-reporting, Docker-образ, артефакт сборки или историю Git. Значит, incident закрывается не после commit, а после проверки границ распространения и отзыва старого credential. В этом учебном разборе я не объявляю утечку закрытой по одному исправленному месту: ниже каждый вывод привязан к наблюдаемой проверке.

\n

Тезис: конфигурация и credential живут в разных контурах

\n

Настройка описывает поведение приложения: имя окружения, URL зависимости, уровень логирования, таймаут. Credential даёт право действовать от имени приложения. У этих данных разные требования к хранению, доставке и журналированию. Шаблон конфигурации можно положить рядом с кодом. Значение токена должно приходить через защищённый канал запуска и не должно попадать в image, commit или диагностический объект.

\n

Ротация решает другой вопрос: какое значение сейчас может принимать провайдер. Redaction решает вопрос вывода: какие поля можно показать оператору. Cleanup решает вопрос доступных копий. Нельзя подменять одно другим. Маска не отзывает токен. Удаление файла не очищает backup. Новый commit не делает историческое значение недействительным.

\n

Как возникает утечка

\n

В приложении есть обычный путь запроса и путь ошибки. Обычный путь передаёт заголовки HTTP-клиенту. Путь ошибки добавляет request context в JSON для лога. Если сериализатор не знает, какие поля чувствительны, он копирует объект целиком. Так credential покидает границу процесса и начинает жить в системах, которые команда могла не учитывать.

\n

Механизм легко проверить на учебном значении. Функция должна принимать структуру заголовков, заменять чувствительные поля и сохранять безопасный request ID. В коде ниже нет реального доступа и нет настоящего токена. Строка DEMO_NOT_A_REAL_TOKEN ограничивает пример: она проверяет форму результата, но не доказывает безопасность production-логгера.

\n
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 и все сериализаторы, а не только функцию из одного модуля.

\n

Симптом → причина → проверка → действие

\n
Учебная матрица первичной диагностики
СимптомПричинаПроверкаДействие
В логе виден AuthorizationОшибка сериализует headers без redactionВоспроизвести только на фиктивном значении и проверить весь error pathОстановить новый вывод, добавить маску и ограничить доступ к найденным записям
Строку удалили, но credential всё ещё принимаетсяCleanup перепутали с отзывомПолучить у провайдера статус старой пары через разрешённый каналВыпустить replacement, переключить consumers и отозвать старую пару
После deploy один worker получает 401Consumer не получил новую версию конфигурацииПроверить имя версии и redacted startup record у каждого ownerОстановить revoke для неизвестного consumer, доставить новую конфигурацию и повторить проверку
Токен найден в image или artifactСекрет вошёл в build context, ENV, ARG или файл результатаПроверить manifest и слои только в разрешённом контуре, не копируя значение в issueУдалить путь доставки, заменить credential и отдельно оценить retention образа или artifact
Команда говорит «утечки больше нет»Не определены носители и граница доказательстваСопоставить список известных носителей, consumers и время revokeОставить неизвестные копии открытым риском с владельцем и не объявлять incident закрытым
\n

Матрица нужна до изменения конфигурации. Она не требует собирать секрет в одном месте. В карточке incident достаточно имени переменной, типа credential, времени обнаружения, носителя, request ID и ссылки на закрытый канал владельца. Само значение, его полный hash и частичные фрагменты не стоит копировать в чат или issue: каждая новая копия получает отдельный срок хранения и круг читателей.

\n

Иллюстрация границы

\n
\"Схема
Ротация — последовательность зависимых действий. Старый credential отзывают после проверки новой поставки, а cleanup следов ведут отдельным контролируемым шагом.
\n

Владелец сервиса и владелец credential могут быть разными людьми. Дежурный разработчик видит запись, но не всегда имеет право менять ключ у провайдера. Worker может запускаться редко и не попасть в быстрый smoke test. Поэтому список consumers строят по имени переменной и контракту доставки: web-процесс, worker, cron, локальная инструкция, CI job и тестовый контур. Неизвестный consumer — это причина остановиться, а не повод предположить, что он неважен.

\n

Сначала закрываем новый поток, затем меняем значение

\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. Учебный шаблон может выглядеть так:

\n
# 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

Ротация с двумя активными парами

\n

Если провайдер поддерживает две активные пары, безопасный порядок выглядит так: создать новую пару, доставить её всем consumers, проверить каждый процесс, затем отозвать старую. Проверка не должна печатать token. Достаточно ID версии, успешного разрешённого запроса и redacted startup record. После revoke повторно проверьте старый путь: запрос с прежней парой должен быть отклонён провайдером. Это проверка состояния credential, а не доказательство отсутствия всех копий.

\n

Если провайдер не допускает overlap, сначала согласуйте окно переключения. Остановите consumers, замените значение, запустите узкую функциональную проверку и зафиксируйте длительность простоя. Не обещайте бесшовную ротацию там, где API провайдера допускает только одну активную пару. Если потребитель не может подтвердить новую конфигурацию, отложите revoke и передайте риск владельцу. Молчаливый отзыв создаст отказ, который сложнее отличить от исходного incident.

\n

Новая пара должна иметь собственный идентификатор и владельца. В записи не нужен secret value. Нужны version ID, список consumers, момент доставки, результат проверки и момент revoke. Так команда может доказать порядок действий, не создавая ещё один защищаемый документ с credential.

\n

Порядок действий

\n
  1. Создайте закрытую запись incident: имя credential, время, носитель, request ID, сервис и владельцы. Значение не копируйте.
  2. Остановите новый поток: исправьте serializer или логгер, ограничьте доступ к найденному логу и оставьте безопасные диагностические поля.
  3. Составьте список consumers по имени переменной и назначьте owner каждому процессу: web, worker, cron, CI и тестовый контур.
  4. Проверьте способ замены у провайдера. Выберите две активные пары или согласованное окно простоя.
  5. Создайте replacement через разрешённый канал. Не записывайте значение в commit, issue, image, artifact или общий чат.
  6. Доставьте новую конфигурацию каждому consumer и выполните его узкую функциональную проверку. Сохраните только ID версии и результат.
  7. Отзовите старый credential после проверки всех известных consumers. Если owner отсутствует, остановитесь и эскалируйте риск.
  8. Проверьте repository, image, CI log, error-reporting и logging path на следы старой схемы. Cleanup retention выполняйте отдельной согласованной процедурой.
  9. Добавьте отрицательный тест redaction и проверку, что диагностический результат не содержит исходного значения. Закройте incident только после проверки revoke и списка остаточных рисков.
\n

Ограничения и отрицательный путь

\n

Эта схема не выполняет ротацию реального сервиса, не открывает provider portal и не подтверждает состояние production. Код использует фиктивную строку. Иллюстрация показывает порядок, а не успешный результат конкретной команды. Документы провайдера могут задавать другой срок действия, лимит активных пар или порядок отзыва; эти условия нужно проверить до изменения.

\n

Схема также не обещает найти все копии. Она помогает назвать известные носители и неизвестность. Логи, backups, error-reporting и старые images могут иметь отдельные retention policy. Нельзя удалять их без владельца и согласованного способа восстановления. Нельзя считать зелёный тест доказательством, что все consumers обновились. Нельзя считать новый commit доказательством, что старый credential больше не действует.

\n

Если после исправления один worker получает 401, путь не продолжается автоматически. Остановите revoke для оставшихся consumers, проверьте версию конфигурации и owner, затем повторите проверку. Если провайдер не подтверждает revoke, incident остаётся открытым. Если найден новый носитель, расширьте карту распространения и отдельно оцените его доступ. Отрицательный путь должен быть таким же конкретным, как успешный.

\n

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

\n

Инцидент можно считать технически закрытым, когда старая пара отозвана провайдером и каждый известный consumer подтвердил новую версию безопасным результатом. Error path не выдаёт чувствительные поля, а список проверенных носителей и остаточных неизвестных записан с владельцами. Если хотя бы одно условие не выполнено, статус должен оставаться открытым или ограниченным. Следующая проверка должна иметь конкретного владельца.

\n

Такой критерий не говорит, что утечки не было и что все копии уничтожены. Он фиксирует только проверяемое состояние: старый доступ больше не принимается, новая поставка работает у известных потребителей, диагностический путь не повторяет ошибку, а неизвестность не скрыта за словом «готово».

\n

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

\n" }