BloggenDevOps
DevOps

CI/CD Pipeline Security: SAST, DAST, Secrets Scanning, and Dependency Auditing

Your CI/CD pipeline is an attack surface. Compromised pipelines lead to supply chain attacks that inject malicious code into production. This guide covers securing GitHub Actions, implementing SAST with Semgrep, DAST with OWASP ZAP, secrets scanning with Gitleaks, and dependency auditing with Trivy.

M

Marcus Rodriguez

Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.

March 25, 2026
blogDetail.minRead

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.

M

Marcus Rodriguez

Lead DevOps Engineer specializing in CI/CD pipelines, container orchestration, and infrastructure automation.

Klaar om uw infrastructuur te transformeren?

Laten we bespreken hoe we u kunnen helpen vergelijkbare resultaten te bereiken.