Mert Özen Further With Every Line
Security DevSecOps — #06

DAST: Scanning a Running Application with ZAP — Baseline, API Scan and Authenticated Scanning

Mert Özen Nov 14, 2026 15 min 7 views
DAST: Scanning a Running Application with ZAP — Baseline, API Scan and Authenticated Scanning

DAST sends requests to the running application from outside and finds behavioral flaws such as headers, cookies, error leaks and reflected input; but it only scans the endpoints it can reach and cannot see authorization bugs. We scan Bookstore.Api with ZAP 2.17 using baseline, API scan and an Automation Framework plan, and show with real output how to catch a dropped session in a token-based scan, turn findings into integration tests and run DAST on main.

Version note: The code in this post was built and run with .NET 10; the scans were run with ZAP 2.17.0 (ghcr.io/zaproxy/zaproxy:stable) against a local Bookstore.Api environment started with docker compose, and the regression tests were run with xUnit v3 4.0.

Guess First

Bookstore.Api's test environment runs in a container. The OpenAPI document is exposed, one endpoint writes a user-supplied parameter into an HTML (HyperText Markup Language) page without encoding it, and another endpoint is only open to signed-in users and returns a stack trace on unexpected input. You run ZAP's "baseline" scan against the root of the API. Guess: how many URLs does it see? If you scan the same application actively with the OpenAPI document, does it find the XSS (Cross-Site Scripting) hole? And if the token expires in the middle of the scan, does the report tell you?

In this post we add DAST to the Bookstore pipeline: what DAST sees and misses in a running application, ZAP's three scan modes, the Automation Framework YAML, authenticated scanning and noticing when the session drops, turning findings into permanent tests, and managing noise. Every scan ran only against Bookstore in my own container on my own machine; never run any command from this post against a target you do not own or have no written permission to test.

What DAST Sees and What It Cannot See

The SAST from the fourth post looked at source code; DAST talks to the application from the outside, sending HTTP requests like a client and inspecting the responses. It does not know the code and does not care about the language; all it sees is how the running system behaves. That is why it easily spots things SAST struggles with: missing security headers, cookie flags, stack traces leaking from error pages, input reflected into the response without encoding, the wrong environment setting. Most of these live in configuration or in the framework's runtime behavior, not in the code.

What it cannot see follows from the same fact. DAST does not know whether a request is "right"; it only looks for a trace it recognizes in the response. Reading Alan's order with Ada's token is a perfectly normal 200 from the response's point of view. Business rule and authorization bugs are only caught by a test that knows what should happen; we will see this with a real experiment at the end of the post. The second limit is coverage: the scanner only scans the endpoints it can reach, so how good a map and how meaningful the input you give it directly decides the result.

And a rule: an active DAST scan sends real attack payloads. Run it only against an environment you set up yourself for this job. Do not actively scan production; if you are going to scan a shared staging environment, talk to its owner first.

An Ephemeral Environment and Three Scan Modes

The target is an environment the pipeline builds from scratch on every run and destroys at the end. A single-service compose file is enough for Bookstore.Api; the signing key comes from outside, from a pipeline secret:

name: bookstore-dast

services:
  api:
    build: .
    container_name: devsecseries-api
    environment:
      ASPNETCORE_ENVIRONMENT: Staging
      Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Information
      Auth__SigningKey: ${DAST_SIGNING_KEY:?DAST_SIGNING_KEY is required}
    ports:
      - "127.0.0.1:18080:8080"

I kept the request log on, because the application's own log is the most reliable way to see what the scanner really does. The first version set the environment to Development; we will look at what this common test environment mistake leaks below.

The ZAP Docker image ships three ready-made scripts. zap-baseline.py crawls the target with the spider and runs only the passive rules, so it sends no attack requests. zap-full-scan.py adds an active scan. zap-api-scan.py starts from an OpenAPI, SOAP or GraphQL definition instead of the spider and runs an active scan tuned for APIs. I started with the cheapest, the baseline:

docker run --rm --network bookstore-dast_default -v "$PWD/reports:/zap/wrk:rw" \
  ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t http://api:8080/ -J baseline-v1.json
Total of 4 URLs
WARN-NEW: Storable and Cacheable Content [10049] x 2
	http://api:8080/ (404 Not Found)
	http://api:8080/sitemap.xml (404 Not Found)
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 1	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 67
Automation plan warnings:
	Job spider error accessing URL http://api:8080/ status code returned : 404 expected 200

