Safeguard
DevSecOps

How to set up DAST scanning in a CI/CD pipeline

A practical guide to building a DAST scanning CI/CD pipeline with OWASP ZAP — setup, integration, rule tuning, and troubleshooting for real deployments.

Priya Mehta
DevSecOps Engineer
Updated 7 min read

Shipping fast means shipping vulnerabilities you can't see until an attacker finds them first. Static analysis catches insecure code patterns, but it can't tell you what happens when your application is actually running — how it handles malformed input, broken authentication flows, or injection attempts against a live endpoint. That's the gap dynamic application security testing (DAST) closes, and the only way to make it stick is to build DAST scanning into your CI/CD pipeline instead of running it manually before a release (if at all).

This guide walks through setting up a DAST scanning CI/CD pipeline from scratch: choosing where the scan runs, standing up OWASP ZAP as your scanning engine, wiring it into GitHub Actions or GitLab CI, tuning it so it doesn't drown your team in noise, and gating merges on the results. By the end, you'll have an automated DAST pipeline that runs on every deploy to staging without slowing your team down.

Step 1: Decide Where DAST Scanning Fits in Your CI/CD Pipeline

DAST needs a running application to attack, which means it can't run at the same stage as your unit tests or linting. Most teams place it right after deployment to a staging or ephemeral preview environment, and before promotion to production. A typical pipeline looks like:

  1. Build and unit test
  2. Deploy to a staging/ephemeral environment
  3. Run DAST scan against the staging URL
  4. Gate promotion on scan results
  5. Deploy to production

If you don't have a persistent staging environment, spin up an ephemeral one per pull request (Docker Compose, a Kubernetes namespace, or a preview deployment from your hosting provider). Scanning against a real, reachable URL is non-negotiable — DAST tools test behavior, not source code.

Step 2: Pick a Scanning Engine and Do a Basic Dynamic Application Security Testing Setup

OWASP ZAP (Zed Attack Proxy) is the most common starting point because it's free, actively maintained, and has first-class automation support. Before wiring it into CI, run it locally against a target to confirm your dynamic application security testing setup actually produces useful results:

docker run -t zaproxy/zap-stable zap-baseline.py \
  -t https://staging.yourapp.com \
  -r zap-report.html

The zap-baseline.py script performs a passive scan — it crawls the app and inspects traffic without sending attack payloads, so it's safe to run against real environments. For deeper coverage once your pipeline is stable, zap-full-scan.py adds active scanning (SQL injection, XSS payloads, etc.), but expect longer run times and plan to run it on a schedule rather than every commit.

Step 3: Add OWASP ZAP CI Integration to Your Pipeline

With a working local scan, the next step is OWASP ZAP CI integration into your actual workflow. Here's a GitHub Actions example that scans a staging deployment after every push to main:

name: dast-scan
on:
  push:
    branches: [main]

jobs:
  zap-scan:
    runs-on: ubuntu-latest
    steps:
      - name: Wait for staging deploy
        run: sleep 30

      - name: OWASP ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.12.0
        with:
          target: 'https://staging.yourapp.com'
          rules_file_name: '.zap/rules.tsv'
          cmd_options: '-a'
          fail_action: true

      - name: Upload ZAP report
        uses: actions/upload-artifact@v4
        with:
          name: zap-report
          path: report_html.html

The equivalent in GitLab CI uses the built-in DAST template:

include:
  - template: DAST.gitlab-ci.yml

dast:
  variables:
    DAST_WEBSITE: https://staging.yourapp.com
    DAST_FULL_SCAN_ENABLED: "false"
  stage: dast
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Both approaches produce a machine-readable report (JSON or SARIF) that you can parse in a later step to make pass/fail decisions.

Step 4: Tune Scan Rules to Cut False Positives

An automated DAST pipeline that fails builds on every low-severity informational finding will get disabled by the second sprint. Use a rules file to explicitly ignore, warn, or fail on specific alert IDs:

# .zap/rules.tsv
10202	IGNORE	(Absence of Anti-CSRF Tokens on login form — accepted risk)
10096	WARN	(Timestamp Disclosure)
40012	FAIL	(Cross Site Scripting - Reflected)
40018	FAIL	(SQL Injection)

