Вызов Управления Секретами в CI/CD

Современные CI/CD пайплайны требуют доступа к чувствительным учетным данным - паролям баз данных, API ключам, токенам облачных провайдеров, ключам шифрования и учетным данным сервисных аккаунтов. Традиционные подходы хардкодинга секретов в коде, хранения их в конфигурационных файлах открытым текстом или передачи как незашифрованные переменные окружения создают серьезные уязвимости безопасности и риски соответствия нормам.

Эта статья исследует комплексные стратегии управления секретами используя HashiCorp Vault, SOPS (Secrets OPerationS), и паттерны безопасного тестирования для CI/CD пайплайнов.

Интеграция HashiCorp Vault

Конфигурация Vault для CI/CD

# vault-config.hcl
storage "consul" {
  address = "127.0.0.1:8500"
  path    = "vault/"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = 0
  tls_cert_file = "/etc/vault/tls/vault.crt"
  tls_key_file  = "/etc/vault/tls/vault.key"
}

api_addr = "https://vault.example.com:8200"
ui = true

Настройка Secret Engine

#!/bin/bash
# scripts/vault-setup.sh

# Включить KV v2 secrets engine
vault secrets enable -path=secret kv-v2

# Создать секреты для разных окружений
vault kv put secret/test/database \
  username=test_user \
  password=secure_password \
  host=test-db.example.com \
  port=5432

# Включить AppRole auth для CI/CD
vault auth enable approle

# Создать политику для доступа CI/CD
vault policy write cicd-policy - <<EOF
path "secret/data/test/*" {
  capabilities = ["read", "list"]
}

path "auth/token/renew-self" {
  capabilities = ["update"]
}
EOF

# Создать AppRole для CI/CD
vault write auth/approle/role/cicd-role \
  token_policies="cicd-policy" \
  token_ttl=1h \
  token_max_ttl=4h

Vault Клиент для Автоматизации Тестирования

# tests/vault_client.py
import hvac
import os

class VaultClient:
    def __init__(self, vault_addr=None, approle_id=None, approle_secret=None):
        self.vault_addr = vault_addr or os.getenv('VAULT_ADDR')
        self.approle_id = approle_id or os.getenv('VAULT_ROLE_ID')
        self.approle_secret = approle_secret or os.getenv('VAULT_SECRET_ID')

        self.client = hvac.Client(url=self.vault_addr)
        self._authenticate()

    def _authenticate(self):
        """Аутентификация используя AppRole"""
        response = self.client.auth.approle.login(
            role_id=self.approle_id,
            secret_id=self.approle_secret
        )
        self.client.token = response['auth']['client_token']

    def get_secret(self, path: str):
        """Получить секрет из Vault"""
        secret = self.client.secrets.kv.v2.read_secret_version(
            path=path,
            mount_point='secret'
        )
        return secret['data']['data']

    def get_database_credentials(self, environment: str):
        """Получить учетные данные БД для указанного окружения"""
        return self.get_secret(f"{environment}/database")

@pytest.fixture(scope="session")
def vault_client():
    """Pytest fixture для Vault клиента"""
    return VaultClient()

def test_database_connection_with_vault(vault_client):
    """Тестировать подключение к БД используя учетные данные Vault"""
    db_creds = vault_client.get_database_credentials('test')

    conn = psycopg2.connect(
        host=db_creds['host'],
        port=db_creds['port'],
        user=db_creds['username'],
        password=db_creds['password']
    )

    cursor = conn.cursor()
    cursor.execute("SELECT 1")
    assert cursor.fetchone()[0] == 1
    conn.close()

Интеграция GitLab CI с Vault

# .gitlab-ci.yml
variables:
  VAULT_ADDR: "https://vault.example.com:8200"

.vault_auth: &vault_auth
  before_script:
    - |
      # Аутентификация в Vault используя JWT
      export VAULT_TOKEN=$(curl -s \
        --request POST \
        --data "{\"jwt\": \"${CI_JOB_JWT}\", \"role\": \"cicd-role\"}" \
        ${VAULT_ADDR}/v1/auth/jwt/login | jq -r '.auth.client_token')
    - |
      # Получить секреты
      export DB_PASSWORD=$(curl -s \
        --header "X-Vault-Token: ${VAULT_TOKEN}" \
        ${VAULT_ADDR}/v1/secret/data/test/database | jq -r '.data.data.password')

test_with_vault_secrets:
  stage: test
  <<: *vault_auth
  script:
    - pytest tests/ -v --db-password=$DB_PASSWORD
  after_script:
    - unset VAULT_TOKEN
    - unset DB_PASSWORD

SOPS (Secrets OPerationS) для Зашифрованных Секретов

Конфигурация SOPS

# .sops.yaml
creation_rules:
  - path_regex: secrets/test/.*\.yaml$
    kms: 'arn:aws:kms:us-east-1:123456789012:key/test-key-id'
    pgp: 'FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4'

  - path_regex: secrets/staging/.*\.yaml$
    kms: 'arn:aws:kms:us-east-1:123456789012:key/staging-key-id'
    pgp: 'FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4'

