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:
-
Exploration (crawling): the scanner browses the application to discover pages, forms and endpoints. What it does not discover, it does not attack.
-
Passive analysis: it reads the responses obtained without changing anything: security headers, cookies, information leaks.
-
Active analysis: on each entry point discovered, it injects attack payloads (SQL, system command, XSS) and observes the response.
-
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.
| Aspect | DAST | SAST |
|---|---|---|
| When | Deployed application | During development |
| How | Network requests, black box | Reading the source code |
| Finds | Flaws observed in responses | Potential flaws in the code |
| Coverage | Paths reached by the exploration | All the code |
| Also sees | Real configuration: headers, TLS, errors | Nothing 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:
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.
mkdir -p wrk && chmod 777 wrk # the container writes as the zap userdocker run --rm --network dast-net -v "$PWD/wrk:/zap/wrk:rw" "$ZAP" \ zap-baseline.py -t http://cible:5000 -r baseline.html -J baseline.jsonMounting /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 1WARN-NEW: X-Content-Type-Options Header Missing [10021] x 1WARN-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 3WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 3WARN-NEW: Storable and Cacheable Content [10049] x 2WARN-NEW: Permissions Policy Header Not Set [10063] x 3WARN-NEW: Cross-Origin-Embedder-Policy Header Missing or Invalid [90004] x 3FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 7 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 60These 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.
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.jsonMeasured: 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.
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).
openapi: 3.0.3info: title: Démo version: 1.0.0servers: - url: http://cible:5000paths: /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 pingdocker 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.jsonMeasured: 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: 109The 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:
| Code | Meaning |
|---|---|
0 | No warning and no failure |
1 | At least one rule at FAIL level raised a finding |
2 | At least one warning, no failure |
3 | Scan 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.
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:
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 -sIGNORE-NEW: Server Leaks Version Information via "Server" HTTP Response Header Field [10036] x 2FAIL-NEW: SQL Injection [40018] x 1FAIL-NEW: Remote OS Command Injection [90020] x 1FAIL-NEW: 2 FAIL-INPROG: 0 WARN-NEW: 9 WARN-INPROG: 0 INFO: 0 IGNORE: 1 PASS: 108Measured: 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.
docker volume create nuclei-templatesN=(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 onlyMeasured 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:
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 /wrkMeasured, in three variants that do not say the same thing:
| Variant | Result | Remark |
|---|---|---|
-dast | command injection found | through a callback request to oast.me |
-dast -ni | nothing | without an external callback server |
-dast -ni -fa high | command injection found | labelled 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 deploymentThe 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.
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: falseThree 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.
dast: stage: dast image: ghcr.io/zaproxy/zaproxy:2.17.0@sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef needs: - deploy_staging script: - mkdir -p /zap/wrk - cp .zap/rules.tsv /zap/wrk/rules.tsv - zap-api-scan.py -t "$CI_ENVIRONMENT_URL/openapi.yaml" -f openapi -c rules.tsv -I -r zap-report.html after_script: - cp /zap/wrk/zap-report.html "$CI_PROJECT_DIR/zap-report.html" artifacts: when: always paths: - zap-report.htmlThe detour through /zap/wrk is not an affectation: the ZAP scripts read the
rules file there and write their reports there. Without the copy to
$CI_PROJECT_DIR, the artifact comes back empty. The after_script
retrieves the report even when the scan fails, that is, precisely when you
need it.
Preparing the environment and respecting the legal framework
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
-Ireserve 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Why static secrets are a problem
A new full quiz with random questions
Work again only on the questions you missed