Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

DAST: testing the security of a running application

By updated

Improve this page

DAST (Dynamic Application Security Testing) attacks an application while it is running, as an attacker would, to find the flaws that are really exploitable. This lesson is for a developer or a DevOps engineer who has already seen SAST and wants to know what a dynamic scan brings, and what it misses. Everything rests on a measurement: the same small Flask application as in the SAST lesson, with an SQL injection and a command injection. An OWASP ZAP scan run with its default settings returns 7 warnings, all about HTTP headers, and sees neither of the two injections. The lesson shows why, and how to get a scan that finds them and makes the pipeline fail.

What you will learn

  • Distinguish what DAST detects from what SAST already sees
  • Run the three ZAP scans: baseline, full and API
  • Measure the coverage of a scan before trusting its report
  • Make a CI job fail on the chosen flaws only, with a rules file
  • Position Nuclei, a template engine, relative to ZAP
  • Plug the scan in after the staging deployment, within the legal framework

What is DAST?

DAST tests a deployed application by sending it malformed requests and analysing its responses. It is a black-box test: the scanner does not read the source code, it interacts with the application over the network, like a user or an attacker. It answers a simple question: "is my application, as it runs, vulnerable to known attacks?".

A dynamic scan always goes through the same phases, and the first one decides everything else:

  1. Exploration (crawling): the scanner browses the application to discover pages, forms and endpoints. What it does not discover, it does not attack.

  2. Passive analysis: it reads the responses obtained without changing anything: security headers, cookies, information leaks.

  3. Active analysis: on each entry point discovered, it injects attack payloads (SQL, system command, XSS) and observes the response.

  4. Report: each finding comes out with a severity, the request that triggered it and the response obtained.

DAST and SAST: what each one sees

The two families do not observe the same object. SAST reads source text and sees all the code, including branches that are never executed. DAST observes a process that answers on the network and only sees what its exploration has reached. On the test application, SAST found both injections by reading app.py; DAST, as we are about to see, only finds them if it is led to them.

AspectDASTSAST
WhenDeployed applicationDuring development
HowNetwork requests, black boxReading the source code
FindsFlaws observed in responsesPotential flaws in the code
CoveragePaths reached by the explorationAll the code
Also seesReal configuration: headers, TLS, errorsNothing that is not in the repository

What SAST cannot see is in the last row: a missing security header, a talkative error page or weak TLS do not exist in the code, they are born at deployment. The evidence it provides is also more convincing: the request and the response are attached to the finding, which cuts short the "is it really exploitable?" debate.

The test bench

All the outputs of this lesson come from a local bench: the Flask application from the SAST lesson, containerised and run in a dedicated Docker network, named dast-net, under the name cible. It exposes two vulnerable routes, /user?id= (SQL injection in SQLite) and /ping?host= (command injection through shell=True), and a home page that contains no link to them, like an API without a web interface.

The two scanners run in their official image, pinned by digest:

Terminal window
ZAP="ghcr.io/zaproxy/zaproxy:2.17.0@sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef"
NUCLEI="projectdiscovery/nuclei:v3.11.1@sha256:582d5546902e67052097cb2d07296c642d50a1afc5e44623cb038845df9a32eb"

Before the first scan, check by hand that the flaw exists: it is the one the scanner must find again. /user?id=1 returns [(1, 'alice')], and /user?id=1 OR 1=1 returns both users of the table.

What does a first ZAP scan find?

OWASP ZAP (Zed Attack Proxy) is an open source DAST scanner, used here as the demonstration scanner. Its image provides three ready-to-use scripts, from the lightest to the most complete. The first one, zap-baseline.py, explores the application then sticks to passive analysis: it sends no attack payload, which makes it safe on any environment.

Terminal window
mkdir -p wrk && chmod 777 wrk # the container writes as the zap user
docker run --rm --network dast-net -v "$PWD/wrk:/zap/wrk:rw" "$ZAP" \
zap-baseline.py -t http://cible:5000 -r baseline.html -J baseline.json

Mounting /zap/wrk is mandatory to retrieve the reports: the -r and -J options write there, never to the current folder. Measured: the scan takes 36 seconds, discovers 3 URLs (/, robots.txt and sitemap.xml), passes 60 rules and raises 7 warnings, all about HTTP headers:

WARN-NEW: Missing Anti-clickjacking Header [10020] x 1
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 1
WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 3
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 3
WARN-NEW: Storable and Cacheable Content [10049] x 2
WARN-NEW: Permissions Policy Header Not Set [10063] x 3
WARN-NEW: Cross-Origin-Embedder-Policy Header Missing or Invalid [90004] x 3
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 7 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 60

These warnings are real and useful: they describe the deployment configuration. But the two injections appear nowhere, and that is normal: the baseline does not attack, and its exploration never reached /user or /ping.

The full scan does not see any better

The second script, zap-full-scan.py, adds active analysis: it injects attack payloads into each entry point discovered. It is longer and more intrusive, to be kept for a test environment.

