Секреты в Helm: helm-secrets, sops, внешние

Самая частая дыра в безопасности Helm — пароли в values под git. Разбираем, как делать правильно.

Стандартный Helm-Secret в кластере хранится в base64, а не зашифрованным. А пароли в values.yaml под git — это утечка. Решения: шифровать values (sops/helm-secrets) или вообще не хранить секреты в Helm (внешние сторы).

В чём проблема

Соблазн прост: положить пароль в values/prod.yaml и закоммитить. Последствия: (1) пароль виден всем с доступом к репозиторию и в истории git навсегда; (2) даже Kubernetes-объект Secret кодируется base64, а не шифруется — kubectl get secret -o yaml | base64 -d вернёт пароль. Helm сам по себе шифрования секретов не предоставляет.

Особенно коварен пункт про историю git. Многие думают, что достаточно удалить пароль из файла следующим коммитом — но git хранит каждую ревизию, и старое значение остаётся доступным через git log -p навсегда, пока историю не перепишут force-push'ем (что в общем репозитории почти нереально). Поэтому единственный по-настоящему правильный ответ на «закоммиченный пароль» — считать его скомпрометированным и ротировать, а не «убрать из файла». Это и объясняет, почему вопрос секретов решают до первого коммита, а не после инцидента.

Стоит уточнить и природу base64 в Kubernetes Secret. Это не «слабое шифрование», а вообще не шифрование — лишь кодировка для безопасной передачи бинарных данных в YAML. Любой, у кого есть права get secret в namespace, или доступ к снапшоту etcd, читает значение открыто. Защита здесь — это RBAC (кому вообще можно читать Secret'ы), encryption-at-rest для etcd (чтобы дамп диска не выдал секреты) и подходы из этого урока, которые не дают паролю попасть в git в принципе.

Подход 1: шифровать values через sops + helm-secrets

sops (Secrets OPerationS) шифрует значения в YAML/JSON, оставляя ключи читаемыми, ключами из KMS/age/PGP. Плагин helm-secrets интегрирует sops в Helm: зашифрованный файл лежит в git, а Helm расшифровывает его на лету при деплое.

helm plugin install https://github.com/jkroepke/helm-secrets

# зашифровать файл с секретами (значения станут шифротекстом)
sops --encrypt secrets.prod.yaml > secrets.prod.enc.yaml

# деплой: helm-secrets расшифрует на лету
helm secrets upgrade --install web ./webapp -n prod   -f values/prod.yaml -f secrets.prod.enc.yaml

В git попадает только secrets.prod.enc.yaml с зашифрованными значениями — ключи видны, пароли нет. Расшифровать может лишь тот, у кого есть доступ к KMS-ключу. Это «GitOps-дружественный» способ: секреты версионируются, но безопасно.

Почему именно «значения, а не файл целиком»

Ключевое инженерное решение sops — шифровать каждое значение по отдельности, оставляя структуру и ключи карты открытыми. Это даёт два важных свойства. Во-первых, осмысленный git diff: вы видите, что менялся именно db.password, а не «весь файл стал другим бинарным блоком», что бесценно при ревью pull request'ов. Во-вторых, sops дописывает в конец файла служебный блок sops: с метаданными — каким ключом (age, PGP, AWS/GCP KMS) зашифровано и контрольными суммами. Благодаря MAC-сумме sops замечает, если кто-то вручную подправил зашифрованный файл, и отказывается его расшифровывать — это защита от тихой порчи секретов.

Управление доступом здесь сводится к управлению ключами расшифровки, а не к правам на файл. В .sops.yaml описывают правила: какие файлы каким получателям (recipients) шифровать. Добавить нового инженера в команду — значит перешифровать файлы на его публичный ключ (одной командой sops updatekeys); отозвать доступ уволенному — убрать его ключ и перешифровать. Сам пароль при этом менять не обязательно, потому что в git он всегда лежал зашифрованным.

Подход 2: внешние секрет-сторы

Более зрелый путь — не держать секреты в Helm вообще. Секреты живут в специализированном хранилище (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager), а в кластер их доставляет оператор. Самый популярный — External Secrets Operator: вы описываете ExternalSecret, который ссылается на ключ во внешнем сторе, а оператор создаёт из него обычный Kubernetes Secret.

# ExternalSecret в чарте: значение тянется из Vault, не из values
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: db-credentials
spec:
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: db-credentials
  data:
    - secretKey: password
      remoteRef:
        key: prod/db
        property: password

Чарт не содержит пароля — только ссылку. Это убирает секрет из git и из values полностью.

У этого подхода есть свойства, которых нет у sops. Главное — ротация без передеплоя: оператор периодически синхронизирует значение из стора, поэтому при смене пароля в Vault обновится и Kubernetes Secret, а вам не нужно перешифровывать файл и катить новый релиз. Это особенно ценно для динамических секретов (например, временные креды БД, которые Vault выдаёт на час). Минус — цена входа: нужно поднять и сопровождать сам стор (Vault — нетривиальная инфраструктура с собственными unseal-ключами и HA), настроить аутентификацию кластера в нём (обычно через ServiceAccount-токены) и развернуть оператор. Поэтому маленьким проектам sops чаще выгоднее, а External Secrets раскрывается на зрелой инфраструктуре с десятками сервисов и требованием аудита доступа к секретам.

Важно не путать слои: ExternalSecret в итоге всё равно создаёт обычный Kubernetes Secret в кластере. То есть от base64 в etcd этот подход сам по себе не спасает — encryption-at-rest и RBAC по-прежнему нужны. Его ценность в другом: секрет никогда не лежит в git и в values, источником истины служит защищённый стор, а в кластер значение попадает контролируемо и обновляемо.

Сравнение подходов

ПодходГде живёт секретКогда
sops/helm-secretsзашифрован в gitGitOps, нет внешнего стора
External Secrets / Vaultво внешнем хранилищезрелая инфраструктура, много секретов

Как helm-secrets работает под капотом

Плагин оборачивает обычные команды Helm. Перед рендером он находит файлы значений, помеченные как зашифрованные, вызывает sops для их расшифровки во временный файл в памяти/на диске, подставляет расшифрованные значения в обычный -f, запускает Helm, а затем удаляет временные данные. Ключевой момент: расшифрованные секреты существуют лишь на время команды и в git не попадают. sops, в свою очередь, шифрует только значения, а ключи карты оставляет открытыми — поэтому diff зашифрованного файла читаем (видно, какие ключи менялись), а сами секреты защищены KMS-ключом, к которому привязан файл.

Частые ошибки

  • Пароль в открытом values под git. Главная ошибка; даже удалив его потом, он остаётся в истории git.
  • Считать Kubernetes Secret шифрованием. Это base64; включайте encryption-at-rest в etcd и/или внешние сторы.
  • Коммитить расшифрованный временный файл. Держите *.dec.yaml в .gitignore.

Итог

  • Helm не шифрует секреты; values под git и base64-Secret — не защита.
  • sops + helm-secrets шифруют значения в git (ключи открыты, пароли — нет) — GitOps-дружественно.
  • External Secrets/Vault убирают секрет из Helm вовсе, оставляя в чарте лишь ссылку.
Проверьте себя
1. Почему обычный Kubernetes Secret — не шифрование?
AОн шифрован сильно
BЗначения в нём всего лишь base64, легко декодируются
CОн не хранит данные
DЕго нельзя прочитать
2. Что шифрует sops в файле секретов?
AВесь файл целиком
BТолько значения, оставляя ключи карты читаемыми для понятного diff
CТолько имена файлов
DНичего
3. В чём идея подхода External Secrets / Vault?
AХранить пароли в values
BНе держать секрет в Helm вовсе — чарт ссылается на внешний стор, оператор создаёт Secret
CШифровать чарт
DОтключить секреты