Вызов Управления Секретами в 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, тестированием ротации и детекцией утечек, команды могут поддерживать безопасность при обеспечении эффективной автоматизации тестирования.
Ключ - относиться к секретам как к динамическим краткосрочным учетным данным, которые никогда не хардкодятся, всегда шифруются в состоянии покоя, всесторонне аудируются и автоматически ротируются.