Terminal window
docker run --rm --network dast-net -v "$PWD/wrk:/zap/wrk:rw" "$ZAP" \
zap-full-scan.py -t http://cible:5000 -r full.html -J full.json

Measured: 72 seconds, 4 URLs discovered, 135 rules passed without raising a single injection. The active scan carefully attacked what it had at hand, that is, the home page. A green report on this scope says nothing about the application.

Why coverage decides everything

A dynamic scanner only attacks what it knows. An API without an interface, routes reached only through JavaScript or an area behind a login form remain invisible to the exploration, and the report looks reassuring by mistake. The remedy is to give the scanner the map: an OpenAPI description for an API, an authenticated context for a web application.

Coverage of a DAST scan on the test application: with its crawler alone, ZAP discovers 3 URLs and its report only contains HTTP headers, with no injection; starting from an OpenAPI description, the active scan attacks 32 URLs and finds the SQL injection and the command injection.Applicationdeployed to stagingCrawler alone:3 URLs foundOpenAPIdescriptionActive scan:32 URLs attackedReport: HTTPheaders, no injectionSQL injection andcommand injection
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The third script, zap-api-scan.py, reads an API description instead of exploring. For the test application, the description fits in a few lines: each route, its parameter and an example value (the file below is the one used for the measurement, hence its French descriptions).

wrk/openapi.yaml
openapi: 3.0.3
info:
title: Démo
version: 1.0.0
servers:
- url: http://cible:5000
paths:
/user:
get:
parameters:
- name: id
in: query
required: true
schema:
type: string
example: "1"
responses:
"200":
description: Utilisateur
/ping:
get:
parameters:
- name: host
in: query
required: true
schema:
type: string
example: localhost
responses:
"200":
description: Résultat du ping
Terminal window
docker run --rm --network dast-net -v "$PWD/wrk:/zap/wrk:rw" "$ZAP" \
zap-api-scan.py -t /zap/wrk/openapi.yaml -f openapi -r api.html -J api.json

Measured: 39 seconds, 32 URLs attacked, and both flaws come out, each with the request that proves it:

WARN-NEW: SQL Injection [40018] x 1
http://cible:5000/user?id=3-2 (200 OK)
WARN-NEW: Remote OS Command Injection [90020] x 1
http://cible:5000/ping?host=localhost%26cat+%2Fetc%2Fpasswd%26 (200 OK)
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 11 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 109

The proof of the SQL injection is elegant: id=3-2 returns the same user as id=1, so the database computed the expression, so the value enters the query without being treated as data. The report also contains noise: a "Buffer Overflow" on a Python application, raised because an overly long payload caused a 500 error, and 17 "Unexpected Content-Type". A DAST finding is replayed by hand before being fixed or dismissed.

How do you make the pipeline fail?

Look at the last line of the API scan: FAIL-NEW: 0. A confirmed SQL injection comes out as WARN, not FAIL: by default, the ZAP scripts put all rules at warning level. The exit code depends on this classification:

CodeMeaning
0No warning and no failure
1At least one rule at FAIL level raised a finding
2At least one warning, no failure
3Scan execution error

The three previous scans all exited with code 2. A job that fails on any non-zero code therefore blocks on a missing header as much as on an injection, and the team ends up disabling the control. The countermeasure fits in a rules file, one line per rule, separated by tabs: the identifier, the desired level, and a name for the reader.

wrk/rules.tsv
40018 FAIL (SQL Injection)
90020 FAIL (Remote OS Command Injection)
10036 IGNORE (Server Leaks Version Information)

The -c option loads this file, and -I prevents a mere warning from making the scan fail. Combined, they give exactly the desired policy:

Terminal window
docker run --rm --network dast-net -v "$PWD/wrk:/zap/wrk:rw" "$ZAP" \
zap-api-scan.py -t /zap/wrk/openapi.yaml -f openapi -c rules.tsv -I -s
IGNORE-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 2
FAIL-NEW: SQL Injection [40018] x 1
FAIL-NEW: Remote OS Command Injection [90020] x 1
FAIL-NEW: 2 FAIL-INPROG: 0 WARN-NEW: 9 WARN-INPROG: 0 INFO: 0 IGNORE: 1 PASS: 108

Measured: this scan exits with code 1, and the baseline run again with -I alone exits with code 0 despite its 7 warnings. The two injections block, the headers stay visible in the report without stopping delivery. The complete rules file is generated with -g gen.conf: start from it, and only move a rule to IGNORE with a written justification in review.

Nuclei: templates, not a crawler

Nuclei, from ProjectDiscovery, works differently: it does not explore the application, it runs YAML templates, each describing a request and the response that gives away a known flaw. Its strength is volume: the measured template repository holds 13,742 files, 251 of them dedicated to DAST fuzzing. Its weakness is symmetrical: it looks for what its templates describe, mostly CVEs of known products and configuration exposures.

