Automated Security Scanning with OWASP ZAP & Playwright in CI/CD

Overview

OWASP ZAP can be integrated into your application’s CI/CD pipeline to perform security testing right after Playwright tests. By utilizing ZAP’s proxy mode, traffic is captured during tests, allowing you to run scans like Spider, AJAX Spider, and generate a security report.

With seamless integration into your Azure DevOps pipeline, security checks are automatically triggered as part of your CI/CD process, ensuring vulnerabilities are identified and addressed early in the development lifecycle.


Why Integrate OWASP ZAP with Playwright?

  • Capture real browser interactions during testing.
  • Eliminate redundant crawling.
  • Ensure every user flow you test is also scanned for vulnerabilities.
  • Automate the entire process in CI/CD using Azure Pipelines.

Folder Structure

security-scan-utils/
├── generate_zap_reports.js
├── perform_ajax_spider_scan.js
├── perform_spider_scan.js
└── setup_zap.sh

ZAP Client Setup

Use zaproxy npm package to interact with ZAP’s API:

 npm install zaproxy

Initialize the ZAP client only if PERFORM_ZAP_SCAN=true and ZAP_PORT is available:

const ZapClient = require('zaproxy');
const zapClient = new ZapClient({
  apiKey: process.env.ZAP_API_KEY,
  proxy: { host: '127.0.0.1', port: process.env.ZAP_PORT }
});

Playwright + ZAP Integration Scripts

To ensure ZAP captures real browser interactions and runs scans after tests:

Cucumber Hooks

BeforeAll — Configure Playwright to Use ZAP Proxy

const { chromium } = require('@playwright/test');
const fs = require('fs');
require('dotenv').config();

let browser, context, page;

BeforeAll(async () => {
  if (process.env.PERFORM_ZAP_SCAN === 'true') {
    const zapPort = process.env.ZAP_PORT || 8090;

    browser = await chromium.launch();
    context = await browser.newContext({
      ignoreHTTPSErrors: true,
      proxy: { server: `http://127.0.0.1:${zapPort}` }
    });
    page = await context.newPage();

    // Make Playwright page available globally if needed
    global.page = page;

    // Attach the zapClient globally if needed later
    const ZapClient = require('zaproxy');
    global.zapClient = new ZapClient({
      apiKey: process.env.ZAP_API_KEY,
      proxy: { host: '127.0.0.1', port: zapPort }
    });

    console.log(`[ZAP] Proxy configured at http://127.0.0.1:${zapPort}`);
  }
});

AfterAll — Trigger ZAP Scans & Generate Report

AfterAll(async () => {
  if (process.env.PERFORM_ZAP_SCAN === 'true' && global.zapClient) {
    const TARGET_URL = process.env.TARGET_URL || 'http://localhost:3000';

    console.log(`[ZAP] Starting Spider scan...`);
    await performSpiderScan(global.zapClient, TARGET_URL);

    console.log(`[ZAP] Starting AJAX Spider scan...`);
    await performAjaxSpiderScan(global.zapClient, TARGET_URL);

    console.log(`[ZAP] Generating security report...`);
    await generateZapReport(global.zapClient);

    console.log('[ZAP] Scan complete. Report generated in /security-report');
  }

  if (context) await context.close();
  if (browser) await browser.close();
});

Cucumber Hooks Usage

We are utilizing two Cucumber hooks (BeforeAll and AfterAll) to integrate OWASP ZAP with Playwright tests. These hooks manage the setup and teardown process of ZAP proxy during the testing lifecycle.

