How to Set Up Jenkins for Security Scanning with OWASP ZAP

How to Set Up Jenkins for Security Scanning with OWASP ZAP

Security testing used to be something I bolted on right before a release, usually as a manual pentest a week before launch. That’s a terrible way to catch vulnerabilities — by the time you find them, the code has already moved three sprints ahead. Adding OWASP ZAP (Zed Attack Proxy) into a Jenkins pipeline changes that entirely: every build gets an automated security scan, and vulnerabilities show up as build artifacts right next to your test results.

What is OWASP ZAP

OWASP ZAP is a free, open-source dynamic application security testing (DAST) tool maintained by the OWASP Foundation. Unlike static analysis tools that read source code, ZAP actively attacks a running application — probing for SQL injection, XSS, broken authentication, insecure headers, and dozens of other issues — the same way an attacker would.

Jenkins Architecture Refresher

For a security pipeline, it helps to think in terms of stages that build up context:

This “deploy then attack” model means ZAP needs a live, reachable instance of your application — typically a staging or ephemeral test environment spun up specifically for the scan.

Prerequisites

Step 1: Install Docker on the Jenkins Agent

sudo apt update
sudo apt install -y docker.io
sudo usermod -aG docker jenkins
sudo systemctl restart jenkins

Running ZAP via its official Docker image (zaproxy/zap-stable) avoids manual installation and keeps the scanner version consistent across builds.

Step 2: Pull the ZAP Docker Image

docker pull zaproxy/zap-stable

Step 3: Install Supporting Jenkins Plugins

Step 4: Write the Jenkinsfile

Here’s a pipeline that deploys the app to a test environment, runs a ZAP baseline scan, and publishes the results:

pipeline {
    agent any

    environment {
        TARGET_URL = 'http://staging.myapp.internal:8080'
        ZAP_REPORT_DIR = "${WORKSPACE}/zap-report"
    }

    stages {
        stage('Checkout') {
            steps {
                git branch: 'main', url: 'https://github.com/yourorg/webapp.git'
            }
        }

        stage('Build') {
            steps {
                sh 'docker build -t myapp:latest .'
            }
        }

        stage('Deploy to Test Environment') {
            steps {
                sh '''
                    docker rm -f myapp-test || true
                    docker run -d --name myapp-test -p 8080:8080 myapp:latest
                    sleep 15
                '''
            }
        }

        stage('ZAP Baseline Scan') {
            steps {
                sh '''
                    mkdir -p $ZAP_REPORT_DIR
                    docker run --network host -v $ZAP_REPORT_DIR:/zap/wrk/:rw \
                      zaproxy/zap-stable zap-baseline.py \
                      -t $TARGET_URL \
                      -r zap_report.html \
                      -J zap_report.json \
                      -I
                '''
            }
        }

        stage('Evaluate Results') {
            steps {
                script {
                    def report = readJSON file: "${ZAP_REPORT_DIR}/zap_report.json"
                    def highRiskCount = report.site[0].alerts.findAll { it.riskcode.toInteger() >= 3 }.size()
                    if (highRiskCount > 0) {
                        error "ZAP found ${highRiskCount} high-risk vulnerabilities. Failing build."
                    }
                }
            }
        }
    }

    post {
        always {
            publishHTML(target: [
                reportDir: 'zap-report',
                reportFiles: 'zap_report.html',
                reportName: 'OWASP ZAP Security Report'
            ])
            sh 'docker rm -f myapp-test || true'
        }
    }
}

The -I flag tells ZAP to continue rather than exit non-zero on warnings, letting the pipeline’s own Evaluate Results stage decide the failure threshold — this gives you more control than relying purely on ZAP’s exit code.

Baseline Scan vs Full Active Scan

A common pattern is running the baseline scan on every commit and scheduling the full active scan nightly or before a release, on a dedicated ephemeral environment.

stage('Nightly Full Scan') {
    when {
        triggeredBy 'TimerTrigger'
    }
    steps {
        sh '''
            docker run --network host -v $ZAP_REPORT_DIR:/zap/wrk/:rw \
              zaproxy/zap-stable zap-full-scan.py \
              -t $TARGET_URL -r zap_full_report.html -I
        '''
    }
}

Authenticated Scanning

