8 lines
23 KiB
JSON
8 lines
23 KiB
JSON
{
|
||
"index": 283,
|
||
"slug": "editorial-2020-02-field-configs-secrets",
|
||
"title": "Токен попал в лог: как провести ротацию и закрыть путь утечки",
|
||
"excerpt": "Удалить строку из кода недостаточно, если credential уже попал в лог, образ или историю Git. Разбираем учебный incident: ограничиваем распространение, меняем потребителей, отзываем старое значение и проверяем, что ошибка не вернулась.",
|
||
"contentHtml": "<p>Сервис отвечает ошибкой 500, а в централизованном логе рядом с request ID виден заголовок <code>Authorization</code>. Поиск по логам находит ту же строку ещё в нескольких записях. Это не просто неудачный формат диагностики. Пока credential действует, читатель лога может использовать его как доступ к внешней системе. Цена ошибки — отзыв ключа, переключение всех потребителей, разбор копий в CI и backup, а иногда и вынужденное окно простоя.</p>\n<p>Первый импульс обычно неверен: удалить поле из логгера, стереть найденную запись и закрыть задачу. Эти действия убирают симптом, но не меняют уже выданное значение. Секрет мог попасть в другой лог, error-reporting, Docker-образ, артефакт сборки или историю Git. Значит, incident закрывается не после commit, а после проверки границ распространения и отзыва старого credential. В этом учебном разборе я не объявляю утечку закрытой по одному исправленному месту: ниже каждый вывод привязан к наблюдаемой проверке.</p>\n<h2>Тезис: конфигурация и credential живут в разных контурах</h2>\n<p>Настройка описывает поведение приложения: имя окружения, URL зависимости, уровень логирования, таймаут. Credential даёт право действовать от имени приложения. У этих данных разные требования к хранению, доставке и журналированию. Шаблон конфигурации можно положить рядом с кодом. Значение токена должно приходить через защищённый канал запуска и не должно попадать в image, commit или диагностический объект.</p>\n<p>Ротация решает другой вопрос: какое значение сейчас может принимать провайдер. Redaction решает вопрос вывода: какие поля можно показать оператору. Cleanup решает вопрос доступных копий. Нельзя подменять одно другим. Маска не отзывает токен. Удаление файла не очищает backup. Новый commit не делает историческое значение недействительным.</p>\n<h2>Как возникает утечка</h2>\n<p>В приложении есть обычный путь запроса и путь ошибки. Обычный путь передаёт заголовки HTTP-клиенту. Путь ошибки добавляет request context в JSON для лога. Если сериализатор не знает, какие поля чувствительны, он копирует объект целиком. Так credential покидает границу процесса и начинает жить в системах, которые команда могла не учитывать.</p>\n<p>Механизм легко проверить на учебном значении. Функция должна принимать структуру заголовков, заменять чувствительные поля и сохранять безопасный request ID. В коде ниже нет реального доступа и нет настоящего токена. Строка <code>DEMO_NOT_A_REAL_TOKEN</code> ограничивает пример: она проверяет форму результата, но не доказывает безопасность production-логгера.</p>\n<pre><code>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' }</code></pre>\n<p>Сохраните этот блок как <code>redact-headers.mjs</code> и запустите <code>node redact-headers.mjs</code>: при нарушении отрицательной проверки процесс завершится с ошибкой. Тест должен убедиться, что исходная строка отсутствует в сериализованном результате, а request ID остался. Тест не должен печатать вход до redaction: иначе сам тест создаёт новую копию утечки. В реальном приложении нужно проверить все error paths и все сериализаторы, а не только функцию из одного модуля.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Учебная матрица первичной диагностики</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>В логе виден <code>Authorization</code></td><td>Ошибка сериализует headers без redaction</td><td>Воспроизвести только на фиктивном значении и проверить весь error path</td><td>Остановить новый вывод, добавить маску и ограничить доступ к найденным записям</td></tr><tr><td>Строку удалили, но credential всё ещё принимается</td><td>Cleanup перепутали с отзывом</td><td>Получить у провайдера статус старой пары через разрешённый канал</td><td>Выпустить replacement, переключить consumers и отозвать старую пару</td></tr><tr><td>После deploy один worker получает 401</td><td>Consumer не получил новую версию конфигурации</td><td>Проверить имя версии и redacted startup record у каждого owner</td><td>Остановить revoke для неизвестного consumer, доставить новую конфигурацию и повторить проверку</td></tr><tr><td>Токен найден в image или artifact</td><td>Секрет вошёл в build context, ENV, ARG или файл результата</td><td>Проверить manifest и слои только в разрешённом контуре, не копируя значение в issue</td><td>Удалить путь доставки, заменить credential и отдельно оценить retention образа или artifact</td></tr><tr><td>Команда говорит «утечки больше нет»</td><td>Не определены носители и граница доказательства</td><td>Сопоставить список известных носителей, consumers и время revoke</td><td>Оставить неизвестные копии открытым риском с владельцем и не объявлять incident закрытым</td></tr></tbody></table></div>\n<p>Матрица нужна до изменения конфигурации. Она не требует собирать секрет в одном месте. В карточке incident достаточно имени переменной, типа credential, времени обнаружения, носителя, request ID и ссылки на закрытый канал владельца. Само значение, его полный hash и частичные фрагменты не стоит копировать в чат или issue: каждая новая копия получает отдельный срок хранения и круг читателей.</p>\n<h2>Иллюстрация границы</h2>\n<figure><img src=\"/assets/editorial/2020/config-secret-rotation-2020.svg\" alt=\"Схема ротации credential: обнаружить и ограничить распространение, создать замену, обновить потребителей, отозвать старое значение, проверить следы и добавить защиту.\" loading=\"lazy\" /><figcaption>Ротация — последовательность зависимых действий. Старый credential отзывают после проверки новой поставки, а cleanup следов ведут отдельным контролируемым шагом.</figcaption></figure>\n<p>Владелец сервиса и владелец credential могут быть разными людьми. Дежурный разработчик видит запись, но не всегда имеет право менять ключ у провайдера. Worker может запускаться редко и не попасть в быстрый smoke test. Поэтому список consumers строят по имени переменной и контракту доставки: web-процесс, worker, cron, локальная инструкция, CI job и тестовый контур. Неизвестный consumer — это причина остановиться, а не повод предположить, что он неважен.</p>\n<h2>Сначала закрываем новый поток, затем меняем значение</h2>\n<p>Первое техническое изменение должно остановить появление новых копий. Уберите сериализацию заголовков из общего error path или направьте её через redaction. Ограничьте доступ к конкретному поисковому запросу и сохраните только безопасные поля: request ID, timestamp, версию сервиса и тип ошибки. Не удаляйте все логи вслепую. Они нужны для определения масштаба, но расследование должно проходить в разрешённом контуре.</p>\n<p>После этого проверьте путь доставки. Credential не должен находиться в Dockerfile, tracked <code>.env</code>, build artifact, публичном config endpoint или переменной, которую приложение возвращает в debug-ответе. Для Docker важно различать этап сборки и запуск: <code>ENV</code> сохраняется в конфигурации образа, а <code>ARG</code> может попасть в историю сборки и provenance. Поэтому ни один из них не подходит для секретов. Если credential нужен только во время сборки, используйте временный BuildKit secret mount. Учебный шаблон может выглядеть так:</p>\n<pre><code># 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 приходит отдельным защищённым каналом.</code></pre>\n<p>Пример не задаёт способ хранения для конкретной платформы. В одном контуре это secret store, в другом — защищённая переменная job или механизм оркестратора. Важно наблюдаемое свойство: образ и репозиторий содержат имя настройки и безопасный placeholder, а runtime получает значение отдельно. Проверка должна смотреть не только исходный файл, но и итоговый image, artifact и логи сборки.</p>\n<h2>Ротация с двумя активными парами</h2>\n<p>Если провайдер поддерживает две активные пары, безопасный порядок выглядит так: создать новую пару, доставить её всем consumers, проверить каждый процесс, затем отозвать старую. Проверка не должна печатать token. Достаточно ID версии, успешного разрешённого запроса и redacted startup record. После revoke повторно проверьте старый путь: запрос с прежней парой должен быть отклонён провайдером. Это проверка состояния credential, а не доказательство отсутствия всех копий.</p>\n<p>Если провайдер не допускает overlap, сначала согласуйте окно переключения. Остановите consumers, замените значение, запустите узкую функциональную проверку и зафиксируйте длительность простоя. Не обещайте бесшовную ротацию там, где API провайдера допускает только одну активную пару. Если потребитель не может подтвердить новую конфигурацию, отложите revoke и передайте риск владельцу. Молчаливый отзыв создаст отказ, который сложнее отличить от исходного incident.</p>\n<p>Новая пара должна иметь собственный идентификатор и владельца. В записи не нужен secret value. Нужны version ID, список consumers, момент доставки, результат проверки и момент revoke. Так команда может доказать порядок действий, не создавая ещё один защищаемый документ с credential.</p>\n<h2>Порядок действий</h2>\n<ol><li>Создайте закрытую запись incident: имя credential, время, носитель, request ID, сервис и владельцы. Значение не копируйте.</li><li>Остановите новый поток: исправьте serializer или логгер, ограничьте доступ к найденному логу и оставьте безопасные диагностические поля.</li><li>Составьте список consumers по имени переменной и назначьте owner каждому процессу: web, worker, cron, CI и тестовый контур.</li><li>Проверьте способ замены у провайдера. Выберите две активные пары или согласованное окно простоя.</li><li>Создайте replacement через разрешённый канал. Не записывайте значение в commit, issue, image, artifact или общий чат.</li><li>Доставьте новую конфигурацию каждому consumer и выполните его узкую функциональную проверку. Сохраните только ID версии и результат.</li><li>Отзовите старый credential после проверки всех известных consumers. Если owner отсутствует, остановитесь и эскалируйте риск.</li><li>Проверьте repository, image, CI log, error-reporting и logging path на следы старой схемы. Cleanup retention выполняйте отдельной согласованной процедурой.</li><li>Добавьте отрицательный тест redaction и проверку, что диагностический результат не содержит исходного значения. Закройте incident только после проверки revoke и списка остаточных рисков.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Эта схема не выполняет ротацию реального сервиса, не открывает provider portal и не подтверждает состояние production. Код использует фиктивную строку. Иллюстрация показывает порядок, а не успешный результат конкретной команды. Документы провайдера могут задавать другой срок действия, лимит активных пар или порядок отзыва; эти условия нужно проверить до изменения.</p>\n<p>Схема также не обещает найти все копии. Она помогает назвать известные носители и неизвестность. Логи, backups, error-reporting и старые images могут иметь отдельные retention policy. Нельзя удалять их без владельца и согласованного способа восстановления. Нельзя считать зелёный тест доказательством, что все consumers обновились. Нельзя считать новый commit доказательством, что старый credential больше не действует.</p>\n<p>Если после исправления один worker получает 401, путь не продолжается автоматически. Остановите revoke для оставшихся consumers, проверьте версию конфигурации и owner, затем повторите проверку. Если провайдер не подтверждает revoke, incident остаётся открытым. Если найден новый носитель, расширьте карту распространения и отдельно оцените его доступ. Отрицательный путь должен быть таким же конкретным, как успешный.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Инцидент можно считать технически закрытым, когда старая пара отозвана провайдером и каждый известный consumer подтвердил новую версию безопасным результатом. Error path не выдаёт чувствительные поля, а список проверенных носителей и остаточных неизвестных записан с владельцами. Если хотя бы одно условие не выполнено, статус должен оставаться открытым или ограниченным. Следующая проверка должна иметь конкретного владельца.</p>\n<p>Такой критерий не говорит, что утечки не было и что все копии уничтожены. Он фиксирует только проверяемое состояние: старый доступ больше не принимается, новая поставка работает у известных потребителей, диагностический путь не повторяет ошибку, а неизвестность не скрыта за словом «готово».</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://docs.github.com/en/code-security/tutorials/remediate-leaked-secrets/remediating-a-leaked-secret\" target=\"_blank\" rel=\"noopener noreferrer\">GitHub Docs: Remediating a leaked secret</a> — активный секрет считают скомпрометированным: его отзывают, а при необходимости сначала переводят потребителей на замену.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Logging Cheat Sheet</a> — access tokens, пароли и другие технические секреты не следует записывать в логи; их нужно удалить, замаскировать или защитить до записи.</li><li><a href=\"https://git-scm.com/docs/gitignore\" target=\"_blank\" rel=\"noopener noreferrer\">Git documentation: gitignore</a> — правило игнорирования не убирает уже отслеживаемый файл из индекса.</li><li><a href=\"https://docs.docker.com/build/building/secrets/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Docs: Build secrets</a> — <code>ARG</code> и переменные окружения не подходят для секретов сборки, а secret mount временно предоставляет значение инструкции без встраивания в итоговый образ.</li></ul>"
|
||
}
|