Terminal window
docker volume create nuclei-templates
N=(docker run --rm --network dast-net -v nuclei-templates:/root/nuclei-templates -v "$PWD:/wrk" "$NUCLEI")
"${N[@]}" -u http://cible:5000 # all templates
"${N[@]}" -u http://cible:5000 -s critical,high # high severities only

Measured on the test application, the full scan takes 378 seconds and raises 12 findings, all of info severity: missing headers, Python detection, OPTIONS method. Filtered on critical,high, it takes 86 seconds and finds nothing. That is expected: the application uses no known vulnerable product, and its flaws are specific to its code.

The -dast mode enables the fuzzing templates, to be applied to a list of URLs with parameters, because Nuclei does not discover them on its own:

Terminal window
printf 'http://cible:5000/user?id=1\nhttp://cible:5000/ping?host=localhost\n' > urls.txt
"${N[@]}" -l /wrk/urls.txt -dast # the current folder is mounted on /wrk

Measured, in three variants that do not say the same thing:

VariantResultRemark
-dastcommand injection foundthrough a callback request to oast.me
-dast -ninothingwithout an external callback server
-dast -ni -fa highcommand injection foundlabelled linux-lfi-fuzz, file read

The SQL injection came out in none of the variants. And the first row hides a point of caution: the OAST (Out-of-band Application Security Testing) templates make the target call a public server, oast.me here, to prove the execution. The callback domain therefore goes through a third-party service. On a sensitive environment, disable them with -ni or host your own interactsh server and pass it with -iserver.

One last measured point: Nuclei exits with code 0 even when it finds something. To block a pipeline, you must test the results file produced by -o, not the exit code. The Nuclei guide (in French) details the templates and how to write them.

Where to plug DAST into the pipeline?

The dynamic scan needs an application that runs: it goes right after the staging deployment, on an environment configured like production, and never directly on production, which an active scan can disrupt (data created, saturated queues, alerts triggered).

build → tests → staging deployment → DAST scan → production deployment

The trigger matters as much as the position: the job waits for the end of the deployment, not the mere push, otherwise it tests the old version still online.

.github/workflows/dast.yml
name: DAST
on:
workflow_run:
workflows: ["Deploy to Staging"]
types: [completed]
permissions: {}
jobs:
zap-api-scan:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: ZAP API scan
uses: zaproxy/action-api-scan@5158fe4d9d8fcc75ea204db81317cce7f9e5453d # v0.10.0
with:
target: https://staging.mon-app.example.com/openapi.yaml
format: openapi
docker_name: ghcr.io/zaproxy/zaproxy:2.17.0@sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef
rules_file_name: .zap/rules.tsv
cmd_options: -I
fail_action: true
allow_issue_writing: false

Three inputs of the action are set explicitly. docker_name defaults to ghcr.io/zaproxy/zaproxy:stable, a mutable reference: it is replaced with the pinned image. fail_action: true makes the job fail on codes 1 and 2 (read in the action's code), hence -I in cmd_options so that only the FAIL rules block. Finally, by default the action opens a summary issue, which would require issues: write: with allow_issue_writing: false, the job makes do with contents: read.

An active scan sends thousands of abnormal requests. Without preparation, it triggers the WAF, saturates the rate limiting and wakes up the on-call team for a fake attack. Four settings are made once and for all, before the first scan:

  • isolate the staging environment, without production data;
  • allow the scanner's address in the WAF and the firewall;
  • exclude destructive endpoints (logout, deletion) from the scope;
  • notify the operations team of the scan window.

The legal framework is not configured, it is respected. Unlike SAST, which reads a repository you own, DAST attacks a system reachable over the network. The fact that it belongs to your employer is not enough: it is the written authorisation of the person responsible for the system that protects you, and it names the URLs and the testing window.

What are the limits of DAST?

The most dangerous limit is partial coverage, and the bench showed it plainly: a green report means "nothing found on the paths reached", never "healthy application". Always note the number of URLs discovered (Total of N URLs at the top of the output) and compare it with what you know of the application: 3 URLs for an API with two routes invalidate the report before you even read the alerts.

DAST also comes late in the cycle, when the flaw is already deployed, and it needs a running environment. It sees business logic poorly: a scanner notes that a resource answers, it does not know whether the requested identifier belongs to the logged-in account. That is why it complements SAST and SCA, without replacing them: each one sees what the other two do not.

Key points

  • DAST attacks the deployed application, as a black box, and sees what the code does not contain: headers, errors, TLS.
  • A scan only attacks what its exploration has discovered: measured, 3 URLs and no injection without help.
  • An OpenAPI description gives the scanner the map: 32 URLs attacked, both injections found.
  • By default, ZAP puts everything at WARN and exits with code 2: a rules file and -I reserve failure for the chosen flaws.
  • Nuclei excels at CVEs of known products; its OAST templates make the target call a third-party server, and it exits with code 0 even when it finds something.
  • The scan is plugged in after the staging deployment, with a pinned image; outside your own systems, the professional rule requires a written authorisation.

Knowledge check

Check that you have the essentials of this lesson. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn