Back to Blog

DAST in CI/CD: Automate Security Scanning on Every Single Pull Request

Static analysis finds code vulnerabilities. DAST finds runtime exploits attackers actually use. Learn how to integrate OWASP ZAP, automate security scanning, and catch critical vulnerabilities before they reach production.

Published

12 min read

Reading time

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

  1. Start with Baseline Scans: Quick scans (5-10 minutes) in PR builds
  2. Full Scans Nightly: Comprehensive scans (1-2 hours) on schedule
  3. Use Service Accounts: Don't test with production credentials
  4. Scan Staging First: Never DAST prod (it's intrusive)
  5. Tune False Positives: Mark false positives to reduce noise
  6. Integrate with Ticketing: Auto-create tickets for medium+ severity
  7. Track Remediation Time: Measure MTTR for security issues
  8. 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:

  1. DAST finds runtime exploits SAST can't detect
  2. Automate in CI/CD for every PR and nightly
  3. Set severity thresholds to block high-risk vulnerabilities
  4. Combine with SAST for comprehensive coverage
  5. Scan staging, not production (DAST is intrusive)

Start implementing DAST today:

  1. Add OWASP ZAP to CI/CD
  2. Run baseline scans on PRs (10 minutes)
  3. Run full scans nightly (1-2 hours)
  4. Set blocking thresholds for critical vulnerabilities
  5. 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.

Related Posts