Most real applications sit behind a login. ZAP supports authenticated scans via a context file that describes the login form and session handling:

docker run --network host -v $ZAP_REPORT_DIR:/zap/wrk/:rw \
  zaproxy/zap-stable zap-full-scan.py \
  -t $TARGET_URL \
  -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 $API_TOKEN'"

For form-based login, ZAP’s context file (exported from the ZAP desktop UI) can be mounted into the container and passed with -n /zap/wrk/context.context.

Integrating with Git and Pull Requests

Fail fast by wiring ZAP results into pull request checks. If your pipeline runs on a PR trigger, publish a summarized comment back to GitHub using the GitHub API or a plugin like GitHub Checks API, so reviewers see security findings without leaving the PR.

Real-World Workflow

  1. Every push to a feature branch triggers a build, deploy to an ephemeral container, and a ZAP baseline scan (fast feedback, a few minutes).
  2. Merge to main triggers deployment to staging and a scheduled nightly full active scan.
  3. Release candidates require a clean full scan report as a manual gate before promotion to production.
  4. All reports are archived as build artifacts for audit and compliance purposes.

Security and Operational Best Practices

Troubleshooting

Combining ZAP with Other Security Tools

A single DAST scan is one layer in a much larger security posture, and Jenkins is a natural place to string these layers together into one pipeline:

stage('Dependency Vulnerability Scan') {
    steps {
        sh 'npm audit --audit-level=high'
    }
}

stage('SAST with Semgrep') {
    steps {
        sh 'semgrep --config=auto --json --output=semgrep-report.json .'
    }
}

stage('Container Image Scan with Trivy') {
    steps {
        sh 'trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest'
    }
}

stage('ZAP Baseline Scan') {
    steps {
        sh '''
            docker run --network host -v $ZAP_REPORT_DIR:/zap/wrk/:rw \
              zaproxy/zap-stable zap-baseline.py -t $TARGET_URL -r zap_report.html -I
        '''
    }
}

Running dependency scanning, static analysis, container scanning, and dynamic scanning together gives you coverage across the full software supply chain: known-vulnerable libraries, insecure code patterns, vulnerable base images, and runtime behavior — each catching classes of issues the others can’t see.

Tracking Security Debt Over Time

Rather than treating every ZAP finding as a build-breaker, many teams route medium and low-risk findings into a tracked backlog while only failing builds on high/critical issues. A simple pattern is to export ZAP’s JSON report and push a summarized count to a tracking system or dashboard on every run:

stage('Track Security Metrics') {
    steps {
        script {
            def report = readJSON file: "${ZAP_REPORT_DIR}/zap_report.json"
            def counts = [high: 0, medium: 0, low: 0, info: 0]
            report.site[0].alerts.each { alert ->
                def risk = alert.riskcode.toInteger()
                if (risk == 3) counts.high++
                else if (risk == 2) counts.medium++
                else if (risk == 1) counts.low++
                else counts.info++
            }
            echo "Security findings — High: ${counts.high}, Medium: ${counts.medium}, Low: ${counts.low}, Info: ${counts.info}"
        }
    }
}

Trending this over weeks tells you whether the team’s security debt is actually shrinking, which is a far more useful signal than a single pass/fail per build.

FAQs

Does ZAP replace static code analysis (SAST) tools? No — ZAP is dynamic (DAST) and tests the running application; pair it with a SAST tool like SonarQube for source-level vulnerability detection.

Can I run ZAP scans against a Kubernetes-deployed staging environment? Yes, point the -t target at the service’s ingress or NodePort URL; just ensure the Jenkins agent has network access to the cluster.

Is the baseline scan safe to run against a shared staging environment used by other teams? Generally yes, since it’s passive, but always confirm with your security team — even passive scans generate noticeable traffic.

How do I keep security findings from blocking every single deploy? Set the failure threshold to high/critical findings only, and route medium/low findings to a tracked backlog instead of failing the build outright.

Summary

Bolting OWASP ZAP onto a Jenkins pipeline turns security testing from a pre-release scramble into a routine, automated part of every deployment. Start with fast baseline scans on every commit, layer in scheduled full active scans against isolated environments, and use Jenkins’ own scripting to decide what severity actually blocks a release. The result is vulnerabilities caught in minutes instead of months.

References

Exit mobile version