Most infrastructure outgrows its security tooling. New services get added, old ones drift, and before long you're relying on hope and the occasional manual check. This post is about fixing that — building a proper Dynamic Application Security Testing (DAST) pipeline using Ansible, a handful of open source scanners, and DefectDojo as the central findings management platform. Automated, repeatable, and actually actionable.

Background

I'd been looking for a DevSecOps-friendly way to manage vulnerability scanning for a while. I tried Faraday first — it promises a unified workspace for security findings, but in practice I found it buggy and frustrating to work with. Getting data in and out reliably was more effort than it was worth.

Eventually I stopped looking for a single tool that does everything and decided to build the pipeline myself using tools I already trusted. Ansible was the obvious choice for orchestration: idempotent, agentless, and easy to extend. The setup is a dedicated VM running security scanners, with Ansible coordinating the scans, collecting results, and pushing findings into DefectDojo for triage.

The same pipeline serves two purposes: ongoing vulnerability management (scheduled scans against production) and DevSecOps (scans triggered automatically by deployments in CI/CD). One codebase, two use cases.

The Goal

The end result I was aiming for:

  • Multiple web scanners running automatically against targets
  • All findings centralised in DefectDojo
  • Verified findings automatically creating tickets in your ticketing system
  • Everything managed as code, repeatable, and extensible

The Stack

Tool Purpose
Ansible Orchestration — running scanners, pushing results
Nikto Web server scanning
OWASP ZAP Full web application DAST
WPScan WordPress-specific vulnerability scanning
Nuclei Template-based CVE and misconfiguration scanning
testssl.sh TLS/SSL configuration auditing
DefectDojo Findings management and deduplication
Ticketing system Ticket creation for verified findings

All scanners run via Docker on a dedicated scanner host, keeping the control node clean.

The Ansible Role Structure

Each scanner follows the same pattern:

roles/<scanner>/
  defaults/main.yml   # variables with sensible defaults
  tasks/main.yml      # install, scan, push to DefectDojo
  tasks/defectdojo_push.yml  # slurp results + POST to DefectDojo API
  meta/main.yml

A few conventions I settled on after some trial and error:

Timestamped output files — every scan run produces a new file so re-runs don't skip due to idempotency checks:

- name: Set scan timestamp
  ansible.builtin.set_fact:
    scan_ts: "{{ lookup('pipe', 'date +%Y%m%d_%H%M%S') }}"
  run_once: true

Slurp + URI for DefectDojo uploads — scan results live on the remote scanner host, not the Ansible controller. Using slurp reads the file remotely and passes it as base64 to the uri module:

- name: Slurp result file
  ansible.builtin.slurp:
    src: "{{ output_file }}"
  become: true
  register: slurped

- name: Upload to DefectDojo
  ansible.builtin.uri:
    url: "{{ defectdojo_url }}/api/v2/import-scan/"
    method: POST
    headers:
      Authorization: "Token {{ defectdojo_api_key }}"
    body_format: form-multipart
    body:
      scan_type: "Nikto Scan"
      engagement: "{{ engagement_id | string }}"
      file:
        content: "{{ slurped.content | b64decode }}"
        filename: "{{ output_file | basename }}"
        mime_type: "application/xml"

include_tasks not import_tasks for conditional blocks — learned this the hard way. import_tasks with a when condition evaluates template variables in loop_control.label even when skipped, causing undefined variable errors. include_tasks skips the whole file cleanly.

Scanner Notes

Nikto

Straightforward. Use -ssl explicitly for HTTPS targets and -Tuning 1234567890abc to enable all check categories. The default scan is surprisingly light.

OWASP ZAP

ZAP runs via Docker. Two gotchas:

  • Use -x for XML output (not -r which generates HTML despite what you might expect)
  • Add --user root to the docker run command — the output directory is root-owned and the zap user can't write to it
docker run --rm --user root \
  -v /opt/scans/zap:/zap/wrk:rw \
  ghcr.io/zaproxy/zaproxy:stable \
  zap-full-scan.py -t https://target.example.com -x report.xml -I

WPScan

WPScan needs its vulnerability database to be useful. A few lessons:

  1. The DB lives at /root/.cache/wpscan/db/ inside the container — mount a persistent volume there so you're not re-downloading it every run
  2. Separate the --update into its own task that runs once, then pass --no-update to the scan task
  3. Full enumeration (vp,vt,u,tt,cb,dbe) takes 20+ minutes per site. For regular scanning, vp,vt (vulnerable plugins and themes only) is much more practical
