8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 284,
|
||
"slug": "editorial-2020-02-mechanism-configs-secrets",
|
||
"title": "Как конфигурация доходит до процесса и превращается в утечку",
|
||
"excerpt": "Сервис получает настройки через несколько границ: Git, сборку, образ, delivery и runtime. Разбираем, где значение должно остановиться, почему .gitignore не удаляет секрет из истории и как проверить путь без раскрытия credential.",
|
||
"contentHtml": "<p>Симптом виден после выпуска: сервис в development работает, а в production получает пустой URL, неверный режим или старый token. Иногда приложение отвечает ошибкой, и обработчик добавляет в JSON весь объект конфигурации. В нём оказывается credential. Цена ошибки — простой, отзыв доступа, выпуск нового значения и поиск всех мест, куда попал старый секрет. Удалить одну строку из кода уже недостаточно.</p><p>Проблема возникает раньше runtime. Значение проходит через репозиторий, build context, job CI, Docker image, переменные процесса и систему логирования. У каждой границы своя аудитория и свой срок жизни. Если считать их одним «env», команда не видит, где значение скопировалось и где его можно прочитать.</p><h2>Тезис: секрет должен попасть только в нужный процесс</h2><p>Код хранит имя настройки и правила проверки. Сборка создаёт один и тот же артефакт для разных контуров. Delivery передаёт значение выбранному процессу. Loader проверяет обязательные имена на старте. Логи показывают идентификатор конфигурации и маскируют значения. Такой маршрут не делает секрет невидимым для владельца процесса, но сокращает число носителей и облегчает проверку.</p><p>Переменная окружения — канал доставки, а не хранилище с гарантией секретности. Её может прочитать wrapper, дочерний процесс, crash handler или диагностический код. Поэтому правило звучит шире: не только не коммитить пароль, но и не копировать его в образ, bundle, аргументы команды и общий лог.</p><h2>Пять границ одного значения</h2><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><p>Одна и та же строка может пересечь все пять границ, но ей не нужно этого делать. Например, <code>APP_ENV</code> может жить в образе как безопасный default. <code>PAYMENTS_TOKEN</code> должен появиться только при запуске и остаться доступным процессу, которому он нужен. Если token попал в Git или image, считать его «спрятанным» уже нельзя.</p><figure><img src='/assets/editorial/2020/config-secret-delivery-path-2020.svg' alt='Путь конфигурации от имён в репозитории через delivery к runtime с redacted-логом' loading='lazy' /><figcaption>Схема показывает границы пути. Репозиторий хранит имена, delivery подаёт значение отдельно, loader проверяет контракт, а лог получает безопасный отчёт.</figcaption></figure><h2>Учебный пример: код, образ и runtime</h2><p>Ниже учебный пример. В нём нет рабочего credential: token — строка <code>DEMO_ONLY_NOT_A_SECRET</code>, а адрес <code>https://gateway.invalid</code> предназначен только для теста. В настоящем контуре значение приходит по отдельному защищённому каналу.</p><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><p>Если <code>loadConfig</code> вызвать во время запуска, процесс остановится до первого запроса при отсутствии обязательного имени. Ошибка содержит имя поля, но не его значение. После загрузки модули получают готовый объект и не обходят проверку через прямые чтения <code>process.env</code>. Это уменьшает число мест, где можно случайно сериализовать окружение.</p><pre><code>const testEnv = {\n APP_ENV: 'staging',\n PAYMENTS_API_URL: 'https://gateway.invalid',\n PAYMENTS_TOKEN: 'DEMO_ONLY_NOT_A_SECRET',\n};\n\nconst config = loadConfig(testEnv);\nconsole.log(safeConfigReport(config).paymentsToken);\n// [REDACTED]\n\nconst missingToken = { ...testEnv };\ndelete missingToken.PAYMENTS_TOKEN;\n// loadConfig(missingToken) throws an error naming PAYMENTS_TOKEN</code></pre><p>Этот сценарий проверяет контракт кода: успешную загрузку, маскирование и отрицательный путь. Он не доказывает права доступа, ротацию, настройки CI или безопасность конкретного хранилища. Эти свойства проверяются на соответствующих границах.</p><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><p>Здесь <code>APP_ENV</code> — безопасный пример runtime default. В Dockerfile нет настоящего адреса платежей и нет token. Значения <code>ENV</code> сохраняются в образе и доступны контейнерам, созданным из него; их можно увидеть через <code>docker inspect</code>. <code>ARG</code> не следует использовать для credentials: Docker предупреждает, что build arguments видны в <code>docker history</code> и могут попасть в provenance-метаданные. Если секрет нужен именно во время сборки, используйте механизм secret mount. Для обычного runtime-секрета сборка ему не нужна.</p><p>В примере оставлен <code>node:12-alpine</code>, потому что он фиксирует контекст исходной заметки 2020 года, а не является рекомендацией для нового сервиса. Линия Node.js 12.x завершила поддержку 30 апреля 2022 года. В новом проекте выбирают поддерживаемый LTS и отдельно проверяют совместимость команд сборки; исторический <code>npm ci --only=production</code> нельзя переносить автоматически в современный pipeline.</p><pre><code># .dockerignore\n.env.local\n.env.*.local\n*.key</code></pre><p>Такой файл уменьшает риск отправить локальные настройки в build context, но не заменяет проверку репозитория и не очищает уже собранный image. Шаблон нужно подстроить под проект: если сертификат с расширением <code>.key</code> действительно требуется внутри образа, его нельзя исключать механически — сначала нужно определить безопасный способ доставки.</p><h2>Почему .gitignore создаёт ложное чувство защиты</h2><p>Правило <code>.env.local</code> помогает не добавить новый локальный файл. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже tracked, новое правило не удалит его из index и не очистит историю. При обнаружении credential в commit его нужно считать скомпрометированным: удалить строку мало, сначала отозвать старое значение и выпустить новое.</p><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><h2>Симптом → причина → проверка → действие</h2><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><h2>Порядок действий</h2><ol><li>Зафиксировать наблюдаемый симптом: пустое имя, неверный URL, credential в image или value в логе. Не менять одновременно код и job.</li><li>Составить карту переменных: имя, класс значения, потребитель, источник, владелец смены и допустимый носитель.</li><li>Проверить границу Git. В шаблоне оставить имена и фиктивные defaults. Если credential уже tracked, начать с отзыва и ротации.</li><li>Проверить build boundary. Осмотреть Dockerfile, <code>.dockerignore</code>, 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><h2>Ограничения</h2><p>Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.</p><p>Описанный порядок не выбирает за проект конкретное secret-хранилище. Небольшая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.</p><h2>Проверяемый критерий готовности</h2><p>Проверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.</p><h2>Проверяемые источники</h2><ul><li><a href='https://docs.docker.com/reference/dockerfile/' target='_blank' rel='noopener noreferrer'>Docker Docs: Dockerfile reference</a> — описывает <code>ENV</code>, <code>ARG</code>, <code>RUN --mount=type=secret</code> и ограничения передачи 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><li><a href='https://github.com/nodejs/release#release-schedule' target='_blank' rel='noopener noreferrer'>Node.js Release Working Group: release schedule</a> — фиксирует дату окончания поддержки линии Node.js 12.x.</li></ul>"
|
||
}
|