diff --git a/editorial/agent-rewrites/284.json b/editorial/agent-rewrites/284.json index 9fd603e..21949f4 100644 --- a/editorial/agent-rewrites/284.json +++ b/editorial/agent-rewrites/284.json @@ -3,5 +3,5 @@ "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.Симптом виден после выпуска: сервис в development работает, а в production получает пустой URL, неверный режим или старый token. Иногда приложение отвечает ошибкой, и обработчик добавляет в JSON весь объект конфигурации. В нём оказывается credential. Цена ошибки — простой, отзыв доступа, выпуск нового значения и поиск всех мест, куда попал старый секрет. Удалить одну строку из кода уже недостаточно.
Проблема возникает раньше runtime. Значение проходит через репозиторий, build context, job CI, Docker image, переменные процесса и систему логирования. У каждой границы своя аудитория и свой срок жизни. Если считать их одним «env», команда не видит, где значение скопировалось и где его можно прочитать.
Код хранит имя настройки и правила проверки. Сборка создаёт один и тот же артефакт для разных контуров. Delivery передаёт значение выбранному процессу. Loader проверяет обязательные имена на старте. Логи показывают идентификатор конфигурации и маскируют значения. Такой маршрут не делает секрет невидимым для владельца процесса, но сокращает число носителей и облегчает проверку.
Переменная окружения — канал доставки, а не хранилище с гарантией секретности. Её может прочитать wrapper, дочерний процесс, crash handler или диагностический код. Поэтому правило звучит шире: не только не коммитить пароль, но и не копировать его в образ, bundle, аргументы команды и общий лог.
| Граница | Что допустимо | Кто видит | Опасная ошибка |
|---|---|---|---|
| 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, считать его «спрятанным» уже нельзя.
Ниже учебный пример. В нём нет рабочего credential: token — строка DEMO_ONLY_NOT_A_SECRET, а адрес https://gateway.invalid предназначен только для теста. В настоящем контуре значение приходит по отдельному защищённому каналу.
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}Если loadConfig вызвать во время запуска, процесс остановится до первого запроса при отсутствии обязательного имени. Ошибка содержит имя поля, но не его значение. После загрузки модули получают готовый объект и не обходят проверку через прямые чтения process.env. Это уменьшает число мест, где можно случайно сериализовать окружение.
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Этот сценарий проверяет контракт кода: успешную загрузку, маскирование и отрицательный путь. Он не доказывает права доступа, ротацию, настройки CI или безопасность конкретного хранилища. Эти свойства проверяются на соответствующих границах.
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# Контур запуска подаёт его процессу отдельно от образа.Здесь APP_ENV — безопасный пример runtime default. В Dockerfile нет настоящего адреса платежей и нет token. Значения ENV сохраняются в образе и доступны контейнерам, созданным из него; их можно увидеть через docker inspect. ARG не следует использовать для credentials: Docker предупреждает, что build arguments видны в docker history и могут попасть в provenance-метаданные. Если секрет нужен именно во время сборки, используйте механизм secret mount. Для обычного runtime-секрета сборка ему не нужна.
В примере оставлен node:12-alpine, потому что он фиксирует контекст исходной заметки 2020 года, а не является рекомендацией для нового сервиса. Линия Node.js 12.x завершила поддержку 30 апреля 2022 года. В новом проекте выбирают поддерживаемый LTS и отдельно проверяют совместимость команд сборки; исторический npm ci --only=production нельзя переносить автоматически в современный pipeline.
# .dockerignore\n.env.local\n.env.*.local\n*.keyТакой файл уменьшает риск отправить локальные настройки в build context, но не заменяет проверку репозитория и не очищает уже собранный image. Шаблон нужно подстроить под проект: если сертификат с расширением .key действительно требуется внутри образа, его нельзя исключать механически — сначала нужно определить безопасный способ доставки.
Правило .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, отозвать старую |
.dockerignore, scripts, generated bundle и job log. Не использовать printenv как диагностику.ENV, ARG, слой или build context. Учесть, что удаление файла в следующем слое не отменяет предыдущую историю.Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.
Описанный порядок не выбирает за проект конкретное secret-хранилище. Небольшая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.
Проверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.
ENV, ARG, RUN --mount=type=secret и ограничения передачи credentials через build arguments.process.env.