Zum Inhalt springen

Secrets in CI/CD-Pipelines richtig absichern

Schütze Zugangsdaten in CI/CD-Pipelines mit Secret-Managern, Umgebungsisolation, Least Privilege und Audit-Trails — ohne Tempoverlust.

6 Min. Lesezeit
Diagramm einer CI/CD-Pipeline mit den Injection-Punkten für Secrets, der Vault-Integration und den Grenzen der Umgebungsisolation

Jede CI/CD-Pipeline braucht Secrets: API-Keys, Datenbank-Zugangsdaten, Signaturzertifikate, Tokens für Cloud-Provider. Wie du mit diesen Secrets umgehst, entscheidet darüber, ob ein kompromittierter Build-Agent nur ein kleines Ärgernis oder ein katastrophaler Sicherheitsvorfall ist. Das gängigste Muster – Secrets als Umgebungsvariablen in der CI-Plattform zu speichern – ist der Ausgangspunkt, nicht das Ziel. Echte Secret-Sicherheit bedeutet, den Blast Radius zu begrenzen, Zugangsdaten automatisch zu rotieren und Audit-Trails zu führen, die genau zeigen, wer wann worauf zugegriffen hat.

Tatsache ist: CI/CD-Pipelines sind hochattraktive Angriffsziele. Sie haben weitreichenden Zugriff auf Produktionssysteme, führen Code aus Pull Requests aus und verfügen oft über schwächere Zugriffskontrollen als die Produktionsinfrastruktur selbst. Ein einziges Secret, das in einem Build-Log durchsickert, kann eine Kettenreaktion auslösen und die gesamte Infrastruktur kompromittieren.

Das Secret-Sprawl-Problem

Secrets sammeln sich in CI/CD-Systemen wie Staub an. Jede neue Integration bringt eine weitere Zugangsdaten-Art mit sich, und nur wenige Teams behalten den Überblick, wo diese Secrets tatsächlich liegen oder wer darauf zugreifen kann.

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: langlebige Zugangsdaten eliminieren

Die größte Verbesserung, die du erreichen kannst, ist die vollständige Abschaffung langlebiger Zugangsdaten. Mit OpenID Connect (OIDC) können sich CI-Runner bei Cloud-Providern mit kurzlebigen Tokens authentifizieren.

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"
        }
      }
    }
  ]
}

Muster für die Secret-Injection

Wenn du nicht um Secrets herumkommst – etwa bei API-Keys von Drittanbietern ohne OIDC-Unterstützung –, injiziere sie mit minimalem Geltungsbereich und minimaler Lebensdauer.

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');
  }
}

Das Durchsickern von Secrets in Logs verhindern

Build-Logs sind der häufigste Weg, über den Secrets durchsickern. Ein einziges echo $SECRET oder ein zu geschwätziger Dependency-Installer genügt, um Zugangsdaten in Logs zu schreiben, die monatelang erhalten bleiben.

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);
    };
  }
}

Rotation und Lifecycle-Management

Secrets, die nie rotiert werden, sind tickende Zeitbomben. Automatisierte Rotation sorgt dafür, dass kompromittierte Zugangsdaten nur für ein begrenztes Zeitfenster nutzbar sind.

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`
    );
  }
}

Zugriffe auf Secrets auditieren

Du musst wissen, wer wann auf welche Secrets zugegriffen hat – sowohl für Sicherheitsuntersuchungen als auch für die Compliance.

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,
  }));
}

Die wichtigsten Erkenntnisse

Eliminiere langlebige Zugangsdaten, wo immer möglich, durch OIDC-Föderation: CI-Plattformen wie GitHub Actions können sich direkt bei AWS, GCP und Azure mit kurzlebigen Tokens authentifizieren, die auf bestimmte Repositories und Branches beschränkt sind – damit fällt die gefährlichste Klasse von CI/CD-Secrets komplett weg. Beschränke den Geltungsbereich von Secrets auf ein Minimum: Jeder Pipeline-Job sollte nur auf die Secrets zugreifen, die er wirklich braucht, Secrets sollten auf Step-Ebene statt auf Workflow-Ebene injiziert werden, und dynamische Zugangsdaten aus einem Vault mit einstündiger TTL sind erheblich sicherer als dauerhafte API-Keys in der CI-Konfiguration. Build-Logs sind der wichtigste Weg, über den Secrets durchsickern: Maskiere zur Laufzeit abgerufene Secrets mit der Masking-API deiner CI-Plattform, deaktiviere das Echo von Befehlen in Shell-Skripten und logge niemals Connection-Strings oder API-Antworten, die Zugangsdaten enthalten könnten. Automatisiere die Rotation mit einem Deploy-Verify-Promote-Muster, damit selbst bei einem kompromittierten Secret das Zeitfenster der Gefährdung durch dein Rotationsintervall begrenzt wird – und nicht dadurch, wann jemand zufällig daran denkt, das Passwort zu ändern.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX