Back

CICD-SEC-6: Insufficient Credential Hygiene - Code Examples

Each pair below shows an insecure way of handling a CI/CD secret and the secure version of the same task. The focus is the mishandling that dominates real findings: secrets in pipeline YAML, static keys, secrets in logs, secrets baked into images, and the absence of scanning.

1. Cloud Credentials in Pipeline YAML (GitHub Actions)

Insecure

# .github/workflows/deploy.yml
name: deploy
on: { push: { branches: [main] } }
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to S3
        run: aws s3 sync ./dist s3://prod-assets
        env:
          # Long-lived static keys pasted directly into the workflow file.
          AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
          AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

Why it's dangerous: the keys are committed to version control (and recoverable from history), long-lived, and broadly scoped—anyone who reads the repo owns the account until someone notices and rotates.

Secure

# .github/workflows/deploy.yml - no stored keys at all; OIDC mints a short-lived token
name: deploy
on: { push: { branches: [main] } }
permissions:
  id-token: write        # request an OIDC identity token
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/ci-deployer
          aws-region: us-east-1        # role is minted per job, expires in minutes
      - name: Deploy to S3
        run: aws s3 sync ./dist s3://prod-assets

Why it's safe: nothing is stored to leak. The workflow presents a signed OIDC token; the cloud returns a short-lived credential bound to this repo and branch (see the trust policy in Prevention, section 2).

2. Static Secret vs. CI Secret Store (GitLab CI)

Insecure

# .gitlab-ci.yml
deploy:
  script:
    - export DB_PASSWORD="S3cr3tP@ss!"          # hardcoded in the pipeline file
    - export REGISTRY_TOKEN="glpat-abc123def456" # committed for all to see
    - ./deploy.sh

Why it's dangerous: the secrets live in the repository, are visible to every project member, and persist in history and forks.

Secure

# .gitlab-ci.yml - values come from protected, masked, environment-scoped variables
deploy_prod:
  stage: deploy
  environment: production          # variable visible only to prod deploys
  script:
    - ./deploy.sh                  # DB_PASSWORD / REGISTRY_TOKEN injected at runtime
  # In Settings > CI/CD > Variables: mark each as "Masked" and "Protected",
  # and scope to the "production" environment so test jobs never see them.

Why it's safe: the secret is stored outside the repo, masked in logs, and only exposed to the specific protected job that needs it.

3. Secrets Leaked to Build Logs

Insecure

build:
  script:
    - set -x                                   # traces EVERY expanded variable
    - echo "Using token $DEPLOY_TOKEN"         # explicit print defeats masking
    - curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/deploy
    # Log now contains:  + curl -H 'Authorization: Bearer ghp_ab12...'

Why it's dangerous: shell tracing and explicit echoes push the raw secret into logs that are retained, forwarded to aggregators, and broadly readable.

Secure

build:
  script:
    - set +x                                   # never trace around secrets
    # Register any dynamically derived secret with the masker:
    - 'echo "::add-mask::$DEPLOY_TOKEN"'       # GitHub Actions masking directive
    # Pass the secret without printing it - let the tool read it from the env:
    - curl --silent -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/deploy
    # No echo of the value; tracing off; log access restricted to the team.

Why it's safe: the value never reaches stdout/stderr. Masking is a backstop, not the primary control—the secret is simply never printed.

4. Secret Baked into a Container Image

Insecure

# Dockerfile
FROM node:20-slim
# ARG/ENV secrets are stored in the image layers forever:
ARG NPM_TOKEN=npm_9f8a7b6c5d4e3f2a1b0c
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm ci
# `docker history` and layer extraction reveal NPM_TOKEN to anyone who pulls the image.

Why it's dangerous: build-time ARG/ENV secrets persist in image layers and travel to every registry, laptop, and environment the image reaches.

Secure

# Dockerfile - BuildKit secret mount: available for one step, stored in NO layer
# syntax=docker/dockerfile:1
FROM node:20-slim
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci

# Build command supplies the secret from the environment, not the image:
#   DOCKER_BUILDKIT=1 docker build --secret id=npm_token,env=NPM_TOKEN -t app .
# Runtime config is injected by the orchestrator (K8s Secret / secret manager),
# never baked into the image.

Why it's safe: the token is mounted only for the npm ci step and leaves no trace in the final image; docker history shows nothing.

5. Over-Shared Token vs. Scoped, Least-Privilege Identity

Insecure

# One "god" token stored once and reused by every pipeline:
#   - policy: AdministratorAccess  ("Action": "*", "Resource": "*")
#   - used by 14 workflows across dev, staging, and prod
# Result: a leak from the lowest-value pipeline compromises everything,
# and rotating it breaks all 14 pipelines at once.

Secure

# Distinct, least-privilege identity per environment/pipeline. Example policy
# for a deploy job that only needs to sync one bucket:
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:ListBucket"],
    "Resource": [
      "arn:aws:s3:::prod-assets",
      "arn:aws:s3:::prod-assets/*"
    ]
  }]
}
# A leak of THIS credential exposes one bucket, and rotating it affects one pipeline.

Why it's safe: narrow scope contains the blast radius of any leak and makes rotation a routine, low-risk operation.

6. Adding Secret Scanning to the Pipeline

Insecure (no scanning at all)

# No pre-commit hook and no CI gate:
#   a committed secret merges to main, is mirrored and forked,
#   and is discovered by an attacker's scanner before anyone internal notices.

Secure (scan pre-commit AND in CI, full history)

# .pre-commit-config.yaml - block secrets before they are committed
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
# .github/workflows/secret-scan.yml - fail the build on any detected secret
name: secret-scan
on: [pull_request, push]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }             # scan FULL history, not just the tip
      - name: gitleaks
        run: gitleaks detect --source . --redact --exit-code 1
      - name: trufflehog (verified secrets only)
        run: trufflehog git file://. --only-verified --fail

Why it's safe: leaks are caught pre-commit and again in CI across the whole history, so a secret is blocked or surfaced immediately—before the attacker's scanner finds it.

What Changed, and Why

MishandlingInsecureSecure
Cloud accessStatic keys in pipeline YAMLShort-lived OIDC-federated role, per job
StorageHardcoded in code / YAMLCI secret store or secrets manager, runtime-injected
Logsset -x / echo print the valueNever printed; masking as backstop
ImagesSecret in a build ARG/ENV layerBuildKit secret mount; runtime injection
ScopeOne shared admin tokenPer-pipeline least-privilege identity
DetectionNonegitleaks/trufflehog pre-commit + CI, full history

Next Steps