- name: Update WPScan DB (once)
  ansible.builtin.command:
    cmd: docker run --rm --user root
      -v /opt/scans/wpscan-db:/root/.cache/wpscan:rw
      wpscanteam/wpscan:latest --update
  timeout: 300

- name: Scan target
  ansible.builtin.command:
    cmd: docker run --rm --user root
      -v /opt/scans/wpscan:/output:rw
      -v /opt/scans/wpscan-db:/root/.cache/wpscan:rw
      wpscanteam/wpscan:latest
      --url https://target.example.com
      --enumerate vp,vt
      --no-update
      --format json
  timeout: 900

Nuclei

Nuclei's template library is massive — CVEs, misconfigs, WordPress-specific checks, nginx, exposed files. Use tags to scope scans:

nuclei_tags: "cve,wordpress,nginx,misconfig"
nuclei_severity: "medium,high,critical"

testssl.sh

The best tool for TLS/SSL auditing. Checks cipher suites, protocol versions, HSTS, certificate validity, OCSP, and more. DefectDojo has a CSV parser for it (Testssl Scan).

DefectDojo Integration

Every scanner role posts results to DefectDojo via the /api/v2/import-scan/ endpoint. A few things to get right:

  • All form-multipart values must be strings — pass integers through | string filter or they'll fail with a cryptic type error
  • Set verified: false on import — findings go through a triage step before being actioned
  • Use scan_type that exactly matches DefectDojo's parser name (e.g. "Nikto Scan", "ZAP Scan", "Wpscan")

Ticketing Integration

DefectDojo has a native Jira integration, but it only supports on-premise Jira Server/Data Center. If you're using a cloud-hosted ticketing system (or any tool with a webhook), you can build the bridge yourself with a short Ansible playbook:

  1. Query DefectDojo API for verified, active findings that haven't been pushed yet
  2. POST each finding to your ticketing system's incoming webhook
  3. Tag the finding in DefectDojo so it isn't sent again
- name: Fetch verified findings
  ansible.builtin.uri:
    url: "{{ defectdojo_url }}/api/v2/findings/?verified=true&active=true&not_tags=ticketing-pushed&limit=100"
    headers:
      Authorization: "Token {{ defectdojo_api_key }}"
  register: dd_findings

- name: Post to ticketing webhook
  ansible.builtin.uri:
    url: "{{ ticketing_webhook_url }}"
    method: POST
    headers:
      Authorization: "{{ ticketing_webhook_token }}"
    body_format: json
    body:
      title: "[{{ item.severity }}] {{ item.title }}"
      severity: "{{ item.severity }}"
      defectdojo_url: "{{ defectdojo_url }}/finding/{{ item.id }}"
  loop: "{{ dd_findings.json.results }}"

- name: Tag as pushed
  ansible.builtin.uri:
    url: "{{ defectdojo_url }}/api/v2/findings/{{ item.item.id }}/"
    method: PATCH
    headers:
      Authorization: "Token {{ defectdojo_api_key }}"
    body_format: json
    body:
      tags: "{{ item.item.tags + ['ticketing-pushed'] }}"
  loop: "{{ webhook_results.results }}"

The tag acts as a simple idempotency guard — re-running the playbook won't create duplicate tickets. On the receiving end, your ticketing automation rule maps the webhook payload fields to issue fields and creates the ticket.

This pattern works with any system that exposes an incoming webhook: Jira Cloud (Automation), ServiceNow, PagerDuty, Linear, or even a custom endpoint.

The Full Flow

Ansible playbook triggered (manually or CI/CD)
    ↓
Scanner runs against target (Docker on scanner-1)
    ↓
Result file written to /opt/scans/<tool>/
    ↓
Slurped and POSTed to DefectDojo
    ↓
Security team triages findings in DefectDojo
    ↓
Finding marked Verified
    ↓
defectdojo_to_ticketing.yml runs (scheduled every 30 min)
    ↓
Ticket created → finding tagged ticketing-pushed

What's Next

  • Wire the scanning playbooks into CI/CD so scans trigger automatically after deployments
  • Build a combined webscan playbook that runs all scanners against a target list in one go
  • Run WPScan across the full site inventory
  • Add nuclei and testssl to the regular rotation