Files
progcode/editorial/agent-rewrites/285.json
T

8 lines
21 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 285,
"slug": "editorial-2020-02-practice-configs-secrets",
"title": "Как доставить конфигурацию до процесса и не передать секрет в образ и лог",
"excerpt": "Сервис запускается локально, но в CI получает пустой URL, а токен может оказаться в Git, Docker-образе или логе. Разбираем границы конфигурации, добавляем проверку на старте и задаём воспроизводимый критерий готовности.",
"contentHtml": "<p>Сервис запускается на ноутбуке, но после выпуска получает пустой URL или неверный уровень логирования. Команда открывает CI-лог, печатает окружение и находит там токен. Иногда секрет уже попал в Docker-образ или старый commit. Цена ошибки складывается из простоя и ротации доступа. Нужно отозвать credential, выпустить новое значение, найти всех потребителей старого, проверить кэш и логи, а затем повторить релиз. Обычная настройка превращается в инцидент.</p>\n<p>Причина обычно не в синтаксисе <code>.env</code>. Проект смешивает два разных класса данных: настройка меняет поведение, а секрет даёт право на действие. Оба значения проходят через несколько поверхностей: репозиторий, сборку, image, канал доставки, процесс и журнал. У каждой поверхности своя аудитория и срок жизни. Без явной границы секрет перемещается туда, где его удобнее отладить.</p>\n<p>Тезис простой: храните в коде имена и безопасные примеры, передавайте чувствительные значения при запуске, валидируйте их на границе runtime и выводите только безопасный отчёт. Это не заменяет систему управления секретами и права доступа. Зато такой контракт делает утечку заметной до первого запроса.</p>\n<h2>Разделите значение по назначению</h2>\n<p><code>LOG_LEVEL=info</code> — настройка поведения. Её имя и пример можно хранить рядом с кодом. <code>PAYMENTS_API_URL</code> — адрес зависимости. Он не обязан быть секретом, но внутренний адрес всё равно может раскрывать топологию. <code>PAYMENTS_TOKEN</code> — учётные данные (credential). Его имя нужно приложению, а значение не нужно репозиторию, образу или общему логу. <code>CONFIG_REVISION</code> — технический идентификатор набора. Он помогает сопоставить запуск и конфигурацию, но не заменяет секрет.</p>\n<div class=\"table-scroll\"><table><caption>Минимальный контракт конфигурации</caption><thead><tr><th scope=\"col\">Класс</th><th scope=\"col\">Пример</th><th scope=\"col\">Допустимый носитель</th><th scope=\"col\">Диагностика</th></tr></thead><tbody><tr><td>Поведение</td><td><code>LOG_LEVEL=info</code></td><td>Шаблон, документация, runtime</td><td>Имя и выбранное значение</td></tr><tr><td>Адрес</td><td><code>PAYMENTS_API_URL</code></td><td>Шаблон с фиктивным адресом и runtime</td><td>Имя; значение только при безопасной видимости</td></tr><tr><td>Секрет</td><td><code>PAYMENTS_TOKEN</code></td><td>Отдельный защищённый канал запуска</td><td>Имя и <code>[REDACTED]</code></td></tr><tr><td>Идентификатор</td><td><code>CONFIG_REVISION=sample-42</code></td><td>Шаблон и запись выпуска</td><td>Идентификатор набора без credential</td></tr></tbody></table></div>\n<p>Таблица задаёт рабочую гипотезу, а не универсальную классификацию. Внутренний URL может быть чувствительным. Идентификатор может раскрывать детали релиза. Перед переносом значения спросите: кто его читает, сколько оно живёт, нужно ли ему попадать в этот носитель и кто отвечает за замену.</p>\n<figure><img src=\"/assets/editorial/2020/config-secret-boundary-2020.svg\" alt=\"Схема границы конфигурации: репозиторий хранит имена, delivery передаёт значение, runtime валидирует его, а лог получает маску\" loading=\"lazy\" /><figcaption>Конфигурация проходит четыре границы. Секрет останавливается в runtime и не становится частью репозитория, образа или общего диагностического вывода.</figcaption></figure>\n<h2>Как значение доходит до процесса</h2>\n<p>Репозиторий должен содержать шаблон. В нём есть имена, назначение и фиктивные значения по умолчанию. Настоящий токен в шаблон не подставляют. Сборка создаёт код и зависимости, но не должна запекать credential в bundle или Docker-образ. Если самой сборке нужен доступ к приватному registry, передайте его через временный BuildKit <code>secret mount</code>, а не через <code>ARG</code> или <code>ENV</code>. Delivery выбирает конкретную среду и передаёт runtime нужные значения своим защищённым способом. Loader получает строки, проверяет обязательные имена и создаёт объект, который код использует дальше.</p>\n<p>Переменная окружения — только канал передачи. Она не гарантирует секретность. Процесс может передать окружение дочерней команде, библиотека может записать его в ошибку, а shell-скрипт может напечатать команду целиком. Поэтому проверяйте не только источник, но и все места, куда значение копируется.</p>\n<h2>Учебный пример: шаблон и loader</h2>\n<p>Ниже приведён учебный пример. Значение токена фиктивное, домен <code>.invalid</code> не обозначает реальный сервис. Пример показывает границу контракта и не доказывает безопасность конкретного production-контура.</p>\n<pre><code># 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</code></pre>\n<pre><code>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}</code></pre>\n<p>Loader останавливает запуск до первого внешнего запроса, если обязательное имя отсутствует или URL не проходит проверку. Ошибка сообщает имя настройки, но не значение токена. Отчёт сохраняет полезные поля и маскирует token. Маска не закрывает доступ к процессу. Она убирает один распространённый путь случайной публикации через лог или support-диагностику.</p>\n<p>Воспроизводимый тест может передать обычный объект вместо <code>process.env</code>. Валидный объект проверяет окружение, HTTPS-URL и уровень логирования. Отрицательный сценарий передаёт пустой <code>PAYMENTS_TOKEN</code> и ожидает ошибку до внешнего запроса. Третий сценарий проверяет, что отчёт содержит <code>[REDACTED]</code>. Эти сценарии не обращаются к платёжной системе и не используют настоящий credential.</p>\n<pre><code>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);</code></pre>\n<h2>Почему одного .gitignore недостаточно</h2>\n<p>Добавьте локальный файл в <code>.gitignore</code>, чтобы новый <code>.env.local</code> не попал в индекс случайно. Но ignore-правило не удаляет уже отслеживаемый файл и не стирает значение из истории. Если секрет был закоммичен, cleanup файла не завершает работу. Сначала отзовите credential и выпустите новый. Затем проверьте историю, кэши, артефакты и журналы по правилам вашего контура.</p>\n<pre><code>git check-ignore -v .env.local\ngit ls-files --error-unmatch .env.local</code></pre>\n<p>Первая команда показывает правило для неотслеживаемого пути. Вторая должна завершиться ошибкой, если файл не отслеживается. Эти команды проверяют только Git. Они ничего не говорят о Docker context, CI cache и старых образах. Для каждой поверхности нужен отдельный check.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Короткая диагностика конфигурации</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Локально работает, в среде пустой URL</td><td>Имя или источник не совпадает между средами</td><td>Сверить контракт loader и итоговые имена без вывода значений</td><td>Зафиксировать источник и добавить fail-fast на старте</td></tr><tr><td>Токен виден в commit</td><td>Файл был tracked или значение попало в шаблон</td><td>Проверить <code>git ls-files</code> и историю</td><td>Отозвать токен, выпустить новый, затем удалить носители</td></tr><tr><td>Токен виден в образе</td><td>Секрет передали через <code>ENV</code>, <code>ARG</code> или скрипт сборки</td><td>Проверить Dockerfile, build args, слои и метаданные образа</td><td>Убрать значение из build; для build-time доступа использовать <code>secret mount</code>, для runtime — защищённую доставку</td></tr><tr><td>Ошибка содержит весь env</td><td>Logger или serializer получил объект окружения целиком</td><td>Пройти error path и поискать dump в коде и логах</td><td>Передавать safe report и тестировать редактирование полей</td></tr><tr><td>После смены ключа часть запросов падает</td><td>Потребители используют разные каналы или версии</td><td>Сопоставить revision, владельцев и сроки действия</td><td>Составить порядок ротации и проверить каждый потребитель</td></tr></tbody></table></div>\n<h2>Порядок проверки перед выпуском</h2>\n<ol><li>Составьте инвентарь переменных. Для каждой запишите класс, потребителя, источник, срок жизни и владельца. Не переносите неизвестное значение «на всякий случай».</li><li>Создайте versioned-шаблон. Оставьте имена, безопасные defaults и фиктивные адреса. Не вставляйте реальные значения даже в комментарии и учебные fixtures.</li><li>Добавьте один loader на границе runtime. Он проверяет обязательные имена, типы и допустимые значения. Он не печатает credential при ошибке.</li><li>Проверьте канал delivery. Зафиксируйте, кто выдаёт секрет процессу, кто меняет его и как команда получает уведомление о ротации.</li><li>Проверьте границу сборки. Уберите секреты из Dockerfile, build args, сгенерированного bundle, shell-команд и CI-комментариев. Для build-time доступа используйте <code>RUN --mount=type=secret</code>; runtime-токен передавайте только при запуске. Проверьте, что лог не печатает окружение.</li><li>Проверьте отрицательный путь. Удалите обязательное значение, прервите запуск и убедитесь, что процесс не сделал внешний запрос и не раскрыл значение.</li><li>Запустите зависимый учебный или тестовый сценарий с фиктивным credential. Сопоставьте только результат, имя конфигурации и безопасный идентификатор revision.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Этот порядок не выбирает за команду vault, CI-секреты или файл на хосте. У разных контуров разные требования к доступу, аудиту, резервированию и ротации. Переменная окружения не становится безопасной только потому, что она не видна в исходниках. Пользователь процесса, администратор хоста и библиотека с правом чтения окружения всё ещё могут получить её.</p>\n<p>Если credential уже утёк, не ограничивайтесь добавлением <code>.gitignore</code> или маской в новом логе. Такой путь отрицателен: старый ключ продолжает действовать, а старые копии остаются доступными. Сначала отзовите и замените credential. Потом определите носители и сроки удаления. После этого добавьте проверку, которая не даст повторить тот же путь.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Работа готова, если команда может назвать источник каждого обязательного значения, показать versioned-шаблон без реальных секретов, запустить loader с фиктивными данными и получить безопасный отчёт. Loader отклоняет пустые обязательные значения, неизвестные окружения, неверный уровень логирования и URL без HTTPS. В Git локальный файл не отслеживается. В Dockerfile и build output нет credential. При отсутствии обязательного значения процесс завершается до внешнего запроса. В логах остаются имя поля, результат проверки и revision, но не значение. Если хотя бы один пункт нельзя проверить повторяемой командой или тестом, конфигурация ещё не готова к выпуску.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://git-scm.com/docs/gitignore\" target=\"_blank\" rel=\"noopener noreferrer\">Git documentation: gitignore</a> — правила для неотслеживаемых путей и границы их действия.</li><li><a href=\"https://docs.docker.com/reference/dockerfile/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Docs: Dockerfile reference</a> — поведение <code>ENV</code> и <code>ARG</code>, включая сохранение <code>ENV</code> в образе и видимость build args в истории.</li><li><a href=\"https://docs.docker.com/build/building/secrets/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Docs: Build secrets</a> — почему для build-time секретов нужны временные secret mounts.</li><li><a href=\"https://nodejs.org/download/release/v12.0.0/docs/api/process.html#process_process_env\" target=\"_blank\" rel=\"noopener noreferrer\">Node.js v12.0.0 documentation: process.env</a> — доступ процесса к переменным окружения; ссылка закреплена на версии, близкой к дате статьи.</li></ul>"
}