Twenty-four seconds, 67 PASS results and almost no information. The spider found no links to follow at the root of an API; the only reason 67 rules passed is that there was nothing to look at. The baseline makes sense for a web application you can crawl through links (like Bookstore's Angular front end); it is the wrong tool for an API that returns JSON (JavaScript Object Notation). A "green" DAST report says nothing until you see what the scan actually visited.

Next I ran the API scan with the OpenAPI document. ZAP imported 11 URLs and raised two warnings: the X-Content-Type-Options and Cross-Origin-Resource-Policy headers were missing. Counting the requests in the application log revealed why:

 122 Request finished HTTP/1.1 GET http://api:8080/preview/10?… - 404
 122 Request finished HTTP/1.1 GET http://api:8080/orders/export?… - 401

ZAP generated the value 10 for the id path parameter by default; there is no book 10 in Bookstore's test data, so all 122 attack requests to the preview endpoint hit a 404 and never reached the code. The ones to the order endpoint got 401 because there was no token. When I gave id the value 1 through ZAP's form handler setting, the preview page returned 200 and the cookie and HTML header findings appeared; but there was still no XSS. Looking at the script inside the image showed why: the API scan uses a fixed policy called API-Minimal, and the XSS rules are off in that policy. Because Bookstore has a preview endpoint that returns HTML, the API-specific policy is blind here.

The Automation Framework: Writing the Scan as Code

The ready-made scripts are good for a quick start, but when you want to see exactly what runs and review it in the repo, the Automation Framework is the better tool: the scan is a YAML plan made of jobs that run in order. Bookstore's plan looks like this:

env:
  contexts:
    - name: bookstore
      urls:
        - http://api:8080
  parameters:
    failOnError: true
    progressToStdout: true

jobs:
  - type: alertFilter
    alertFilters:
      - ruleId: 10104
        newRisk: False Positive
        url: http://api:8080/orders/export.*
        urlRegex: true
  - type: requestor
    requests:
      - name: session-check-before
        url: http://api:8080/orders/1
        responseCode: 200
  - type: openapi
    parameters:
      apiUrl: http://api:8080/openapi/v1.json
      context: bookstore
  - type: passiveScan-config
    parameters:
      maxAlertsPerRule: 10
  - type: activeScan
    parameters:
      context: bookstore
      maxScanDurationInMins: 10
  - type: passiveScan-wait
  - type: requestor
    requests:
      - name: session-check-after
        url: http://api:8080/orders/1
        responseCode: 200
  - type: report
    parameters:
      template: traditional-json
      reportDir: /zap/wrk
      reportFile: zap-report
  - type: exitStatus
    parameters:
      errorLevel: High
      warnLevel: Medium

The openapi job loads the map, activeScan runs the active scan with the default policy, and exitStatus turns the result into an exit code: an alert of High or above gives 1, Medium gives 2, a clean scan gives 0. We will come to the requestor and alertFilter jobs below. The plan runs like this; the form handler setting provides an id value that really exists in the test data:

docker run --rm --network bookstore-dast_default -v "$PWD/reports:/zap/wrk:rw" \
  ghcr.io/zaproxy/zaproxy:stable zap.sh -cmd -autorun /zap/wrk/plan.yaml \
  -config 'formhandler.fields.field(0).fieldId=id' \
  -config 'formhandler.fields.field(0).value=1' \
  -config 'formhandler.fields.field(0).enabled=true'

A second lesson came up here. On my first attempt I had set the value to 1001, because Ada's order number was 1001; this time the book and preview endpoints returned 404 and the XSS finding disappeared. The form handler works by field name and gives the same value to every path parameter called id. The fix was to adjust the test data, not the scanner: I arranged the ephemeral environment's seed data so that Ada's order 1 and book 1 can both be found with the same value. The data in a DAST environment is part of the scan configuration.

The active scan took two minutes. The result for the first version:

RuleFindingRiskWhere
40012Cross Site Scripting (Reflected)High/preview/1?campaign=
40026Cross Site Scripting (DOM Based)High/preview/1?campaign=
10038Content Security Policy (CSP) Header Not SetMedium/preview/1
10020Missing Anti-clickjacking HeaderMedium/preview/1
10010, 10054Cookie No HttpOnly Flag, Cookie without SameSite AttributeLow/books/1
10021X-Content-Type-Options Header MissingLow5 URLs

Same application, same ZAP version: the baseline found nothing, the API scan found the headers, and the active scan with the default policy found two high XSS issues. The difference is not in the tool but in the map, input and policy you give the scan.

Authenticated Scanning and the Dropped Session

The order endpoints still returned 401, so the most valuable part of the application had not been scanned at all. I defined a separate test issuer for the ephemeral environment: the environment trusts only tokens signed with a key that lives in a pipeline secret, and the pipeline creates a short-lived JWT for the test user Ada. Thanks to .NET 10's file-based apps you do not even need a project for this:

#:package Microsoft.IdentityModel.JsonWebTokens@8.23.0

using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

// For the ephemeral test environment only: the key comes from a pipeline secret
string key = Environment.GetEnvironmentVariable("DAST_SIGNING_KEY")
    ?? throw new InvalidOperationException("DAST_SIGNING_KEY is not set.");
int minutes = args.Length > 0 ? int.Parse(args[0]) : 30;

string token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
    Issuer = "bookstore-test-issuer",
    Audience = "bookstore-api",
    Subject = new ClaimsIdentity([new Claim("sub", "ada")]),
    Expires = DateTime.UtcNow.AddMinutes(minutes),
    SigningCredentials = new SigningCredentials(
        new SymmetricSecurityKey(Convert.FromBase64String(key)), SecurityAlgorithms.HmacSha256),
});
Console.WriteLine(token);