Start conservative: ignore anything that's a known false positive for your stack (e.g., framework-specific headers ZAP misreads), warn on informational findings, and fail only on confirmed high-confidence, high-severity classes like reflected/stored XSS and SQL injection. Revisit the rules file monthly as your app changes — a rule that was a false positive in one release can become a real gap after a refactor.

Step 5: Authenticate the Scanner for Realistic Coverage

Unauthenticated scans only test your public-facing surface. Most real vulnerabilities live behind a login. Configure ZAP's authentication so it can crawl and attack logged-in routes:

docker run -t zaproxy/zap-stable zap-full-scan.py \
  -t https://staging.yourapp.com \
  -z "-config replacer.full_list(0).description=auth \
      -config replacer.full_list(0).enabled=true \
      -config replacer.full_list(0).matchtype=REQ_HEADER \
      -config replacer.full_list(0).matchstr=Authorization \
      -config replacer.full_list(0).replacement='Bearer ${CI_TEST_TOKEN}'"

Store the test token as a CI secret, never in the pipeline file, and use a dedicated low-privilege test account rather than a real user's credentials.

Step 6: Gate Deployments on Scan Results

The whole point of building DAST into CI/CD is enforcement, not just visibility. Fail the pipeline job when the scan returns high-severity findings, and route the report to your team's usual triage channel:

      - name: Fail on high severity findings
        run: |
          HIGH_COUNT=$(jq '[.site[].alerts[] | select(.riskcode=="3")] | length' report_json.json)
          if [ "$HIGH_COUNT" -gt 0 ]; then
            echo "Found $HIGH_COUNT high-severity issues"
            exit 1
          fi

Pair this with a notification step (Slack, email, or your issue tracker) so failures don't just block a merge silently — someone needs to see the finding and act on it.

Step 7: Schedule Full Scans Separately from Per-Commit Baseline Scans

Active full scans can take 20-60+ minutes depending on application size, which is too slow for a per-commit gate. Run the fast baseline scan on every push, and schedule the comprehensive active scan nightly or weekly against a stable environment:

on:
  schedule:
    - cron: '0 3 * * *'

This two-tier approach — a lightweight automated DAST pipeline on every commit plus a deeper scheduled scan — gives you fast feedback without sacrificing thoroughness.

Troubleshooting and Verification

Scan reports zero findings on an app you know has issues. Check that the scanner actually reached authenticated routes — an unauthenticated crawl will only see the login page. Verify the auth header replacement is firing by inspecting ZAP's request log (-config log.level=DEBUG).

Pipeline times out before the scan finishes. Increase the job timeout and confirm the target environment is actually up before the scan starts; a sleep is fragile — poll a health endpoint instead:

until curl -sf https://staging.yourapp.com/health; do sleep 5; done

Too many findings to triage. You likely skipped Step 4. Start with a strict allowlist of rule IDs to fail on and expand gradually rather than trying to fix everything the first week.

Findings don't reproduce when you test manually. Some ZAP alerts are context-dependent (rate limiting, WAF behavior, session state). Cross-reference the alert's evidence field in the HTML report before dismissing it as a false positive — and before ignoring it in your rules file.

Verify your setup is actually working by intentionally deploying a known-vulnerable test endpoint (OWASP's Juice Shop or a deliberately unescaped query parameter) to a scratch environment and confirming the pipeline fails as expected. If it doesn't, your scan isn't actually gating anything — it's cosmetic.

How Safeguard Helps

Standing up OWASP ZAP CI integration by hand gets you a working scanner, but keeping it useful — tuning rules as your app evolves, correlating DAST findings with the code and dependency that introduced them, and proving to auditors that dynamic testing runs on every release — is an ongoing maintenance burden most teams underestimate.

Safeguard plugs DAST results directly into your software supply chain security posture: findings from your pipeline scans are automatically correlated with the build artifact, commit, and dependency graph that produced the vulnerable behavior, so your team isn't triaging DAST alerts in isolation from the rest of your security signal. Policy gates let you enforce severity thresholds across every repo from one place instead of hand-editing rules files service by service, and every scan run is logged with full provenance — which is exactly what SOC 2 and similar audits ask for when they want evidence that dynamic testing is continuous, not a once-a-quarter checkbox. If you're already running DAST scans but struggling to make the results actionable across a growing set of services, that's where Safeguard picks up where a standalone ZAP pipeline leaves off.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.