Security Testing in CI/CD: Integrating SAST and DAST into Pipelines
Security testing bolted on at the end of the development cycle catches vulnerabilities too late — when fixing them means rework, delayed releases, and expensive patches. The solution is shifting security into the CI/CD pipeline, where it runs automatically on every change and catches issues when they're cheapest to fix.
This guide covers building a layered security testing pipeline using SAST, DAST, dependency scanning, and secret detection.
The Layered Security Pipeline
Security in CI/CD isn't a single tool — it's a series of automated checks, each covering a different layer:
| Layer | Tool Type | When to Run | What It Catches |
|---|---|---|---|
| Secrets | Secret scanner | Pre-commit, PR | Hardcoded credentials, API keys |
| Dependencies | SCA (Snyk, OWASP Dependency-Check) | PR, nightly | Known CVEs in packages |
| Code | SAST (Semgrep, CodeQL) | PR | SQL injection, XSS, path traversal in your code |
| Infrastructure | IaC scanner (Checkov, tfsec) | PR | Misconfigured cloud resources |
| Runtime | DAST (ZAP, Nuclei) | Post-deploy to staging | Exploitable vulnerabilities in the running app |
| Application logic | Functional security tests | Post-deploy | Broken access control, business logic flaws |
Each layer catches different issues. None covers everything alone.
Stage 1: Pre-Commit Secrets Scanning
Prevent secrets from ever reaching the repository:
# Install pre-commit
pip install pre-commit
<span class="hljs-comment"># Install Gitleaks
brew install gitleaks.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-credentialspre-commit installNow git commit runs the secret scanner automatically. Any commit containing an AWS key, private key, or pattern matching a credential format is blocked.
For repositories where secrets may already exist in history, run gitleaks detect --source . --log-opts "HEAD" to scan the full history.
Stage 2: Dependency Scanning in Pull Requests
Add Snyk or OWASP Dependency-Check to your PR pipeline:
GitHub Actions with Snyk
name: Dependency Security Scan
on:
pull_request:
branches: [main, develop]
jobs:
snyk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Snyk vulnerability scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: >
--severity-threshold=high
--fail-on=upgradable
--sarif-file-output=snyk.sarif
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: snyk.sarifResults appear in GitHub's Security tab and as inline PR comments on the affected package.json lines.
OWASP Dependency-Check (Alternative, Open Source)
dependency-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run OWASP Dependency-Check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'my-project'
path: '.'
format: 'HTML'
args: >
--failOnCVSS 7
--enableRetired
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: dependency-check-report
path: reports/Stage 3: SAST in Pull Requests
Static analysis scans your code for security vulnerabilities without running it.
Semgrep
Semgrep has a free tier and covers most languages with security-specific rule sets:
sast-semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >
p/security-audit
p/secrets
p/owasp-top-ten
p/nodejs
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}Semgrep rules are readable YAML — you can write custom rules for your codebase:
# .semgrep/custom-rules.yaml
rules:
- id: no-hardcoded-jwt-secrets
patterns:
- pattern: jwt.sign($PAYLOAD, "$SECRET", ...)
message: Hardcoded JWT secret. Use environment variable instead.
languages: [javascript, typescript]
severity: ERRORGitHub CodeQL (Free for Public Repos, Included in Advanced Security)
codeql:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript, python
- name: Build
run: npm ci
- name: Run CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:javascript"CodeQL's query language allows deep analysis of code flows — finding SQL injection that spans multiple files and function calls.
Stage 4: Infrastructure as Code Scanning
Catch security misconfigurations before they reach the cloud:
Checkov (Terraform, CloudFormation, Kubernetes, Helm)
checkov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: infra/
framework: terraform
output_format: sarif
output_file_path: checkov.sarif
soft_fail: false
check: >
CKV_AWS_20,
CKV_AWS_57,
CKV_AWS_18
- name: Upload results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: checkov.sarifCommon checks: S3 buckets with public access, unencrypted databases, security groups open to the world, logging disabled.
Stage 5: DAST After Deployment
Dynamic scanning requires a running application. Run after deploying to staging:
dast-zap:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Wait for staging to be ready
run: |
timeout 120 bash -c 'until curl -sf ${{ vars.STAGING_URL }}/health; do sleep 5; done'
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: ${{ vars.STAGING_URL }}
fail_action: true
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a'
- name: ZAP API Scan
if: always()
uses: zaproxy/action-api-scan@v0.7.0
with:
target: ${{ vars.STAGING_URL }}/openapi.json
format: openapi
fail_action: true
- name: Upload ZAP Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: zap-reports
path: |
report_html.html
report_json.json.zap/rules.tsv defines what fails the build:
10202 IGNORE (Absence of Anti-CSRF Tokens)
10038 WARN (Content Security Policy Header Not Set)
40012 FAIL (Cross Site Scripting Reflected)
40014 FAIL (Cross Site Scripting Persistent)
90019 FAIL (Server Side Code Injection)
40018 FAIL (SQL Injection)Stage 6: Functional Security Tests
The layer most teams skip — and the most important for application-specific security.
Automated SAST and DAST find known vulnerability patterns. They cannot find:
- Whether a regular user can access the admin panel in your specific app
- Whether Customer A can view Customer B's orders
- Whether deleting an item you don't own returns an error or silently succeeds
These require tests that know your application's authorization model:
# Test: unauthorized access to admin endpoints
Go To https://staging.example.com/login
Fill Text input[name="email"] regularuser@example.com
Fill Text input[name="password"] password123
Click button[type="submit"]
Wait For Elements State .dashboard visible timeout=10s
Save As RegularUser
As RegularUser
Go To https://staging.example.com/admin/users
${status}= Get Text .page-title
Should Not Contain ${status} Admin Users
Should Contain ${current_url} /403These tests run in your regular pipeline alongside unit and integration tests.
Building a Staged Security Policy
Not all security issues block deployment equally. A tiered policy:
Block PR merge:
- Secrets detected in code (zero tolerance)
- Critical/High SAST findings with a fix available
- New dependency vulnerabilities with severity ≥ High
Block staging promotion:
- DAST findings rated High or above
- IaC misconfigurations that expose data publicly
Track and fix within N days:
- Medium dependency vulnerabilities
- Low SAST findings
- Informational DAST findings
Log only:
- Style/convention issues
- False positives (documented)
Configure this in your pipeline with conditional failures:
# Snyk: only fail on high+ that have fixes
--severity-threshold=high --fail-on=upgradable
# ZAP: fail only on critical findings
rules_file_name: .zap/rules.tsv # HIGH/CRITICAL = FAIL, MEDIUM = WARN
# Semgrep: error level fails, warning level doesn't
--error # only exits non-zero on ERROR severity rulesDeveloper Experience Matters
Security scanning that creates noise gets ignored. A few rules:
Give developers the fix, not just the finding. Tools like Snyk and GitHub code scanning show the vulnerability and the fix in the same view. Tools that only show "vulnerable" without "here's how to fix it" create frustration.
Run fast checks on PRs, slow checks nightly. SAST and dependency scanning should complete in under 5 minutes. Full DAST scans can take 30+ minutes — schedule those nightly, not on every PR.
Suppress false positives properly. An unfixable false positive that fails every build trains developers to add --ignore-all flags. Document each suppression with a reason and expiry date.
Show security results in the PR. SARIF format uploads scan results to GitHub's security tab, surfacing issues inline in the diff view. Developers see security feedback in the same place as code review feedback.
Complete Pipeline Example
name: CI/CD with Security
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high --fail-on=upgradable
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: semgrep/semgrep-action@v1
with:
config: p/security-audit p/owasp-top-ten
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
iac-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bridgecrewio/checkov-action@v12
with:
directory: infra/
soft_fail: true # Warn on IaC issues, don't block PRs
# These run only after merge to main + staging deploy
dast:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: zaproxy/action-baseline@v0.12.0
with:
target: ${{ vars.STAGING_URL }}
fail_action: trueMeasuring Progress
Track these metrics to know if your security pipeline is working:
- Mean time to detect — how long from code commit to vulnerability detection
- Vulnerability backlog — count of open findings by severity
- Fix rate — percentage of findings resolved within SLA
- False positive rate — percentage of suppressed findings (high = noisy scanner, reconfigure)
- Escape rate — vulnerabilities found in production that weren't caught in CI (goal: 0)
Start with detection — get the scanners running and noisy. Then tune to reduce false positives. Then optimize for fix time. Security posture improves incrementally; the pipeline just makes it measurable.