Що ви подумаєте, якщо я вам скажу, що зберігати паролі, токени та іншу чутливу інформацію в репозиторії — нормально?

Звичайно, є одне “але”: нормально — коли значення зашифровані, а не лежать у відкритому вигляді. І це доволі легко зробити за допомогою SOPS.

Проблема, яку вирішує SOPS

Класична дилема з секретами виглядає так. Або секрети лежать у репозиторії в чистому вигляді — і тоді будь-хто з доступом до репо (а після першого ж витоку — будь-хто взагалі) читає ваші паролі від продакшн-бази. Або секрети живуть окремо від коду — у Vault, AWS Secrets Manager чи ще якомусь сховищі — і тоді починаються незручності:

  • конфіг і секрет до нього змінюються в різних місцях: PR окремо, секрет окремо;
  • історія змін розпадається на два паралельні треки, і “хто і коли поміняв цей токен” вже не видно в git log;
  • рев’ю конфігурації не бачить секретної частини змін;
  • для локальної розробки і CI потрібні окремі механізми доставки секретів.

А якщо ви живете за GitOps-підходом, то він взагалі вимагає, щоб усе було декларативним і лежало в git — включно з секретами. Але точно не у відкритому вигляді.

SOPS дозволяє всидіти на обох стільцях: секрети лежать у git поруч з конфігами, проходять той самий PR-процес — але в зашифрованому вигляді.

Що це таке

SOPS розшифровується як Secrets OPerationS. Інструмент з’явився у Mozilla ще у 2015 році, у 2023-му був переданий до CNCF як Sandbox-проєкт і зараз розвивається спільнотою під парасолькою getsops.

Технічно це утиліта-редактор для шифрування файлів: YAML, JSON, ENV, INI та бінарних. Але головна фішка, яка відрізняє SOPS від підходу “зашифрував файл цілком і закомітив блоб”:

SOPS шифрує значення, а не ключі. Структура файлу лишається читабельною — видно, які поля там взагалі є; diff у PR показує, яке саме поле змінилось; мерж-конфлікти лишаються керованими. Ось як виглядає зашифрований YAML:

database:
  host: db.internal
  username: app
  password: ENC[AES256_GCM,data:Ekb+Fbmz...,tag:...]

host і username — читабельні, бо вони не секрет. Зашифрований лише password — і на рев’ю видно, що змінився саме він.

Для самого шифрування SOPS підтримує кілька бекендів:

  • офлайн-ключі: age — сучасний і рекомендований за замовчуванням варіант, та PGP/GnuPG — легасі з відомими болями ротації ключів;
  • хмарні KMS: AWS KMS, GCP KMS, Azure Key Vault, HuaweiCloud KMS — зручно, коли доступ до секретів має контролюватись IAM-політиками хмари;
  • HashiCorp Vault / OpenBAO — transit-шифрування, якщо Vault у вас вже є.

Бекенди можна комбінувати: один файл може бути зашифрований одночасно, наприклад, age-ключем розробника і AWS KMS для CI — розшифрує будь-хто з них.

Як це виглядає на практиці

Спочатку — як це виглядає вживу: файл із секретами, правила в .sops.yaml, шифрування на місці та точкове розшифрування одного значення.

Демо SOPS у терміналі

Тепер по кроках. Генеруємо ключову пару age:

age-keygen -o key.txt
export SOPS_AGE_KEY_FILE=$PWD/key.txt

У корені репозиторію кладемо .sops.yaml — правила, що і яким ключем шифрувати:

creation_rules:
  - path_regex: .*\.yaml$
    encrypted_regex: ^(password|token)$
    age: age1... # публічний ключ з key.txt

encrypted_regex каже шифрувати лише поля password і token — решта лишається відкритим текстом. Маємо файл із секретами:

app:
  name: demo-app
  environment: production
database:
  host: db.internal
  username: app
  password: SuperSecret123!
api:
  url: https://api.example.com
  token: glpat-verySecretToken

Шифруємо на місці:

sops encrypt --in-place secrets.yaml

Після цього файл можна спокійно комітити: структура і несекретні поля читабельні, секретні значення — ENC[AES256_GCM,...], плюс у файлі з’являється службова секція sops з метаданими (зашифрований data-ключ, MAC для контролю цілісності).

Далі з файлом працюють так:

sops secrets.yaml            # відкриє розшифрований файл у $EDITOR,
                             # при збереженні зашифрує назад
sops decrypt secrets.yaml    # розшифрувати в stdout
sops decrypt --extract '["database"]["password"]' secrets.yaml  # одне значення

