Your CI/CD Pipeline Is an Attack Surface
The SolarWinds breach (2020), the Codecov attack (2021), the XZ Utils backdoor (2024) — all were supply chain attacks that compromised the build pipeline, not the application itself. Attackers increasingly target CI/CD because it's the most privileged part of your infrastructure: it has access to production secrets, can push to registries, and can deploy to Kubernetes clusters.
In 2025, 45% of significant data breaches involved some form of build pipeline compromise (CISA report). The attack vectors are well-understood: stolen GitHub tokens, malicious pull requests that exfiltrate secrets in CI logs, compromised npm dependencies, and typosquatting attacks targeting popular packages.
DevSecOps — integrating security into the CI/CD pipeline — is the response. This guide shows how to build a security-first pipeline that catches vulnerabilities before they reach production, without slowing development velocity.
The Security Scanning Pipeline
Developer Push/PR → CI/CD Pipeline
│
┌────┴──────────────────────────────┐
│ Security Gate (all must pass) │
│ │
│ 1. Secrets Scan (Gitleaks) │ ← 30 seconds
│ 2. Dependency Audit (Trivy/npm) │ ← 2 minutes
│ 3. SAST (Semgrep/CodeQL) │ ← 3-10 minutes
│ 4. Container Scan (Trivy) │ ← 2 minutes
│ 5. IaC Scan (Checkov/tfsec) │ ← 1 minute
│ 6. DAST (OWASP ZAP) │ ← 10-30 minutes
└───────────────────────────────────┘
│
Deploy to Production
Securing GitHub Actions Workflows
Principle of Least Privilege for Workflows
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
# CRITICAL: Restrict default permissions
permissions:
contents: read # Only read repository content by default
jobs:
security-scan:
runs-on: ubuntu-latest
# Grant only what this job needs
permissions:
contents: read
security-events: write # Required for uploading SARIF results
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false # Don't persist GitHub token
fetch-depth: 0 # Full history for git-based scanning
deploy:
needs: [security-scan, build, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
id-token: write # For OIDC (keyless AWS auth, no stored secrets)
steps:
- name: Configure AWS credentials (OIDC, no stored secret)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
# No AWS_ACCESS_KEY_ID or SECRET needed — uses short-lived OIDC token!
Pinning Action Versions (Prevent Tampered Actions)
# BAD: Using mutable tags — maintainer could push malicious code to @v4
- uses: actions/checkout@v4 # ❌ Tag can be moved to different commit
# GOOD: Pin to commit SHA — immutable
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 ✅
# Use Dependabot to keep pinned SHAs updated:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns: ["*"]
Protecting Secrets in CI Logs
# Prevent secret exfiltration from PR workflows
on:
pull_request:
# NEVER trigger "pull_request_target" for untrusted PRs
# pull_request_target runs with WRITE access — dangerous for forked PRs
# Safe: pull_request runs with READ access only
# Secrets are NOT available to pull_request events from forks (correct!)
# Adding review gate for external PRs
- name: Check if external PR
if: github.event.pull_request.head.repo.full_name != github.repository
run: echo "External PR — secrets not available, security scan only mode"
# Audit your secrets usage:
# Run: gitleaks detect --source . --config .gitleaks.toml
# or: git log --all | grep -E "(password|secret|key|token)" -i
Secrets Scanning with Gitleaks
# .github/workflows/secrets-scan.yml
name: Secrets Scan
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # Scan full git history
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: {{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: {{ secrets.GITLEAKS_LICENSE }}
# .gitleaks.toml — Custom rules
title = "Company Gitleaks Config"
[extend]
# Use community rules as base
useDefault = true
[[rules]]
id = "company-api-key"
description = "Company internal API key"
regex = '''COMPANY_[A-Z0-9]{32}'''
tags = ["company", "api-key"]
[[rules]]
id = "jwt-token"
description = "JWT token (hardcoded)"
regex = '''eyJ[A-Za-z0-9-_=]+.eyJ[A-Za-z0-9-_=]+.?[A-Za-z0-9-_.+/=]*'''
tags = ["jwt"]
[allowlist]
description = "Allowlisted files"
paths = [
'''.gitleaks.toml''',
'''tests/fixtures/''',
'''docs/examples/''',
]
regexes = [
'''EXAMPLE_KEY_DO_NOT_USE''',
'''your-api-key-here''',
]
# Pre-commit hook to catch secrets before push
pip install pre-commit
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: detect-private-key
- id: check-added-large-files
args: ['--maxkb=500']
EOF
pre-commit install # Install hooks in local repo
Static Analysis Security Testing (SAST) with Semgrep
# .github/workflows/sast.yml
name: SAST
on: [push, pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run Semgrep
run: |
semgrep scan --config auto --config "p/owasp-top-ten" --config "p/security-audit" --config "p/python" --config "p/javascript" --config "p/typescript" --config "p/dockerfile" --sarif --output semgrep.sarif --error # Exit non-zero on findings
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
# Custom Semgrep rules (.semgrep/company-rules.yml)
rules:
- id: hardcoded-credentials
patterns:
- pattern: |
$X = "..."
...
password = $X
message: "Possible hardcoded credential in $X"
languages: [python, javascript, typescript]
severity: ERROR
- id: sql-injection-string-format
patterns:
- pattern: |
cursor.execute(f"... {$VAR} ...")
- pattern: |
cursor.execute("..." % $VAR)
message: "Potential SQL injection via string formatting. Use parameterized queries."
languages: [python]
severity: ERROR
- id: missing-auth-decorator
patterns:
- pattern: |
@app.route(...)
def $FUNC(...):
...
- pattern-not: |
@login_required
@app.route(...)
def $FUNC(...):
...
message: "Route handler missing @login_required decorator"
languages: [python]
severity: WARNING
- id: unsafe-yaml-load
pattern: yaml.load($DATA)
message: "yaml.load() with untrusted input is unsafe. Use yaml.safe_load()"
languages: [python]
severity: ERROR
Dependency Vulnerability Scanning
# .github/workflows/dependency-scan.yml
name: Dependency Scan
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 8 * * 1' # Weekly Monday 8am scan
jobs:
trivy-fs:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# Scan filesystem for vulnerable dependencies
- name: Trivy filesystem scan
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-fs.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-fs.sarif
npm-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- name: npm audit
run: npm audit --audit-level=high --json | tee npm-audit.json
continue-on-error: true
- name: Check audit results
run: |
VULNS=$(cat npm-audit.json | jq '.metadata.vulnerabilities.high + .metadata.vulnerabilities.critical')
if [ "$VULNS" -gt "0" ]; then
echo "Found $VULNS high/critical vulnerabilities!"
cat npm-audit.json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical") | .key'
exit 1
fi
Infrastructure as Code Security Scanning
# Scan Terraform, Kubernetes manifests, Dockerfiles for misconfigs
name: IaC Security Scan
jobs:
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform,kubernetes,dockerfile,github_actions
output_format: sarif
output_file_path: checkov.sarif
soft_fail: false
check: CKV_AWS_,CKV_K8S_,CKV_DOCKER_ # AWS, K8s, Docker checks
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov.sarif
tfsec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run tfsec
uses: aquasecurity/tfsec-action@v1
with:
working_directory: infrastructure/terraform
format: sarif
sarif_file: tfsec.sarif
Dynamic Application Security Testing (DAST) with OWASP ZAP
# DAST runs against a running application (staging environment)
name: DAST Scan
on:
push:
branches: [main]
jobs:
zap-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Start application
run: |
docker compose -f docker-compose.test.yml up -d
# Wait for health check
for i in {1..30}; do
curl -sf http://localhost:8080/health && break
sleep 2
done
- name: ZAP Baseline Scan (quick, non-invasive)
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'http://localhost:8080'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a' # Include alpha passive rules
allow_issue_writing: false
- name: ZAP Full Scan (thorough, use for main branch only)
if: github.ref == 'refs/heads/main'
uses: zaproxy/action-full-scan@v0.10.0
with:
target: 'http://localhost:8080'
rules_file_name: '.zap/rules.tsv'
- name: Shutdown
if: always()
run: docker compose -f docker-compose.test.yml down
# .zap/rules.tsv — Configure which ZAP alerts to fail/warn/ignore
# Format: rule-id ACTION
10202 WARN # Absence of Anti-CSRF Tokens (warn, not fail, for APIs)
10038 FAIL # Content Security Policy Header Not Set
10020 FAIL # X-Frame-Options Header Not Set
10037 FAIL # Server Leaks Information via "X-Powered-By"
40012 FAIL # Cross Site Scripting (Reflected)
40014 FAIL # Cross Site Scripting (Persistent)
90022 WARN # Application Error Disclosure
SBOM and Software Supply Chain
# Generate SBOM on every build for compliance
name: SBOM Generation
jobs:
sbom:
runs-on: ubuntu-latest
permissions:
contents: write # To attach SBOM to release
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# Generate SBOM from source
- name: Generate SBOM with Syft
uses: anchore/sbom-action@v0
with:
format: spdx-json
output-file: sbom.spdx.json
# Scan SBOM for known vulnerabilities
- name: Scan SBOM with Grype
uses: anchore/scan-action@v3
with:
sbom: sbom.spdx.json
fail-build: true
severity-cutoff: critical
# Attest SBOM to container image
- name: Attest SBOM
uses: actions/attest-sbom@v1
with:
subject-name: ghcr.io/{{ github.repository }}
subject-digest: {{ steps.build.outputs.digest }}
sbom-path: sbom.spdx.json
Pipeline Security Scorecard
Use OpenSSF Scorecard to automatically audit your repository's security posture:
name: OpenSSF Scorecard
on:
branch_protection_rule:
schedule:
- cron: '0 8 * * 1' # Weekly
permissions:
security-events: write
id-token: write
contents: read
actions: read
jobs:
scorecard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@v2.3.1
with:
results_file: scorecard-results.sarif
results_format: sarif
publish_results: true
# Scorecard checks:
# - Branch Protection (are main/release branches protected?)
# - Code Review (are PRs reviewed before merge?)
# - Maintained (is the project actively maintained?)
# - Dependency Update Tool (Dependabot/Renovate configured?)
# - Token Permissions (minimal permissions in workflows?)
# - Pinned Dependencies (are actions pinned to SHAs?)
# - SAST (static analysis tools configured?)
# - Fuzzing (fuzz testing configured?)
Incident Response for Pipeline Compromises
# Immediate response if pipeline compromise suspected
# 1. Revoke all pipeline secrets immediately
gh secret delete AWS_ACCESS_KEY_ID -R org/repo
gh secret delete DEPLOY_KEY -R org/repo
# 2. Check GitHub audit log for suspicious access
gh api /orgs/ORGNAME/audit-log --paginate --jq '.[] | select(.action | startswith("secrets"))' | grep -E "created_at|actor|action|repo"
# 3. Check recent workflow runs for exfiltration attempts
gh run list --limit 50 --json databaseId,workflowName,conclusion,createdAt
# 4. Rotate AWS credentials used by CI
aws iam create-access-key --user-name github-actions-ci
# Update GitHub secret, then:
aws iam delete-access-key --access-key-id OLD_KEY_ID --user-name github-actions-ci
# 5. Check if any secrets were logged
gh run view RUN_ID --log | grep -iE "(token|secret|password|key)" | head -20
# 6. Invalidate any deployed tokens (JWT secret rotation, session invalidation)
# Notify security team and prepare incident report
Conclusion
A secure CI/CD pipeline implements defense in depth: secrets scanning catches credentials before they're committed, dependency scanning catches known CVEs before deployment, SAST catches code-level vulnerabilities during development, container scanning ensures clean images, and DAST validates running application behavior. Together, these gates catch the vast majority of security issues before they reach production.
The key is making security a fast lane, not a speed bump. Most of these scans run in parallel and complete in under 10 minutes total. The cost of a pipeline security scan is minutes. The cost of a supply chain compromise is months of incident response, regulatory scrutiny, and customer trust loss.
Marcus Rodriguez
Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.