Files
progcode/editorial/agent-rewrites/283.json
T

8 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>"
}