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:
- SAST (Static Application Security Testing) — analyzes source code for vulnerability patterns without running the code
- SCA (Software Composition Analysis) — scans dependencies for known CVEs
- Secret scanning — detects credentials committed to source control
- 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.sarifThe 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-warnJava:
- name: SpotBugs with FindSecBugs
run: mvn com.github.spotbugs:spotbugs-maven-plugin:check -Dspotbugs.effort=maxGo:
- 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:
- Running SAST in "audit" mode (report-only, no failures) for 2 weeks
- Review findings — mark false positives, fix true positives
- Set threshold at high severity only to fail builds
- 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 = lowLayer 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 anythingnpm audit fix resolves most issues automatically. For packages without automatic fixes, evaluate:
- Is there a newer version that fixes the CVE?
- Is this package actually reachable from an attacker's perspective?
- 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=highSnyk 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 updatesThis 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:
- Repository Settings → Security → Secret scanning → Enable
- 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 installHandling 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 warningsFull 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: openapiConfiguring 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 InjectionStart 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.htmlManaging Findings Effectively
Don't Block Every Build
The goal is not zero findings — it's zero unacknowledged high-risk findings. For everything else:
- Critical/High: Block the build. Fix before merge.
- Medium: Create a tracking ticket. Fix within sprint.
- 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
fiFeature 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:
- Penetration Testing for Web Applications — the manual testing methodology
- OWASP Top 10 Testing Checklist — what to check for
- API Security Testing with Postman — manual API security testing