diff --git a/editorial/agent-rewrites/285.json b/editorial/agent-rewrites/285.json index 06c1644..56a1cc5 100644 --- a/editorial/agent-rewrites/285.json +++ b/editorial/agent-rewrites/285.json @@ -1,7 +1,7 @@ { "index": 285, "slug": "editorial-2020-02-practice-configs-secrets", - "title": "Настройки и секреты: как провести конфигурацию до процесса без утечки", - "excerpt": "Сервис запускается локально, но в CI получает пустой URL, а токен может остаться в Git, Docker-образе или логе. Разбираем границы конфигурации, проверяем их короткими командами и задаём критерий готовности.", - "contentHtml": "

Сервис запускается на ноутбуке, но после выпуска получает пустой URL или неверный уровень логирования. Команда открывает CI-лог, печатает окружение и находит там токен. Иногда секрет уже попал в Docker-образ или старый commit. Цена ошибки складывается из простоя и ротации доступа. Нужно выпустить новую пару ключей, найти всех потребителей старой, проверить кэш и логи, а затем повторить релиз. Обычная настройка превращается в инцидент.

\n

Причина обычно не в синтаксисе .env. Проект смешивает два разных класса данных: настройка меняет поведение, а секрет даёт право на действие. Оба значения проходят через несколько поверхностей: репозиторий, сборку, image, канал доставки, процесс и журнал. У каждой поверхности своя аудитория и срок жизни. Без явной границы секрет перемещается туда, где его удобнее отладить.

\n

Тезис простой: храните в коде имена и безопасные примеры, передавайте чувствительные значения при запуске, валидируйте их на границе runtime и выводите только безопасный отчёт. Это не заменяет систему управления секретами и права доступа. Зато такой контракт делает утечку заметной до первого запроса.

\n

Разделите значение по назначению

\n

LOG_LEVEL=info — настройка поведения. Её имя и пример можно хранить рядом с кодом. PAYMENTS_API_URL — адрес зависимости. Он не обязан быть секретом, но внутренний адрес всё равно может раскрывать топологию. PAYMENTS_TOKEN — credential. Его имя нужно приложению, а значение не нужно репозиторию, образу или общему логу. CONFIG_REVISION — технический идентификатор набора. Он помогает сопоставить запуск и конфигурацию, но не заменяет секрет.

\n
Минимальный контракт конфигурации
КлассПримерДопустимый носительДиагностика
ПоведениеLOG_LEVEL=infoШаблон, документация, runtimeИмя и выбранное значение
АдресPAYMENTS_API_URLШаблон с фиктивным адресом и runtimeИмя; значение только при безопасной видимости
СекретPAYMENTS_TOKENОтдельный защищённый канал запускаИмя и [REDACTED]
ИдентификаторCONFIG_REVISION=sample-42Шаблон и запись выпускаИдентификатор набора без credential
\n

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

\n
\"Схема
Конфигурация проходит четыре границы. Секрет останавливается в runtime и не становится частью репозитория, образа или общего диагностического вывода.
\n

Как значение доходит до процесса

\n

Репозиторий должен содержать шаблон. В нём есть имена, назначение и фиктивные defaults. Настоящий токен в шаблон не подставляют. Build собирает код и зависимости. Он не должен запекать credential в bundle или Docker image. Delivery выбирает конкретную среду и передаёт runtime нужные значения своим защищённым способом. Loader получает строки, проверяет обязательные имена и создаёт объект, который код использует дальше.

\n

Переменная окружения — только канал передачи. Она не гарантирует секретность. Процесс может передать окружение дочерней команде, библиотека может записать его в ошибку, а shell-скрипт может напечатать команду целиком. Поэтому проверяйте не только источник, но и все места, куда значение копируется.

\n

Учебный пример: шаблон и loader

\n

Ниже приведён учебный пример. Значение токена фиктивное, домен .invalid не обозначает реальный сервис. Пример показывает границу контракта и не доказывает безопасность конкретного production-контура.

\n
# config.example.env — можно хранить в репозитории\nAPP_ENV=development\nPAYMENTS_API_URL=https://gateway.invalid\nPAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET\nLOG_LEVEL=info\nCONFIG_REVISION=sample-42
\n
const required = [\"APP_ENV\", \"PAYMENTS_API_URL\", \"PAYMENTS_TOKEN\"];\n\nexport function loadConfig(env = process.env) {\n  for (const name of required) {\n    if (!env[name]) {\n      throw new Error(`Missing required setting: ${name}`);\n    }\n  }\n\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    revision: env.CONFIG_REVISION || \"unknown\",\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    revision: config.revision,\n  };\n}
\n

Loader останавливает запуск до первого внешнего запроса, если обязательное имя отсутствует. Ошибка сообщает имя настройки, но не значение. Отчёт сохраняет полезные поля и маскирует token. Маска не закрывает доступ к процессу. Она убирает один распространённый путь случайной публикации через лог или support-диагностику.

\n

Тест loader может передать обычный объект вместо process.env. Один сценарий удаляет PAYMENTS_TOKEN и ожидает ошибку. Другой проверяет URL и уровень логирования. Третий проверяет, что отчёт содержит [REDACTED]. Эти сценарии не обращаются к платёжной системе и не используют настоящий credential.

\n

Почему одного .gitignore недостаточно

\n

Добавьте локальный файл в .gitignore, чтобы новый .env.local не попал в индекс случайно. Но ignore-правило не удаляет уже отслеживаемый файл и не стирает значение из истории. Если секрет был закоммичен, cleanup файла не завершает работу. Сначала отзовите credential и выпустите новый. Затем проверьте историю, кэши, артефакты и журналы по правилам вашего контура.

\n
git check-ignore -v .env.local\ngit ls-files --error-unmatch .env.local
\n

Первая команда показывает правило для неотслеживаемого пути. Вторая должна завершиться ошибкой, если файл не tracked. Эти команды проверяют только Git. Они ничего не говорят о Docker context, CI cache и старых образах. Для каждой поверхности нужен отдельный check.

\n

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

\n
Короткая диагностика конфигурации
СимптомПричинаПроверкаДействие
Локально работает, в среде пустой URLИмя или источник не совпадает между средамиСверить контракт loader и итоговые имена без вывода значенийЗафиксировать источник и добавить fail-fast на старте
Токен виден в commitФайл был tracked или значение попало в шаблонПроверить git ls-files и историюОтозвать токен, выпустить новый, затем удалить носители
Токен виден в imageCredential передали через ENV, ARG или build scriptПроверить Dockerfile, build args, слои и метаданные образаУбрать значение из build и передавать его при запуске
Ошибка содержит весь envLogger или serializer получил объект окружения целикомПройти error path и поискать dump в коде и логахПередавать safe report и тестировать редактирование полей
После смены ключа часть запросов падаетПотребители используют разные каналы или версииСопоставить revision, владельцев и сроки действияСоставить порядок ротации и проверить каждый потребитель
\n

Порядок проверки перед выпуском

\n
  1. Составьте инвентарь переменных. Для каждой запишите класс, потребителя, источник, срок жизни и владельца. Не переносите неизвестное значение «на всякий случай».
  2. Создайте versioned-шаблон. Оставьте имена, безопасные defaults и фиктивные адреса. Не вставляйте реальные значения даже в комментарии и учебные fixtures.
  3. Добавьте один loader на границе runtime. Он проверяет обязательные имена, типы и допустимые значения. Он не печатает credential при ошибке.
  4. Проверьте канал delivery. Зафиксируйте, кто выдаёт секрет процессу, кто меняет его и как команда получает уведомление о ротации.
  5. Проверьте build boundary. Уберите секреты из Dockerfile, build args, generated bundle, shell-команд и CI-комментариев. Проверьте, что лог не печатает окружение.
  6. Проверьте отрицательный путь. Удалите обязательное значение, прервите запуск и убедитесь, что процесс не сделал внешний запрос и не раскрыл значение.
  7. Запустите зависимый учебный или тестовый сценарий с фиктивным credential. Сопоставьте только результат, имя конфигурации и безопасный идентификатор revision.
\n

Ограничения и отрицательный путь

\n

Этот порядок не выбирает за команду vault, CI-секреты или файл на хосте. У разных контуров разные требования к доступу, аудиту, резервированию и ротации. Переменная окружения не становится безопасной только потому, что она не видна в исходниках. Пользователь процесса, администратор хоста и библиотека с правом чтения окружения всё ещё могут получить её.

\n

Если credential уже утёк, не ограничивайтесь добавлением .gitignore или маской в новом логе. Такой путь отрицателен: старый ключ продолжает действовать, а старые копии остаются доступными. Сначала отзовите и замените credential. Потом определите носители и сроки удаления. После этого добавьте проверку, которая не даст повторить тот же путь.

\n

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

\n

Работа готова, если команда может назвать источник каждого обязательного значения, показать versioned-шаблон без реальных секретов, запустить loader с фиктивными данными и получить безопасный отчёт. В Git локальный файл не tracked. В Dockerfile и build output нет credential. При отсутствии обязательного значения процесс завершается до внешнего запроса. В логах остаются имя поля, результат проверки и revision, но не значение. Если хотя бы один пункт нельзя проверить повторяемой командой или тестом, конфигурация ещё не готова к выпуску.

\n

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

\n" + "title": "Как доставить конфигурацию до процесса и не передать секрет в образ и лог", + "excerpt": "Сервис запускается локально, но в CI получает пустой URL, а токен может оказаться в Git, Docker-образе или логе. Разбираем границы конфигурации, добавляем проверку на старте и задаём воспроизводимый критерий готовности.", + "contentHtml": "

Сервис запускается на ноутбуке, но после выпуска получает пустой URL или неверный уровень логирования. Команда открывает CI-лог, печатает окружение и находит там токен. Иногда секрет уже попал в Docker-образ или старый commit. Цена ошибки складывается из простоя и ротации доступа. Нужно отозвать credential, выпустить новое значение, найти всех потребителей старого, проверить кэш и логи, а затем повторить релиз. Обычная настройка превращается в инцидент.

\n

Причина обычно не в синтаксисе .env. Проект смешивает два разных класса данных: настройка меняет поведение, а секрет даёт право на действие. Оба значения проходят через несколько поверхностей: репозиторий, сборку, image, канал доставки, процесс и журнал. У каждой поверхности своя аудитория и срок жизни. Без явной границы секрет перемещается туда, где его удобнее отладить.

\n

Тезис простой: храните в коде имена и безопасные примеры, передавайте чувствительные значения при запуске, валидируйте их на границе runtime и выводите только безопасный отчёт. Это не заменяет систему управления секретами и права доступа. Зато такой контракт делает утечку заметной до первого запроса.

\n

Разделите значение по назначению

\n

LOG_LEVEL=info — настройка поведения. Её имя и пример можно хранить рядом с кодом. PAYMENTS_API_URL — адрес зависимости. Он не обязан быть секретом, но внутренний адрес всё равно может раскрывать топологию. PAYMENTS_TOKEN — учётные данные (credential). Его имя нужно приложению, а значение не нужно репозиторию, образу или общему логу. CONFIG_REVISION — технический идентификатор набора. Он помогает сопоставить запуск и конфигурацию, но не заменяет секрет.

\n
Минимальный контракт конфигурации
КлассПримерДопустимый носительДиагностика
ПоведениеLOG_LEVEL=infoШаблон, документация, runtimeИмя и выбранное значение
АдресPAYMENTS_API_URLШаблон с фиктивным адресом и runtimeИмя; значение только при безопасной видимости
СекретPAYMENTS_TOKENОтдельный защищённый канал запускаИмя и [REDACTED]
ИдентификаторCONFIG_REVISION=sample-42Шаблон и запись выпускаИдентификатор набора без credential
\n

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

\n
\"Схема
Конфигурация проходит четыре границы. Секрет останавливается в runtime и не становится частью репозитория, образа или общего диагностического вывода.
\n

Как значение доходит до процесса

\n

Репозиторий должен содержать шаблон. В нём есть имена, назначение и фиктивные значения по умолчанию. Настоящий токен в шаблон не подставляют. Сборка создаёт код и зависимости, но не должна запекать credential в bundle или Docker-образ. Если самой сборке нужен доступ к приватному registry, передайте его через временный BuildKit secret mount, а не через ARG или ENV. Delivery выбирает конкретную среду и передаёт runtime нужные значения своим защищённым способом. Loader получает строки, проверяет обязательные имена и создаёт объект, который код использует дальше.

\n

Переменная окружения — только канал передачи. Она не гарантирует секретность. Процесс может передать окружение дочерней команде, библиотека может записать его в ошибку, а shell-скрипт может напечатать команду целиком. Поэтому проверяйте не только источник, но и все места, куда значение копируется.

\n

Учебный пример: шаблон и loader

\n

Ниже приведён учебный пример. Значение токена фиктивное, домен .invalid не обозначает реальный сервис. Пример показывает границу контракта и не доказывает безопасность конкретного production-контура.

\n
# config.example.env — можно хранить в репозитории\nAPP_ENV=development\nPAYMENTS_API_URL=https://gateway.invalid\nPAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET\nLOG_LEVEL=info\nCONFIG_REVISION=sample-42
\n
const required = [\"APP_ENV\", \"PAYMENTS_API_URL\", \"PAYMENTS_TOKEN\"];\nconst allowedEnvironments = new Set([\"development\", \"test\", \"production\"]);\nconst allowedLogLevels = new Set([\"debug\", \"info\", \"warn\", \"error\"]);\n\nexport function loadConfig(env = process.env) {\n  for (const name of required) {\n    if (typeof env[name] !== \"string\" || env[name].trim() === \"\") {\n      throw new Error(`Missing required setting: ${name}`);\n    }\n  }\n\n  if (!allowedEnvironments.has(env.APP_ENV)) {\n    throw new Error(`Invalid APP_ENV: ${env.APP_ENV}`);\n  }\n\n  let paymentsApiUrl;\n  try {\n    paymentsApiUrl = new URL(env.PAYMENTS_API_URL);\n  } catch {\n    throw new Error(\"Invalid PAYMENTS_API_URL\");\n  }\n  if (paymentsApiUrl.protocol !== \"https:\") {\n    throw new Error(\"PAYMENTS_API_URL must use HTTPS\");\n  }\n\n  const logLevel = env.LOG_LEVEL || \"info\";\n  if (!allowedLogLevels.has(logLevel)) {\n    throw new Error(`Invalid LOG_LEVEL: ${logLevel}`);\n  }\n\n  return {\n    appEnv: env.APP_ENV,\n    paymentsApiUrl: paymentsApiUrl.toString(),\n    paymentsToken: env.PAYMENTS_TOKEN,\n    logLevel,\n    revision: env.CONFIG_REVISION || \"unknown\",\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    revision: config.revision,\n  };\n}
\n

Loader останавливает запуск до первого внешнего запроса, если обязательное имя отсутствует или URL не проходит проверку. Ошибка сообщает имя настройки, но не значение токена. Отчёт сохраняет полезные поля и маскирует token. Маска не закрывает доступ к процессу. Она убирает один распространённый путь случайной публикации через лог или support-диагностику.

\n

Воспроизводимый тест может передать обычный объект вместо process.env. Валидный объект проверяет окружение, HTTPS-URL и уровень логирования. Отрицательный сценарий передаёт пустой PAYMENTS_TOKEN и ожидает ошибку до внешнего запроса. Третий сценарий проверяет, что отчёт содержит [REDACTED]. Эти сценарии не обращаются к платёжной системе и не используют настоящий credential.

\n
const demoEnv = {\n  APP_ENV: \"test\",\n  PAYMENTS_API_URL: \"https://gateway.invalid\",\n  PAYMENTS_TOKEN: \"DEMO_ONLY_NOT_A_SECRET\",\n  LOG_LEVEL: \"info\",\n  CONFIG_REVISION: \"sample-42\",\n};\n\nconst config = loadConfig(demoEnv);\nconst report = safeConfigReport(config);\n\nif (report.paymentsToken !== \"[REDACTED]\") {\n  throw new Error(\"secret was not redacted\");\n}\n\ntry {\n  loadConfig({ ...demoEnv, PAYMENTS_TOKEN: \"\" });\n  throw new Error(\"negative test did not fail\");\n} catch (error) {\n  if (!error.message.includes(\"PAYMENTS_TOKEN\")) throw error;\n}\n\nconsole.log(report);
\n

Почему одного .gitignore недостаточно

\n

Добавьте локальный файл в .gitignore, чтобы новый .env.local не попал в индекс случайно. Но ignore-правило не удаляет уже отслеживаемый файл и не стирает значение из истории. Если секрет был закоммичен, cleanup файла не завершает работу. Сначала отзовите credential и выпустите новый. Затем проверьте историю, кэши, артефакты и журналы по правилам вашего контура.

\n
git check-ignore -v .env.local\ngit ls-files --error-unmatch .env.local
\n

Первая команда показывает правило для неотслеживаемого пути. Вторая должна завершиться ошибкой, если файл не отслеживается. Эти команды проверяют только Git. Они ничего не говорят о Docker context, CI cache и старых образах. Для каждой поверхности нужен отдельный check.

\n

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

\n
Короткая диагностика конфигурации
СимптомПричинаПроверкаДействие
Локально работает, в среде пустой URLИмя или источник не совпадает между средамиСверить контракт loader и итоговые имена без вывода значенийЗафиксировать источник и добавить fail-fast на старте
Токен виден в commitФайл был tracked или значение попало в шаблонПроверить git ls-files и историюОтозвать токен, выпустить новый, затем удалить носители
Токен виден в образеСекрет передали через ENV, ARG или скрипт сборкиПроверить Dockerfile, build args, слои и метаданные образаУбрать значение из build; для build-time доступа использовать secret mount, для runtime — защищённую доставку
Ошибка содержит весь envLogger или serializer получил объект окружения целикомПройти error path и поискать dump в коде и логахПередавать safe report и тестировать редактирование полей
После смены ключа часть запросов падаетПотребители используют разные каналы или версииСопоставить revision, владельцев и сроки действияСоставить порядок ротации и проверить каждый потребитель
\n

Порядок проверки перед выпуском

\n
  1. Составьте инвентарь переменных. Для каждой запишите класс, потребителя, источник, срок жизни и владельца. Не переносите неизвестное значение «на всякий случай».
  2. Создайте versioned-шаблон. Оставьте имена, безопасные defaults и фиктивные адреса. Не вставляйте реальные значения даже в комментарии и учебные fixtures.
  3. Добавьте один loader на границе runtime. Он проверяет обязательные имена, типы и допустимые значения. Он не печатает credential при ошибке.
  4. Проверьте канал delivery. Зафиксируйте, кто выдаёт секрет процессу, кто меняет его и как команда получает уведомление о ротации.
  5. Проверьте границу сборки. Уберите секреты из Dockerfile, build args, сгенерированного bundle, shell-команд и CI-комментариев. Для build-time доступа используйте RUN --mount=type=secret; runtime-токен передавайте только при запуске. Проверьте, что лог не печатает окружение.
  6. Проверьте отрицательный путь. Удалите обязательное значение, прервите запуск и убедитесь, что процесс не сделал внешний запрос и не раскрыл значение.
  7. Запустите зависимый учебный или тестовый сценарий с фиктивным credential. Сопоставьте только результат, имя конфигурации и безопасный идентификатор revision.
\n

Ограничения и отрицательный путь

\n

Этот порядок не выбирает за команду vault, CI-секреты или файл на хосте. У разных контуров разные требования к доступу, аудиту, резервированию и ротации. Переменная окружения не становится безопасной только потому, что она не видна в исходниках. Пользователь процесса, администратор хоста и библиотека с правом чтения окружения всё ещё могут получить её.

\n

Если credential уже утёк, не ограничивайтесь добавлением .gitignore или маской в новом логе. Такой путь отрицателен: старый ключ продолжает действовать, а старые копии остаются доступными. Сначала отзовите и замените credential. Потом определите носители и сроки удаления. После этого добавьте проверку, которая не даст повторить тот же путь.

\n

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

\n

Работа готова, если команда может назвать источник каждого обязательного значения, показать versioned-шаблон без реальных секретов, запустить loader с фиктивными данными и получить безопасный отчёт. Loader отклоняет пустые обязательные значения, неизвестные окружения, неверный уровень логирования и URL без HTTPS. В Git локальный файл не отслеживается. В Dockerfile и build output нет credential. При отсутствии обязательного значения процесс завершается до внешнего запроса. В логах остаются имя поля, результат проверки и revision, но не значение. Если хотя бы один пункт нельзя проверить повторяемой командой или тестом, конфигурация ещё не готова к выпуску.

\n

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

\n" }