How much of the application did the DAST scan actually test? Crawl reach, session loss, unlinked APIs and how to measure coverage

DedSec Application Security ·

Consider an authenticated dynamic scan of a staging environment that finishes overnight with no high-severity findings. The next morning someone greps the API gateway log for the scanner's user agent and puts the paths it requested next to the framework's route table. Whole areas are missing: the bulk export endpoints, the second half of the onboarding wizard, a v1 API the mobile app still uses, and everything under /admin. The scan was not wrong about what it tested. It said nothing about what it did not test, because a scanner has no list of what it should have tested. Only you do.

This post covers what a crawler can reach, what ends a session without anyone noticing, which surface no page links to, which responses look clean without anything being tested, and how to measure what a scan covered. Where a named tool's behaviour is described, it comes from that tool's own help output: Katana v1.4.0, ffuf 2.1.0-dev and Nuclei v3.7.0, as installed where this post was written. Arjun is mentioned by purpose only. Everything else is general practice.

Three kinds of coverage

"Did the scan cover it?" is three questions, and a route can fail any one of them.

A request log answers the first. Parameter-level counts answer the second. The status-code distribution, and controls you plant yourself, answer the third. A scan report on its own answers none of them.

What a crawler reaches

A crawler fetches a page, extracts links, forms and script references, and repeats. On a server-rendered application that loop can find much of what a user can click. On a single-page application it can find almost nothing, because the HTML can be little more than an empty shell and a script bundle. The routes live in the JavaScript: a client-side router maps paths to components, and the API calls fire when those components mount.

There are two ways in, and they find different things.

Static parsing of JavaScript reads the bundle and pulls out strings that look like paths. It is fast and needs no browser. It misses paths assembled at runtime from a base URL, a version and an identifier, routes in code-split chunks the crawler never fetched, and any request body whose shape is only known once the code runs. Katana makes this opt-in (-jc, "enable endpoint parsing / crawling in javascript file").

Headless crawling drives a real browser, so the router runs and components make their API calls. Katana marks its headless and hybrid modes as experimental, and offers an option to extract XHR request URLs and methods, which is what matters for API coverage: the requests a page makes are more interesting than the page. A headless crawler still sees only what happens on load or on the interactions it attempts. A request sent only after the user picks a dropdown option, opens a modal's second tab, or holds a feature flag stays invisible.

Then the limits that cut a crawl short:

The practical response is to stop depending on the crawl alone. Record the flows that matter, through a proxy or from existing end-to-end tests, and give them to the scanner as seeds.

What ends a session without telling you

The post on "the scan passed" versus "the scan ran" covers the main failure: a session that expires mid-scan, after which the scan spends its remaining time testing a login page. Three related mechanisms end or poison a session through the crawl itself.

The crawler logs itself out. A logout link is a link, and a crawler follows links. From that request on, everything is unauthenticated. It can also find "delete account", "revoke all sessions" or "rotate API key", and if any of them are reachable by GET (a bug in its own right) the crawler will trigger them. Exclusion rules belong in the scan configuration before the first run, not after the test account disappears. Katana's out-of-scope regex is one mechanism; check your crawler for the equivalent.

Rotating CSRF tokens break replay. Where an application issues a fresh anti-CSRF token per form render and rejects a stale one, a fuzzer that records a submission once and replays it with payload variations sends the same token every time. The first replay may succeed; the rest are rejected by the CSRF check before they reach the handler. Hundreds of payloads went into that form and none were processed. The fix is fetching a fresh token before each submission, or disabling the check for the scanner's session in the test environment. If you choose the second, record it, because that scan no longer tests CSRF protection.

Nobody checks the session is alive. The general practice is a pair of indicators: a pattern present only when logged in (the account name, a who-am-I response) and one present only when logged out (the login form, a redirect to /login, a 401). With either configured, a scanner can detect session loss and re-authenticate. Without them, nothing distinguishes an authenticated 200 from the 200 the login page returns.

The surface no page links to

Some routes are deployed and linked from nothing a crawler can load, however good it is.

Three general techniques bring this surface into a scan.

Import the schema. With an OpenAPI document, the scanner can generate requests for every documented operation instead of waiting to discover them. Nuclei accepts OpenAPI and Swagger files as an input mode, alongside Burp exports, JSONL and YAML. Its help also lists a -required-only flag that builds requests from required fields only, which is a coverage decision hidden in a flag: optional parameters (a filter, a sort expression, an include-related option) can be the ones that change behaviour. For GraphQL, the schema comes from introspection where enabled, or from the repository where not. A GraphQL endpoint is one URL, so a crawler counting URLs reports it covered after one request.

Brute-force content discovery. Wordlist tools request candidate paths and report those whose responses match a filter, either a status-code list or a difference from a known-nonexistent path. ffuf's default matcher accepts 200 to 299, 301, 302, 307, 401, 403, 405 and 500, which shows what "responds" has to mean: a 403 on /admin/export is a discovery, not a dead end. This finds undocumented legacy and admin routes, bounded by the wordlist.