This key exists only for this environment, and the production issuer does not recognize these tokens. If you have a real identity provider (Keycloak, Entra ID), you do the same thing with a test user defined in that provider whose password lives in a pipeline secret; how to bring secrets into the pipeline is covered in the CI/CD Fundamentals series post "Pipeline Security and Secrets: Least Privilege, Short-Lived Credentials and Supply Chain".

The simplest way to give ZAP the token is environment variables: ZAP_AUTH_HEADER_VALUE is the value added to every request, ZAP_AUTH_HEADER is the header name (Authorization by default), and ZAP_AUTH_HEADER_SITE says which sites get the header. On my first try I set the site value to api:8080; the scan finished without errors, the report was identical to the previous scan, and the application log contained AuthenticationScheme: Bearer was challenged 617 times. The header had never been added. I repeated the experiment with three values: with api:8080 and http://api:8080 the requests got 401, and only the bare host name api sent the token. ZAP gave no warning about the failed match.

That is what the requestor jobs are for. At the start and end of the plan I request /orders/1, which only a signed-in user can see, and expect a 200. I deliberately created a one-minute token and started the two-minute scan:

Automation plan warnings:
	Difference in response code values for message GET http://api:8080/orders/1 Expected : 200 Received : 401

The application log told the same story: 130 of the requests to the order endpoints got 401, and 2,066 lines contained this message:

Bearer was not authenticated. Failure message: IDX10223: Lifetime validation failed. The token is expired. ValidTo (UTC): '11/13/2026 22:26:13', Current time (UTC): '11/13/2026 22:26:17'.

The report, on the other hand, listed the same findings as a full scan, because the important ones had been caught before the token expired. Next time you may not be that lucky. You cannot tell from the report itself that the session dropped; either you make the scanner send a check request or you look at the target's log. In the pipeline we do both. With a real identity provider you can also use ZAP's own authentication and session management (login flow, verification strategy, poll URL) and refresh the token during the scan; but when you hand ZAP a token through a header as we do here, keeping it valid for the whole scan is your job.

The scan with a token added a finding the anonymous scan could not see: the request /orders/export?format=format returned 500 and ZAP reported 90022 Application Error Disclosure. Because the environment was Development, the response started like this:

System.NotSupportedException: Export format 'xml' is not supported.
   at Bookstore.Api.Controllers.OrdersController.Export(String format) in /src/src/Bookstore.Api/Controllers/OrdersController.cs:line 26

Class name, file path, line number. Why error pages must depend on the environment was covered in the OWASP series post "OWASP A02: Security Misconfiguration — Error Pages, OpenAPI, Headers and XXE"; what matters here is that this finding only showed up in the authenticated scan.

Fixing the Finding and Locking It in with a Test

A DAST finding is a symptom; the fix belongs in the code and the permanence in a test. I made four fixes. The preview page encodes its input with HtmlEncoder:

HtmlEncoder encoder = HtmlEncoder.Default;
string html = $"<html><head><title>{encoder.Encode(book.Title)}</title></head>" +
              $"<body><h1>{encoder.Encode(book.Title)}</h1>" +
              $"<p>Campaign: {encoder.Encode(campaign ?? "")}</p></body></html>";
return Content(html, "text/html");

