CoursesSecure CI/CD with GitLabDAST & the scan pipeline

DAST & the scan pipeline

Test the running app; wire it all together.

Advanced12 min · lesson 12 of 17

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.

.gitlab-ci.yml
stages: [test, build, deploy-review, dast]
include:
- template: Security/DAST.gitlab-ci.yml
deploy-review:
stage: deploy-review
environment:
name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_COMMIT_REF_SLUG.review.acme.internal
script:
- ./deploy-review-app.sh "$CI_ENVIRONMENT_URL"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
dast:
stage: dast
needs: [deploy-review]
variables:
DAST_TARGET_URL: https://$CI_COMMIT_REF_SLUG.review.acme.internal
rules:
- 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:

dast job log (excerpt)
2026-07-14T02:14:07.312Z INF GitLab DAST 5.1.2 (browser-based) starting
2026-07-14T02:14:07.318Z INF Target (DAST_TARGET_URL): https://feature-login.review.acme.internal
2026-07-14T02:14:09.004Z INF Launched instrumented Chromium 146.0.7686.98
2026-07-14T02:19:52.771Z INF Crawl complete: navigated 38 paths, discovered 214 requests
2026-07-14T02:33:41.220Z INF Active scan: 214 requests attacked, 1187 checks run
2026-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.json
Uploading artifacts...
gl-dast-report.json: found 1 matching artifact files and directories
Job 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:

gl-dast-report.json (one vulnerability)
{
"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:

verify the report from your shell
$ 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 | sort
High Cross-site scripting (reflected) /search
Low Sensitive cookie without HttpOnly attribute /login
Low 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.

A green DAST job usually means it scanned nothing
A dast job that finishes in a few seconds with zero findings is the classic false pass. The review app died on boot with a 500, or the runner could not resolve the internal review hostname, or every route bounced to a login the crawler could not get past. So the analyzer crawled an empty error page and faithfully reported it clean. Two more traps. With no authentication configured, browser-based DAST only ever sees your logged-out surface. And on merge requests from forks the review app and its CI/CD variables are usually unavailable, so DAST quietly does nothing at all. Never read an absence of findings as a pass. Gate on proof of coverage, by asserting that the log's navigated-paths and requests-attacked counts are non-trivial, rather than on the exit status alone.
How a DAST job attacks a live review app
dast job (on the CI runner)
instrumented Chromium
runs JS, crawls the rendered DOM & client-side routes
GitLab DAST active scan
mutates observed requests, checks responses
gl-dast-report.json
artifacts:reports:dast → MR security widget
review environment (must be running)
review app @ $CI_ENVIRONMENT_URL
the deployed change under test
backend API
live, reachable over HTTP(S)
seeded test database
realistic data to crawl, safe to attack
The runner attacks the review app over HTTP(S) and writes gl-dast-report.json back to the merge request. Static scanners read files at rest; DAST needs this live target.
Quick check
01Your dast job goes green in 6 seconds with zero findings, against a React single-page app you know is buggy. What is most likely going on?
Incorrect — No. Static and dynamic scanners see different classes of bug, and a clean run that took six seconds means nothing got scanned, not that nothing is wrong.
Correct — A few-second clean run is the signature of zero coverage. Check the navigated-paths and requests-attacked numbers in the log before you trust the result.
Incorrect — Backwards. Browser-based DAST exists precisely so it can crawl JavaScript-rendered apps. The old proxy analyzer was the one that saw an empty shell, and GitLab removed it in 17.3.
02The payments merge request passes SAST, secret detection, dependency scanning and image scanning, then ships a /search page that echoes the q parameter back unescaped and responses with no Content-Security-Policy header. Why was DAST the only scanner that caught them?
Incorrect — No. Neither flaw is visible in any file at rest, so no amount of SAST tuning would surface them.
Correct — DAST (Dynamic Application Security Testing) attacks the live app over HTTP and sees behavior static scanners cannot.
Incorrect — No. DAST reads no source at all. It is black-box testing against a running URL.
Incorrect — No. An echoed parameter and a missing response header are your own app's runtime behavior, not known flaws in a published package.
03A teammate suggests pointing DAST_TARGET_URL at your live production URL so the scan sees 'the most realistic data.' What is wrong with that?
Incorrect — No. The instrumented Chromium handles HTTPS fine. The danger is the attack traffic, not the scheme.
Incorrect — No. An active scan submits crafted attack payloads that can change data.
Incorrect — No. The template targets whatever URL you hand it. Nothing stops you aiming it at production.
Correct — Active DAST is genuinely adversarial and belongs against a disposable target, never production or shared data you care about.

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.

Related