Hidden parameter discovery. A route can accept parameters no form or schema mentions: a debug flag, a legacy field the handler still reads, an override the internal client uses. The general technique sends batches of candidate parameter names and looks for a change in the response (length, status, a reflected value), then narrows down which name caused it. Arjun is a tool built for this. A parameter that changes nothing visible will not be found this way.

A schema is a claim about the API, not the API. Treat it as one more inventory to compare.

Responses that look clean without testing anything

A payload tests something only if the handler processes it. These responses mean it did not, and none matches a vulnerability signature.

For test environments, allowlist the scanner's source address at the firewall and rate limiter so the scan tests the application, and check the firewall separately if you want to know how it behaves. A scan that fought the firewall the whole way has tested the firewall.

Measuring what was covered

Coverage is a join between two lists: what exists, and what the scanner sent.

1. Build the route inventory from more than one source, because each misses something.

Normalise all of it to method plus route template (/v1/invoices/{id}, not /v1/invoices/inv_8f2c1e).

2. Capture the scanner's traffic. Tag it with a header, user agent or fixed source address, and pull it from the server-side log. That log shows what arrived and what the application answered, not what the scanner believes it sent.

3. Compute per-route figures: requests received (zero means unreached); distinct parameters that carried a payload, against the parameters the route accepts; the status-code distribution; and the share answered with the logged-in indicator present or attributed to an authenticated user.

4. Read the status distribution. Mostly 401, or 302 to login: not tested authenticated. Mostly 403 or 429: tested against the firewall or limiter. Identical 200 bodies regardless of input: probably a catch-all. A healthy mix includes successes, validation errors (a 400 or 422 shows the handler parsed the payload and rejected it) and the occasional 500, worth reading because the handler failed on input the scanner chose.

5. Plant positive controls. A canary is a deliberately vulnerable handler that exists only in the staging build, and the scan must report it. Plant several, each testing a different link: a reflected parameter behind authentication tests the session; one at the last step of a multi-step form tests form progression; one on an API route the UI never calls tests schema import; one deep in the client-side router tests JavaScript route discovery. If a run misses a canary, its silence about every route like it is not evidence of anything.

6. Diff coverage between runs. A route reached last week and not this week is a regression in the scan, not an improvement in the application: a renamed route the seeds still point at, a tightened scope rule, a login flow the authentication script no longer handles. New inventory routes with no scanner traffic were added since the scan configuration last changed.

None of this needs a particular scanner. It needs an inventory, a log and a join, and it produces a figure to put next to the findings count: of the routes that exist, how many were reached, exercised and observed.

Where AppSecD fits

AppSecD's dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks. In its lifecycle this is the runtime stage: code review cannot see a misconfigured production header, an exposed API route or an injection that only appears once the application is assembled and running, so DAST and API testing run against the deployed application.

That is the whole claim. This post does not claim anything about how AppSecD handles session expiry or re-authentication, whether or how it renders single-page applications, how it treats CSRF tokens or firewall blocks, how it configures the tools it runs, or whether it reports route-level coverage against an inventory. The flags quoted above come from the tools' own help output. The coverage measurement is general practice that works with any scanner, a gateway log and a route table.

A checklist

  1. Inventory routes from the route table, the API schema and production gateway logs, as method plus template.
  2. Seed the scanner with recorded multi-step flows and the API schema, not the crawl alone.
  3. Enable JavaScript route discovery for single-page applications and check it against the client-side router.
  4. Check depth, duration and scope against your deepest pages and your API host.
  5. Exclude logout and destructive routes before the first run.
  6. Handle CSRF tokens with a per-request refresh, or disable the check for the scanner and record it.
  7. Configure logged-in and logged-out indicators.
  8. Allowlist the scanner at the firewall and rate limiter in test environments.
  9. Report per route from the server-side log: reached, parameters exercised, status distribution, authenticated share.
  10. Plant canaries for authentication, form progression, schema import and client-side routing; a missed canary is a failed scan.
  11. Diff coverage between runs and investigate every route that dropped out.

Frequently asked questions

Why did my DAST scan find so few endpoints on a single-page application?

The server returns a shell and a bundle, and the routes exist only in the JavaScript. Link-following sees the shell. Static JavaScript parsing finds paths written as literals; headless crawling finds requests that fire on load or on the interactions it attempts. Neither finds routes that appear only after specific input, so seed those from recordings.

Is importing the OpenAPI spec enough?

It covers what is documented. Undocumented routes are missing by definition, and legacy versions and admin endpoints may be too, and generating from required fields only skips optional parameters. Compare the spec with the route table and production traffic.

How do I know the scan stayed logged in?

Track the share of scanner requests answered with a logged-in indicator, or attributed to an authenticated user in the application log, across the scan's duration. A drop marks where the session was lost. A canary behind authentication confirms it end to end.

What is a good coverage target?

There is no universal figure, and a percentage of routes reached hides the routes that matter most. Track reached, exercised and observed per route, make sure authentication, payment, upload, export and admin routes pass all three, and treat any drop between runs as a defect in the scan.

A clean report covers the requests the scanner sent. Before reading it as a statement about the application, check those requests against the routes that exist.

More about AppSecD · All posts