Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +20 XP

Секреты: почему пароли не живут в YAML

Секреты конфигурации Go — пароли баз, ключи API, токены — не живут в config.yaml, и это не перестраховка: файл попадает в git-историю, клоны и бэкапы, а отладочный дамп уносит пароль в логи. Разберём, где секретам место в деплое, как viper читает их через BindEnv и AutomaticEnv и что делать, если ключ уже закоммичен.

Содержание урока · 10 разделов

Утренний коммит добавил в config.yaml строку database.password со значением — сервис подключился, тикет закрыт, все довольны. Через полгода репозиторий отдали подрядчику под интеграцию, и пароль уехал вместе с ним: не в текущей версии файла, а в истории, откуда его достаёт git show годичной давности. Секреты конфигурации Go по привычке кладут рядом с портом и таймаутами, в тот же файл, — и это самый дешёвый путь к утечке, потому что у config.yaml больше читателей, чем у любого другого артефакта проекта. В прошлом уроке про профили окружений мы договорились: в файлах репозитория живут только нечувствительные настройки, а пароли и ключи приходят переменными деплоя. Здесь разберём этот договор построчно: сколько на самом деле путей у секрета наружу, как viper доходит до него через окружение и что делать, когда ключ всё-таки в истории git.

Секреты конфигурации Go: кто ещё читает config.yaml#

Файл конфига — не приватная папка, и привычка держать пароли в конфиге обходится дороже, чем кажется. Первый и самый вечный читатель — git-история: коммит попадает в неё навсегда, и никакой следующий коммит с git rm её не стирает — этому ниже посвящён отдельный раздел. Второй — клоны и копии: CI-раннеры, зеркала, бэкапы хостинга, диски каждого, кто когда-то делал git clone; репозиторий разъезжается быстрее, чем меняются пароли. Третий — инфраструктура вокруг git: архивы GitLab, экспорты, вложенные в тикеты логи сборки, в которых WriteConfig из урока про запись конфига успела вывести реестр целиком. Четвёртый — сами логи: дамп конфига на старте, отладочная печать, отчёт об ошибке. Ни один из этих каналов не контролируется вами после утечки: вы не можете отозвать чужой клон или строку, уже записанную в собранные логи. Отсюда единственное рабочее правило — у секрета не должно быть копий в файлах, которые куда-то копируются.

Дамп конфига, который печатает пароль

GO
v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
v.ReadInConfig() // в файле и database.password: "..."