Интеграция SOPS в Тестах

# tests/sops_secrets.py
import subprocess
import yaml

class SOPSSecretsManager:
    def __init__(self, secrets_path: str):
        self.secrets_path = secrets_path

    def decrypt_file(self, file_path: str):
        """Расшифровать файл зашифрованный SOPS"""
        result = subprocess.run(
            ['sops', '--decrypt', file_path],
            capture_output=True,
            text=True,
            check=True
        )
        return yaml.safe_load(result.stdout)

    def get_secret(self, environment: str, secret_name: str):
        """Получить конкретный секрет для окружения"""
        file_path = f"{self.secrets_path}/{environment}/{secret_name}.enc.yaml"
        return self.decrypt_file(file_path)

@pytest.fixture(scope="session")
def sops_secrets():
    """Pytest fixture для SOPS секретов"""
    return SOPSSecretsManager('secrets')

def test_database_connection_with_sops(sops_secrets):
    """Тестировать подключение к БД используя зашифрованные SOPS секреты"""
    db_config = sops_secrets.get_secret('test', 'database')

    conn = psycopg2.connect(
        host=db_config['database']['host'],
        port=db_config['database']['port'],
        user=db_config['database']['username'],
        password=db_config['database']['password']
    )

    assert conn is not None
    conn.close()

GitHub Actions с SOPS

# .github/workflows/test-with-sops.yml
name: Test with SOPS Secrets

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install SOPS
        run: |
          wget https://github.com/mozilla/sops/releases/download/v3.8.1/sops-v3.8.1.linux.amd64
          sudo mv sops-v3.8.1.linux.amd64 /usr/local/bin/sops
          sudo chmod +x /usr/local/bin/sops

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Decrypt secrets
        run: |
          sops --decrypt secrets/test/database.enc.yaml > /tmp/database.yaml

      - name: Run tests
        run: pytest tests/ -v

      - name: Cleanup secrets
        if: always()
        run: rm -f /tmp/database.yaml

Тестирование Ротации Секретов

# tests/test_secret_rotation.py
class SecretRotationTester:
    def test_database_credential_rotation(self):
        """Тестировать процесс ротации учетных данных БД"""
        # Получить начальные учетные данные
        initial_creds = self.vault.get_database_credentials('test')
        initial_password = initial_creds['password']

        # Проверить подключение с начальными учетными данными
        conn1 = psycopg2.connect(
            host=initial_creds['host'],
            user=initial_creds['username'],
            password=initial_password
        )
        assert conn1 is not None
        conn1.close()

        # Запустить ротацию учетных данных
        self._rotate_database_credentials('test')
        time.sleep(5)

        # Получить ротированные учетные данные
        rotated_creds = self.vault.get_database_credentials('test')
        rotated_password = rotated_creds['password']

        # Проверить что пароль изменился
        assert rotated_password != initial_password

        # Проверить подключение с новыми учетными данными
        conn2 = psycopg2.connect(
            host=rotated_creds['host'],
            user=rotated_creds['username'],
            password=rotated_password
        )
        assert conn2 is not None
        conn2.close()

Детекция Утечки Секретов

# tests/test_secret_detection.py
import re
from pathlib import Path

class SecretLeakDetector:
    PATTERNS = {
        'aws_access_key': r'AKIA[0-9A-Z]{16}',
        'github_token': r'ghp_[0-9a-zA-Z]{36}',
        'private_key': r'-----BEGIN (RSA |EC )?PRIVATE KEY-----',
        'jwt': r'eyJ[A-Za-z0-9_-]*\\.eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*'
    }

    def scan_file(self, file_path: Path):
        """Сканировать файл на потенциальные секреты"""
        findings = []

        with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
            content = f.read()

            for secret_type, pattern in self.PATTERNS.items():
                matches = re.finditer(pattern, content, re.IGNORECASE)
                for match in matches:
                    findings.append({
                        'file': str(file_path),
                        'type': secret_type,
                        'line': content[:match.start()].count('\n') + 1
                    })

        return findings

def test_no_secrets_in_code():
    """Проверить что нет секретов в репозитории"""
    detector = SecretLeakDetector()

    findings = detector.scan_repository(exclude_patterns=[
        '*.enc.yaml',
        '.git/*',
        'tests/fixtures/*'
    ])

    assert len(findings) == 0, f"Найдены потенциальные секреты: {findings}"

Заключение

Управление секретами в CI/CD требует комплексного подхода комбинирующего безопасное хранилище (HashiCorp Vault, SOPS), автоматизированную ротацию, строгие контроли доступа и непрерывный мониторинг для детекции утечек. Внедряя правильное управление секретами с интеграцией Vault, шифрованием SOPS, тестированием ротации и детекцией утечек, команды могут поддерживать безопасность при обеспечении эффективной автоматизации тестирования.

Ключ - относиться к секретам как к динамическим краткосрочным учетным данным, которые никогда не хардкодятся, всегда шифруются в состоянии покоя, всесторонне аудируются и автоматически ротируются.