Тобто для людини з ключем робота з секретами майже не відрізняється від роботи зі звичайним файлом — шифрування прозоре.

Усі команди з цього демо лежать у моєму репозиторії скриптів до статей: gitlab.com/punkoivan/punka-space-scripts — можна відтворити локально, там же і VHS-сценарій, яким записана гіфка.

CI/CD та GitOps

Локальний age-ключ — це добре для демо і невеликих команд, але в пайплайнах зазвичай зручніше хмарний KMS: CI-джоба отримує роль/сервіс-акаунт з правом Decrypt на KMS-ключ і розшифровує секрети без жодного приватного ключа в змінних CI.

У Kubernetes-світі SOPS теж давно свій:

  • Flux підтримує SOPS з коробки — kustomize-контролер сам розшифровує маніфести перед застосуванням, є офіційний гайд;
  • ArgoCD нативної підтримки не має, але є усталені інтеграції через плагіни (ksops, helm-secrets).

В обох випадках схема однакова: секрети живуть у git як SOPS-зашифровані маніфести, а право на розшифрування має лише контролер у кластері.

А чому не Vault чи Sealed Secrets?

Коротке порівняння з альтернативами

Sealed Secrets (Bitnami) — Kubernetes-специфічне рішення: шифрує весь об’єкт Secret публічним ключем контролера в кластері. Працює добре, але тільки для K8s і тільки для Secret-об’єктів. SOPS універсальніший: будь-який YAML/JSON/ENV/INI, хоч values.yaml для Helm, хоч .env для docker-compose, і diff показує змінене поле, а не один суцільний блоб.

HashiCorp Vault — це взагалі інша вагова категорія: секрети не в git, динамічна видача креденшлів, ротація на льоту, детальний аудит-лог доступу. Але це й окремий сервіс, який треба розгортати, обслуговувати і берегти як зіницю ока.

SOPS — не заміна Vault. Це рішення для випадків, коли секрет має жити поруч з конфігом у git і проходити той самий review/PR-процес. Якщо вам потрібні динамічні креденшли і аудит кожного читання секрету — це до Vault. Якщо потрібно, щоб values.yaml з парою токенів безпечно лежав у репозиторії — SOPS значно дешевший у володінні.

Ризики та підводні камені

Куди ж без цього. SOPS знімає одні ризики, але додає нові точки, які варто врахувати в моделі загроз:

  • Втрата приватного ключа = втрата всіх зашифрованих секретів. Кнопки “забув пароль” тут немає. Резервні копії ключів мають існувати — і зберігатись окремо від репозиторію. Хороша практика — шифрувати кожен файл кількома отримувачами (наприклад, ключі кількох людей + KMS), щоб один втрачений ключ не став катастрофою.
  • .sops.yaml легко налаштувати неправильно. Якщо encrypted_regex не покриває якесь чутливе поле — воно поїде в git відкритим текстом, і виглядатиме це цілком легально: файл же “зашифрований”. Правила варто рев’ювити так само прискіпливо, як і самі секрети, а на CI можна додати перевірку, що у файлах немає незашифрованих підозрілих полів.
  • Право на розшифрування — це і є секрет. Доступ до KMS-ключа чи age-приватного ключа еквівалентний доступу до всіх секретів, які ним зашифровані. Тобто контроль доступу нікуди не зникає — він переїжджає на рівень керування ключами: IAM-політики на KMS, зберігання age-ключів, offboarding людей з їхніми ключами (до речі, після відкликання ключа секрети треба не лише перешифрувати, а й ротувати — старі значення людина вже бачила).
  • Секрет розшифровується там, де використовується. SOPS захищає секрети в репозиторії — але в рантаймі (CI-джоба, под у кластері) вони все одно з’являються у відкритому вигляді. Логи, дампи і артефакти пайплайнів лишаються класичними каналами витоку.

Підсумок

SOPS — простий спосіб зробити так, щоб секрети жили в git разом з конфігурацією: зашифровані значення, читабельна структура, робочі diff’и та звичний PR-процес. Поріг входу мінімальний — age-ключ і .sops.yaml на п’ять рядків, а далі можна дорощувати до KMS і GitOps-інтеграцій.

Головне пам’ятати: ви не позбулись задачі керування доступом — ви перенесли її з секретів на ключі. Але ключів сильно менше, ніж секретів, і керувати ними значно легше.

Скрипти з усіма командами зі статті — у punka-space-scripts.