Automated Security Testing in CI/CD Pipelines

Automated Security Testing in CI/CD Pipelines

Manual penetration testing finds vulnerabilities — but it happens quarterly, or when someone remembers to schedule it. Meanwhile, your pipeline deploys new code every day.

Automated security testing in CI catches vulnerabilities at the moment they're introduced — when the developer who wrote the code is still context-switched into that feature, making fixes fast and cheap.

This guide covers what to add to your CI/CD pipeline, how to configure each tool, and how to avoid the two failure modes: catching nothing (too loose) or blocking every build (too strict).

The Four Layers of CI Security Testing

Each layer catches different kinds of problems:

  1. SAST (Static Application Security Testing) — analyzes source code for vulnerability patterns without running the code
  2. SCA (Software Composition Analysis) — scans dependencies for known CVEs
  3. Secret scanning — detects credentials committed to source control
  4. DAST (Dynamic Application Security Testing) — attacks a running application to find vulnerabilities

Run them in order: SAST and SCA are fast and run against code; DAST requires a running environment and runs last.

Layer 1: SAST — Static Code Analysis

SAST scans your source code for patterns that commonly indicate vulnerabilities: unsanitized user input, dangerous function calls, insecure configurations.

Semgrep

Semgrep is the most practical SAST tool for CI. It's fast, has thousands of community rules, and produces low false-positive rates.

# .github/workflows/security.yml
- name: Semgrep SAST
  uses: semgrep/semgrep-action@v1
  with:
    config: >-
      p/security-audit
      p/owasp-top-ten
      p/javascript
      p/typescript
    generateSarif: true
  env:
    SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

- name: Upload SARIF results
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: semgrep.sarif

The p/security-audit and p/owasp-top-ten rulesets cover the most important patterns. SARIF upload shows findings inline in GitHub PR reviews.

Language-Specific SAST

For deeper analysis, add language-specific tools:

Python:

- name: Bandit (Python security)
  run: |
    pip install bandit
    bandit -r . -f json -o bandit-results.json
    bandit -r . -ll  # Only high-severity findings (ll = high confidence/severity)

Ruby on Rails:

- name: Brakeman
  run: |
    gem install brakeman
    brakeman -q --no-pager -o brakeman.json --format json
    brakeman -q --no-pager --exit-on-warn

Java:

- name: SpotBugs with FindSecBugs
  run: mvn com.github.spotbugs:spotbugs-maven-plugin:check -Dspotbugs.effort=max

Go:

- name: gosec
  uses: securego/gosec@master
  with:
    args: ./...

Configuring Severity Thresholds

Don't fail builds on every finding — that leads to alert fatigue and developers disabling the check. Start by:

  1. Running SAST in "audit" mode (report-only, no failures) for 2 weeks
  2. Review findings — mark false positives, fix true positives
  3. Set threshold at high severity only to fail builds
  4. Gradually tighten to medium severity as the team adapts
# Semgrep — only fail on high severity
- name: Semgrep
  run: semgrep --config=p/security-audit --severity=ERROR --error
  # ERROR = high severity only; WARNING = medium; INFO = low

Layer 2: SCA — Dependency Scanning

Your code might be clean, but your dependencies might not be. SCA checks every library you import against known CVE databases.

npm audit / yarn audit

- name: Dependency audit
  run: |
    npm audit --audit-level=high
    # 'high' = fail only on high/critical vulnerabilities
    # 'moderate' = fail on medium and above
    # 'low' = fail on anything

npm audit fix resolves most issues automatically. For packages without automatic fixes, evaluate:

  1. Is there a newer version that fixes the CVE?
  2. Is this package actually reachable from an attacker's perspective?
  3. Can you replace the package?

Snyk

Snyk provides deeper analysis than npm audit, including reachability analysis (does your code actually call the vulnerable function?):

- name: Snyk vulnerability scan
  uses: snyk/actions/node@master
  env:
    SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
  with:
    args: --severity-threshold=high

Snyk also monitors your dependencies continuously — you get alerts when new CVEs are published for packages you use, even without a new commit.

Dependabot

Enable GitHub's Dependabot for automated dependency update PRs:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]  # Only minor/patch updates

This keeps dependencies current without requiring manual attention.

Layer 3: Secret Scanning

Credentials in source code are one of the most common and high-impact security failures. A committed AWS key can be exploited within minutes of being pushed.

Gitleaks

Gitleaks scans commits for patterns that look like secrets: API keys, passwords, tokens, private keys.

- name: Gitleaks secret scan
  uses: gitleaks/gitleaks-action@v2
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}

Gitleaks checks not just the current code, but the entire git history. This matters because git rm doesn't remove the secret from history.

GitHub Secret Scanning

If using GitHub, enable native secret scanning:

  1. Repository Settings → Security → Secret scanning → Enable
  2. Also enable "Push protection" — this blocks pushes containing known secret patterns before they're even committed

GitHub has detection patterns for 200+ secret types (AWS keys, Stripe keys, GitHub tokens, etc.).

Pre-commit Hooks

Catch secrets before they're committed, not after:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/zricethezav/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: detect-private-key
      - id: detect-aws-credentials
# Install pre-commit hooks
pip install pre-commit
pre-commit install

Handling False Positives

When a test fixture or documentation legitimately contains something that looks like a secret, use .gitleaksignore:

# .gitleaksignore
[allowlist]
  description = "Global allowlist"
  paths = [
    '''tests/fixtures/''',
    '''docs/examples/'''
  ]
  regexes = [
    '''EXAMPLE_API_KEY_DO_NOT_USE''',
  ]

Layer 4: DAST — Dynamic Testing

DAST attacks a running application. It can't run until your staging environment is deployed, so it runs last in the pipeline.

OWASP ZAP

ZAP is the standard open-source DAST tool. It has three modes for CI:

Baseline scan — passive scan only, no active attacks. Fast, zero false positives, catches obvious issues:

- name: ZAP Baseline Scan
  uses: zaproxy/action-baseline@v0.10.0
  with:
    target: 'https://staging.example.com'
    rules_file_name: '.zap/rules.tsv'
    cmd_options: '-I'  # -I = don't fail on warnings

Full scan — active scan, sends attack payloads. Slower, more thorough, more false positives:

- name: ZAP Full Scan
  uses: zaproxy/action-full-scan@v0.9.0
  with:
    target: 'https://staging.example.com'
    rules_file_name: '.zap/rules.tsv'

API scan — targets an API using an OpenAPI spec:

- name: ZAP API Scan
  uses: zaproxy/action-api-scan@v0.6.0
  with:
    target: 'https://staging.example.com/api/openapi.json'
    format: openapi

Configuring ZAP Rules

Control which alerts fail the build with a rules file:

# .zap/rules.tsv
# Rule ID	Threshold (IGNORE/WARN/FAIL)
10202	IGNORE	# Absence of Anti-CSRF Tokens (too many false positives)
10035	WARN	# Strict-Transport-Security Header Not Set
10038	FAIL	# Content Security Policy Header Not Set
40012	FAIL	# Cross Site Scripting (Reflected)
40014	FAIL	# Cross Site Scripting (Persistent)
40018	FAIL	# SQL Injection

Start with most rules at WARN, manually failing only the most critical. Tighten over time.

Nuclei

Nuclei is template-based and excellent for checking specific known vulnerabilities:

- name: Nuclei vulnerability scan
  uses: projectdiscovery/nuclei-action@main
  with:
    target: https://staging.example.com
    flags: "-t cves/ -t exposures/ -severity critical,high"

Nuclei's community template library (7,000+ templates) covers CVEs, misconfigurations, and exposed panels. The -severity critical,high flag limits output to actionable findings.

Complete Pipeline Example

Here's a complete GitHub Actions workflow that implements all four layers:

# .github/workflows/security.yml
name: Security Testing

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  # Layer 1: SAST - runs in parallel with SCA
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Semgrep
        uses: semgrep/semgrep-action@v1
        with:
          config: p/security-audit p/owasp-top-ten
        env:
          SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

  # Layer 2: SCA
  sca:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - run: npm ci
      
      - name: Dependency audit
        run: npm audit --audit-level=high

  # Layer 3: Secret scanning
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for git scanning
      
      - name: Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Layer 4: DAST - runs after deploy to staging
  dast:
    runs-on: ubuntu-latest
    needs: [sast, sca, secrets]  # Only run if earlier checks pass
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Deploy to staging
        run: ./scripts/deploy-staging.sh
        env:
          STAGING_DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
      
      - name: Wait for deployment
        run: |
          timeout 300 bash -c 'until curl -sf https://staging.example.com/health; do sleep 5; done'
      
      - name: ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.10.0
        with:
          target: 'https://staging.example.com'
          rules_file_name: '.zap/rules.tsv'
      
      - name: Upload ZAP report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: zap-report
          path: report_html.html

Managing Findings Effectively

Don't Block Every Build

The goal is not zero findings — it's zero unacknowledged high-risk findings. For everything else:

  1. Critical/High: Block the build. Fix before merge.
  2. Medium: Create a tracking ticket. Fix within sprint.
  3. Low/Informational: Log and review periodically.

Tracking Security Debt

Create a security-baseline.json that documents accepted risks:

{
  "accepted_risks": [
    {
      "id": "SNYK-JS-LODASH-1040724",
      "reason": "Only used in test code, not reachable in production",
      "accepted_by": "security-team",
      "review_date": "2026-09-01"
    }
  ]
}

Use snyk ignore or semgrep --exclude with documented reasons. Suppressing without documentation is how security debt silently accumulates.

Security Gates for Different Branch Types

Apply different strictness by branch:

- name: Security scan
  run: |
    if [ "$GITHUB_REF" = "refs/heads/main" ]; then
      semgrep --config=p/security-audit --severity=WARNING --error
    else
      semgrep --config=p/security-audit --severity=ERROR --error
    fi

Feature branches fail only on high severity. Main branch fails on medium and above. This allows development velocity while enforcing standards at merge time.

Connecting Automated to Manual Testing

CI security testing is not a replacement for manual penetration testing. It's the layer that:

  • Catches new issues as code is introduced
  • Enforces baseline security hygiene on every PR
  • Provides coverage between manual testing cycles

Use the automated results to scope manual testing: if SAST shows no injection vulnerabilities, the manual pentest can go deeper on business logic and authentication rather than re-testing basics.

For continuous coverage of your authenticated application workflows, HelpMeTest can run security-oriented test scenarios (auth bypass attempts, unauthorized access checks) continuously against staging — bridging the gap between automated scans and manual pentest cycles.

See also:

Read more

Start now free