DAST & the scan pipeline
Test the running app; wire it all together.
A payments merge request sails through the pipeline. SAST (Static Application Security Testing, the scanner that reads your source), secret detection, dependency scanning and the container image scan all come back green. The change ships two real bugs anyway. Its /search page takes whatever a visitor types into the q query parameter and writes it into the page without escaping it, and every response leaves the server with no Content-Security-Policy header to tell the browser what it is allowed to run. Not one static scanner said a word, because neither bug lives in a file sitting on disk. Both exist only while the app is running and a real HTTP request lands on a real endpoint. Closing that gap is the job of DAST.
Static scanners are blueprint readers. They study the plans and tell you the fire door is drawn in the wrong place. DAST (Dynamic Application Security Testing) is the person who walks up to the finished building at 2am and pulls on every handle. It is black-box testing: it aims at the running application and attacks it from the outside over HTTP (the request-and-response protocol browsers speak), the way an unauthenticated stranger on the internet would, with no sight of your code. Instead of reading files it fires crafted requests at live endpoints and reads what comes back, hunting the problems that exist only at runtime. Reflected and stored injection. Broken authentication. Missing or weak security headers. Admin paths left open, error pages that spill their guts, cookies set without protective flags. GitLab's current DAST is browser-based, a GitLab-built analyzer that drives a specially instrumented Chromium browser and turns what it sees into GitLab's DAST report format. (The older proxy-based analyzer it replaced was the one built on OWASP ZAP. That engine is no longer part of GitLab DAST.)
Why DAST needs something running
Every other scanner in this course reads something that holds still. SAST reads source. SCA (Software Composition Analysis, the dependency scanner) reads the lockfile. Image scanning reads the layers of a built container. DAST has no file to open. It has a URL, and until a reachable copy of the app is deployed somewhere there is nothing for it to look at. That single fact decides where DAST goes in the pipeline: after a deploy stage, never beside the static scanners in an early test stage. The usual target is a review app, a GitLab environment spun up per branch or per merge request, each with its own live URL that GitLab hands you as $CI_ENVIRONMENT_URL. The merge request deploys its own change, DAST attacks that copy, and the environment is torn down when the merge request closes. For heavier, slower runs, a shared staging environment is the other option.
Proxy-based vs browser-based crawling
Two scouts, same building. One works from the leaflet pushed under the front door and maps the rooms from that. The other walks in, opens every door and takes the stairs. That is the gap between the two DAST crawlers, and it decides whether DAST sees your app at all. The legacy proxy-based analyzer sat in front of the app as an HTTP proxy, running OWASP ZAP. It worked from the raw HTML the server sent back, pulled links and forms out of it, then replayed mutated versions of those requests. It never ran a line of JavaScript. Point it at a modern single-page app (SPA), where routes, forms and most of the content get built in the browser after the page loads, and the spider sees a nearly empty shell. Coverage collapses to the landing page. Browser-based DAST fixes that by driving a real, instrumented Chromium. It loads each page, runs the JavaScript, crawls the fully rendered DOM (Document Object Model, the live page the browser actually builds) and the client-side routes, records every request the app really makes, then attacks those requests. GitLab deprecated the proxy-based analyzer in 16.9 and removed it in 17.3, so on GitLab 17.x browser-based DAST is the default and the only analyzer. What that changes for you: coverage now rests on the crawler genuinely walking your app, and that is the part you have to verify.
Wiring it up takes two jobs. One publishes the review app and declares the environment so GitLab tracks its URL. The other includes GitLab's DAST template and points DAST_TARGET_URL at that URL. (In the browser-based analyzer the target variable is DAST_TARGET_URL, renamed from the old proxy-based DAST_WEBSITE. The template ships in two versions: the stable Security/DAST.gitlab-ci.yml and the bleeding-edge Security/DAST.latest.gitlab-ci.yml.) The rules:if keys tie both jobs to merge-request pipelines, and needs makes DAST wait for the deploy so it never swings at a target that is not up yet.
stages: [test, build, deploy-review, dast]include:- template: Security/DAST.gitlab-ci.ymldeploy-review:stage: deploy-reviewenvironment:name: review/$CI_COMMIT_REF_SLUGurl: https://$CI_COMMIT_REF_SLUG.review.acme.internalscript:- ./deploy-review-app.sh "$CI_ENVIRONMENT_URL"rules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"dast:stage: dastneeds: [deploy-review]variables:DAST_TARGET_URL: https://$CI_COMMIT_REF_SLUG.review.acme.internalrules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"
On a merge request, deploy-review publishes the app and then dast attacks the live URL. A browser-based run is slow. Crawling a real app and then actively scanning it takes many minutes, so DAST usually does not gate every merge request the way SAST does. A common split is a light baseline on each merge request and a full active scan nightly or before a release. The job log is where you confirm it actually did something:
2026-07-14T02:14:07.312Z INF GitLab DAST 5.1.2 (browser-based) starting2026-07-14T02:14:07.318Z INF Target (DAST_TARGET_URL): https://feature-login.review.acme.internal2026-07-14T02:14:09.004Z INF Launched instrumented Chromium 146.0.7686.982026-07-14T02:19:52.771Z INF Crawl complete: navigated 38 paths, discovered 214 requests2026-07-14T02:33:41.220Z INF Active scan: 214 requests attacked, 1187 checks run2026-07-14T02:41:55.902Z INF Scan finished: 4 vulnerabilities (1 High, 1 Medium, 2 Low)2026-07-14T02:41:56.010Z INF Wrote gl-dast-report.jsonUploading artifacts...gl-dast-report.json: found 1 matching artifact files and directoriesJob succeeded
Two numbers in that log matter more than the green check mark: paths navigated (38) and requests attacked (214). If those sit near zero, the scan found nothing because it saw nothing, not because your app is clean. Findings land in gl-dast-report.json, uploaded through artifacts:reports:dast so GitLab can draw them in the merge request security widget and the vulnerability report. Here is the reflected XSS (cross-site scripting) finding from that /search bug:
{"version": "15.2.4","scan": {"analyzer": { "id": "gitlab-dast", "name": "GitLab DAST", "vendor": { "name": "GitLab" }, "version": "5.1.2" },"scanner": { "id": "gitlab-dast", "name": "GitLab DAST", "vendor": { "name": "GitLab" }, "version": "5.1.2" },"type": "dast","start_time": "2026-07-14T02:14:07","end_time": "2026-07-14T02:41:55","status": "success"},"vulnerabilities": [{"id": "9c2f7b41-3e0a-4a1d-8b6e-1f0d5c9a77e2","name": "Cross-site scripting (reflected)","description": "The q parameter is copied into the HTML response without encoding; a crafted value executed in the instrumented browser during the crawl.","severity": "High","confidence": "Medium","solution": "Context-encode all user input on output; add a Content-Security-Policy that forbids inline script.","scanner": { "id": "gitlab-dast", "name": "GitLab DAST" },"identifiers": [{ "type": "gitlab_dast", "name": "Cross-site scripting", "value": "79.1", "url": "https://docs.gitlab.com/user/application_security/dast/browser/checks/79.1/" },{ "type": "cwe", "name": "CWE-79", "value": "79", "url": "https://cwe.mitre.org/data/definitions/79.html" }],"location": {"hostname": "https://feature-login.review.acme.internal","method": "GET","path": "/search","param": "q"},"evidence": {"request": { "method": "GET", "url": "https://feature-login.review.acme.internal/search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" },"response": { "status_code": 200, "body": "...<div class=\"results\">0 results for <script>alert(1)</script></div>..." }}}]}
Each vulnerability carries a normalized severity, the scanner and its GitLab check id (79.1), the matching CWE (Common Weakness Enumeration, the public catalog of bug types), and a precise location: hostname, method, path and the parameter at fault (q). That location is what makes a DAST finding usable. It hands a developer the exact request that reproduces the bug. You do not need the web interface to triage it either. Pull the same report out of CI, or from your own shell, with glab and jq:
$ glab ci artifact feature-login dast -R acme/payments -p ./dast-art✓ Downloaded artifacts to ./dast-art$ jq -r '.vulnerabilities[] | [.severity, .name, .location.path] | @tsv' \./dast-art/gl-dast-report.json | sortHigh Cross-site scripting (reflected) /searchLow Sensitive cookie without HttpOnly attribute /loginLow Server header exposes version information /Medium Content-Security-Policy analysis /
What DAST costs you is noise and time. An active scan is genuinely adversarial, since it submits real attack payloads, so never aim it at production or at shared data you would miss. A review app backed by a seeded, throwaway database is the safe target. Its findings also lean toward the perimeter (headers, cookies, TLS, reflected injection) and carry more false positives than SAST, so treat the first few runs as a tuning exercise. Confirm the real issues, quiet the noise with a written-down baseline, and only then wire a hard gate, for example failing the job on any High severity. Gate on findings you have triaged, never on raw scanner output.
q parameter back unescaped and responses with no Content-Security-Policy header. Why was DAST the only scanner that caught them?So the static scanners vouch for code, dependencies and image while they sit still, and DAST vouches for the assembled app while it runs. That wall of green is worth something only if the findings become a work queue and the gates really block instead of warning and being ignored. One question is still open. Once every scanner agrees that a specific build is clean, how do you prove that the artifact arriving in production is that exact build and not a swapped one? That is signing, binding the clean verdict cryptographically to the bytes, and it is next.
Try this
Work through “Proxy-based vs browser-based crawling” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: a green DAST job usually means it scanned nothing. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.