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

Тезис: секрет должен попасть только в нужный процесс

\n

Код хранит имя настройки и правила проверки. Сборка создаёт один и тот же артефакт для разных контуров. Delivery передаёт значение выбранному процессу. Loader проверяет обязательные имена на старте. Логи показывают идентификатор конфигурации и маскируют значения. Такой маршрут не делает секрет невидимым для владельца процесса, но сокращает число носителей и облегчает проверку.

\n

Переменная окружения — канал доставки, а не хранилище с гарантией секретности. Её может прочитать wrapper, дочерний процесс, crash handler или диагностический код. Поэтому важно не только «не коммитить пароль», но и не копировать его в образ, bundle, аргументы команды и общий лог.

\n

Пять границ одного значения

\n
Что происходит с конфигурацией на каждом этапе
ГраницаЧто допустимоКто видитОпасная ошибка
Git и шаблонИмена, описание, фиктивные defaultsРазработчики и клоны репозиторияРеальный token в .env или примере конфигурации
Build context и CIИсходники и несекретные параметрыСборщик, job log, cacheprintenv, token в аргументе или echo
Docker imageКод и безопасные runtime defaultsRegistry и любой читатель образаCredential в ENV, ARG или generated bundle
RuntimeНужные процессу настройкиПроцесс и ограниченный контур запускаЛюбой модуль читает окружение и печатает его целиком
Логи и incidentИмена, request ID, revision, маскиПоддержка, мониторинг, участники incidentHeaders, env или config object в диагностике
\n

Одна и та же строка может пересечь все пять границ. Но ей не нужно этого делать. Например, APP_ENV может жить в образе как безопасный default. PAYMENTS_TOKEN должен появиться только при запуске и остаться доступным процессу, которому он нужен. Если token попал в Git или image, считать его «спрятанным» уже нельзя.

\n
\"Путь
Схема показывает границы пути. Репозиторий хранит имена, delivery подаёт значение отдельно, loader проверяет контракт, а лог получает безопасный отчёт.
\n

Учебный пример: код, образ и runtime

\n

Ниже учебный пример. Значение DEMO_ONLY_NOT_A_SECRET не даёт доступа к сервису. В настоящем контуре токен приходит по отдельному защищённому каналу.

\n
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}
\n

Loader останавливает процесс до первого запроса, если обязательное имя отсутствует. Ошибка содержит имя поля, но не его значение. После загрузки модули получают готовый объект конфигурации и не обходят проверку через прямые чтения process.env. Это уменьшает число мест, где можно случайно сериализовать окружение.

\n
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-секрета сборка ему не нужна.

\n

Проверять loader можно без deployment. Передайте ему объект с APP_ENV=staging, адресом https://gateway.invalid и фиктивным token. Удалите обязательное поле и ожидайте ошибку с его именем. Отдельно вызовите safeConfigReport и проверьте маску. Это проверяет контракт кода. Оно не доказывает права доступа, ротацию и безопасность конкретного CI.

\n

Почему .gitignore создаёт ложное чувство защиты

\n

Правило .env.local помогает не добавить новый локальный файл. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже tracked, новое правило не удалит его из index и не очистит историю. При обнаружении credential в commit нужно считать его скомпрометированным: удалить строку мало, сначала отозвать старое значение и выпустить новое.

\n

Проверка должна разделять два вопроса. git check-ignore -v .env.local показывает, какое правило защищает локальный путь. git ls-files --error-unmatch .env.local не должен находить этот файл среди tracked. Эти команды не проверяют Docker context, CI cache, registry и логи. Для каждой поверхности нужен отдельный check.

\n

Симптом → причина → проверка → действие

\n
Точечная диагностика пути конфигурации
СимптомПричинаПроверкаДействие
Пустой обязательный ключ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, отозвать старую
\n

Порядок действий

