DarkRiDDeR15 мин

Конфигурация без утечки: разделяем настройки, секреты и доставку

БезопасностьDevOps

Симптом выглядит бытовым: локально сервис стартует с .env, а перед выпуском кто-то копирует тот же файл в репозиторий, Dockerfile или описание job. Через неделю токен оказывается в error-ответе, логе сборки либо в образе, который можно скачать из registry. Цена не сводится к неловкому commit: доступ нужно отзывать, потребителей переключать, а релиз в этот момент теряет предсказуемость.

Причина обычно не в одном неосторожном человеке. В проекте нет явной границы между настройкой поведения и значением, которое даёт доступ. Исправление начинается не с лозунга «не коммитить пароли», а с небольшого контракта: имя переменной, класс значения, потребитель, канал доставки, правило логирования и владелец смены. Такой контракт связывает backend, delivery и поддержку, но не требует в феврале 2020 года строить отдельную платформу секретов.

Сначала отделяем четыре класса значений

У переменной может быть безопасное имя и опасное значение. LOG_LEVEL меняет поведение процесса и обычно подходит для шаблона. PAYMENTS_API_URL описывает адрес зависимости, но его всё равно не стоит бездумно отдавать в браузерный bundle. PAYMENTS_TOKEN даёт право выполнять действие; он не должен попадать в пример, клиентский код, лог или образ. Отдельно держим технический идентификатор: он полезен для поиска конфигурации, но не заменяет credential.

Инвентарь минимальной конфигурации сервиса
КлассУчебный примерГде хранить имя и примерЧто можно писать в логПроверка перед выпуском
Настройка поведенияLOG_LEVEL=infoШаблон и документацияИмя и выбранный уровеньЗначение соответствует ожидаемому набору
Адрес зависимостиPAYMENTS_API_URL=https://gateway.invalidШаблон с фиктивным адресомИмя; адрес — только если это не чувствительная внутренняя топологияRuntime получает адрес из нужного контура
СекретPAYMENTS_TOKENТолько имя и описание назначенияТолько имя и [REDACTED]Есть отдельный канал доставки и владелец ротации
Технический IDCONFIG_REVISION=sample-42Шаблон или release recordID допустим, если он не credentialID позволяет сопоставить выпуск и набор настроек

Таблица не объявляет адреса или идентификаторы безопасными по умолчанию. Внутренний hostname, имя клиента или путь к административному API тоже могут быть чувствительными в конкретном проекте. Смысл классификации в другом: до deploy команда знает, какое поле нельзя помещать в общий артефакт и что именно проверить, когда конфигурация меняется между средами.

Вертикальная схема границы: репозиторий хранит имена и безопасный шаблон, delivery передаёт значение по отдельному каналу, runtime валидирует наличие, а лог получает только маску.
Один и тот же набор имён проходит четыре поверхности. Секрет не должен пересекать репозиторий, образ и диагностический вывод как обычная настройка.

Инвентарь важнее папки с env-файлами

Начинаю с одного листа, а не с поиска универсального хранилища. Для каждого значения записываю: кто его создаёт, какой процесс читает, может ли оно жить в шаблоне, кто получит уведомление о смене и где значение можно случайно увидеть. Если в строке нет владельца или потребителя, это повод не переносить её в следующий deploy, пока назначение не станет понятным.

Полезно отдельно отметить путь до процесса. Локальная машина может читать неотслеживаемый файл. Сборочная job может получить переменную из защищённой настройки самого CI. Боевой процесс может получить файл или переменную от привычного для команды механизма запуска. Эти способы не обязаны быть одинаковыми; опасно, когда копирование из одного способа в другой происходит молча и вместе с реальным токеном.

Проверка здесь простая: в шаблоне остаются имена, объяснение и очевидно фиктивные значения; в проектной документации — способ получить доступ для разработчика; в журнале release — только факт, какой набор был применён. Никто не просит присылать значение в issue или чат «для проверки». Если без этого нельзя диагностировать ситуацию, сначала нужно добавить безопасный идентификатор конфигурации или отдельный тестовый credential.

Делаем конфигурацию входом runtime, а не глобальной случайностью

Node читает окружение через process.env, но сам объект не гарантирует типы, обязательность и отсутствие лишнего вывода. Если каждый модуль читает его напрямую, один обработчик начнёт падать на пустом значении, другой подставит тестовый URL, третий выведет весь объект в ошибку. Поэтому на старте процесса собираем небольшой объект конфигурации и дальше передаём его по зависимостям явно.

