{ "index": 284, "slug": "editorial-2020-02-mechanism-configs-secrets", "title": "Как конфигурация доходит до процесса и превращается в утечку", "excerpt": "Сервис получает настройки через несколько границ: Git, сборку, образ, delivery и runtime. Разбираем, где значение должно остановиться, почему .gitignore не удаляет секрет из истории и как проверить путь без раскрытия credential.", "contentHtml": "
Симптом виден после выпуска: сервис в development работает, а в production получает пустой URL, неверный режим или старый токен. Иногда приложение отвечает ошибкой, и обработчик добавляет в JSON весь объект конфигурации. В нём оказывается credential. Цена ошибки — простой, отзыв доступа, выпуск нового значения и поиск всех мест, куда попал старый секрет. Удалить одну строку из кода уже недостаточно.
\nПроблема возникает раньше runtime. Значение проходит через репозиторий, build context, job CI, Docker image, переменные процесса и систему логирования. У каждой границы своя аудитория и свой срок жизни. Если считать их одним «env», команда не видит, где значение скопировалось и где его можно прочитать.
\nКод хранит имя настройки и правила проверки. Сборка создаёт один и тот же артефакт для разных контуров. Delivery передаёт значение выбранному процессу. Loader проверяет обязательные имена на старте. Логи показывают идентификатор конфигурации и маскируют значения. Такой маршрут не делает секрет невидимым для владельца процесса, но сокращает число носителей и облегчает проверку.
\nПеременная окружения — канал доставки, а не хранилище с гарантией секретности. Её может прочитать wrapper, дочерний процесс, crash handler или диагностический код. Поэтому важно не только «не коммитить пароль», но и не копировать его в образ, bundle, аргументы команды и общий лог.
\n| Граница | Что допустимо | Кто видит | Опасная ошибка |
|---|---|---|---|
| Git и шаблон | Имена, описание, фиктивные defaults | Разработчики и клоны репозитория | Реальный token в .env или примере конфигурации |
| Build context и CI | Исходники и несекретные параметры | Сборщик, job log, cache | printenv, token в аргументе или echo |
| Docker image | Код и безопасные runtime defaults | Registry и любой читатель образа | Credential в ENV, ARG или generated bundle |
| Runtime | Нужные процессу настройки | Процесс и ограниченный контур запуска | Любой модуль читает окружение и печатает его целиком |
| Логи и incident | Имена, request ID, revision, маски | Поддержка, мониторинг, участники incident | Headers, env или config object в диагностике |
Одна и та же строка может пересечь все пять границ. Но ей не нужно этого делать. Например, APP_ENV может жить в образе как безопасный default. PAYMENTS_TOKEN должен появиться только при запуске и остаться доступным процессу, которому он нужен. Если token попал в Git или image, считать его «спрятанным» уже нельзя.
Ниже учебный пример. Значение DEMO_ONLY_NOT_A_SECRET не даёт доступа к сервису. В настоящем контуре токен приходит по отдельному защищённому каналу.
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}\nLoader останавливает процесс до первого запроса, если обязательное имя отсутствует. Ошибка содержит имя поля, но не его значение. После загрузки модули получают готовый объект конфигурации и не обходят проверку через прямые чтения process.env. Это уменьшает число мест, где можно случайно сериализовать окружение.
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# Контур запуска подаёт его процессу отдельно от образа.\nЗдесь APP_ENV — безопасный пример runtime default. В Dockerfile нет настоящего адреса платежей и нет token. Docker сохраняет значения ENV в окружении контейнеров, созданных из образа. ARG не следует использовать для credentials: значение может быть видно в истории сборки и связанных метаданных. Если секрет нужен именно во время сборки, применяют специальный механизм secret mount, но для обычного runtime-секрета сборка ему не нужна.
Проверять loader можно без deployment. Передайте ему объект с APP_ENV=staging, адресом https://gateway.invalid и фиктивным token. Удалите обязательное поле и ожидайте ошибку с его именем. Отдельно вызовите safeConfigReport и проверьте маску. Это проверяет контракт кода. Оно не доказывает права доступа, ротацию и безопасность конкретного CI.
Правило .env.local помогает не добавить новый локальный файл. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже tracked, новое правило не удалит его из index и не очистит историю. При обнаружении credential в commit нужно считать его скомпрометированным: удалить строку мало, сначала отозвать старое значение и выпустить новое.
Проверка должна разделять два вопроса. git check-ignore -v .env.local показывает, какое правило защищает локальный путь. git ls-files --error-unmatch .env.local не должен находить этот файл среди tracked. Эти команды не проверяют Docker context, CI cache, registry и логи. Для каждой поверхности нужен отдельный check.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Пустой обязательный ключ | Delivery не передал имя или loader использует другой приоритет | Проверить имена и safe startup report без values | Исправить источник и остановить запуск до первого запроса |
| Разные URL в средах | Сборка зафиксировала значение вместо runtime delivery | Осмотреть image, bundle и итоговый набор имён | Вынести адрес в runtime-конфигурацию |
| Token виден в image | Значение попало в ENV, ARG, слой или context | Проверить Dockerfile, history и содержимое context без печати token | Отозвать token, пересобрать image без него |
| Token виден в CI log | Команда напечатала окружение или аргумент | Поискать имена полей и команды вывода в job definition | Удалить вывод, ограничить маскирование и ротировать credential |
| Секрет в JSON-ошибке | Serializer получил config или headers целиком | Негативный тест на error path с фиктивным token | Сериализовать allowlist полей и вернуть [REDACTED] |
| Старый token всё ещё действует | Исправили носитель, но не отозвали credential | Проверить статус у владельца доступа и всех consumers | Выпустить новую пару, переключить consumers, отозвать старую |
printenv как диагностику.ENV, ARG, слой или build context. Учесть, что удаление файла в следующем слое не отменяет предыдущую историю.Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.
\nОписанный порядок не выбирает за проект конкретное secret-хранилище. Маленькая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.
\nПроверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.
\nprocess.env.