Saltar al contenido

Protección de secretos en pipelines de CI/CD

Protege las credenciales en CI/CD con gestores de secretos, aislamiento de entornos, mínimo privilegio y auditoría, sin frenar los despliegues.

6 min de lectura
Diagrama de un pipeline de CI/CD que muestra los puntos de inyección de secretos, la integración con un vault y los límites de aislamiento entre entornos

Todo pipeline de CI/CD necesita secretos: claves de API, credenciales de bases de datos, certificados de firma, tokens de proveedores cloud. La forma en que gestionas esos secretos determina si un agente de compilación comprometido se traduce en una molestia menor o en una brecha catastrófica. El patrón más común (guardar los secretos como variables de entorno en la plataforma de CI) es el punto de partida, no la meta. Una seguridad de secretos real implica limitar el radio de impacto, rotar las credenciales de forma automática y mantener registros de auditoría que muestren exactamente quién accedió a qué y cuándo.

La realidad es que los pipelines de CI/CD son objetivos de alto valor. Tienen acceso amplio a los sistemas de producción, ejecutan código proveniente de pull requests y, a menudo, cuentan con controles de acceso más débiles que la propia infraestructura de producción. Un solo secreto filtrado en un log de compilación puede desencadenar el compromiso total de la infraestructura.

El problema de la dispersión de secretos

Los secretos se acumulan en los sistemas de CI/CD como el polvo. Cada integración añade una credencial más, y pocos equipos llevan un registro real de dónde viven esos secretos o quién puede acceder a ellos.

ymlyaml
# ❌ The "works but terrifying" approach
# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      # 15 secrets scattered across GitHub settings
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      REDIS_URL: ${{ secrets.REDIS_URL }}
      STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
      SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      # Nobody remembers who added these or when they were last rotated
    steps:
      - run: npm run deploy
        # All 15 secrets available to every step
        # Any compromised dependency can read them all
ymlyaml
# ✅ Scoped secrets with minimal exposure
name: Deploy
on:
  push:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    # No secrets needed for testing
    steps:
      - uses: actions/checkout@v4
      - run: npm test
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # For OIDC — no stored credentials
      contents: read
    steps:
      - uses: actions/checkout@v4
 
      # Authenticate via OIDC — no long-lived secrets
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/deploy-role
          aws-region: us-east-1
 
      # Fetch secrets from vault, scoped to this deployment
      - name: Fetch deployment secrets
        id: secrets
        run: |
          # Secrets fetched at runtime, never stored in CI config
          vault kv get -format=json secret/prod/app | \
            jq -r '.data.data | to_entries[] | "\(.key)=\(.value)"' >> "$GITHUB_ENV"
 
      - run: npm run deploy

OIDC: cómo eliminar las credenciales de larga duración

La mejora más importante que puedes hacer es eliminar por completo las credenciales de larga duración. OpenID Connect (OIDC) permite que los runners de CI se autentiquen ante los proveedores cloud usando tokens de corta duración.

tstypescript
// How OIDC works in CI/CD:
// 1. CI runner requests a JWT from the CI platform
// 2. Cloud provider validates the JWT against the CI platform's OIDC endpoint
// 3. Cloud provider issues short-lived credentials
// 4. Credentials expire after the job finishes
 
// ❌ Long-lived access key stored in CI secrets
// - Valid until manually rotated (often never)
// - If leaked, attacker has indefinite access
// - No way to scope to specific workflows
 
// ✅ OIDC: no secrets to leak
// - Token valid for ~1 hour
// - Scoped to specific repo/branch/workflow
// - Cloud provider validates the source
ymlyaml
# GitHub Actions OIDC with AWS
jobs:
  deploy:
    permissions:
      id-token: write
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-deploy
          aws-region: us-east-1
          # No access key or secret — OIDC handles it
jsonjson
// AWS IAM trust policy: only trust specific repo and branch
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Patrones de inyección de secretos

Cuando no te quede más remedio que usar secretos (claves de API de terceros que no admiten OIDC), inyéctalos con el alcance y la duración mínimos posibles.

ymlyaml
# ✅ Fetch from vault at runtime with short TTL
- name: Get database credentials
  id: db-creds
  run: |
    # Dynamic credentials: vault creates a temporary database user
    # that expires after 1 hour
    CREDS=$(vault read -format=json database/creds/deploy-role)
    echo "DB_USER=$(echo $CREDS | jq -r '.data.username')" >> "$GITHUB_OUTPUT"
    echo "DB_PASS=$(echo $CREDS | jq -r '.data.password')" >> "$GITHUB_OUTPUT"
 
- name: Run migrations
  env:
    DB_USER: ${{ steps.db-creds.outputs.DB_USER }}
    DB_PASS: ${{ steps.db-creds.outputs.DB_PASS }}
  run: npm run migrate
  # After this step, credentials are no longer in environment
tstypescript
// Application-side: never log secrets, even accidentally
 
// ❌ Dangerous: secrets can appear in error messages
async function connectDatabase(url: string) {
  try {
    await pool.connect(url);
  } catch (error) {
    // This logs the full connection string including password
    console.error('Failed to connect:', error.message);
    throw error;
  }
}
 
// ✅ Safe: redact sensitive portions
async function connectDatabase(url: string) {
  try {
    await pool.connect(url);
  } catch (error) {
    const safeUrl = url.replace(
      /\/\/([^:]+):([^@]+)@/,
      '//$1:***@'
    );
    console.error(`Failed to connect to ${safeUrl}`);
    throw new Error('Database connection failed');
  }
}

Cómo evitar la fuga de secretos en los logs

Los logs de compilación son el vector más común de fuga de secretos. Basta un simple echo $SECRET o un instalador de dependencias demasiado verboso para volcar credenciales en logs que después persisten durante meses.

ymlyaml
# ✅ GitHub Actions: secrets are automatically masked in logs
# But only secrets stored in GitHub — not secrets fetched at runtime
 
- name: Mask runtime secrets
  run: |
    API_KEY=$(vault read -field=key secret/api)
    # Register with the runner's masking system
    echo "::add-mask::$API_KEY"
    echo "API_KEY=$API_KEY" >> "$GITHUB_ENV"
 
# ✅ Prevent accidental logging in scripts
- name: Deploy with secret protection
  run: |
    set +x  # Disable command echoing
    # Use process substitution to avoid secrets in /proc
    deploy-tool --config <(echo "$DEPLOY_CONFIG")
tstypescript
// Build-time secret protection
class SecretGuard {
  private secrets: Set<string>;
 
  constructor(secretValues: string[]) {
    this.secrets = new Set(secretValues);
  }
 
  // Scan output before it reaches logs
  sanitize(output: string): string {
    let sanitized = output;
    for (const secret of this.secrets) {
      if (secret.length < 4) continue; // Don't mask tiny strings
      sanitized = sanitized.replaceAll(secret, '***REDACTED***');
    }
    return sanitized;
  }
 
  // Wrap console to prevent accidental logging
  wrapConsole(): void {
    const original = console.log;
    console.log = (...args: unknown[]) => {
      const sanitized = args.map((arg) =>
        typeof arg === 'string' ? this.sanitize(arg) : arg
      );
      original.apply(console, sanitized);
    };
  }
}

Rotación y gestión del ciclo de vida

Los secretos que nunca rotan son bombas de tiempo. La rotación automatizada garantiza que, aunque una credencial se vea comprometida, su ventana de utilidad para un atacante sea limitada.

tstypescript
// Automated secret rotation workflow
interface SecretRotationConfig {
  secretPath: string;
  rotationDays: number;
  generator: () => Promise<string>;
  deployer: (newSecret: string) => Promise<void>;
  verifier: () => Promise<boolean>;
}
 
async function rotateSecret(config: SecretRotationConfig): Promise<void> {
  const newSecret = await config.generator();
 
  // Store new version (old version still active)
  await vault.write(`${config.secretPath}/pending`, {
    value: newSecret,
    created: new Date().toISOString(),
  });
 
  // Deploy new secret to consuming services
  await config.deployer(newSecret);
 
  // Verify services work with new secret
  const healthy = await config.verifier();
 
  if (healthy) {
    // Promote new secret, archive old one
    await vault.write(config.secretPath, { value: newSecret });
    await vault.delete(`${config.secretPath}/pending`);
    console.log(`Rotated ${config.secretPath} successfully`);
  } else {
    // Rollback: remove pending secret
    await vault.delete(`${config.secretPath}/pending`);
    throw new Error(
      `Rotation failed for ${config.secretPath} — rolled back`
    );
  }
}

Auditoría del acceso a los secretos

Necesitas saber quién accedió a qué secretos y cuándo, tanto para las investigaciones de seguridad como para el cumplimiento normativo.

ymlyaml
# Vault audit log configuration
storage "raft" {
  path = "/vault/data"
}
 
listener "tcp" {
  address = "0.0.0.0:8200"
  tls_cert_file = "/vault/certs/cert.pem"
  tls_key_file = "/vault/certs/key.pem"
}
 
# Enable audit logging — every secret access is recorded
audit {
  type = "file"
  path = "/vault/logs/audit.log"
  options = {
    # HMAC secret values so they don't appear in audit logs
    hmac_accessor = true
    log_raw       = false
  }
}
tstypescript
// Query audit logs for security review
interface AuditQuery {
  secretPath?: string;
  actor?: string;
  operation?: 'read' | 'write' | 'delete';
  startTime: Date;
  endTime: Date;
}
 
async function querySecretAccess(query: AuditQuery) {
  // "Who accessed production database credentials this week?"
  // "Were any secrets accessed outside business hours?"
  // "Which CI jobs read the Stripe API key?"
 
  const logs = await vault.auditLog.query({
    path: query.secretPath,
    auth_entity: query.actor,
    operation: query.operation,
    start_time: query.startTime.toISOString(),
    end_time: query.endTime.toISOString(),
  });
 
  return logs.map((entry) => ({
    timestamp: entry.time,
    actor: entry.auth.display_name,
    operation: entry.request.operation,
    path: entry.request.path,
    source_ip: entry.request.remote_address,
  }));
}

Ideas clave

Elimina las credenciales de larga duración recurriendo a la federación OIDC siempre que sea posible: plataformas de CI como GitHub Actions pueden autenticarse directamente con AWS, GCP y Azure mediante tokens de corta duración limitados a repositorios y ramas específicos, lo que elimina por completo la clase más peligrosa de secretos de CI/CD. Limita el alcance de los secretos al mínimo: cada job del pipeline debería acceder únicamente a los secretos que necesita, los secretos deberían inyectarse a nivel de step y no de workflow, y las credenciales dinámicas de un vault con TTL de una hora son muchísimo más seguras que las claves de API permanentes guardadas en la configuración de CI. Los logs de compilación son el principal vector de fuga: enmascara los secretos obtenidos en tiempo de ejecución con la API de enmascarado de tu plataforma de CI, desactiva el eco de comandos en los scripts de shell y nunca registres cadenas de conexión ni respuestas de API que puedan contener credenciales. Automatiza la rotación con un patrón de despliegue-verificación-promoción, de modo que, aunque un secreto se vea comprometido, la ventana de exposición quede acotada por tu intervalo de rotación y no por el momento en que alguien se acuerde de cambiar la contraseña.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX