El Desafío de Gestión de Secrets en CI/CD

Los pipelines modernos de CI/CD requieren acceso a credenciales sensibles - contraseñas de bases de datos, claves API, tokens de proveedores cloud, claves de cifrado y credenciales de cuentas de servicio. Los enfoques tradicionales de hardcodear secrets en código, almacenarlos en archivos de configuración en texto plano, o pasarlos como variables de entorno sin cifrar crean vulnerabilidades severas de seguridad y riesgos de cumplimiento.

Este artículo explora estrategias integrales de gestión de secrets usando HashiCorp Vault, SOPS (Secrets OPerationS), y patrones de testing seguro para pipelines CI/CD.

Integración de HashiCorp Vault

Configuración de Vault para 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

Configuración de Secret Engine

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

# Habilitar KV v2 secrets engine
vault secrets enable -path=secret kv-v2

# Crear secrets para diferentes entornos
vault kv put secret/test/database \
  username=test_user \
  password=secure_password \
  host=test-db.example.com \
  port=5432

# Habilitar AppRole auth para CI/CD
vault auth enable approle

# Crear política para acceso CI/CD
vault policy write cicd-policy - <<EOF
path "secret/data/test/*" {
  capabilities = ["read", "list"]
}

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

# Crear AppRole para CI/CD
vault write auth/approle/role/cicd-role \
  token_policies="cicd-policy" \
  token_ttl=1h \
  token_max_ttl=4h

Cliente Vault para Automatización de Pruebas

# 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):
        """Autenticar usando 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):
        """Recuperar secret de 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):
        """Obtener credenciales de BD para entorno especificado"""
        return self.get_secret(f"{environment}/database")

@pytest.fixture(scope="session")
def vault_client():
    """Pytest fixture para cliente Vault"""
    return VaultClient()

def test_database_connection_with_vault(vault_client):
    """Probar conexión a BD usando credenciales 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()

Integración GitLab CI con Vault

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

.vault_auth: &vault_auth
  before_script:
    - |
      # Autenticar en Vault usando 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')
    - |
      # Recuperar secrets
      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) para Secrets Cifrados

Configuración de 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'

Integración de SOPS en Pruebas

# 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):
        """Descifrar archivo cifrado con 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):
        """Obtener secret específico para entorno"""
        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 para secrets SOPS"""
    return SOPSSecretsManager('secrets')

def test_database_connection_with_sops(sops_secrets):
    """Probar conexión a BD usando secrets cifrados 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 con 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

Testing de Rotación de Secrets

# tests/test_secret_rotation.py
class SecretRotationTester:
    def test_database_credential_rotation(self):
        """Probar proceso de rotación de credenciales de BD"""
        # Obtener credenciales iniciales
        initial_creds = self.vault.get_database_credentials('test')
        initial_password = initial_creds['password']

        # Verificar conexión con credenciales iniciales
        conn1 = psycopg2.connect(
            host=initial_creds['host'],
            user=initial_creds['username'],
            password=initial_password
        )
        assert conn1 is not None
        conn1.close()

        # Disparar rotación de credenciales
        self._rotate_database_credentials('test')
        time.sleep(5)

        # Obtener credenciales rotadas
        rotated_creds = self.vault.get_database_credentials('test')
        rotated_password = rotated_creds['password']

        # Verificar que la contraseña cambió
        assert rotated_password != initial_password

        # Verificar conexión con nuevas credenciales
        conn2 = psycopg2.connect(
            host=rotated_creds['host'],
            user=rotated_creds['username'],
            password=rotated_password
        )
        assert conn2 is not None
        conn2.close()

Detección de Filtración de Secrets

# 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):
        """Escanear archivo en busca de secrets potenciales"""
        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():
    """Verificar que no hay secrets en el repositorio"""
    detector = SecretLeakDetector()

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

    assert len(findings) == 0, f"Se encontraron secrets potenciales: {findings}"

Conclusión

La gestión de secrets en CI/CD requiere un enfoque integral combinando almacenamiento seguro (HashiCorp Vault, SOPS), rotación automatizada, controles de acceso estrictos y monitoreo continuo para detección de filtraciones. Implementando gestión adecuada de secrets con integración Vault, cifrado SOPS, testing de rotación y detección de fugas, los equipos pueden mantener seguridad mientras habilitan automatización eficiente de pruebas.

La clave es tratar los secrets como credenciales dinámicas de corta duración que nunca se hardcodean, siempre se cifran en reposo, se auditan exhaustivamente y se rotan automáticamente.