[Информационная безопасность, PostgreSQL, Go] Избавляемся от паролей в репе с кодом с помощью HashiCorp Vault Dynamic Secrets

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
02-Июл-2021 16:31

Привет, Хабр! Меня зовут Сергей, я IT Head в компании Quadcode. Сегодня хотел бы рассказать о том, как я решил проблему с хранением паролей в открытом виде в коде одного из моих pet-проектов. Думаю, это знакомая для многих ситуация. Знакомая — и неприятная. Сразу скажу, что когда я начинал работу над проектом, ничего страшного в этом не видел, меня все устраивало. Но когда настало время подключать к разработке проекта кого-то извне, стало понятно, что хранить пароли в открытом виде небезопасно (да и перед контрибьюторами будет немного стыдно за такие банальные вещи) — это проблема. Вариантов решения было несколько. Под катом — рассказ о том, какое решение я выбрал и что получилось в итоге.
TL;DRКому не интересна сама история — вот спойлер, т.е. исходники приложения-примера. В важных местах я снабдил код уточняющими комментариями. Как запустить пример написано в README.md в корне репозитория с кодом.Выбор варианта решенияНадеюсь, саму проблему я описал достаточно понятно. Теперь требовалось найти её решение. Их было несколько, но почти каждое по той либо иной причине не подходило.Например, можно было просто вынести пароли в переменные окружения CI/CD системы и не ломать голову. В этом случае у меня не было уверенности в том, что пароль не утечёт через добавление в CI-пайплайн строки echo $DB_PASSWORD. Да и пароль всё равно надо было менять, так как он уже засветился в git history. И это надо будет делать каждый раз вручную.Еще один вариант — хранить пароль в зашифрованном виде вместе с кодом приложения. Но и здесь не все в порядке — ведь мы получаем ещё один ключ шифрования, который нужно как-то безопасно хранить и периодически ротировать. Так что этот вариант тоже не подходил.Стало понятно, что нужно устранять причину, корень проблемы, а не следствие. Хранение паролей в репозитории с кодом — как раз следствие самой возможности передавать имя пользователя и пароль через промежуточный этап, такой, как конфигурационный файл в репозитории с кодом. В этом случае учётные данные нужно получать напрямую из защищённого хранилища секретов по защищённому каналу связи. И никак иначе. Но и способов ликвидировать причину проблемы было несколько.Можно передавать учётные данные через переменные окружения, в которые оркестратор (в проекте это был Kubernetes) будет внедрять учётные данные полученные из защищённого хранилища секретов. Звучит неплохо, если бы не ситуация с отзывом скомпрометированных учётных данных. Учитывая возраст проекта, не было уверенности в том что приложение сможет корректно обработать такую ситуацию без дополнительных правок в коде, так как могли быть участки кода, которые могли интерпретировать отзыв учётных данных как временную проблему (такую как разрыв соединения с базой данных), а по сути приложение должно либо аварийно завершить свою работу либо пересоздать все соединения с базой с уже новыми учётными данными. Этот вариант тоже отвалился.Можно сделать так чтобы само приложение получало учётные данные из защищённого хранилища, а в случае сложностей с получением учётных данных аварийно завершало свою работу. Здесь нужно было вносить правки в код, в котором создаётся пул коннектов к базе данных. И это довольно простая задача, которая явно не влечёт за собой кучу регресса в бизнес-логике приложения. В целом, вариант подходящий, именно на нем я решил остановиться.Но и это не все. Следующий этап — выбор инструментария для реализации выбранного решения. Тут оказалось всё проще, так как в проекте уже был устоявшийся стек:
  • Golang — приложения проекта написаны на этом языке, поэтому естественно доработки тоже будут на этом языке.
  • Postgresql — это, собственно, СУБД с которой работают приложения.
  • HashiCorp Vault — это инструмент с открытым исходным кодом, который обеспечивает безопасный и надежный способ хранения и распространения секретов, таких как ключи API, токены доступа и пароли. Выбрал его по следующим причинам:
    • можно развернуть инсталляцию Vault на своих серверах;
    • есть интеграция с Kubernetes;
    • официальная Golang-библиотека для работы с API Vault;
    • подробная документация с простыми гайдами;
    • на моей текущей работе Security-отдел тоже использует Vault — это тоже повлияло на моё решение, так как Vault доверяют те люди которые имеют немалый опыт работы в роли Security Engineer.
