DAST in CI/CD: Automate Security Scanning on Every Single Pull Request
Your team just shipped a new feature. Code review passed. Unit tests passed. Integration tests passed. Then a security researcher reports they extracted all user emails from your API in 15 minutes.
The vulnerability? An unsanitized query parameter that allowed SQL injection. Your SAST (Static Application Security Testing) didn't catch it because the SQL query was dynamically constructed. Your regular tests didn't find it because you tested with valid inputs.
This is why DAST matters.
Dynamic Application Security Testing (DAST) tests your running application like an attacker would—sending malicious payloads, fuzzing inputs, probing for common vulnerabilities—finding exploits that static analysis and functional tests miss.
This guide shows you how to integrate DAST into your CI/CD pipeline to catch security vulnerabilities automatically before they reach production.
SAST vs DAST: Understanding the Difference
graph LR
subgraph "SAST (Static)"
A[Source Code] --> B[Static Analysis]
B --> C[Code Vulnerabilities]
C --> D[Buffer Overflow<br/>Hardcoded Secrets<br/>Weak Crypto]
end
subgraph "DAST (Dynamic)"
E[Running App] --> F[Attack Simulation]
F --> G[Runtime Vulnerabilities]
G --> H[SQL Injection<br/>XSS<br/>Auth Bypass]
end
style A fill:#e1f5ff
style E fill:#fff3e0
| Aspect | SAST (Static) | DAST (Dynamic) |
|---|---|---|
| Analysis Target | Source code | Running application |
| When to Run | During build | After deployment |
| Speed | Fast (seconds) | Slower (minutes-hours) |
| False Positives | Higher (20-40%) | Lower (5-15%) |
| Coverage | Code paths | API endpoints & UI |
| Finds | Code-level flaws | Runtime exploits |
| Example Tools | SonarQube, Snyk | OWASP ZAP, Burp Suite |
| Best For | Early feedback | Real-world validation |
You need both. SAST catches code issues early. DAST validates security in the actual runtime environment.
DAST Architecture in CI/CD
graph TD
A[Code Commit] --> B[Build Stage]
B --> C{SAST Scan}
C -->|Pass| D[Deploy to Test Env]
C -->|Fail| E[Block Pipeline]
D --> F[DAST Scanner]
F --> G[OWASP ZAP Scan]
F --> H[Custom Security Tests]
G --> I{Vulnerabilities?}
H --> I
I -->|Critical/High| J[Block Deployment]
I -->|Medium| K[Create Tickets]
I -->|Low| L[Log & Report]
J --> M[Security Review]
K --> N[Deploy to Staging]
L --> N
N --> O[DAST Full Scan]
O --> P{Production Ready?}
P -->|Yes| Q[Deploy to Prod]
P -->|No| R[Fix Issues]
style C fill:#bbdefb
style G fill:#ffccbc
style I fill:#fff9c4
style J fill:#ffccbc
Implementation: OWASP ZAP Integration
1. Docker-Based ZAP Setup
# docker-compose.zap.yml
version: '3.8'
services:
zap:
image: ghcr.io/zaproxy/zaproxy:stable
command: zap.sh -daemon -port 8080 -host 0.0.0.0 -config api.disablekey=true
ports:
- '8080:8080'
networks:
- security-test-net
app-under-test:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=test
- DATABASE_URL=postgresql://test:test@db:5432/testdb
depends_on:
- db
networks:
- security-test-net
db:
image: postgres:15
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
networks:
- security-test-net
networks:
security-test-net:
driver: bridge
2. ZAP Automation Script
// security/zap-scanner.ts
import axios from 'axios';
import { writeFileSync } from 'fs';
interface ZAPConfig {
zapUrl: string;
targetUrl: string;
apiKey?: string;
scanPolicy: 'baseline' | 'full' | 'api';
maxDuration: number; // minutes
}
interface Vulnerability {
alert: string;
risk: 'Informational' | 'Low' | 'Medium' | 'High';
confidence: 'Low' | 'Medium' | 'High';
url: string;
description: string;
solution: string;
cweid: string;
}
class ZAPScanner {
private config: ZAPConfig;
private baseUrl: string;
constructor(config: ZAPConfig) {
this.config = config;
this.baseUrl = `${config.zapUrl}/JSON`;
}
async runScan(): Promise<{
vulnerabilities: Vulnerability[];
summary: { high: number; medium: number; low: number; info: number };
}> {
console.log('🔒 Starting OWASP ZAP security scan...');
console.log(` Target: ${this.config.targetUrl}`);
try {
// Step 1: Spider the application (discover pages)
await this.spider();
// Step 2: Active scan for vulnerabilities
const scanId = await this.activeScan();
// Step 3: Wait for scan completion
await this.waitForScanCompletion(scanId);
// Step 4: Retrieve and parse results
const vulnerabilities = await this.getVulnerabilities();
const summary = this.summarize(vulnerabilities);
// Step 5: Generate report
await this.generateReport(vulnerabilities, summary);
console.log(`✅ Security scan complete:`);
console.log(` High: ${summary.high}`);
console.log(` Medium: ${summary.medium}`);
console.log(` Low: ${summary.low}`);
return { vulnerabilities, summary };
} catch (error) {
console.error('❌ Security scan failed:', error);
throw error;
}
}
private async spider(): Promise<void> {
console.log('🕷️ Spidering application...');
const response = await axios.get(`${this.baseUrl}/spider/action/scan/`, {
params: {
url: this.config.targetUrl,
maxChildren: 10,
recurse: true,
},
});
const spiderId = response.data.scan;
// Wait for spider to complete
let progress = 0;
while (progress < 100) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const statusResponse = await axios.get(`${this.baseUrl}/spider/view/status/`, {
params: { scanId: spiderId },
});
progress = parseInt(statusResponse.data.status);
console.log(` Spider progress: ${progress}%`);
}
console.log('✅ Spider complete');
}
private async activeScan(): Promise<string> {
console.log('🎯 Starting active scan...');
// Configure scan policy
await this.configureScanPolicy();
const response = await axios.get(`${this.baseUrl}/ascan/action/scan/`, {
params: {
url: this.config.targetUrl,
recurse: true,
inScopeOnly: false,
scanPolicyName: this.config.scanPolicy,
},
});
return response.data.scan;
}
private async configureScanPolicy(): Promise<void> {
// Configure scan rules based on policy
const policies = {
baseline: {
// Basic security checks (fast)
enabled: ['40012', '40014', '40016', '40017', '40018'], // SQL Injection, XSS, etc.
threshold: 'MEDIUM',
},
full: {
// Comprehensive scan (slow)
enabled: ['all'],
threshold: 'LOW',
},
api: {
// API-specific tests
enabled: ['40003', '40012', '40014', '40018', '40019', '40020'],
threshold: 'MEDIUM',
},
};
const policy = policies[this.config.scanPolicy];
// Enable/disable scan rules
// This is simplified - in production, configure each rule individually
console.log(` Using ${this.config.scanPolicy} scan policy`);
}
private async waitForScanCompletion(scanId: string): Promise<void> {
const maxWaitTime = this.config.maxDuration * 60 * 1000;
const startTime = Date.now();
let progress = 0;
while (progress < 100) {
if (Date.now() - startTime > maxWaitTime) {
throw new Error(`Scan timeout after ${this.config.maxDuration} minutes`);
}
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await axios.get(`${this.baseUrl}/ascan/view/status/`, {
params: { scanId },
});
progress = parseInt(response.data.status);
console.log(` Scan progress: ${progress}%`);
}
console.log('✅ Active scan complete');
}
private async getVulnerabilities(): Promise<Vulnerability[]> {
const response = await axios.get(`${this.baseUrl}/core/view/alerts/`, {
params: {
baseurl: this.config.targetUrl,
},
});
return response.data.alerts.map((alert: any) => ({
alert: alert.alert,
risk: alert.risk,
confidence: alert.confidence,
url: alert.url,
description: alert.description,
solution: alert.solution,
cweid: alert.cweid,
}));
}
private summarize(vulnerabilities: Vulnerability[]): {
high: number;
medium: number;
low: number;
info: number;
} {
return {
high: vulnerabilities.filter((v) => v.risk === 'High').length,
medium: vulnerabilities.filter((v) => v.risk === 'Medium').length,
low: vulnerabilities.filter((v) => v.risk === 'Low').length,
info: vulnerabilities.filter((v) => v.risk === 'Informational').length,
};
}
private async generateReport(
vulnerabilities: Vulnerability[],
summary: { high: number; medium: number; low: number; info: number },
): Promise<void> {
// Generate HTML report
const htmlResponse = await axios.get(`${this.baseUrl}/core/other/htmlreport/`);
writeFileSync('zap-report.html', htmlResponse.data);
// Generate JSON report for CI/CD
const report = {
timestamp: new Date().toISOString(),
targetUrl: this.config.targetUrl,
summary,
vulnerabilities: vulnerabilities.filter((v) => v.risk !== 'Informational'),
};
writeFileSync('zap-report.json', JSON.stringify(report, null, 2));
console.log('📊 Reports generated: zap-report.html, zap-report.json');
}
}
3. Authenticated Scanning
// security/zap-authenticated-scan.ts
interface AuthConfig {
type: 'form' | 'header' | 'oauth';
loginUrl?: string;
usernameField?: string;
passwordField?: string;
username?: string;
password?: string;
token?: string;
headerName?: string;
}
class AuthenticatedZAPScanner extends ZAPScanner {
private authConfig: AuthConfig;
constructor(config: ZAPConfig, authConfig: AuthConfig) {
super(config);
this.authConfig = authConfig;
}
async runScan() {
// Authenticate before scanning
await this.authenticate();
return super.runScan();
}
private async authenticate(): Promise<void> {
console.log('🔐 Authenticating...');
switch (this.authConfig.type) {
case 'form':
await this.authenticateWithForm();
break;
case 'header':
await this.authenticateWithHeader();
break;
case 'oauth':
await this.authenticateWithOAuth();
break;
}
console.log('✅ Authentication complete');
}
private async authenticateWithForm(): Promise<void> {
const { loginUrl, usernameField, passwordField, username, password } = this.authConfig;
// Configure form-based authentication
await axios.get(`${this.baseUrl}/authentication/action/setAuthenticationMethod/`, {
params: {
contextId: 1,
authMethodName: 'formBasedAuthentication',
authMethodConfigParams: `loginUrl=${loginUrl}&loginRequestData=${usernameField}={%username%}&${passwordField}={%password%}`,
},
});
// Set credentials
await axios.get(`${this.baseUrl}/users/action/newUser/`, {
params: {
contextId: 1,
name: 'test-user',
},
});
await axios.get(`${this.baseUrl}/users/action/setAuthenticationCredentials/`, {
params: {
contextId: 1,
userId: 0,
authCredentialsConfigParams: `${usernameField}=${username}&${passwordField}=${password}`,
},
});
await axios.get(`${this.baseUrl}/users/action/setUserEnabled/`, {
params: {
contextId: 1,
userId: 0,
enabled: true,
},
});
}
private async authenticateWithHeader(): Promise<void> {
const { headerName, token } = this.authConfig;
// Add authorization header to all requests
await axios.get(`${this.baseUrl}/replacer/action/addRule/`, {
params: {
description: 'Auth Header',
enabled: true,
matchType: 'REQ_HEADER',
matchString: headerName,
replacement: token,
},
});
}
private async authenticateWithOAuth(): Promise<void> {
// OAuth flow implementation
console.log(' OAuth authentication configured');
// Implementation depends on OAuth provider
}
}
4. CI/CD Pipeline Integration
# .github/workflows/security-scan.yml
name: Security Scan (DAST)
on:
pull_request:
branches: [main, staging]
push:
branches: [main]
schedule:
- cron: '0 2 * * *' # Daily at 2 AM
jobs:
dast-scan:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build application
run: |
docker build -t app-under-test .
- name: Start application
run: |
docker run -d --name app \
-p 3000:3000 \
-e NODE_ENV=test \
-e DATABASE_URL=postgresql://test:test@postgres:5432/testdb \
--network host \
app-under-test
# Wait for app to be ready
timeout 60 bash -c 'until curl -f http://localhost:3000/health; do sleep 2; done'
- name: Start OWASP ZAP
run: |
docker run -d --name zap \
-p 8080:8080 \
--network host \
ghcr.io/zaproxy/zaproxy:stable \
zap.sh -daemon -port 8080 -host 0.0.0.0 -config api.disablekey=true
# Wait for ZAP to be ready
timeout 60 bash -c 'until curl -f http://localhost:8080; do sleep 2; done'
- name: Run security scan
run: |
npm install
npx ts-node security/run-scan.ts
env:
ZAP_URL: http://localhost:8080
TARGET_URL: http://localhost:3000
SCAN_POLICY: baseline
MAX_DURATION: 15
- name: Check security thresholds
run: |
npx ts-node security/check-thresholds.ts
- name: Upload scan results
if: always()
uses: actions/upload-artifact@v4
with:
name: zap-scan-results
path: |
zap-report.html
zap-report.json
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('zap-report.json', 'utf8'));
const body = `## 🔒 Security Scan Results
| Severity | Count |
|----------|-------|
| 🔴 High | ${report.summary.high} |
| 🟡 Medium | ${report.summary.medium} |
| 🔵 Low | ${report.summary.low} |
${report.summary.high > 0 ? '⚠️ **High severity vulnerabilities detected! Review required before merge.**' : '✅ No high severity vulnerabilities detected.'}
[View full report](https://github.com/${{github.repository}}/actions/runs/${{github.run_id}})
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.name,
body: body
});
- name: Fail pipeline if critical vulnerabilities
run: |
HIGHS=$(jq '.summary.high' zap-report.json)
if [ "$HIGHS" -gt 0 ]; then
echo "❌ Found $HIGHS high severity vulnerabilities"
exit 1
fi
5. Vulnerability Threshold Management
// security/check-thresholds.ts
import { readFileSync } from 'fs';
interface SecurityThresholds {
high: number;
medium: number;
blocking: string[]; // CWE IDs that always block
}
const thresholds: SecurityThresholds = {
high: 0, // Zero tolerance for high severity
medium: 5, // Allow up to 5 medium severity (with review)
blocking: [
'89', // SQL Injection
'79', // XSS
'287', // Authentication Bypass
'798', // Hardcoded Credentials
'639', // Insecure Direct Object Reference
],
};
function checkThresholds(): void {
const report = JSON.parse(readFileSync('zap-report.json', 'utf8'));
const { summary, vulnerabilities } = report;
console.log('🔍 Checking security thresholds...');
// Check for blocking CWE IDs
const blockingVulns = vulnerabilities.filter((v: any) => thresholds.blocking.includes(v.cweid));
if (blockingVulns.length > 0) {
console.error('❌ BLOCKING: Critical vulnerability types detected:');
blockingVulns.forEach((v: any) => {
console.error(` - ${v.alert} (CWE-${v.cweid}) at ${v.url}`);
});
process.exit(1);
}
// Check severity thresholds
if (summary.high > thresholds.high) {
console.error(`❌ FAILED: ${summary.high} high severity vulnerabilities (max: ${thresholds.high})`);
process.exit(1);
}
if (summary.medium > thresholds.medium) {
console.warn(`⚠️ WARNING: ${summary.medium} medium severity vulnerabilities (max: ${thresholds.medium})`);
console.warn(' Create tickets and plan remediation');
}
console.log('✅ Security thresholds passed');
}
checkThresholds();
Common Vulnerabilities DAST Finds
| Vulnerability | Description | Example | DAST Detection |
|---|---|---|---|
| SQL Injection | Unsanitized SQL queries | SELECT * FROM users WHERE id=${req.params.id} |
Payload fuzzing |
| XSS | Script injection in UI | <script>alert('XSS')</script> |
Reflected/stored input tests |
| CSRF | Cross-site request forgery | Missing CSRF tokens | Token validation checks |
| Auth Bypass | Broken access control | Missing authorization checks | Role escalation tests |
| IDOR | Direct object reference | /api/users/123 accessing other users |
ID enumeration |
| XXE | XML external entity | Malicious XML parsing | XML payload fuzzing |
| SSRF | Server-side request forgery | Fetching internal URLs | URL parameter fuzzing |
Best Practices
- Start with Baseline Scans: Quick scans (5-10 minutes) in PR builds
- Full Scans Nightly: Comprehensive scans (1-2 hours) on schedule
- Use Service Accounts: Don't test with production credentials
- Scan Staging First: Never DAST prod (it's intrusive)
- Tune False Positives: Mark false positives to reduce noise
- Integrate with Ticketing: Auto-create tickets for medium+ severity
- Track Remediation Time: Measure MTTR for security issues
- Combine with SAST: Both tools complement each other
Real-World Impact
| Metric | Before DAST | After DAST | Improvement |
|---|---|---|---|
| Security bugs in prod | 12/year | 1/year | 92% reduction |
| Time to detect vulns | 45 days | 1 day | 98% faster |
| Security incidents | 3/year | 0/year | 100% prevention |
| Remediation cost | $50k/incident | $2k/bug | 96% cheaper |
Conclusion
DAST transforms security from a release-blocking manual review into an automated CI/CD check that catches vulnerabilities early.
Key takeaways:
- DAST finds runtime exploits SAST can't detect
- Automate in CI/CD for every PR and nightly
- Set severity thresholds to block high-risk vulnerabilities
- Combine with SAST for comprehensive coverage
- Scan staging, not production (DAST is intrusive)
Start implementing DAST today:
- Add OWASP ZAP to CI/CD
- Run baseline scans on PRs (10 minutes)
- Run full scans nightly (1-2 hours)
- Set blocking thresholds for critical vulnerabilities
- Track and remediate findings
Security isn't a phase—it's a continuous practice. DAST makes it automatic.
Ready to automate security testing in your pipeline? Sign up for ScanlyApp and integrate DAST scanning into your CI/CD workflow today.
Related articles: Also see the manual and automated security tests DAST augments, the full DevSecOps checklist DAST is part of, and OWASP vulnerabilities DAST is designed to detect automatically.