BeforeAll — Configure Playwright to Use ZAP Proxy

  • Purpose: This hook runs before any tests start. It’s responsible for configuring the Playwright browser to route all traffic through the OWASP ZAP proxy.
  • Steps:
    1. Launch Playwright browser: The browser is launched with a proxy configuration that points to the ZAP proxy (http://127.0.0.1:8090 or the port defined in the environment variables).
    2. Create new context and page: A new Playwright browser context and page are created, ensuring that the browser interactions are captured by the ZAP proxy.
    3. Global variables setup:
    • global.page is assigned to the current page, which can be used in tests to interact with the application.
    • global.zapClient is set up with the ZAP API key and proxy settings, allowing us to communicate with ZAP and trigger scans later.
  • Outcome: All subsequent tests will automatically route traffic through the ZAP proxy, enabling ZAP to capture real browser interactions.

AfterAll — Trigger ZAP Scans & Generate Report

  • Purpose: This hook runs after all tests have finished. It is responsible for triggering the ZAP scans and generating a security report.
  • Steps:
    1. Perform Spider Scan: A Spider scan is initiated to crawl and discover all reachable URLs within the target application.
    2. Perform AJAX Spider Scan: After the Spider scan, an AJAX Spider scan is performed to identify dynamically loaded content, like content that may be fetched via JavaScript.
    3. Generate Security Report: Once the scans are complete, a security report is generated detailing any vulnerabilities or issues identified during the scanning process.
  • Outcome: At the end of the test run, a comprehensive security report is available for review. The report is saved in the /security-report directory.

Security Scan Scripts

1. generate_zap_reports.js

const fs = require('fs');
const path = require('path');

async function generateZapReport(zapClient) {
    try {
        const REPORT_DIR = path.join(process.cwd(), "security-report");

        // Create directory if it doesn't exist
        if (!fs.existsSync(REPORT_DIR)) {
            fs.mkdirSync(REPORT_DIR, { recursive: true });
        }
        let filename = 'zap-report';
        console.log("📑 Generating ZAP Security Report...");

        // Generate report using the client library's reports API
        const reportResponse = await zapClient.reports.generate({
            title: `Security Report - ${filename}`,
            template: "traditional-html",
            reportfilename: `${filename}-report-${Date.now()}.html`,
            reportdir: REPORT_DIR,
            display: "false",
            description: "Automated security scan report",
            includedrisks: "High|Medium|Low|Informational",
            includedconfidences: "Confirmed|High|Medium|Low",
        });

        // The response should contain the path where ZAP saved the report
        const reportPath = path.join(
            REPORT_DIR,
            `${filename}-report-${Date.now()}.html`
        );
        console.log(`✅ Report generated at: ${reportPath}`);
    } catch (error) {
        console.error("❌ ZAP report generation failed:", error);
        throw error;
    }
}

module.exports = {
    generateZapReport
}

2. perform_spider_scan.js

async function performSpiderScan(zapClient, target) {
    try {
        const spiderScan = await zapClient.spider.scan({ url: target });
        let spiderProgress = 0;
        while (spiderProgress < 100) {
            const resp = await zapClient.spider.status({ scanId: spiderScan.scan });
            spiderProgress = parseInt(resp.status, 10);
            console.log('Spider Progress:', spiderProgress);
            await new Promise(res => setTimeout(res, 2000));
        }
        console.log('✅ Spider Scan completed');
        return true;
    } catch (error) {
        console.error("❌ ZAP Spider failed:", error);
        throw error;
    }
}

module.exports = {
    performSpiderScan
}

3. perform_ajax_spider_scan.js

async function performAjaxSpiderScan(zapClient, target) {
    try {
        console.log(`⚡ Starting AJAX Spider Scan on ${target}`);
        await zapClient.ajaxSpider.scan({ url: target });
        let statusResp = {};
        while (statusResp.status !== 'stopped') {
            statusResp = await zapClient.ajaxSpider.status();
            console.log('AJAX Spider Scan Status:', statusResp);
            await new Promise(res => setTimeout(res, 2000));
        }
        console.log('✅ AJAX Spider Scan completed');
        return true;
    } catch (error) {
        console.error("❌ ZAP AJAX Spider failed:", error);
        throw error;
    }
}

module.exports = {
    performAjaxSpiderScan
}

4. setup_zap.sh

#!/bin/bash
set -e

ZAP_HOME_DIR="/tmp/zap-session-$RANDOM"
mkdir -p "$ZAP_HOME_DIR"

# Find a free port dynamically
ZAP_PORT=$(python3 -c "import socket; s = socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()")
export ZAP_PORT
echo "ZAP_PORT=$ZAP_PORT" >> .env

# echo to Azure DevOps env pipeline file
echo "##vso[task.setvariable variable=ZAP_PORT]$ZAP_PORT"

# Download and extract ZAP only if not already present
if [ ! -d "ZAP_2.16.0" ]; then
  wget https://github.com/zaproxy/zaproxy/releases/download/v2.16.0/ZAP_2.16.0_Linux.tar.gz
  tar -xzf ZAP_2.16.0_Linux.tar.gz
fi

cd ZAP_2.16.0
./zap.sh -daemon -port "$ZAP_PORT" \
  -host 127.0.0.1 \
  -dir "$ZAP_HOME_DIR" \
  -addoninstall callhome \
  -config api.key="$ZAP_API_KEY" &

sleep 40

Scripts Usage

  • perform_spider_scan.js: Performs classic crawling of all links.
  • perform_ajax_spider_scan.js: AJAX Spider is used to crawl modern JavaScript-heavy pages.
  • generate_zap_reports.js: Generates a traditional HTML report in security-report/.
  • setup_zap.sh: Detects free port, starts ZAP, and exports the ZAP_PORT to the environment.

Azure DevOps Pipeline Setup

Environment Variables

Set the following environment variables to configure the ZAP scanning in the pipeline:

  1. ZAP_API_KEY:
    This is the API key used to authenticate with the ZAP instance. You can set a static or randomized API key for security purposes.

    Example:

    - name: ZAP_API_KEY
      value: "ZAP_API_KEY_00001"
    
  2. ZAP_PORT:
    ZAP needs to run on a specific port to capture the traffic from Playwright tests. Instead of hardcoding the port, we dynamically assign a free port to ZAP at the start of the pipeline using a script (setup_zap.sh). This port is exported as the ZAP_PORT environment variable so that Playwright can connect to it.

    In the pipeline, this environment variable will be dynamically set after running the setup_zap.sh script, and Playwright will use this ZAP_PORT to route traffic through the ZAP proxy.

  3. PERFORM_ZAP_SCAN:
    This flag tells the pipeline whether or not to run ZAP security scans. You need to set this environment variable to true to activate the ZAP scanning steps. If it’s set to false, the scans won’t be triggered.


Pipeline Stage

- stage: Test
  jobs:    
    - job: IntegrationTesting
      displayName: "UI Testing"
      condition: and(or(succeeded(), contains(variables['System.PullRequest.SourceBranch'], 'test')), eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/releases'))
      pool:
        vmImage: "ubuntu-latest"
      steps:
        - script: |
            chmod +x features/security-scan-utils/setup_zap.sh
            ./features/security-scan-utils/setup_zap.sh
          displayName: Start ZAP and Export Dynamic Port

        - script: |
            npm install pm2@5.3.0 -g
            echo "ZAP_PORT=$ZAP_PORT" >> .env
            source .env
            npm install express --force
            pm2 start scripts/server.js
            sleep 60
            mkdir reports
            mkdir reports-xml
            npm install nyc --save-dev --force
            npx playwright install --with-deps chromium
            node bdd-execution.js
          displayName: Integration Testing

Sample Report Directory

After scan execution, a report will be stored in:

/security-report/zap-report-<timestamp>.html

You can upload this as a pipeline artifact.


Active Scans

In addition to passive scanning (Spider and AJAX Spider), ZAP also supports Active Scans, which aggressively test your application for potential vulnerabilities by sending crafted inputs. Active scans are ideal for catching more complex or rare security issues but can be more resource-intensive and time-consuming.


Conclusion

Combining Playwright’s browser automation with OWASP ZAP’s security scanning provides a powerful solution, giving your team instant feedback on vulnerabilities.

  • Passive scans capture traffic and find vulnerabilities without attacking the system. These should be run frequently in your CI/CD pipeline.
  • Active scans attack the application with malicious payloads, probing for deeper vulnerabilities. It should be run selectively, based on your development cycle, rather than every time.

Related Resources


5 Likes