Every response gets nosniff and a restrictive CSP; the cookie is written with HttpOnly, Secure and SameSite=Lax; an unknown export format returns a 400 ProblemDetails instead of an exception, and the environment was changed to Staging:

app.UseExceptionHandler();
app.Use(async (context, next) =>
{
    // On every response: no MIME sniffing, no framing, nothing loads by default
    context.Response.Headers.XContentTypeOptions = "nosniff";
    context.Response.Headers.ContentSecurityPolicy = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
    await next();
});

The CSP line was originally just default-src 'none'; frame-ancestors 'none'. In the scan after the fix the XSS, cookie and error leak findings disappeared, but a new medium appeared: 10055 CSP: Failure to Define Directive with No Fallback. base-uri and form-action are directives that do not fall back to default-src; unless you write them explicitly they are not restricted. After adding both, the plan returned 0 with Automation plan succeeded!. The detailed design of the headers is in the ASP.NET Core Security series post "Emitting Security Headers and Nonce-Based CSP from ASP.NET Core".

Now the step that really matters: turn each finding into an integration test that needs no active scan and runs in seconds on every PR. DAST takes two minutes and runs on main; the test catches the regression the moment the code comes in:

// ZAP 40012: the campaign parameter was reflected into HTML without encoding
[Fact]
public async Task Preview_encodes_campaign_parameter()
{
    string html = await _client.GetStringAsync("/preview/1?campaign=%3Cscript%3Ealert(1)%3C/script%3E");

    Assert.DoesNotContain("<script>", html);
    Assert.Contains("&lt;script&gt;", html);
}

// ZAP 90022: an unknown format returns a 400 ProblemDetails, not a stack trace
[Fact]
public async Task Unknown_export_format_returns_problem_without_stack_trace()
{
    using HttpRequestMessage request = new(HttpMethod.Get, "/orders/export?format=xml");
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", CreateToken("ada"));

    HttpResponseMessage response = await _client.SendAsync(request);
    string body = await response.Content.ReadAsStringAsync();

    Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
    Assert.DoesNotContain("   at ", body);
}

Next to these, the test class has a theory with three URLs for the headers and another test for the cookie flags; WebApplicationFactory sets up the environment as Staging with a signing key generated randomly on each run. I first ran the tests against the code before the fix; all six failed. On the fixed code:

Test run summary: Passed!
  total: 6
  failed: 0
  succeeded: 6
  skipped: 0

The general method for going from a finding to a regression test is in the API Security series post "Continuously Testing and Monitoring API Security"; in this flow DAST is a source of new findings and the test is the permanent memory.

Noise and Gates: alertFilter, exitStatus and the Pipeline

After the last scan a single informational finding was left: 10104 User Agent Fuzzer noted that the export endpoint answered different User-Agent values differently. On this endpoint the difference comes from deliberate input validation, so for us it is noise. The alertFilter job in the plan turns this rule into a false positive for that URL only; the filter lives in the repo, goes through review and states exactly which rule and which URL it applies to. In the ready-made scripts you do the same with a rules file passed with -c, mapping each rule ID to IGNORE, WARN, FAIL or OUTOFSCOPE. ZAP 2.17 helps on its side too: the passive scanner merges equivalent requests to reduce duplicate alerts and marks site-wide header findings as "Systemic", reporting a few examples instead of thousands.

The gate decision is in exitStatus: High and above breaks the pipeline, Medium warns. That is the DAST version of the "block new findings" principle from the first post in the series. The workflow:

name: dast

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  zap:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    environment: dast
    env:
      ZAP_IMAGE: ghcr.io/zaproxy/zaproxy@sha256:9624669db72f54d86c4b46a7064518bd9bf79d8c61a4c31befa9923489f328af # 2.17.0
      DAST_SIGNING_KEY: ${{ secrets.DAST_SIGNING_KEY }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
        with:
          dotnet-version: 10.0.x
      - name: Start ephemeral environment
        run: |
          docker compose up -d --build
          curl -sf --retry 15 --retry-delay 2 --retry-connrefused -o /dev/null http://127.0.0.1:18080/openapi/v1.json
      - name: ZAP scan (authenticated)
        run: |
          token=$(dotnet run tools/mint-test-token.cs -- 30)
          echo "::add-mask::$token"
          mkdir -p reports && chmod 777 reports && cp zap/plan.yaml reports/
          docker run --rm --network bookstore-dast_default \
            -e ZAP_AUTH_HEADER_VALUE="Bearer $token" -e ZAP_AUTH_HEADER_SITE=api \
            -v "$PWD/reports:/zap/wrk:rw" "$ZAP_IMAGE" \
            zap.sh -cmd -autorun /zap/wrk/plan.yaml \
            -config 'formhandler.fields.field(0).fieldId=id' \
            -config 'formhandler.fields.field(0).value=1' \
            -config 'formhandler.fields.field(0).enabled=true'
      - name: Fail if the session dropped
        if: always()
        run: |
          if docker compose logs api | grep -q "The token is expired"; then
            echo "::error::The test token expired during the scan; results are incomplete."
            exit 1
          fi
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        if: always()
        with:
          name: zap-report
          path: reports/zap-report.json
      - name: Tear down
        if: always()
        run: docker compose down

I validated the workflow with actionlint 1.7.12 and ran the step commands locally in the same order; the "session dropped" check fired on the log of the run with the expired token and stayed quiet on the clean run. In one run the token step produced a broken value and the application rejected it with IDX14122: JWT is not a well formed JWE; the requestor at the start of the plan showed this in the first second of the scan. The ZAP image is pinned by digest rather than a tag: the rule from the Trivy incident in the previous post applies to scanner images too. environment: dast exposes the secret only to this job; the details are in the GitHub Actions series post "Secrets, Variables and GITHUB_TOKEN Permissions in GitHub Actions".

Authorization: What DAST Cannot See

I tested the limit I mentioned at the beginning. I removed the ownership check (o.CustomerId == sub) from OrdersController, rebuilt the image and requested Alan's order with Ada's token:

{"id":2,"customerId":"alan","bookIds":[2]}

This is the classic authorization hole called IDOR. The same plan scanned for two minutes with the same token and returned 0 with Automation plan succeeded!; the only entry was the informational alert turned into a false positive. From ZAP's point of view, a response that returns 200, contains JSON and has correct headers was flawless. With the check back in place the same request returned 404. What catches this hole is a test that says "Alan's order must return 404 with Ada's token"; the authorization regression tests in the API Security series fill exactly this gap. DAST, SAST and these tests do not replace each other; each looks at the others' blind spot.

Production Notes

Set a time budget. On Bookstore's small API the active scan took two minutes; as the number of endpoints grows this time grows quickly. maxScanDurationInMins and the job's timeout-minutes keep the scan from locking up the pipeline. For large applications, a reasonable balance is running the active scan on a nightly schedule rather than on every push to main, and the passive part on every deploy.

Make the environment resemble production but do not use production data. The environment variable, the header middleware and the exception handler should be as in production; otherwise you either chase findings that do not exist in production or miss the hole that does. The data, however, must be synthetic: an active scan sends thousands of meaningless records to write endpoints.

Keep the report as an artifact and date it. When a finding first appeared is the input for the "vulnerability age" metric we will measure in the ninth post.

Update the ZAP image with Dependabot or Renovate. Pinning a digest gives reproducibility, but the rule sets and the scanner itself are dependencies that get updated.

Common Pitfalls

First, running a baseline scan against an API and taking the clean report as proof of security. If the spider has no links to crawl, the report is just a list of pages it never visited.

Second, not checking whether the inputs the scanner generates reach the code. Look at the status code distribution in the application log; if most requests are 404 or 401, the scan turned back at the door.

Third, setting up authenticated scanning and never verifying it. Verify that the header is really added and stays valid for the whole scan with a check request before and after the scan and with the target's log.

Fourth, silencing a DAST finding only through scanner settings. A finding is either fixed in the code and locked in with a test, or filtered with a justification; a silent IGNORE line can also hide the next real hole.

Fifth, running the test environment as Development. The developer exception page both leaks information and makes you test behavior that does not exist in production.

A Small Experiment

★☆☆ Run zap-baseline.py, and zap-api-scan.py with the OpenAPI document, against your own API (only your own local environment). Compare the number of URLs the two scans visited and the status code distribution in the application log.

★★☆ Write an Automation Framework plan and put a requestor check for a protected endpoint at its start and end. Start the scan with a short-lived token and watch the check catch the dropped token.

★★★ Fix a header or encoding finding from ZAP in the code and turn it into an integration test with WebApplicationFactory. Verify that the test fails on the code before the fix and passes after it; then watch the same finding disappear in the next scan.

We now scan the code, the dependencies, the image and the running application. But when a new CVE is published tomorrow, we cannot answer "does this affect us?" without knowing which package runs at which version in which image. In the next post we record what Bookstore uses with an SBOM and VEX.