Skip to content

Security in CI/CD

Security Belongs in the Pipeline

Security checks that run only at the end are too late. Shift left: catch vulnerabilities at the PR stage, not after deploy.

PR → [Secret scan] → [Dependency audit] → [SAST] → [Image scan] → Deploy

Every stage adds a layer. No single tool catches everything.


Secrets Management

Vaults

Never store secrets in CI/CD YAML files. Use a secret store.

Tool Provider Best For
GitHub Secrets GitHub Simple CI secrets
HashiCorp Vault Any Dynamic secrets, rotation
AWS Secrets Manager AWS AWS-native workloads
GCP Secret Manager GCP GCP-native workloads
Kubernetes Secrets K8s Pod-level secret injection
# GitHub Secrets — injected as env vars, never printed in logs
- name: Deploy
  run: ./deploy.sh
  env:
    DB_URL: ${{ secrets.PROD_DB_URL }}
    JWT_SECRET: ${{ secrets.JWT_SECRET }}

GitHub masks secret values in logs automatically.

Secret Rotation

Secrets must expire. Static credentials that never rotate are a permanent liability.

  • Database passwords: rotate every 90 days
  • API keys: rotate on team member departure
  • CI tokens: rotate every 30 days for production access
  • Use HashiCorp Vault dynamic secrets where possible (TTL-based auto-rotation)

Dependency Scanning

Scan for known CVEs in dependencies. Block on critical severity.

- name: Dependency audit
  run: uv run pip-audit --fail-on-vuln --severity critical

Automated Dependency Updates

Use Dependabot or Renovate to auto-create PRs for dependency updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "pip"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5

  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"

Static Application Security Testing (SAST)

SAST analyses source code for security anti-patterns without running it.

- name: SAST with Bandit (Python)
  run: uv run bandit -r src/ -ll -ii

- name: SAST with Semgrep
  uses: returntocorp/semgrep-action@v1
  with:
    config: "p/python p/secrets p/sql-injection"
Tool Language Checks
Bandit Python Hardcoded passwords, SQL injection, unsafe deserialization
Semgrep Multi Custom rules, OWASP Top 10
CodeQL Multi Deep semantic analysis

Container Image Scanning

Docker images contain OS packages with their own CVEs.

- name: Scan image with Trivy
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
    format: sarif
    output: trivy-results.sarif
    exit-code: '1'
    severity: 'CRITICAL,HIGH'

- name: Upload to GitHub Security tab
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: trivy-results.sarif

Scan on every image push. Results appear in GitHub's Security tab.


Supply Chain Security

Verify that the code that built the artifact is the code that was reviewed.

# Sign Docker images with Cosign (SLSA compliance)
- name: Sign image
  uses: sigstore/cosign-installer@v3

- name: Cosign sign
  run: |
    cosign sign --yes \
      ghcr.io/${{ github.repository }}:${{ github.sha }}
  env:
    COSIGN_EXPERIMENTAL: 1

Consumers verify the signature before deploying:

cosign verify ghcr.io/org/api:sha-abc1234 \
  --certificate-identity-regexp="https://github.com/org/repo"


Minimal Permission Principle in CI

Each job must have the minimum permissions it needs. Nothing more.

permissions:
  contents: read         # checkout only
  packages: write        # push to registry
  security-events: write # upload SARIF results
  id-token: write        # OIDC for cloud auth

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write    # only what deploy needs

Avoid permissions: write-all — it grants unnecessary access to every resource.