8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 284,
|
||
"slug": "editorial-2020-02-mechanism-configs-secrets",
|
||
"title": "Как конфигурация доходит до процесса и превращается в утечку",
|
||
"excerpt": "Сервис получает настройки через несколько границ: Git, сборку, образ, delivery и runtime. Разбираем, где значение должно остановиться, почему .gitignore не удаляет секрет из истории и как проверить путь без раскрытия credential.",
|
||
"contentHtml": "<p>Симптом виден после выпуска: сервис в development работает, а в production получает пустой URL, неверный режим или старый токен. Иногда приложение отвечает ошибкой, и обработчик добавляет в JSON весь объект конфигурации. В нём оказывается credential. Цена ошибки — простой, отзыв доступа, выпуск нового значения и поиск всех мест, куда попал старый секрет. Удалить одну строку из кода уже недостаточно.</p>\n<p>Проблема возникает раньше runtime. Значение проходит через репозиторий, build context, job CI, Docker image, переменные процесса и систему логирования. У каждой границы своя аудитория и свой срок жизни. Если считать их одним «env», команда не видит, где значение скопировалось и где его можно прочитать.</p>\n<h2>Тезис: секрет должен попасть только в нужный процесс</h2>\n<p>Код хранит имя настройки и правила проверки. Сборка создаёт один и тот же артефакт для разных контуров. Delivery передаёт значение выбранному процессу. Loader проверяет обязательные имена на старте. Логи показывают идентификатор конфигурации и маскируют значения. Такой маршрут не делает секрет невидимым для владельца процесса, но сокращает число носителей и облегчает проверку.</p>\n<p>Переменная окружения — канал доставки, а не хранилище с гарантией секретности. Её может прочитать wrapper, дочерний процесс, crash handler или диагностический код. Поэтому важно не только «не коммитить пароль», но и не копировать его в образ, bundle, аргументы команды и общий лог.</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>Git и шаблон</td><td>Имена, описание, фиктивные defaults</td><td>Разработчики и клоны репозитория</td><td>Реальный token в <code>.env</code> или примере конфигурации</td></tr><tr><td>Build context и CI</td><td>Исходники и несекретные параметры</td><td>Сборщик, job log, cache</td><td><code>printenv</code>, token в аргументе или echo</td></tr><tr><td>Docker image</td><td>Код и безопасные runtime defaults</td><td>Registry и любой читатель образа</td><td>Credential в <code>ENV</code>, <code>ARG</code> или generated bundle</td></tr><tr><td>Runtime</td><td>Нужные процессу настройки</td><td>Процесс и ограниченный контур запуска</td><td>Любой модуль читает окружение и печатает его целиком</td></tr><tr><td>Логи и incident</td><td>Имена, request ID, revision, маски</td><td>Поддержка, мониторинг, участники incident</td><td>Headers, env или config object в диагностике</td></tr></tbody></table></div>\n<p>Одна и та же строка может пересечь все пять границ. Но ей не нужно этого делать. Например, <code>APP_ENV</code> может жить в образе как безопасный default. <code>PAYMENTS_TOKEN</code> должен появиться только при запуске и остаться доступным процессу, которому он нужен. Если token попал в Git или image, считать его «спрятанным» уже нельзя.</p>\n<figure><img src=\"/assets/editorial/2020/config-secret-delivery-path-2020.svg\" alt=\"Путь конфигурации от имён в репозитории через delivery к runtime с redacted-логом\" loading=\"lazy\" /><figcaption>Схема показывает границы пути. Репозиторий хранит имена, delivery подаёт значение отдельно, loader проверяет контракт, а лог получает безопасный отчёт.</figcaption></figure>\n<h2>Учебный пример: код, образ и runtime</h2>\n<p>Ниже учебный пример. Значение <code>DEMO_ONLY_NOT_A_SECRET</code> не даёт доступа к сервису. В настоящем контуре токен приходит по отдельному защищённому каналу.</p>\n<pre><code>const required = [\"APP_ENV\", \"PAYMENTS_API_URL\", \"PAYMENTS_TOKEN\"];\n\nfunction readRequired(name, env) {\n const value = env[name];\n if (!value) throw new Error(\"Missing required setting: \" + name);\n return value;\n}\n\nexport function loadConfig(env = process.env) {\n for (const name of required) readRequired(name, env);\n return {\n appEnv: env.APP_ENV,\n paymentsApiUrl: env.PAYMENTS_API_URL,\n paymentsToken: env.PAYMENTS_TOKEN,\n logLevel: env.LOG_LEVEL || \"info\",\n };\n}\n\nexport function safeConfigReport(config) {\n return {\n appEnv: config.appEnv,\n paymentsApiUrl: config.paymentsApiUrl,\n paymentsToken: \"[REDACTED]\",\n logLevel: config.logLevel,\n };\n}</code></pre>\n<p>Loader останавливает процесс до первого запроса, если обязательное имя отсутствует. Ошибка содержит имя поля, но не его значение. После загрузки модули получают готовый объект конфигурации и не обходят проверку через прямые чтения <code>process.env</code>. Это уменьшает число мест, где можно случайно сериализовать окружение.</p>\n<pre><code>FROM node:12-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nENV APP_ENV=production\nCMD [\"node\", \"server.js\"]\n\n# PAYMENTS_TOKEN не передаём через ARG или ENV Dockerfile.\n# Контур запуска подаёт его процессу отдельно от образа.</code></pre>\n<p>Здесь <code>APP_ENV</code> — безопасный пример runtime default. В Dockerfile нет настоящего адреса платежей и нет token. Docker сохраняет значения <code>ENV</code> в окружении контейнеров, созданных из образа. <code>ARG</code> не следует использовать для credentials: значение может быть видно в истории сборки и связанных метаданных. Если секрет нужен именно во время сборки, применяют специальный механизм secret mount, но для обычного runtime-секрета сборка ему не нужна.</p>\n<p>Проверять loader можно без deployment. Передайте ему объект с <code>APP_ENV=staging</code>, адресом <code>https://gateway.invalid</code> и фиктивным token. Удалите обязательное поле и ожидайте ошибку с его именем. Отдельно вызовите <code>safeConfigReport</code> и проверьте маску. Это проверяет контракт кода. Оно не доказывает права доступа, ротацию и безопасность конкретного CI.</p>\n<h2>Почему .gitignore создаёт ложное чувство защиты</h2>\n<p>Правило <code>.env.local</code> помогает не добавить новый локальный файл. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже tracked, новое правило не удалит его из index и не очистит историю. При обнаружении credential в commit нужно считать его скомпрометированным: удалить строку мало, сначала отозвать старое значение и выпустить новое.</p>\n<p>Проверка должна разделять два вопроса. <code>git check-ignore -v .env.local</code> показывает, какое правило защищает локальный путь. <code>git ls-files --error-unmatch .env.local</code> не должен находить этот файл среди tracked. Эти команды не проверяют Docker context, CI cache, registry и логи. Для каждой поверхности нужен отдельный check.</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>Пустой обязательный ключ</td><td>Delivery не передал имя или loader использует другой приоритет</td><td>Проверить имена и safe startup report без values</td><td>Исправить источник и остановить запуск до первого запроса</td></tr><tr><td>Разные URL в средах</td><td>Сборка зафиксировала значение вместо runtime delivery</td><td>Осмотреть image, bundle и итоговый набор имён</td><td>Вынести адрес в runtime-конфигурацию</td></tr><tr><td>Token виден в image</td><td>Значение попало в <code>ENV</code>, <code>ARG</code>, слой или context</td><td>Проверить Dockerfile, history и содержимое context без печати token</td><td>Отозвать token, пересобрать image без него</td></tr><tr><td>Token виден в CI log</td><td>Команда напечатала окружение или аргумент</td><td>Поискать имена полей и команды вывода в job definition</td><td>Удалить вывод, ограничить маскирование и ротировать credential</td></tr><tr><td>Секрет в JSON-ошибке</td><td>Serializer получил config или headers целиком</td><td>Негативный тест на error path с фиктивным token</td><td>Сериализовать allowlist полей и вернуть <code>[REDACTED]</code></td></tr><tr><td>Старый token всё ещё действует</td><td>Исправили носитель, но не отозвали credential</td><td>Проверить статус у владельца доступа и всех consumers</td><td>Выпустить новую пару, переключить consumers, отозвать старую</td></tr></tbody></table></div>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать наблюдаемый симптом: пустое имя, неверный URL, credential в image или value в логе. Не менять одновременно код и job.</li><li>Составить карту переменных: имя, класс значения, потребитель, источник, владелец смены и допустимый носитель.</li><li>Проверить границу Git. В шаблоне оставить имена и фиктивные defaults. Если credential уже tracked, начать с отзыва и ротации.</li><li>Проверить build boundary. Осмотреть Dockerfile, scripts, generated bundle и job log. Не использовать <code>printenv</code> как диагностику.</li><li>Проверить image. Убедиться, что token не попал в <code>ENV</code>, <code>ARG</code>, слой или build context. Учесть, что удаление файла в следующем слое не отменяет предыдущую историю.</li><li>Проверить delivery. Для каждого обязательного имени назвать источник и владельца. В release record сохранить только revision или идентификатор набора.</li><li>Проверить runtime loader. Отсутствующее поле должно остановить запуск, а safe report — показать маску вместо значения.</li><li>Проверить отрицательный путь: ошибка, retry, debug endpoint, HTTP logger и issue-шаблон не должны копировать env, headers или config object целиком.</li><li>Проверить зависимый сценарий с тестовым credential или безопасным тестовым контуром. Не считать зелёный deploy доказательством отсутствия утечки.</li></ol>\n<h2>Ограничения</h2>\n<p>Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.</p>\n<p>Описанный порядок не выбирает за проект конкретное secret-хранилище. Маленькая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Проверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://docs.docker.com/reference/dockerfile/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Docs: Dockerfile reference</a> — описывает ENV, ARG и предупреждает не передавать credentials через build arguments.</li><li><a href=\"https://git-scm.com/docs/gitignore.html\" target=\"_blank\" rel=\"noopener noreferrer\">Git documentation: gitignore</a> — уточняет, что правило не действует на уже tracked файлы.</li><li><a href=\"https://nodejs.org/api/process.html\" target=\"_blank\" rel=\"noopener noreferrer\">Node.js documentation: process</a> — описывает окружение процесса через <code>process.env</code>.</li></ul>"
|
||
}
|