Безопасное хранение и ротация секретов в CI/CD: интеграция HashiCorp Vault с GitHub Actions и Laravel
Введение: почему хранить секреты в .env и переменных окружения опасно
Большинство PHP-проектов по-прежнему хранят базы данных credentials, API-ключи и токены в .env-файлах или в переменных окружения CI/CD-системы. На первый взгляд это удобно: Laravel «из коробки» читает .env, GitHub Actions позволяет задать Secrets в настройках репозитория. Но у такого подхода есть системные уязвимости.
- Статичность. Секрет, однажды выданный, живёт месяцами или годами. Компрометация одного токена открывает доступ ко всей инфраструктуре.
- Отсутствие аудита. Вы не знаете, кто из коллег скопировал значение переменной, какой воркер читал БД-пароль и когда.
- Расползание секретов. Secrets попадают в логи, в артефакты сборки, в Docker-образы через
ARG-инструкции. - Ручная ротация. Смена пароля базы данных требует синхронного обновления переменных во всех окружениях — это всегда риск простоя.
Решение — централизованное управление секретами с динамической выдачей и автоматической ротацией. Де-факто стандарт в этой нише — HashiCorp Vault. В этой статье мы разберём, как интегрировать Vault с GitHub Actions через OIDC и как Laravel-приложение получает DATABASE_URL, REDIS_PASSWORD и APP_KEY напрямую из Vault при каждом деплое.
Архитектура HashiCorp Vault: динамические секреты, lease и revocation
Vault — это секрет-менеджер с HTTP API, поддерживающий множество secrets engines (движков секретов) и методов аутентификации. Ключевые концепции, которые необходимо понять перед интеграцией:
Secrets Engines
Vault не просто хранилище ключ-значение. Движки секретов умеют генерировать учётные данные на лету. Для нашей задачи важны:
- KV v2 — хранилище статичных секретов с версионированием. Подходит для
APP_KEY, API-ключей сторонних сервисов. - Database Secrets Engine — динамически создаёт временные пользователей в PostgreSQL, MySQL и других СУБД с ограниченным временем жизни.
- Transit — шифрование данных без их хранения в Vault (encryption-as-a-service).
Lease и Revocation
Каждый динамический секрет выдаётся с lease — временем жизни (TTL). По истечении TTL Vault автоматически отзывает секрет (удаляет временного пользователя из PostgreSQL, инвалидирует токен). Приложение может продлить lease через API, но только в пределах max_ttl. Это означает, что утечка динамического секрета ограничена по времени — принципиальное отличие от статичных паролей.
Политики доступа
Vault использует HCL-политики для управления правами. Пример политики для GitHub Actions CI-задачи:
# policy: github-actions-deploy.hcl
# Чтение статичных секретов приложения
path "secret/data/myapp/*" {
capabilities = ["read"]
}
# Получение динамических credentials для PostgreSQL
path "database/creds/myapp-deploy-role" {
capabilities = ["read"]
}
# Продление lease
path "sys/leases/renew" {
capabilities = ["update"]
}
Интеграция Vault с GitHub Actions: OIDC-аутентификация без статичных токенов
Традиционный подход — положить Vault-токен в GitHub Secrets. Это снова статичный секрет, только теперь он даёт доступ ко всем остальным секретам. OIDC (OpenID Connect) решает эту проблему: GitHub Actions генерирует короткоживущий JWT для каждого job-запуска, Vault верифицирует его через GitHub's OIDC endpoint и выдаёт ограниченный токен.
Настройка JWT Auth в Vault
# Включаем JWT auth метод
vault auth enable jwt
# Настраиваем OIDC discovery через GitHub
vault write auth/jwt/config \
oidc_discovery_url="https://token.actions.githubusercontent.com" \
bound_issuer="https://token.actions.githubusercontent.com"
# Создаём роль для деплоя конкретного репозитория
vault write auth/jwt/role/github-actions-deploy \
role_type="jwt" \
bound_audiences="https://vault.example.com" \
user_claim="actor" \
bound_claims_type="glob" \
bound_claims='{
"sub": "repo:your-org/your-repo:environment:production"
}' \
policies="github-actions-deploy" \
ttl="15m"
Обратите внимание на bound_claims: мы привязываем роль к конкретному репозиторию и окружению GitHub Environments. Токен, полученный из другого репозитория или бранча, не пройдёт валидацию.
GitHub Actions Workflow с получением секретов из Vault
name: Deploy Laravel to Production
on:
push:
branches: [main]
permissions:
id-token: write # Обязательно для OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Import Secrets from Vault
uses: hashicorp/vault-action@v3
id: vault
with:
url: https://vault.example.com
method: jwt
role: github-actions-deploy
# audience должен совпадать с bound_audiences в роли
jwtGithubAudience: https://vault.example.com
secrets: |
secret/data/myapp/production app_key | APP_KEY ;
secret/data/myapp/production redis_password | REDIS_PASSWORD ;
database/creds/myapp-deploy-role username | DB_USERNAME ;
database/creds/myapp-deploy-role password | DB_PASSWORD
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install Composer dependencies
run: composer install --no-dev --optimize-autoloader
- name: Run database migrations
env:
DB_HOST: db.internal.example.com
DB_PORT: 5432
DB_DATABASE: myapp_prod
DB_USERNAME: ${{ steps.vault.outputs.DB_USERNAME }}
DB_PASSWORD: ${{ steps.vault.outputs.DB_PASSWORD }}
APP_KEY: ${{ steps.vault.outputs.APP_KEY }}
REDIS_PASSWORD: ${{ steps.vault.outputs.REDIS_PASSWORD }}
run: php artisan migrate --force
- name: Deploy application
# ... rsync, kubectl apply, etc.
run: echo "Deploying..."
После завершения job Vault автоматически отзывает динамические credentials PostgreSQL. Временной пользователь в базе данных перестаёт существовать.
Практический пример: Laravel получает секреты из Vault при деплое
Рассмотрим, как настроить Laravel-приложение для работы с Vault-секретами в production-окружении. Для runtime-доступа к Vault удобно использовать пакет vault-php или прямые запросы к HTTP API.
Структура секретов в Vault KV v2
# Записываем статичные секреты приложения
vault kv put secret/myapp/production \
app_key="base64:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=" \
redis_password="sup3r-s3cur3-r3d1s-p4ss"
# Проверяем
vault kv get secret/myapp/production
Конфигурация Laravel: config/database.php
В CI/CD-пайплайне секреты уже переданы через переменные окружения шагом vault-action. Laravel читает их стандартно через env():
// config/database.php
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'myapp'),
'username' => env('DB_USERNAME'), // динамический пользователь из Vault
'password' => env('DB_PASSWORD'), // динамический пароль из Vault
'charset' => 'utf8',
'sslmode' => env('DB_SSLMODE', 'require'), // всегда TLS в prod
],
Конфигурация Redis
// config/database.php — секция redis
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'), // из Vault KV
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
],
Bootstrap в runtime: Vault SDK для PHP
Если приложению нужно обращаться к Vault в runtime (например, для шифрования данных через Transit engine), используйте пакет:
composer require vault-php/vault-php
<?php
// app/Services/VaultService.php
namespace App\Services;
use Vault\Client;
use Vault\AuthenticationStrategies\AppRoleAuthenticationStrategy;
class VaultService
{
private Client $client;
public function __construct()
{
$this->client = new Client(
new \GuzzleHttp\Client(['base_uri' => config('vault.address')])
);
// AppRole auth для runtime-доступа (не OIDC, т.к. нет GitHub context)
$this->client->setAuthenticationStrategy(
new AppRoleAuthenticationStrategy(
config('vault.role_id'),
config('vault.secret_id')
)
);
$this->client->authenticate();
}
public function getSecret(string $path): array
{
$response = $this->client->read($path);
return $response->getData()['data'] ?? [];
}
}
Автоматическая ротация секретов PostgreSQL через Vault Database Secrets Engine
Database Secrets Engine — одна из самых мощных возможностей Vault. Вместо единого пользователя приложения в PostgreSQL Vault создаёт временного пользователя с уникальными credentials для каждого запроса.
Настройка Database Secrets Engine
# Включаем движок
vault secrets enable database
# Настраиваем подключение к PostgreSQL
vault write database/config/myapp-postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="myapp-deploy-role,myapp-app-role" \
connection_url="postgresql://{{username}}:{{password}}@db.internal.example.com:5432/myapp_prod?sslmode=require" \
username="vault_root_user" \
password="vault_root_password"
# Создаём роль для деплоя (короткое TTL — только для миграций)
vault write database/roles/myapp-deploy-role \
db_name=myapp-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT ALL PRIVILEGES ON DATABASE myapp_prod TO \"{{name}}\"; \
GRANT ALL ON SCHEMA public TO \"{{name}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="15m" \
max_ttl="30m"
# Создаём роль для приложения (более длинное TTL, ограниченные права)
vault write database/roles/myapp-app-role \
db_name=myapp-postgres \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\"; \
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";" \
revocation_statements="DROP ROLE IF EXISTS \"{{name}}\";" \
default_ttl="1h" \
max_ttl="4h"
Тестирование динамической выдачи
# Запрашиваем динамические credentials
vault read database/creds/myapp-deploy-role
# Ответ:
# Key Value
# --- -----
# lease_id database/creds/myapp-deploy-role/AbCdEf123...
# lease_duration 15m
# lease_renewable true
# password A1b2C3d4E5f6-unique-per-request
# username v-github-myapp-QrStUv
Каждый запрос credentials возвращает уникальную пару логин/пароль. После истечения lease_duration пользователь автоматически удаляется из PostgreSQL через revocation statement.
Мониторинг и аудит: кто и когда запрашивал секреты
Vault поддерживает несколько типов audit devices. Включение audit log — обязательный шаг для production-окружения.
Настройка File Audit Device
# Включаем запись аудита в файл
vault audit enable file file_path=/var/log/vault/audit.log
# Проверяем
vault audit list
Каждая операция фиксируется в JSON-формате: время, метод аутентификации, путь, результат (success/deny), IP-адрес клиента. Пример записи:
{
"time": "2024-11-15T14:23:01.123Z",
"type": "response",
"auth": {
"client_token": "hmac-sha256:...",
"accessor": "hmac-sha256:...",
"display_name": "jwt-github-actions",
"policies": ["default", "github-actions-deploy"],
"metadata": {
"actor": "john-doe",
"repository": "your-org/your-repo",
"workflow": "Deploy Laravel to Production"
}
},
"request": {
"operation": "read",
"path": "database/creds/myapp-deploy-role"
},
"response": {
"data": {"username": "hmac-sha256:...", "password": "hmac-sha256:..."}
}
}
Обратите внимание: Vault никогда не пишет секреты в audit log в открытом виде — только HMAC. Но метаданные (кто, что, когда запросил) записываются полностью. Это позволяет интегрировать логи с SIEM-системами (Splunk, Elasticsearch) для алертинга на аномалии.
Метрики через Prometheus
Vault экспортирует метрики в формате Prometheus по пути /v1/sys/metrics. Ключевые метрики для мониторинга:
vault_core_active— активность нодыvault_secret_kv_count— количество секретовvault_token_count— количество активных токеновvault_audit_log_response_failure— сбои аудита (если audit device недоступен, Vault блокирует запросы)
Типичные ошибки и best practices
Ошибки, которые допускают чаще всего
- Хранение VAULT_TOKEN в GitHub Secrets. Это возвращает нас к статичным секретам. Используйте OIDC.
- Слишком широкие политики. Политика
path "*" { capabilities = ["read"] }в production — серьёзная уязвимость. Принцип минимальных привилегий: каждый job получает доступ только к нужным путям. - Игнорирование max_ttl. Без ограничения
max_ttlприложение может бесконечно продлевать lease, превращая динамический секрет в фактически статичный. - Vault без HA в production. Single-node Vault — единая точка отказа. Используйте Raft Integrated Storage с минимум тремя нодами или Consul backend.
- Отключённый audit log. Если Vault запущен без audit device, вы теряете весь трейл доступа. Это критично для compliance (SOC 2, PCI DSS).
- Docker-образы с секретами в слоях. Не передавайте секреты через
ARGв Dockerfile — они сохраняются в истории образа. Передавайте через runtime environment.
Best Practices
- Используйте GitHub Environments с обязательными reviewer'ами для production-деплоев. OIDC claim
subсодержит имя environment — привязывайте к нему Vault-роли. - Настройте Vault Agent Sidecar для Kubernetes-деплоев: агент автоматически обновляет secrets в файловой системе пода и поддерживает lease.
- Версионируйте секреты через KV v2. При инциденте вы сможете откатиться к предыдущей версии и увидеть историю изменений.
- Разделяйте секреты по окружениям:
secret/myapp/staging/*,secret/myapp/production/*. Разные политики, разные роли. - Для Laravel используйте config:cache осторожно: кешированный конфиг фиксирует значения секретов на момент кеширования. При ротации необходимо инвалидировать кеш.
- Регулярно запускайте
vault operator key-statusи ротируйте ключи шифрования Vault (encryption key rotation).
Заключение
Безопасный деплой PHP-приложений в 2024 году невозможен без централизованного управления секретами. Связка HashiCorp Vault + GitHub Actions OIDC + Laravel даёт вам:
- Нулевые статичные токены в CI/CD — каждый job аутентифицируется через короткоживущий JWT.
- Динамические credentials для PostgreSQL и других баз данных — компрометация секрета ограничена временем lease.
- Полный аудитный след — вы знаете, какой именно workflow, какого пользователя GitHub запросил какой секрет и когда.
- Автоматическую ротацию без простоя — Vault создаёт нового пользователя до отзыва старого.
Внедрение этой схемы требует первоначальных инвестиций в инфраструктуру Vault (HA-кластер, настройка политик, интеграция с существующими системами), но окупается снижением рисков и упрощением операционной работы. Ротация паролей PostgreSQL, которая раньше требовала координации между командами и рискованного downtime, становится автоматическим фоновым процессом.
Начните с малого: разверните Vault в Docker для локального тестирования, перенесите один некритичный секрет через OIDC в staging-пайплайн, убедитесь в корректности аудит-логов. После этого масштабируйте подход на все окружения и сервисы.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →