DevOps

Безопасное хранение и ротация секретов в CI/CD: интеграция HashiCorp Vault с GitHub Actions и Laravel

Ruslan Ismailov Опубликовано 14 мин чтения
Б

Введение: почему хранить секреты в .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. Подробнее обо мне →