// отладочная печать «всего конфига» на старте
fmt.Printf("startup config: %v
", v.AllSettings())
// map[database:map[host:db.internal password:s3cret-2024 retries:3] server:...]

AllSettings собирает карту значений из всех источников, и пароль из файла входит в неё как обычный ключ. Printf с глаголом %v печатает карту целиком — вместе с database.password. Дальше строка уходит в stdout контейнера, сборщик логов размазывает её по индексам и репликам, и вычистить её оттуда куда неприятнее, чем изначально держать секрет в env деплоя.

Где живут секреты: окружение деплоя#

Куда класть вместо файла — туда, где уже живёт деплой. Три места без изобретения велосипедов: переменные окружения контейнера — docker run -e, env_file в compose, env в манифесте Kubernetes; Secrets Kubernetes, подставляемые в переменные контейнера через valueFrom.secretKeyRef; и EnvironmentFile в systemd — файл с парами KEY=VALUE вне репозитория, который юнит читает на старте. Общее у всех трёх: секрет назначается платформе деплоя, а не git, и до процесса доходит только через память процесса — в рабочей копии файл с паролем не появляется. Viper забирает секрет из окружения штатной лестницей из уроков про AutomaticEnv и префиксы: SetEnvPrefix задаёт неймспейс, SetEnvKeyReplacer превращает ключи в имена переменных, AutomaticEnv проверяет окружение на каждом чтении, а BindEnv привязывает явно — для имён вроде DB_PASSWORD, которые не выводятся из ключа. Контракт получается честный: ключ в коде есть, значения в репозитории нет.

Каркас чтения секрета из окружения

GO
v := viper.New()

v.SetEnvPrefix("APP")                                 // APP_ перед именами
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()                                      // APP_DATABASE_HOST и соседи

// имя, которое не выводится из ключа, привязываем явно
v.BindEnv("database.password", "APP_DB_PASSWORD")

// запуск: APP_DB_PASSWORD=... ./app
if !v.IsSet("database.password") {
    log.Fatal("секрет database.password не задан: проверьте env деплоя")
}

Секрета в файле нет, поэтому единственный источник — окружение. Ключи вроде database.host AutomaticEnv находит сама по имени APP_DATABASE_HOST, а короткое APP_DB_PASSWORD для database.password привязывается явно через BindEnv. IsSet на старте — минимальная проверка, что секрет вообще пришёл: молчаливая пустая строка вместо пароля всплывёт только на первом коннекте. Проверьте каркас локальным Go: без переменной процесс падает на старте, с ней — работает.

Перечитывание секрета: viper не кэширует окружение#

Механика, важная именно для секретов: viper не снимает копию переменной в момент BindEnv или AutomaticEnv — документация фиксирует прямо, что окружение не кэшируется и значение читается при каждом обращении. Для настроек это деталь, для секрета — инструмент. Окружение процесса фиксируется при старте контейнера, но обновить его изнутри можно: os.Setenv меняет память процесса, и следующий Get вернёт уже новое значение. Отсюда рабочий паттерн доставки свежего пароля без рестарта: платформа деплоя кладёт секрет в смонтированный файл, Kubernetes умеет обновлять такие файлы на лету, а маленькая горутина в сервисе перечитывает файл и обновляет переменную через os.Setenv — viper на следующем чтении отдаёт новый секрет, и соединение переподнимается. Это не замена ротации, а механизм доставки новой пары после неё.

Новый секрет без рестарта

GO
v.BindEnv("database.password", "APP_DB_PASSWORD")

fmt.Println(len(v.GetString("database.password")), "байт") // 16 байт

// ротация прошла: горутина перечитала смонтированный секрет
os.Setenv("APP_DB_PASSWORD", "n3w-pass-w0rd-2024-secure")

fmt.Println(len(v.GetString("database.password")), "байт") // 25 байт — свежий

В отладочной печати — длина значения, а не сам секрет: привычка, которая спасает от утечки через лог. Между двумя Println переменная процесса обновилась через os.Setenv, и чтение по привязанному ключу вернуло новое значение — копии окружения в реестре нет. Воспроизведите локальным Go: два разных значения одной и той же строки кода чтения, без пересоздания реестра и без рестарта.

Где живёт секрет и как доходит до кода#

Место храненияКто задаётПуть до кода
Переменные контейнераdocker run -e, compose, манифест k8senv процесса: AutomaticEnv или BindEnv
Secret Kubernetesманифест или менеджер секретовvalueFrom.secretKeyRef в env контейнера
systemd EnvironmentFileфайл вне репозитория на хостепеременные процесса юнита на старте
KV-хранилище: etcd, Consulоператоры через ACLremote-провайдер viper — тема урока 21
config.yaml в репозиторииникто — и это проблеманикогда: место секрета не здесь

Что делать, если пароль уже в истории git#

Пусть пароль всё-таки закоммитили — неделю назад, отдельным коммитом с внятным diff. Первое действие — ротация: смена скомпрометированного пароля в базе, у провайдера, в панели API. Ротация закрывает саму дыру — даже если секрет у кого-то есть, он уже бесполезен. Всё остальное — гигиена, а не защита. Чистка истории делается инструментами вроде git filter-repo или BFG: они переписывают коммиты, убирая файл или строку из всех ревизий, после чего история меняет хеши и нужен force push. И здесь нужна честность, которую неудобно проговаривать: переписанная история не отзывает старые копии. У каждого, кто клонировал репозиторий до чистки — коллеги, CI, форки, — старая история со старым паролем остаётся, и исправить это вы не можете ничем. Поэтому порядок именно такой: ротация, затем поиск всех мест, где секрет засветился, и только потом переписывание истории — ради новых клонов, а не ради иллюзии, что секрет «удалён».

План действий после утечки секрета#

  • Сменить пароль или токен: ротация всегда первая — чистка истории никогда её не отменяет.
  • Найти, где ещё живёт секрет: история git, собранные логи, бэкапы, вложения тикетов, кэши CI.
  • Переписать историю git filter-repo или BFG и заранее согласовать force push с командой.
  • Попросить всех, кто клонировал репозиторий, пере-клонироваться — и принять, что проверить это невозможно.
  • Просмотреть журналы базы и провайдера за период утечки на следы использования старого ключа.
  • Закрыть путь на будущее: секреты идут через env деплоя, в репозитории — только нечувствительные настройки.

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

В config.yaml нашли закоммиченный месяц назад пароль базы. Что нужно сделать первым делом?

Проверка: перечитывание окружения

Ротация прошла, горутина сервиса обновила переменную через os.Setenv. Что вернёт следующий viper.GetString по BindEnv-ключу?

Что выведет программа?

Прочитайте код и выберите, что он выведет:

// configs/config.yaml: database.password: "from-file"

v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
v.ReadInConfig()

v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// запуск: DATABASE_PASSWORD=from-env ./app

fmt.Println(v.GetString("database.password"))

Соберите чтение секрета

Допишите каркас: неймспейс окружения, явную привязку имени переменной и включение проверки env на каждом чтении.

v.SetEnvPrefix("APP") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.() v.("database.password", "APP_DB_PASSWORD") if !v.("database.password") { log.Fatal("секрет database.password не задан") }

Соберите секцию подготовки реестра

Расставьте строки: отдельный реестр, префикс окружения, замена разделителей, включение env, явная привязка секрета и проверка наличия.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: секреты конфигурации

Термин

Почему пароль не кладут в config.yaml

нажмите, чтобы перевернуть
Определение

Файл навсегда попадает в git-историю, разъезжается с клонами и бэкапами, а дампы вроде AllSettings уносят его в логи. Отозвать копии после утечки невозможно — ротация закрывает доступ, но не возвращает секрет.

1 / 6

Спор: окружение деплоя или хранилище секретов#

Моё правило, с которым коллеги спорят регулярно: большинству сервисов секретам достаточно окружения деплоя, и выделенные хранилища не нужны. Env уже управляется платформой, история значений живёт в истории манифестов, ещё одной системы в эксплуатации не появляется, и viper читает секреты без единой дополнительной зависимости. Плата проста: ротация руками и никакого журнала, кто и когда значение доставал. Хранилище оправдано, когда сервисов много, секреты меняются по расписанию, нужен аудит доступа и права раздаются по ACL — тогда viper подключается к etcd или Consul через remote-провайдер: AddRemoteProvider и AddSecureRemoteProvider с шифрованным содержимым описаны в документации, а подробный разбор — в ближайшем уроке про удалённые хранилища. И даже там останется bootstrap-секрет, доступ к самому хранилищу, который живёт в env: у дороги всегда есть первый метр, который ничем другим не закрыть. Не стройте хранилище секретов ради одного пароля от базы — окружение справится.

Секреты закрыты: пароли живут в окружении деплоя, viper достаёт их BindEnv и AutomaticEnv, дампы конфигурации замаскированы, а на случай утечки есть план, где первой строкой идёт ротация. Осталась незакрытой другая боль — сам viper расползся по коду: GetString с одними и теми же ключами в десятке файлов, и каждое переименование превращается в квест по репозиторию. Следующий урок собирает собственный пакет config: Load() возвращает готовую структуру, строковые ключи остаются внутри одного файла, а весь остальной код видит только поля. Общий маршрут по темам — на странице курса viper. И проверьте прямо сейчас: git log -p по вашему config.yaml — нет ли в истории того, что следовало держать в env деплоя.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Секреты конфигурации Go не живут в config.yaml: файл навсегда попадает в git-историю, разъезжается с клонами и бэкапами и светится в дампах.
  • AllSettings и WriteConfig печатают и записывают секреты как обычные ключи — маскируйте значения при любой отладочной печати конфигурации.
  • Место секрета — окружение деплоя: переменные контейнера, Secrets Kubernetes через valueFrom.secretKeyRef, EnvironmentFile в systemd.
  • viper читает секреты штатной лестницей: SetEnvPrefix, SetEnvKeyReplacer, AutomaticEnv, а имена, которые не выводятся из ключа, — BindEnv.
  • Окружение не кэшируется: значение читается при каждом Get, поэтому os.Setenv из горутины доставляет свежий секрет без рестарта.
  • При утечке первым делом ротация: чистка истории git filter-repo не отзывает старые клоны и форки.
  • KV-хранилища вроде etcd и Consul оправданы при многих сервисах и ротации по расписанию; для одного пароля достаточно env деплоя.

Домашнее задание

Заведите в каркасе из прошлого урока секрет DATABASE_PASSWORD через EnvironmentFile или переменную compose, уберите его из config.yaml, добавьте BindEnv и IsSet-проверку на старте. Затем найдите в истории репозитория git log -p все коммиты, где пароль появлялся, и составьте план ротации на случай, если он там всё-таки есть.

Личные заметкиПусто
Хранятся только в вашем браузере (Viper · Секреты: почему пароли не живут в YAML)

Вопросы и ответы по уроку

Можно ли шифровать config.yaml и держать секрет в репозитории?

Технически — да: SOPS, git-crypt и ключи в переменных деплоя. Но появляется ещё один секрет — ключ шифрования, который нельзя потерять, и ещё один инструмент в цепочке. Для одного пароля окружение деплоя проще и надёжнее; шифрование оправдано, когда файл со значениями обязан лежать в git по регламенту.

Пустая переменная для секрета — это ошибка?

По умолчанию viper считает пустую переменную не заданной и проваливается к следующему источнику — файлу или дефолтам. Для секрета это опасно: IsSet может пройти по значению из файла. Задавайте AllowEmptyEnv осознанно или проверяйте переменную на непустоту в критичных местах.

Что делать с секретами в тестах и CI?

Тестовый контур — тоже деплой: тестовый пароль задаётся переменными окружения раннера, а не файлом в репозитории. Префикс контура из урока про профили помогает не перепутать: TEST_DATABASE_PASSWORD не совпадёт с боевой переменной, даже если манифест собран криво.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Chi25 уроков~9 чпрактика25 уроков уже доступны

Chi: роутер для Go HTTP-сервисов

Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.

  • Строить маршруты chi: методы, параметры пути, wildcards
  • Писать middleware и понимать порядок их выполнения
  • Группировать маршруты: Route, Group и подроутеры
  • Прокидывать данные в хендлеры через context.Context