{ "index": 284, "slug": "editorial-2020-02-mechanism-configs-secrets", "title": "Как конфигурация доходит до процесса и превращается в утечку", "excerpt": "Сервис получает настройки через несколько границ: Git, сборку, образ, delivery и runtime. Разбираем, где значение должно остановиться, почему .gitignore не удаляет секрет из истории и как проверить путь без раскрытия credential.", "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. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.

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

" }