Files

8 lines
20 KiB
JSON
Raw Permalink 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": 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>"
}