Секреты и ключи: хранение, ротация, Vault, переменные окружения
Любая нетривиальная система знает что-то, чего не должна знать её среда: пароль к базе, приватный ключ подписи, ключ платёжного шлюза, токен внешнего API. Это знание — единственное, что отличает легитимный процесс от чужого. Всё остальное — сетевые правила, аутентификация пользователей, авторизация — можно обойти, если у атакующего в руках оказался тот же самый секрет, что и у вашего сервиса: с точки зрения системы он и есть ваш сервис.
Отсюда странность дисциплины: управление секретами — не про криптографию (её в статье будет минимум, глубже — в Прикладная криптография), а про логистику. Про то, как значение попадает из места хранения в память процесса, не оставив по дороге ни одной копии; как его заменить, не уронив прод; как узнать, что оно утекло; и как сделать так, чтобы утёкшее перестало работать раньше, чем им воспользуются.
Статья написана с позиции защищающейся стороны. Уязвимые конфигурации показаны, чтобы вы узнали их в своём репозитории и починили, а не чтобы искать их у других. Любые проверки — сканирование истории git, попытки прочитать окружение процесса, разбор образов — только на системах, которыми вы владеете или на которые есть письменное разрешение владельца с зафиксированным объёмом работ. В OWASP Top 10 2021 тема проходит сразу по двум пунктам: A02 «Cryptographic Failures» и A05 «Security Misconfiguration» (см. OWASP Top 10), базовые CWE — CWE-798 «Use of Hard-coded Credentials», CWE-522 «Insufficiently Protected Credentials», CWE-312 «Cleartext Storage of Sensitive Information».
Что считать секретом
Секрет — значение, знание которого даёт доступ или полномочия и которое нельзя восстановить из открытых данных. Практический критерий один: если это значение попадёт в публичный репозиторий, придётся что-то экстренно менять — значит, секрет.
Правая нижняя ветка важна не меньше остальных. Внутренний DNS-адрес или номер облачного аккаунта — не секрет: система, безопасность которой держится на их незнании, сломана по построению (принцип Керкгоффса). Смешивая псевдосекреты с настоящими, вы получаете раздутое хранилище, где никто уже не понимает, что действительно критично, и ротация превращается в ритуал.
Полезно классифицировать по двум осям — радиус поражения (что получит владелец значения) и стоимость замены (насколько больно ротировать).
| Секрет | Радиус поражения | Стоимость ротации | Целевой срок жизни |
|---|---|---|---|
| Пароль к продовой БД | вся пользовательская база | средняя, нужен перезапуск или reload | часы, если динамический |
| Ключ подписи JWT | подделка любой сессии | высокая, ломает живые токены | недели, с перекрытием версий |
| Ключ CI к реестру образов | подмена артефактов | низкая | минуты, OIDC-федерация |
| Приватный ключ TLS | MITM на домене | средняя, нужен новый сертификат | 90 дней и меньше |
| Корневой ключ KMS | всё зашифрованное | очень высокая | годы, с версионированием |
| Токен вебхука платёжки | подделка уведомлений об оплате | низкая | месяцы, с перекрытием |
Строка про ключ подписи JWT показывает, почему ротация — часть проектирования, а не операционная рутина: если формат токена не предусматривает kid, сменить ключ без разлогинивания всех пользователей нельзя (подробно — в JWT и токены).
Модель угроз: где секрет оседает
Секрет утекает не потому, что кто-то «взломал хранилище». Он утекает потому, что по пути от хранилища до места использования система делает десяток невидимых копий, и каждая живёт по своим правилам.
Разберём неочевидные ячейки этой карты.
Git не забывает. Коммит с секретом, «удалённый» следующим коммитом, остаётся объектом в истории и уезжает на все клоны и форки. Даже после git filter-repo и force-push объект может жить в кэше платформы, в открытых PR и в чужих локальных копиях. Отсюда правило номер один при утечке: сначала ротация, потом уборка. Уборка без ротации бесполезна, ротация без уборки — уже работающая защита.
Слои образа неизменяемы. ARG TOKEN в Dockerfile попадает в метаданные слоя и виден любому, кто может скачать образ (docker history, распаковка слоёв). RUN echo $TOKEN > /tmp/f && ... && rm /tmp/f не помогает: удаление в следующем слое не стирает содержимое предыдущего. Правильный инструмент — BuildKit secret mount, монтирующий значение в tmpfs на время одной инструкции.
Логи наблюдаемости — самый недооценённый канал. Токен в query-строке попадает в access-логи балансировщика, в трассировки APM и в метки метрик; локальные переменные фрейма уезжают в сервис сборки ошибок вместе со стектрейсом. Требование «не логировать секреты» невыполнимо вручную — его нужно закрывать типами (см. ниже) и фильтрами на стороне коллектора.
Дампы и бэкапы живут дольше секрета. Снапшот БД полугодовой давности содержит хеши, ключи и токены на момент снятия. Если ротация не сопровождается инвентаризацией «а где ещё лежит старое значение», то старый ключ остаётся валидным ровно там, где про него забыли.
Антипаттерн нулевой: секрет в исходном коде
Начнём по общей схеме трека: уязвимый код → почему это работает → как чинить → как проверить.
Уязвимо:
// config.go — так делать нельзя
package config
// Значение попадает в git, в бинарник, в образ, в дамп памяти.
// Ротация требует пересборки и передеплоя всех потребителей.
const DBPassword = "S3cretProd!2024"
const StripeKey = "sk_live_51Hx9..."
var Fallback = getenvOr("APP_HMAC_KEY", "dev-default-key-please-change")
Почему это работает против вас. Константа в коде видна всем, у кого есть доступ на чтение репозитория — а это обычно вся команда, подрядчики, CI-раннеры, зеркала и резервные копии. Строка остаётся в скомпилированном бинарнике и извлекается тривиально (strings), то есть попадает ещё и ко всем, кто может скачать образ. Отдельный подвох — getenvOr с дефолтом: в проде переменную забудут выставить, приложение молча поднимется на общеизвестном значении разработки, и подпись cookie или вебхуков станет подделываемой любым читателем репозитория (CWE-1188, небезопасная инициализация по умолчанию).
Чиним:
package config
// Secret — обёртка, которая физически не умеет печататься:
// Stringer, GoStringer, json.Marshaler и TextMarshaler переопределены,
// поэтому значение не просочится ни в fmt, ни в лог, ни в сериализацию.
type Secret struct{ v string }
func (s Secret) String() string { return "[REDACTED]" }
func (s Secret) GoString() string { return "[REDACTED]" }
func (s Secret) MarshalJSON() ([]byte, error) { return []byte(`"[REDACTED]"`), nil }
func (s Secret) MarshalText() ([]byte, error) { return []byte("[REDACTED]"), nil }
func (s Secret) Expose() string { return s.v } // единственная точка доступа
// Provider абстрагирует источник: Vault, KMS, файл на tmpfs.
type Provider interface {
Get(ctx context.Context, name string) (Secret, error)
}
// Load — fail fast: отсутствующий секрет означает отказ старта,
// а не тихий переход на дефолт разработки.
func Load(ctx context.Context, p Provider) (*Config, error) {
var c Config
for name, dst := range map[string]*Secret{
"db/password": &c.DBPassword, "stripe/live_key": &c.StripeKey,
} {
s, err := p.Get(ctx, name)
if err != nil || s.v == "" {
return nil, fmt.Errorf("секрет %s недоступен: %w", name, err)
}
*dst = s
}
return &c, nil
}
Приём с типом-обёрткой стоит переоценить: он превращает организационное правило «не логируйте секреты» в свойство системы — log.Printf("config=%+v", cfg) больше не может вас предать. Аналоги: SecureString в .NET, pydantic.SecretStr в Python, secrecy::Secret в Rust.
Как проверить, что починено:
# 1. В собранном бинарнике не должно быть боевых префиксов ключей.
strings ./app | grep -E 'sk_live_|AKIA[0-9A-Z]{16}|-----BEGIN .*PRIVATE KEY' && echo "НАЙДЕНО"
# 2. Вся история репозитория, а не только HEAD.
gitleaks detect --source . --log-opts="--all" --redact --exit-code 1
# 3. Приложение обязано падать без секретов, а не подниматься на дефолтах.
env -i ./app; test $? -ne 0 && echo "fail-fast работает"
Плюс регрессионный тест на логи: собрать fmt.Sprintf по %v, %+v, %#v и json.Marshal от структуры конфигурации и убедиться, что подставленное тестовое значение не встречается ни в одной из строк. Такой тест ловит момент, когда кто-то заменит Secret на обычную string.
Переменные окружения: честный разбор
Двенадцатифакторное приложение советует держать конфигурацию в переменных окружения, и это разумный совет для конфигурации. Для секретов у него есть цена, которую надо знать, а не повторять лозунги «env — это небезопасно».
Что реально плохо:
- Наследование. Дочерние процессы получают копию окружения. Вызвали
ffmpeg, хук на деплое, скрипт бэкапа — они увидели пароль к БД. Если такой процесс что-то логирует или падает с дампом, секрет уходит дальше. /proc/PID/environ. Любой процесс того же UID (и root) читает окружение соседа. В контейнере с несколькими процессами и в sidecar-моделях это ощутимо.- Инспекция платформы.
docker inspect,kubectl get pod -o yaml, панель облака показывают переменные всем, у кого есть право на чтение объекта, — а это право обычно раздают куда шире, чем доступ к секретам. - Дампы и телеметрия. Многие crash-репортеры и профайлеры по умолчанию прикладывают окружение к отчёту.
- Нет ротации. Переменная фиксируется на старте процесса; чтобы поменять значение, нужен рестарт. Для секрета, живущего 15 минут, это неприемлемо.
Что не плохо: сами по себе переменные окружения не «менее защищены», чем файл на диске, — угрозы просто другие. Файл переживает перезагрузку и попадает в бэкап; переменная не переживает, но шире расходится по процессам.
Практический компромисс — конвенция _FILE, знакомая по официальным образам Postgres и MySQL: через окружение передаётся путь, а значение читается приложением из файла на tmpfs. Так секрет не наследуется потомками, не виден в /proc/PID/environ и не попадает в docker inspect.
def read_secret(name: str) -> str:
"""Приоритет: <NAME>_FILE -> /run/secrets/<name> -> ошибка.
Никаких дефолтов: отсутствие секрета — отказ старта."""
p = Path(os.environ.get(f"{name.upper()}_FILE") or f"/run/secrets/{name}")
if not p.is_file():
raise RuntimeError(f"секрет {name} не смонтирован: {p}")
mode = p.stat().st_mode & 0o777
if mode & 0o077: # доступен группе или всем — отказ
raise RuntimeError(f"слишком широкие права на {p}: {oct(mode)}")
return p.read_text(encoding="utf-8").rstrip("\n")
В systemd для этого есть штатный механизм — LoadCredential, который кладёт значение в приватный tmpfs-каталог, недоступный другим юнитам, и подставляет путь в %d:
[Service]
LoadCredential=db-password:/etc/app/db-password
Environment=DB_PASSWORD_FILE=%d/db-password
ExecStart=/usr/local/bin/app
# Доп. изоляция: свой /tmp, запрет на чтение чужих /proc
PrivateTmp=yes
ProtectProc=invisible
NoNewPrivileges=yes
Отдельная тема — обнуление в памяти. В Go и Python строка неизменяема, и «затереть» её нельзя: только []byte в Go и bytearray в Python дают контроль. Полностью гарантировать отсутствие копий не выйдет — сборщик мусора перемещает объекты, страницы уходят в swap. Реалистичная цель — сократить время жизни и запретить свопинг критичных страниц (mlock), а не устроить крипто-театр.
Где хранить: дерево решений
без секрета вообще?"} B -->|"Да: workload identity,
OIDC-федерация, mTLS"| C["Не хранить ничего.
Лучший секрет — отсутствующий"] B -->|Нет| D{"Секрет может быть
короткоживущим?"} D -->|Да| E["Динамические креды:
Vault database/aws engine,
STS AssumeRole"] D -->|Нет| F{"Нужны операции
с ключом, а не сам ключ?"} F -->|"Да: подпись, шифрование"| G["KMS или HSM:
ключ не покидает границу,
наружу только результат"] F -->|Нет| H{"Инфраструктура
с управляемым хранилищем?"} H -->|Да| I["Cloud secret manager
или Vault KV v2"] H -->|Нет| J{"GitOps: всё
должно жить в git?"} J -->|Да| K["SOPS + age/KMS,
шифрование значений полей"] J -->|Нет| L["Файл на tmpfs,
права 0400, владелец сервиса"] C & E & G & I & K & L --> M["Аудит доступа
обязателен в любой ветке"]
Верхняя развилка — главная. Большая часть «управления секретами» в 2020-х состоит в том, чтобы секретов стало меньше: там, где раньше клали статический ключ AWS в CI, теперь работает обмен OIDC-токена раннера на временную роль; там, где сервис ходил в соседний сервис с общим паролем, работает mTLS с сертификатом от внутреннего CA (см. Транспортная безопасность).
KMS и конвертное шифрование
Отдельный класс задач — не «получить пароль», а «зашифровать данные». Здесь ключ вообще не должен покидать защищённую границу, и нужный примитив называется конвертным шифрованием (envelope encryption).
Логика такая: на каждый объект генерируется одноразовый ключ данных DEK, им шифруются данные, а сам DEK отправляется в KMS на «заворачивание» мастер-ключом KEK. Наружу KEK не выходит никогда — KMS предоставляет только операции Wrap/Unwrap (или Encrypt/Decrypt для маленьких блобов). Хранится рядом с данными завёрнутый DEK, бесполезный без доступа к KMS.
package envelope
// KMS — минимальный контракт: значение KEK наружу не отдаётся.
type KMS interface {
Wrap(ctx context.Context, dek []byte) (wrapped []byte, kekID string, err error)
Unwrap(ctx context.Context, wrapped []byte, kekID string) (dek []byte, err error)
}
// Seal шифрует данные новым одноразовым DEK.
// aad — контекст записи, например tenant_id и имя поля: он не шифруется,
// но аутентифицируется, поэтому запись нельзя «переклеить» другому тенанту.
func Seal(ctx context.Context, k KMS, plaintext, aad []byte) ([]byte, error) {
dek := make([]byte, 32) // AES-256, новый на каждый объект
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
defer zero(dek) // обнуляем, как только он не нужен
wrapped, kekID, err := k.Wrap(ctx, dek)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(dek)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// hdr: version | len(kekID) | kekID | len(wrapped) | wrapped | nonce —
// все длины явные, разбор однозначный. Заголовок целиком уходит в AAD
// вместе с прикладным контекстом: подмена kekID сломает проверку тега.
hdr := buildHeader(formatVersion, kekID, wrapped, nonce)
ct := gcm.Seal(nil, nonce, plaintext, append(append([]byte{}, hdr...), aad...))
return append(hdr, ct...), nil
}
func zero(b []byte) { for i := range b { b[i] = 0 } }
Что это даёт на практике. Ротация мастер-ключа дешева: смена KEK требует перешифровать по 40–100 байт на запись (только wrapped_dek), а не терабайты данных, а при ленивой миграции по kek_id старые записи не трогают, пока их не прочитают. Аудит централизован: каждый Unwrap — запись в журнале KMS с идентичностью вызывающего, и аномальный всплеск расшифровок виден без инструментирования приложения. Отзыв мгновенный: отключение KEK делает нечитаемыми все зависимые данные разом — это и защита, и опасность, потому что без резервной копии материала мастер-ключа так теряют архивы.
Ошибка, встречающаяся регулярно: DEK генерируется один на всё приложение и живёт годами в конфиге. Тогда конвертной схемы нет, есть обычный статический ключ с лишними шагами — и все ограничения по числу шифрований на одном ключе AES-GCM (безопасный предел по nonce при случайной генерации — порядка 2^32 сообщений) начинают действовать по-настоящему.
HashiCorp Vault: как он устроен
Vault — не «база с паролями», а сервис идентичности и выдачи. Ключевые понятия:
- Storage backend — где лежат зашифрованные данные (Raft, Consul, S3). Хранилище видит только шифротекст.
- Barrier и master key — всё содержимое зашифровано ключом шифрования, который сам зашифрован мастер-ключом. После старта Vault запечатан (sealed) и не может ничего отдать, пока не получит материал для распечатывания.
- Auth methods — способы доказать идентичность: AppRole, Kubernetes ServiceAccount JWT, JWT/OIDC, AWS IAM, TLS-сертификаты, LDAP.
- Secrets engines — что Vault умеет отдавать:
kv(просто значения),database(динамические пользователи БД),pki(выпуск сертификатов),transit(шифрование как сервис, ключ не выходит наружу),aws/gcp(временные облачные креды). - Policies — HCL-правила «кому какие пути и с какими capabilities доступны».
- Lease — у выданного значения есть срок и владелец; его можно продлить (
renew) и отозвать (revoke), в том числе каскадно.
Распечатывание по умолчанию использует схему разделения секрета Шамира: мастер-ключ разбивается на n осколков, для восстановления нужны любые t из них. Это защита от одного оператора-предателя и от одной украденной флешки, но оно требует людей при каждом рестарте. В проде обычно включают auto-unseal через облачный KMS или HSM: узел при старте просит внешний сервис расшифровать ключ, доказывая свою идентичность машинными средствами. Это переносит доверие на KMS — и это осознанный размен, а не «упрощение».
Типовой доступ из Kubernetes-нагрузки без единого статического секрета:
# Один раз на кластер: Vault учится доверять токенам ServiceAccount.
vault auth enable kubernetes
vault write auth/kubernetes/config kubernetes_host="https://kubernetes.default.svc:443"
# Политика: только чтение своих путей, ничего лишнего.
vault policy write billing - <<'EOF'
path "kv/data/billing/*" { capabilities = ["read"] }
path "database/creds/billing-ro" { capabilities = ["read"] }
EOF
# Привязка: под с ServiceAccount billing в namespace prod получает роль на час.
vault write auth/kubernetes/role/billing \
bound_service_account_names=billing \
bound_service_account_namespaces=prod \
policies=billing ttl=1h
def vault_client() -> hvac.Client:
"""Логин по токену ServiceAccount: статических секретов в поде нет."""
jwt = Path("/var/run/secrets/kubernetes.io/serviceaccount/token").read_text()
c = hvac.Client(url="https://vault.internal:8200")
c.auth.kubernetes.login(role="billing", jwt=jwt)
return c
def db_credentials(c: hvac.Client) -> tuple[str, str, int]:
"""Динамические креды: Vault создаёт пользователя в БД на время lease."""
r = c.secrets.database.generate_credentials(name="billing-ro")
return r["data"]["username"], r["data"]["password"], r["lease_duration"]
Динамические креды — самая недооценённая часть Vault. Приложение получает не «пароль от базы», а свой личный, только что созданный пользователь БД с TTL в час. Утечка такого значения из логов стоит максимум час доступа; в журнале БД видно, какой именно инстанс что делал (v-kubernetes-billing-8f2a вместо общего app_user); ротации в привычном смысле нет — вместо неё истечение срока. Взамен появляется новый риск: Vault становится единой точкой отказа, поэтому нужен HA-кластер, а в клиентах — кэш последних валидных кредов и корректная деградация.
Движок transit решает другую задачу: приложение отправляет данные на шифрование и получает шифротекст, но самого ключа никогда не видит. Это тот же KMS, только self-hosted, и он же даёт бесплатную ротацию: vault write -f transit/keys/orders/rotate создаёт новую версию, старые шифротексты продолжают читаться, а rewrap мигрирует их без доступа к открытому тексту.
Secret zero: откуда берётся первый доступ
Любая схема упирается в вопрос: чтобы получить секреты из хранилища, нужен секрет для доступа к хранилищу. Наивный ответ — положить токен Vault в переменную окружения — возвращает нас в начало. Правильный ответ: первый шаг должен опираться не на знание, а на свойство среды, которое подтверждает третья сторона.
при отказе продления — переподключение с новыми кредами V->>D: по истечении lease DROP ROLE
Тот же приём в облаках называется по-разному, но устроен одинаково: платформа подписывает утверждение о том, кто вы, а хранилище это утверждение проверяет.
| Среда | Механизм | Что доказывается |
|---|---|---|
| Kubernetes | projected ServiceAccount token с audience |
под запущен от конкретного SA в конкретном namespace |
| AWS | IAM Roles for Service Accounts, роли EC2/ECS | нагрузка исполняется в вашем аккаунте с указанной ролью |
| GitHub Actions | OIDC-токен воркфлоу | джоба из конкретного репозитория, ветки, окружения |
| Bare metal и VM | SPIFFE/SPIRE, аттестация узла | процесс на аттестованном узле с определённым selector |
| Устройства | TPM, Secure Element | ключ сгенерирован в железе и не экспортируем |
Два обязательных требования к таким токенам: ограниченная аудитория (aud) и короткий TTL. Токен SA без audience принимается любым сервисом, который умеет проверять подпись кластера, — это превращает его в универсальный пропуск. И отдельно про облачные метаданные: используйте IMDSv2 (сессионный токен, PUT + ограничение hop limit), иначе SSRF в приложении превращается в кражу ролевых кредов — классический сценарий, за который отвечает Безопасность API.
Ротация: как менять ключ, не роняя прод
Ротация — не «поменять строчку». Это переход между состояниями, в котором обязательно есть период, когда валидны два значения сразу.
Ключевой инвариант: сначала все потребители учатся принимать новый ключ, только потом кто-то начинает им пользоваться. В обратном порядке ротация ломает прод в момент выкатки. Технически это требует, чтобы формат данных нёс идентификатор версии ключа — kid в JWT, kek_id в конвертной записи, префикс в HMAC-подписи вебхука.
@dataclass(frozen=True)
class KeyVersion:
kid: str
secret: bytes
not_after: float | None # None = активная версия, ею подписываем
class Signer:
"""Подписываем одной версией, проверяем любой из живых.
Подпись O(n) по длине сообщения; проверка O(n) с константой в виде
поиска по kid; память O(k) на хранение k версий (в норме k = 2).
"""
def __init__(self, versions: list[KeyVersion]) -> None:
self._active = next(v for v in versions if v.not_after is None)
self._all = {v.kid: v for v in versions}
def sign(self, payload: bytes) -> str:
mac = hmac.new(self._active.secret, payload, hashlib.sha256).hexdigest()
return f"v1={self._active.kid}:{mac}" # версия формата и версия ключа
def verify(self, payload: bytes, header: str) -> bool:
scheme, _, rest = header.partition("=")
kid, _, mac = rest.partition(":")
v = self._all.get(kid)
if scheme != "v1" or v is None:
return False
if v.not_after is not None and time.time() > v.not_after:
return False # старая версия уже выведена из обращения
expected = hmac.new(v.secret, payload, hashlib.sha256).hexdigest()
# Постоянное по времени сравнение: иначе утечка через тайминг.
return hmac.compare_digest(expected, mac)
Обратите внимание на hmac.compare_digest: обычное == завершается на первом несовпавшем байте, и разница во времени ответа позволяет подбирать подпись побайтово. Это CWE-208, и правило действует для любого сравнения секретов — токенов, кодов подтверждения, ключей API.
Для пароля к базе перекрытие делается на стороне СУБД. В PostgreSQL — двумя ролями, наследующими одну группу:
-- Группа держит права; логин-роли меняются по очереди.
CREATE ROLE billing_app NOLOGIN;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA app TO billing_app;
CREATE ROLE billing_app_a LOGIN PASSWORD 'из-хранилища' IN ROLE billing_app;
CREATE ROLE billing_app_b LOGIN PASSWORD 'из-хранилища' IN ROLE billing_app;
-- Ротация: меняем пароль неиспользуемой роли, переключаем приложение,
-- убеждаемся по pg_stat_activity, что старых сессий нет, и меняем вторую.
ALTER ROLE billing_app_b PASSWORD 'новое-значение';
Ориентиры по срокам — не догма, а отправная точка. NIST SP 800-57 Part 1 даёт методику расчёта криптопериода, а NIST SP 800-63B прямо отговаривает от календарной ротации пользовательских паролей без признаков компрометации — принудительная смена каждые 90 дней ухудшает качество паролей. Для машинных секретов логика обратная: чем короче, тем лучше, потому что цена автоматической смены близка к нулю.
| Тип | Плановый период | Триггеры внеплановой ротации |
|---|---|---|
| Динамические креды БД | 1 час (lease) | не нужны |
| Токены CI к реестрам | не хранить, OIDC на джобу | — |
| HMAC вебхуков | 90 дней | утечка, уход сотрудника с доступом |
| Ключ подписи сессий/JWT | 30–90 дней с перекрытием | компрометация, смена алгоритма |
| TLS-сертификаты сервисов | 90 дней и меньше, автоматом | компрометация ключа, смена CA |
| KEK в KMS | 1 год, версионированием | подозрение на компрометацию |
Kubernetes: что такое Secret и чем он не является
Объект Secret в Kubernetes — это ConfigMap с двумя отличиями: значение кодируется base64 и API-сервер старается не печатать его в некоторых местах. Base64 — не шифрование. По умолчанию значение лежит в etcd открытым текстом.
Минимум, который нужно закрыть. Шифрование в etcd: EncryptionConfiguration с провайдером kms v2 — тогда etcd видит только шифротекст, а ключ живёт в KMS; провайдер aescbc со статическим ключом на мастере лучше, чем ничего, но этот ключ придётся защищать отдельно. RBAC: право get secrets в namespace эквивалентно доступу ко всем секретам этого namespace, и отдельно проверьте, кто может создавать поды — возможность запустить под с чужим ServiceAccount равна возможности прочитать его токен. Доставка: монтирование томом лучше, чем env, потому что том обновляется при смене Secret, а env фиксируется на старте и виден в /proc.
spec:
serviceAccountName: billing
automountServiceAccountToken: false # выключаем там, где токен не нужен
containers:
- name: app
# ПЛОХО: env из secretKeyRef попадает в /proc/PID/environ и в describe.
# ЛУЧШЕ: передаём путь, значение читаем из смонтированного файла.
env:
- name: DB_PASSWORD_FILE
value: /run/secrets/db/password
volumeMounts:
- { name: db, mountPath: /run/secrets/db, readOnly: true }
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
runAsNonRoot: true
volumes:
- name: db
secret: { secretName: db, defaultMode: 0400 }
Дальше — вопрос, откуда Secret берётся. Хранить его в git открытым нельзя, а GitOps требует, чтобы всё было в git. Три рабочих подхода: External Secrets Operator — в git лежит только ссылка, оператор синхронизирует значение из Vault или облачного менеджера в объект Secret; Sealed Secrets — в git лежит значение, зашифрованное публичным ключом контроллера кластера, и расшифровать его может только контроллер; SOPS + age/KMS — шифруются значения полей YAML, структура остаётся читаемой в дифах, а ключ расшифровки живёт в KMS.
# External Secrets Operator: в репозитории нет значений, только адресация.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: db }
spec:
refreshInterval: 15m # значение перечитывается, ротация доезжает сама
secretStoreRef: { name: vault-prod, kind: SecretStore }
target: { name: db, creationPolicy: Owner } # какой Secret создать
data:
- secretKey: password
remoteRef: { key: kv/data/billing/db, property: password }
Важный нюанс: обновление Secret не перезапускает поды и не заставляет приложение перечитать значение. Либо приложение следит за файлом (inotify) и переоткрывает соединения, либо в деплоймент добавляется аннотация с хешем секрета, чтобы изменение вызывало rollout. Иначе ротация «прошла успешно», а сервис продолжает ходить со старым паролем до ближайшего рестарта — и падает через месяц, когда старое значение отключат.
Подробнее про эксплуатацию кластера — в Kubernetes и Helm и GitOps.
Секреты в CI/CD
Пайплайн — самая привлекательная цель: в нём одновременно живут доступ к коду, к реестру артефактов и к проду. Разбор атак на сборку — в Безопасность цепочки поставок и Безопасность в пайплайне; здесь только про секреты.
# GitHub Actions: доступ в облако без единого статического ключа.
permissions:
id-token: write # разрешаем выпуск OIDC-токена джобы
contents: read # минимум прав по умолчанию
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # ручное подтверждение и отдельный набор секретов
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
# Роль доверяет только этому репозиторию и этой ветке — условие
# прописано в trust policy на стороне AWS. Никаких access-key.
role-to-assume: arn:aws:iam::111122223333:role/deploy-billing
aws-region: eu-central-1
Правила, которые ловят большинство инцидентов:
- Никаких долгоживущих облачных ключей в переменных CI. OIDC-федерация поддерживается всеми крупными платформами; trust policy обязана проверять и репозиторий, и ветку/окружение — иначе форк или произвольная ветка получат прод.
- Секреты не доступны джобам, запускаемым по PR из форков. Триггер
pull_request_targetвыполняет воркфлоу с секретами в контексте базовой ветки — и если он ещё и чекаутит код PR, чужой скрипт исполняется с вашими правами. - Маскирование в логах — страховка, а не защита. Оно ловит точное совпадение;
echo $TOKEN | base64или посимвольный вывод его обходят. Полагаться на маскирование нельзя, включать — обязательно. - Раннеры не переиспользуют состояние. Self-hosted раннер без эфемерности накапливает креды в кэшах,
~/.docker/config.jsonи переменных предыдущих джоб. - Права по принципу минимума на уровне джобы, а не воркфлоу: сборка не должна иметь доступ к деплой-роли.
Как находить секреты, которые уже утекли в код
Детекторы работают двумя способами: правила (регулярки под известные форматы — AKIA… у AWS, ghp_… у GitHub, sk_live_… у Stripe) и энтропия (строка выглядит случайной). Правила точны, но знают только известные форматы; энтропия универсальна, но шумит.
Псевдокод энтропийного детектора:
для каждой добавленной строки диффа:
для каждого токена длиной >= 20 из [A-Za-z0-9+/=_-]:
h <- энтропия Шеннона по символам токена
если h >= порог и токен не в списке разрешений:
пометить как кандидат
Реализация:
TOKEN_RE = re.compile(r"[A-Za-z0-9+/=_\-]{20,}")
def shannon_entropy(s: str) -> float:
"""Энтропия в битах на символ: O(n) по времени,
O(a) по памяти, где a — размер алфавита (здесь не больше 66)."""
n = len(s)
return -sum((c / n) * math.log2(c / n) for c in Counter(s).values())
def find_candidates(line: str, threshold: float = 4.0,
allowlist: frozenset[str] = frozenset()) -> list[str]:
"""Кандидаты на секрет в строке диффа. Проход по всей истории —
O(N) по суммарной длине, память O(1) сверх списка находок."""
return [t for m in TOKEN_RE.finditer(line)
if (t := m.group()) not in allowlist
and shannon_entropy(t) >= threshold]
Порог 4.0 бит/символ для base64-алфавита отсекает осмысленные идентификаторы (у английского текста примерно 1,0–1,5 бит/символ), но пропускает хеши коммитов, UUID, base64-иконки и минифицированный JS. Поэтому боевые сканеры (gitleaks, trufflehog, detect-secrets) комбинируют оба подхода и требуют настройки allowlist — иначе разработчики начинают игнорировать шум, и детектор перестаёт работать.
Эшелонировать проверку нужно в три слоя, потому что каждый следующий ловит то, что просочилось мимо предыдущего: слой 1 — pre-commit-хук с gitleaks локально (самый дешёвый, но его можно пропустить флагом --no-verify); слой 2 — прогон в CI по всей истории, gitleaks detect --log-opts="--all" --exit-code 1, который ловит то, что обошло хук; слой 3, важнейший, — серверная защита платформы, отклоняющая сам push с секретом: её обойти со стороны клиента нельзя.
Отдельно стоит настроить allowlist через .gitleaks.toml, а не через # nosec-комментарии в коде: централизованный список видно на ревью, разбросанные подавления — нет.
Инцидент: секрет утёк
сообщение извне, аномалия в аудите"] --> B["Считать значение
скомпрометированным.
Не спорить о вероятности"] B --> C["Ротация: выпустить новое значение
и перевести потребителей"] C --> D["Отзыв старого:
revoke, а не просто замена"] D --> E{"Есть аудит
использования?"} E -->|Да| F["Проверить обращения
с момента утечки:
IP, время, объём"] E -->|Нет| G["Считать доступ
возможным.
Полный разбор данных"] F --> H["Инвентаризация копий:
git, образы, бэкапы,
логи, тикеты, чаты"] G --> H H --> I["Уборка следов:
filter-repo, пересборка образов,
ротация зависимых секретов"] I --> J["Постмортем без поиска
виноватых: почему детектор
не сработал раньше"] J --> K["Изменение системы:
сделать этот класс утечки
невозможным или дешёвым"]
Три ошибки, которые превращают мелкий инцидент в крупный:
- Начинать с уборки истории git. Пока значение валидно, каждая минута работает против вас; переписывание истории занимает часы и требует координации со всеми клонами. Ротация занимает минуты.
- Ротировать только утёкший секрет. Если утёк токен CI, то всё, до чего он дотягивался, тоже под подозрением: ключи, которые он мог прочитать, образы, которые он мог подменить.
- Не проверять, использовался ли доступ. Отсутствие аудита превращает «мы поменяли пароль» в «мы не знаем, что произошло». Журналы доступа к хранилищу — обязательная часть схемы, а не опция.
Если в затронутых данных есть персональные данные, инженерная часть заканчивается и начинается процедурная — сроки уведомлений и состав действий определяются регуляторикой; на портале это разбирается в материалах о 152-ФЗ и в статье Приватность и соответствие. Здесь никаких юридических советов: фиксируйте факты, время и объём доступа, остальное — к юристам и DPO.
Аудит и наблюдаемость доступа
Хранилище без журнала — сейф со стеклянной стенкой: непонятно, кто и когда его открывал. Минимальный набор событий: аутентификация в хранилище (всплеск отказов означает подбор или сломанный деплой), чтение конкретного пути (кто читает то, что ему не положено), выдача и отзыв lease (сколько живых кредов в системе прямо сейчас), операции с ключами KMS (аномальный объём Decrypt — признак массовой выгрузки данных) и изменение политик — самая опасная операция, требующая обязательного ревью.
Правило, которое надо заложить в код аудита: в журнал пишется имя секрета и идентичность запрашивающего, но никогда не значение. Vault для этого хеширует чувствительные поля в audit device с HMAC-солью — оператор может проверить гипотезу «это было то самое значение», но не восстановить его.
Полезные алерты: чтение продового пути идентичностью, которая раньше его не читала; доступ вне рабочего окна для человеческих аккаунтов; количество Unwrap за минуту выше исторического максимума; выдача lease с TTL, отличным от политики.
Типичные ошибки
- Один секрет на все окружения. Тестовый контур с ключом прода делает прод настолько же защищённым, насколько защищён тест.
- Секрет в URL.
?api_key=...оседает в access-логах, вRefererи в истории браузера. Только заголовок. - Ротация без окна перекрытия. Приводит к падению и к откату, после которого ротацию больше не делают никогда.
- Общий пароль сервисного аккаунта у людей. Разрушает атрибуцию и делает уход сотрудника поводом для аварийной ротации.
- Значения дублируются в
.env«для локальной разработки» — и файл однажды коммитится. Локальная разработка должна работать на заведомо фейковых значениях. - Шифрование значения ключом, лежащим рядом: зашифрованный
secrets.yamlиkey.txtв соседнем каталоге того же репозитория. - Секрет как аргумент командной строки.
mysql -p"пароль"виден вpsвсем пользователям машины и попадает в историю оболочки;kubectl describeв тикете — та же утечка.
Чеклист
- Все секреты имеют владельца, срок жизни и описанный порядок ротации.
- В репозиториях нет секретов: проверено по всей истории, а не по HEAD; включена серверная защита от push.
- Приложение падает при отсутствии секрета и не имеет «дефолтов для разработки» в продовом коде.
- Секреты приходят из хранилища, а не из манифестов; в манифестах — только ссылки.
- Доступ к хранилищу — по идентичности нагрузки, без «секрета нулевого уровня» в образе.
- Там, где возможно, статические креды заменены динамическими с TTL.
- Формат подписанных и зашифрованных данных содержит идентификатор версии ключа.
- Ротация протестирована на стенде целиком, включая окно с двумя валидными версиями.
- Значения не попадают в логи: закрыто типом-обёрткой и тестом, а не соглашением.
- Аудит доступа включён, алерты на аномалии настроены, журналы не содержат значений.
- CI ходит в облако по OIDC; форк-триггеры не получают секретов; раннеры эфемерны.
- Есть отрепетированный runbook по утечке, и в нём ротация стоит раньше уборки.
Источники
- OWASP Top 10 2021, A02: Cryptographic Failures и A05: Security Misconfiguration
- OWASP Secrets Management Cheat Sheet
- OWASP Key Management Cheat Sheet
- CWE: CWE-798 зашитые учётные данные, CWE-522 недостаточно защищённые креды, CWE-312 хранение открытым текстом, CWE-208 утечка через тайминг сравнения
- NIST SP 800-57 Part 1 Rev. 5 — управление ключами и расчёт криптопериода
- NIST SP 800-63B — почему календарная ротация пользовательских паролей вредна
- NIST SP 800-207 — Zero Trust Architecture, идентичность вместо периметра
- RFC 7515 JWS и RFC 7517 JWK — как
kidустроен в стандарте - RFC 5869 — HKDF, вывод нескольких ключей из одного корневого
- HashiCorp Vault Documentation — секретные движки, auth-методы, seal/unseal
- Kubernetes: Encrypting Confidential Data at Rest и Good practices for Kubernetes Secrets
- SPIFFE и SPIRE — универсальная идентичность нагрузок
- Docker BuildKit: build secrets — как не оставить токен в слое образа
- gitleaks, trufflehog, SOPS, External Secrets Operator
- Jeff Nickoloff, «Docker in Action»; Liz Rice, «Container Security», O’Reilly — практика изоляции и доставки секретов в контейнерах
- Niels Ferguson, Bruce Schneier, Tadayoshi Kohno, «Cryptography Engineering» — глава про управление ключами как источник большинства провалов
Что дальше
Мы разобрали секреты, которые нужны вашему коду для работы. Но есть класс доверия, о котором обычно не думают вообще: вы доверяете сотням чужих пакетов, базовым образам, экшенам в пайплайне и бинарникам, скачанным по HTTPS. Компрометация любого из них даёт атакующему исполнение кода внутри вашей сборки — то есть ровно там, где лежат все секреты, о которых шла речь. Следующая статья — про то, как оценивать и контролировать эту цепочку: чем полезен и чем бесполезен lock-файл, что такое SBOM и как его читать, как подписывать артефакты и проверять подписи, и почему воспроизводимая сборка — не академическая экзотика.
Безопасность цепочки поставок: зависимости, SBOM, подпись артефактов