Что же — приступимПосле беглого осмотра официальной документации по Vault был найден гайд по Database Dynamic Secrets. Как оказалось, есть возможность на лету создавать в базе данных пользователя с ограниченным сроком действия. Выглядит подходяще — учётные данные будут создаваться автоматически, так что нет физической возможности запушить пароль в git. Единственное — требуется решить, что делать приложению, когда срок жизни учётных данных истечёт.Вариант “поставить время жизни учётных данных в год” и надеяться, что процесс приложения успеет перезапуститься за год мне не подходил. Это, скорее, бомба с часовым механизмом, выставленным на год вперёд, а не решение. Еще одна проблема — за год в Vault может накопиться большое количество уже ненужных динамических секретов, а следовательно и большое количество ролей в Postgresql.Но сколько должны жить динамические учетные данные? Год — много. Одна минута — мало. Решение оказалось простым: пока жив процесс приложения, должны быть активны и учётные данные для этого процесса. В случае их компрометации можно просто перезапустить процесс, после чего он получит новые учётные данные.Итоговое решение: при запуске процесса приложения будут генерироваться динамические учётные данные со временем жизни 5 минут. Процесс периодически (чаще чем раз в 5 минут) будет производить продление (renew) аренды своих учётных данных, тем самым продлевая доступ к базе данных ещё на 5 минут. Перед завершением процесса учётные данные отзываются. Если процесс будет убит по kill -9, то учётные данные будут автоматически отозваны по истечении срока аренды самим Vault’ом. В случае отзыва учётных данных оператором, процесс должен аварийно завершить свою работу (после чего оркестратор автоматически запустит новый процесс).Выбираем схему и метод практической реализацииДля общего понимания решил сперва показать общие схемы по которым будет происходить взаимодействие между приложением, Vault и Postgresql.
Здесь важный момент, отсутствующий на схеме. В статье намеренно опущен процесс получения токена аутентификации для API Vault, так как растягивать текст и все усложнять не хотелось. Для простоты будем считать, что токен аутентификации приходит в приложение через переменную окружения VAULT_TOKEN, неважно каким образом - это тема отдельного материала. Для тех, кто любит детали оставлю ссылку на официальную документацию Vault, в которой описана, в общих чертах, проблематика получения аутентификационных токенов.Шаг 1: Готовим PostgresqlПо схеме видно, что всё зависит от Postgresql, потому начинаем именно с него. Тут особо чего-то нового не пришлось делать — в базе уже была создана отдельная роль для приложения и ей были выданы гранты на то, что ей можно делать в базе. Для простоты в данной статье роль называется app. Вот как приблизительно создавалась у нас эта роль:
CREATE ROLE app NOINHERIT; GRANT SELECT ON ALL TABLES IN SCHEMA public TO "app";
Единственное, что потребовалось дополнительно — создать в Postgresql отдельную роль для Vault с правами на создание ролей. Вот как выглядел SQL-запрос для этого:
CREATE ROLE vault-root-for-app WITH CREATEROLE NOINHERIT LOGIN PASSWORD 'password-was-removed-from-here';
Всё. Postgresql готов к интеграции с Vault.Шаг 2: Готовим VaultВключаем Vault движок для database secrets. Движок позволяет организовать упомянутую схему по получению динамических секретов для базы данных. Включение производится следующей командой:
vault secrets enable database
Далее создаём конфиг, при помощи которого Vault сможет создавать роли для динамических секретов. Как раз для этого ранее была создана роль vault-root-for-app.
vault write database/config/app-db plugin_name=postgresql-database-plugin connection_url="postgresql://{{username}}:{{password}}@postgres-host:5432/app-db?sslmode=disable" allowed_roles=app username="vault-root-for-app" password="vault-root-for-app-password"
Здесь хочу обратить внимание на то, что для каждой конфигурации лучше заводить отдельную роль в Postgresql для Vault (в примере выше это роль vault-root-for-app). Это обусловлено тем, что у Vault есть функция ротации пароля vault-пользователя в Postgresql (детали тут: https://learn.hashicorp.com/tutorials/vault/database-root-rotation). Если одна и та же роль будет использоваться в двух конфигурациях, то после ротации пароля в одной из конфигураций, другая перестанет работать. Проблема в том, что у неё будет информация только о пароле, который был до ротации. Отсюда вывод — для каждого конфига в Vault лучше заводить отдельную учётку с правами на создание ролей, чтобы избежать конфликтов в учётных данных.Идём дальше — создаем Vault-роль в которой default_ttl=5m. Это как раз та самая длительность аренды секрета, если её не продлевать конечно. У Vault-роли есть еще параметр max_ttl — максимальное время аренды секрета. Когда суммарно время жизни секрета достигает max_ttl, то секрет отзывается даже если мы продлили его аренду. Поэтому я специально не указал max_ttl, так как приложение может на протяжении месяцев продлевать аренду секрета и точное значение max_ttl никогда не угадать (UPD: дальше выяснится, что я ошибался на счёт того, что если не указывать max_ttl то время жизни секрета будет бесконечным).Если же добавлять в приложение логику, которая при приближении времени жизни секрета к max_ttl будет запрашивать новый секрет, то нужно будет решать вопрос с пересозданием текущих коннектов к базе, все еще работающих по истекающему секрету. Отсюда проблема — один из коннектов может выполнять долгий запрос и просто взять и закрыть его нельзя. Надо ждать пока он отработает, а к моменту завершения запроса секрет уже может быть отозван. Решили не усложнять логику приложения добавлением вышеописанной логики.Вот пример команды при помощи которой была создана Vault-роль:
vault write database/roles/app db_name=app-db creation_statements="CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' INHERIT; GRANT app TO "{{name}}";" default_ttl=5m
Всё. Vault настроен. Можно, наконец, приступать к написанию кода.Шаг 3: Пишем Golang-кодПервым делом подключаем официальный Golang-клиент по работе с API Vault:
go get github.com/hashicorp/vault/api
Далее в функции main создаём экземпляр Vault-клиента:
vaultClient, err := api.NewClient(api.DefaultConfig())
Здесь:
  • api.DefaultConfig использует значение переменной окружения VAULT_ADDR в качестве адреса API Vault-сервера. На самом деле это не адрес, а URL, так как перед адресом должен быть указан протокол по которому будет идти взаимодействие с API Vault. Варианта два: http:// или https://.
  • api.NewClient использует значение переменной окружения VAULT_TOKEN в качестве токена аутентификации в API Vault.
Функции из примера выше смотрят ещё на ряд переменных окружения, но пока достаточно только двух. Остальные можно посмотреть в коде Golang-клиента на github.Дальше по коду запрашиваем динамический секрет у Vault:
dbSecret, err := vaultClient.Logical().Read(dbSecretPath)
В переменной dbSecretPath хранится путь со следующим форматом database/creds/{vault-role-name}. В нашем примере в этой переменной будет значение database/creds/app. database/creds/ - это путь к API, который выдаёт динамические секреты.Далее регистрируем defer-функцию, которая будет отзывать секрет перед выходом из функции main:
defer func() {
  err := vaultClient.Sys().Revoke(dbSecret.LeaseID)
  if err != nil {
    log.Printf("Revoke error: %s", err.Error())
  }
}()
Затем мне пришлось немного покопаться в коде Vault-клиента, чтобы понять как можно организовать процесс продления аренды секрета. Наткнулся на такую вещь как Renewer. Посмотрев исходники и пример кода у типа Renewer, понял, что это точно то что нужно, и я ни с чем другим не перепутал этот тип. Вот как выглядит код который задействует Renewer:
dbSecretRenewer, err := vaultClient.NewRenewer(&api.RenewerInput{
  Secret: dbSecret,
})
if err != nil {
  panic(err)
}
go dbSecretRenewer.Renew()
defer dbSecretRenewer.Stop()
go func() {
  for {
    select {
    case err := <-dbSecretRenewer.DoneCh():
      if err != nil {
        log.Printf("Secret renewer done error: %s", err.Error())
      }
      log.Printf("Secret renewer done")
      sigChan <- syscall.SIGTERM
      return
    case renewal := <-dbSecretRenewer.RenewCh():
      log.Printf("Database secret has been renewed at %s\n", renewal.RenewedAt.Format(time.RFC3339))
    }
  }
}()
По сути здесь создается экземпляр Renewer. Он запускает отдельную горутину, которая занимается процессом продления аренды секрета. Сначала регает defer, останавливающий процесс продления перед выходом из функции main. А напоследок создаёт горутину, которая слушает два канала в цикле:
  • Канал RenewCh, который сигнализирует о том, что аренда секрета была успешно продлена. Тут всё просто: пишем в лог факт успешного продления аренды.
  • Канал - DoneCh, который сигнализирует о том, что Renewer завершил свою работу с ошибкой или без ошибки. Здесь есть очень важный момент — если Renewer завершил свою работу с ошибкой, то приложение должно начать процесс аварийного завершения. Проблема в том, что продлить аренду секрета не удалось, так что секрет в любой момент может быть отозван. А это, в свою очередь, повлечёт за собой ошибки аутентификации при создании новых соединений с базой данных, так как в базе данных уже нет информации об отозванной роли выданной приложению.
Я настоятельно рекомендую не просто писать в лог сообщение с ошибкой, а сигнализировать приложению, что пора начинать процесс аварийного завершения. В коде выше у меня был terminate-канал, в который вбрасывался SIGTERM, и процесс думал, что ему пришёл SIGTERM от операционной системы и завершал свою работу.Да, это не аварийное завершение, но если вам нужно завершаться как-то по особенному в случае проблем с продлением секрета, то можете вбрасывать сигнал SIGUSR1, например, реагируя так, как считаете нужным. В моём случае стандартной логики graceful shutdown было достаточно.Ещё я немного попараноил: глянул код Renewer на готовность к кратковременным отказам сети (не дольше секунды), так как без этого любое моргание сети в момент продления аренды будет приводить к аварийному завершению приложения. Обычно в таких случаях делают серию повторных запросов с прогрессивной/линейной задержкой. В коде Renewer’а такой логики не нашёл, но зато нашёл её чуть ниже — на уровне Vault HTTP Client. По-умолчанию HTTP Client делает 2 попытки с минимальной задержкой в 1000ms между попытками. В общем, к кратковременным сетевым скачкам Renewer готов.После выполнения всего, что описано выше я понял, что близок к финалу. Остался последний штрих — поменять код, который создаёт пул коннектов к Postgresql. Нужно сделать так ,чтобы username и password брались теперь не из connection string, а из полученного секрета. Вот какой код получился:
dbConfig, err := pgxpool.ParseConfig(dbConnString)
if err != nil {
  panic(err)
}
dbConfig.ConnConfig.User = dbSecret.Data["username"].(string)
dbConfig.ConnConfig.Password = dbSecret.Data["password"].(string)
db, err := pgxpool.ConnectConfig(ctx, dbConfig)
Теперь точно все, код готов! Дальше уже идёт бизнес-логика приложения, а она у каждого своя.UPD: апокалипсис 768 часов спустяСпустя 32 дня с момента деплоя этого решения меня поджидал небольшой сюрприз. Все процессы проекта аварийно завершили свою работу — и не удалось продлить аренду секрета. Повезло, что оркестратор автоматически перезапустил процессы и они продолжили свою работу с уже новыми свежими секретами.Ларчик открывается просто — у Vault есть глобальная настройка с названием max_lease_ttl, которая по-умолчанию имеет значение 768h. Я наивно думал, что если у секрета не указывать max-ttl, то секрет можно будет продлевать сколько угодно раз. Но Vault очень строг в плане максимального времени жизни секретов, так что эту настройку никак нельзя выставить в бесконечность.Скорее всего ее ввели для того, чтобы нерадивые разработчики не могли настрогать кучу секретов с ttl в 100 лет и забить такими токенами всё хранилище Vault.Поизучав код Vault, понял, что никакой лазейки для обхода max_lease_ttl нет. Так что я пошёл на сделку с совестью и увеличил max_lease_ttl до 43800h (5 лет), считая что ни один процесс столько не проживёт. А если и проживёт, то аварийно завершится и запустится опять.Напоследок интересный факт: даже если роль в Postgresql уже истекла, то созданные соединения от этой роли продолжают работать, через них можно продолжать выполнять SQL-запросы. Но вот новые соединения под этой ролью естественно уже создать не получится.Идея пошла в массыПосле того, как работа была закончена, я поделился полученным опытом с Security Engineer’ами из моей же компании, поинтересовавшись их мнением. Вот их ответ:Hashicorp Vault - действительно гибкий инструмент, который позволяет решить множество вопросов безопасности проектов, а особенно хранение и ротация секретов. Использование Vault для хранение секретов в компании на текущий момент является опциональным требованием и основных причин у этого несколько:
  • Первая и главная — разнообразие пайплайнов команд разработки, что усложняет унификацию подхода к работе с секретами в Vault.
  • Вторая — недостаток ресурсов в команде безопасности для поддержки Vault и оказания услуг командам разработки по управлению секретами.
Сейчас в компании есть несколько вариантов хранения секретов:
  • Vault, за которым присматривают системные администраторы.
  • Переменные окружения в Gitlab.
Стоит отметить, что будущее мы связываем с Vault и в стратегии развития есть цель полноценного внедрения этого инструмента и создание процесса работы с секретами вокруг него.ЗаключениеЕсли вы решите применить подобное решение на своих проектах, то особое внимание нужно обратить вот на что:
  • Для каждого database-конфига в Vault лучше заводить отдельную учётку с правами на создание ролей, чтобы избежать конфликтов в учётных данных при ротации пароля учётки. Не должно быть два database-конфига использующих одно и тоже имя пользователя.
  • Аутентификационный токен для доступа к API Vault (тот что в env-переменной VAULT_TOKEN), нужно тоже безопасно доставлять до приложения. Это тема отдельной статьи, если бы я начал рассказывать и об этом, статья превратилась бы в чудовищный лонгрид.
  • Если вас напрягает то, что каждые 32 дня ваше приложение будет перезапускаться из-за достижения максимального срока аренды, для max_lease_ttl придётся выставить значение побольше или написать механизм переключения коннектов на новые учётные данные.

===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_informatsionnaja_bezopasnost (Информационная безопасность), #_postgresql, #_go, #_golang, #_hashicorp, #_vault, #_postgres, #_postgresql, #_go, #_dynamic_secrets, #_dinamicheskie_sekrety (динамические секреты), #_credentials_auto_creation, #_avtomaticheskoe_sozdanie_parolja (автоматическое создание пароля), #_blog_kompanii_quadcode (
Блог компании Quadcode
)
, #_informatsionnaja_bezopasnost (
Информационная безопасность
)
, #_postgresql, #_go
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 17-Май 16:02
Часовой пояс: UTC + 5