\n
  1. Зафиксировать наблюдаемый симптом: пустое имя, неверный URL, credential в image или value в логе. Не менять одновременно код и job.
  2. Составить карту переменных: имя, класс значения, потребитель, источник, владелец смены и допустимый носитель.
  3. Проверить границу Git. В шаблоне оставить имена и фиктивные defaults. Если credential уже tracked, начать с отзыва и ротации.
  4. Проверить build boundary. Осмотреть Dockerfile, scripts, generated bundle и job log. Не использовать printenv как диагностику.
  5. Проверить image. Убедиться, что token не попал в ENV, ARG, слой или build context. Учесть, что удаление файла в следующем слое не отменяет предыдущую историю.
  6. Проверить delivery. Для каждого обязательного имени назвать источник и владельца. В release record сохранить только revision или идентификатор набора.
  7. Проверить runtime loader. Отсутствующее поле должно остановить запуск, а safe report — показать маску вместо значения.
  8. Проверить отрицательный путь: ошибка, retry, debug endpoint, HTTP logger и issue-шаблон не должны копировать env, headers или config object целиком.
  9. Проверить зависимый сценарий с тестовым credential или безопасным тестовым контуром. Не считать зелёный deploy доказательством отсутствия утечки.
\n

Ограничения

\n

Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.

\n

Описанный порядок не выбирает за проект конкретное secret-хранилище. Маленькая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.

\n

Проверяемый критерий готовности

\n

Проверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.

\n

Проверяемые источники

\n" + "contentHtml": "

Симптом виден после выпуска: сервис в 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, cacheprintenv, token в аргументе или echo
Docker imageКод и безопасные runtime defaultsRegistry и любой читатель образаCredential в ENV, ARG или generated bundle
RuntimeНужные процессу настройкиПроцесс и ограниченный контур запускаЛюбой модуль читает окружение и печатает его целиком
Логи и incidentИмена, request ID, revision, маскиПоддержка, мониторинг, участники incidentHeaders, env или config object в диагностике

Одна и та же строка может пересечь все пять границ, но ей не нужно этого делать. Например, APP_ENV может жить в образе как безопасный default. PAYMENTS_TOKEN должен появиться только при запуске и остаться доступным процессу, которому он нужен. Если token попал в Git или image, считать его «спрятанным» уже нельзя.

Путь конфигурации от имён в репозитории через delivery к runtime с redacted-логом
Схема показывает границы пути. Репозиторий хранит имена, delivery подаёт значение отдельно, loader проверяет контракт, а лог получает безопасный отчёт.

Учебный пример: код, образ и runtime

Ниже учебный пример. В нём нет рабочего 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 действительно требуется внутри образа, его нельзя исключать механически — сначала нужно определить безопасный способ доставки.

Почему .gitignore создаёт ложное чувство защиты

Правило .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, отозвать старую

Порядок действий

  1. Зафиксировать наблюдаемый симптом: пустое имя, неверный URL, credential в image или value в логе. Не менять одновременно код и job.
  2. Составить карту переменных: имя, класс значения, потребитель, источник, владелец смены и допустимый носитель.
  3. Проверить границу Git. В шаблоне оставить имена и фиктивные defaults. Если credential уже tracked, начать с отзыва и ротации.
  4. Проверить build boundary. Осмотреть Dockerfile, .dockerignore, scripts, generated bundle и job log. Не использовать printenv как диагностику.
  5. Проверить image. Убедиться, что token не попал в ENV, ARG, слой или build context. Учесть, что удаление файла в следующем слое не отменяет предыдущую историю.
  6. Проверить delivery. Для каждого обязательного имени назвать источник и владельца. В release record сохранить только revision или идентификатор набора.
  7. Проверить runtime loader. Отсутствующее поле должно остановить запуск, а safe report — показать маску вместо значения.
  8. Проверить отрицательный путь: ошибка, retry, debug endpoint, HTTP logger и issue-шаблон не должны копировать env, headers или config object целиком.
  9. Проверить зависимый сценарий с тестовым credential или безопасным тестовым контуром. Не считать зелёный deploy доказательством отсутствия утечки.

Ограничения

Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.

Описанный порядок не выбирает за проект конкретное secret-хранилище. Небольшая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.

Проверяемый критерий готовности

Проверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.

Проверяемые источники

" }