const required = ["APP_ENV", "PAYMENTS_API_URL", "PAYMENTS_TOKEN"];

function readRequired(name, env = process.env) {
  const value = env[name];
  if (!value) throw new Error("Missing required setting: " + name);
  return value;
}

export function loadConfig(env = process.env) {
  for (const name of required) readRequired(name, env);
  return {
    appEnv: env.APP_ENV,
    paymentsApiUrl: env.PAYMENTS_API_URL,
    paymentsToken: env.PAYMENTS_TOKEN,
    logLevel: env.LOG_LEVEL || "info",
  };
}

export function safeConfigReport(config) {
  return { appEnv: config.appEnv, paymentsApiUrl: config.paymentsApiUrl,
    paymentsToken: "[REDACTED]", logLevel: config.logLevel };
}

В примере нет настоящего ключа: строка с фиктивным значением находится только в шаблоне выше. Загрузка останавливает процесс до первого запроса, если имя не пришло. Ошибка содержит имя настройки, а не её значение. Отчёт для диагностики специально возвращает маску. Это не криптографическая защита и не замена прав доступа; это граница, которая не позволяет обычному console.log превратить конфигурацию в утечку.

Конфигурацию стоит проверить в тесте отдельным объектом env: один сценарий без PAYMENTS_TOKEN должен дать понятную ошибку, другой — вернуть объект с ожидаемым URL, третий — показать [REDACTED] в safe report. Такой тест не обращается к платёжной системе и не знает реальный token. Он проверяет договорённость между кодом и delivery до того, как она станет аварией на окружении.

Git-правило не лечит уже попавший файл

Файл .env.local разумно добавить в .gitignore, чтобы новый локальный файл не попал в git add. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже был закоммичен, новое правило не удалит его из index и не закроет историю. Это частый ложный успех: в рабочем дереве всё выглядит тихо, а в diff или старом commit значение по-прежнему доступно.

# config.example.env — учебный шаблон, его можно хранить рядом с кодом.
APP_ENV=development
PAYMENTS_API_URL=https://gateway.invalid
PAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET
LOG_LEVEL=info

# В настоящем контуре значение токена приходит отдельным защищённым каналом.

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

Путь от шаблона к безопасному запуску

  1. Собрать список переменных у сервиса и отметить для каждой класс, потребителя и владельца. Не переносить неизвестное значение «на всякий случай».
  2. Создать versioned шаблон с именами, безопасными defaults и адресами на доменах .invalid. Реальные значения в шаблон не подставлять.
  3. Добавить один loader на границе runtime: он валидирует обязательные имена и выдаёт безопасный диагностический отчёт.
  4. Выбрать существующий канал delivery для секрета: защищённая переменная CI, файл с ограниченным доступом или механизм хоста. Зафиксировать, кто меняет значение и как уведомляет потребителя.
  5. Проверить Git отдельно от сборки: локальный файл ignored и не tracked; проверить Dockerfile и scripts на отсутствие токенов в аргументах, ENV и echo.
  6. Выпустить change с идентификатором конфигурации без значения. После запуска проверить только факт чтения нужных имён, redacted log и работоспособность зависимого сценария.

Где граница этого рецепта

Этот порядок не выбирает за проект способ хранения или выдачи credentials. У маленькой команды это может быть защищённый файл на host и ручная передача по ограниченному каналу; у другой — переменные CI. До выбора сложной системы важнее получить базовую дисциплину: секрет не лежит в Git, не прошивается в образ, не выводится в лог и имеет человека, который может его заменить.

Не стоит заявлять успех по одному зелёному deploy. Проверка закончена, когда известно: откуда процесс получил имя и значение, какой код остановит запуск при пустом поле, какой лог не раскроет token и кто проведёт ротацию при утечке. Если один из ответов неизвестен, это не повод расширить шаблон. Это точка для маленькой задачи на delivery или backend с явным владельцем.

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

  • Git: gitignore documentation — игнорируются только намеренно неотслеживаемые пути; уже tracked файл правило не убирает из индекса
  • Node.js v12: process.env — Node читает окружение процесса через process.env; это вход runtime, а не схема валидации сама по себе
  • Docker Docs: environment variables in Compose — документация разделяет переменные контейнера, интерполяцию Compose и их приоритет
  • Docker Docs: Dockerfile reference — ENV сохраняется в образе и доступен контейнеру; ARG не следует считать местом для credentials или токенов