iraide@bilbao:~/blog$
· 9 min read

DAST coverage is counted at the server, not in the scanner's report

  • devsecops
  • github-actions
  • ci-cd
  • security

This is the third of four posts. The first built a DevSecOps pipeline for free on a private repo, and its cheapest part was DAST: the app boots with fake adapters behind its ports, so ZAP attacks a throwaway instance inside the runner, with the auth header added at ZAP’s proxy. The second was three weeks of living with the gates.

Where the first post left the scan: six URLs. I’d guessed the per-item routes were missing because an empty pantry has no ids to offer, so I seeded two items before the scan and ran it again. Still six. The reason was one line in the page, if (!token()) return gate(false): it checked sessionStorage for a token before fetching anything, found none, and showed the login gate, so ZAP’s browser never rendered a row. The two API URLs ZAP had came from the script’s source text, not from the page. The scanner was authenticated; the page it was driving wasn’t.

Both posts left something out, and this one is about that: what the scan doesn’t cover, and the two days it took to find out how little it was reaching.

What the scan doesn’t cover

The DAST scans an app with its riskiest parts removed, and that follows directly from the thing I spent the first post praising. REPOSITORY=memory means there is no SQL, so ZAP can’t find an injection in the Drizzle adapter even if one exists. GENERATOR=fake means the LLM adapter is never on the path, so the scan never touches my prompt injection surface. And there is one, because food names are user input and they end up in a prompt. What the DAST covers is the HTTP surface and the domain logic.

Of that HTTP surface, the spider reaches less than it appears to. Six URLs, and the per-item routes and the suggestions endpoint never exercised, because the spider couldn’t log into the page. An OpenAPI definition and ZAP’s API scan would cover them, and the next section is about doing exactly that.

The Telegram webhook isn’t in the ephemeral instance at all, and that’s where the thesis ends. It only registers when a bot token is present, and there isn’t one. So the property the first post opened with, that the app boots without secrets and can therefore be scanned, holds for two of the three adapters. The third needs a secret to exist, and it happens to be the most exposed thing the app has: a public endpoint whose only authentication is a secret header Telegram sends, with no user or session behind it. It has unit tests; it has no scanner. A fake adapter for the Telegram transport would bring it inside the scan the same way the other two came in. Until that’s done, the argument covers the surface I built, not the one that faces the internet.

The SBOM was a record that expired. It was uploaded as a workflow artifact, which GitHub keeps for ninety days, so an advisory landing in month four would have found nothing to compare against. It now attaches to the GitHub release it describes, generated from the image that release shipped. The first releases to carry one are in the next section, because they didn’t go the way I expected.

Semgrep’s rules aren’t pinned, and ZAP’s are worse than I’d first written: the scan scripts refresh the rule packs from ZAP’s marketplace on every run unless you pass -silent, which I don’t, so even a digest-pinned image would fetch fresh rules each time. Both are fixable and I’ve done neither, which is a choice I should own.

And none of it checks business logic. Not one of the scanners in the pipeline will notice that one user can read another user’s pantry. That’s still tests, and it’s still the part you write by hand.

Put together: the adapters are exactly what the scan can’t see, and they’re where I’d look first if I were attacking this.

Two days later

In the first post I said the fix for the per-item routes was an API-definition scan and that I hadn’t made it. Then I made it, and a couple of other things happened on the way. Every step below is measured from the app’s own request log, which the DAST job now dumps at the end, not from ZAP’s report. The rows from before that log existed are ZAP’s own counts, and I say which is which, because the two don’t count the same thing.

The web session moved from sessionStorage to an HttpOnly cookie. The reason was security, not DAST: a token in sessionStorage is a token any script on the page can read. Now POST /auth/login answers with a __Host- cookie, HttpOnly, Secure, SameSite=Strict, thirty days, stored server-side as a SHA-256 digest. The page no longer touches sessionStorage and sends no auth header at all; it starts by asking GET /auth/me. And a test greps the page script and asserts it contains none of sessionStorage, localStorage, Authorization or the dev-mode user header, so none of it can creep back.

The side effect on DAST was immediate, and this time I measured it. The page now fetches before deciding whether to show the login, that fetch goes through ZAP’s proxy, and the proxy adds the Bearer. So the spider’s browser got a 200 from /auth/me, loaded /pantry and rendered the rows, which had never happened before. Six URLs became eight in the app’s log. It even submitted the add form, a POST /pantry/items that Zod rejected with 422, which is proof the request travelled authenticated.

But it still didn’t press the buttons on the rows. Not one PATCH, not one DELETE to /pantry/items/<id>. I gave it three minutes instead of one (-m 3): the exact same ten requests, taking 21 seconds longer. Reverted, with the reason in the commit message. At that point it’s no longer auth and no longer time.

Two crawlers, same ceiling. The spider behind -j turned out to be ZAP’s current Client Spider, not the AJAX Spider I thought I was running (the first post has that story). It reached eight URLs; the older AJAX Spider, asked for explicitly with --ajax-spider, reached eleven, and the two agree on exactly which five carry alerts. Neither touched a per-item route. That’s not a crawler being lazy; it’s a crawler being a crawler. A route that only exists behind a button’s click handler, with no link or form pointing at it, isn’t something a crawl finds. It’s something you have to tell the scanner about.

Boromir at the Council of Elrond, hand to his brow: one does not simply
The Lord of the Rings: The Fellowship of the Ring (New Line Cinema)

So, the API-definition scan. A hand-written openapi.json with the nine routes, and zaproxy/action-api-scan hitting each one through the same header replacer. Two details that matter more than the file:

  • A test keeps the OpenAPI in sync with the routes Fastify actually registers, read from printRoutes(), in both directions: a new route without an entry breaks CI, and an entry without a route breaks it too. The map can’t go stale in silence.
  • The seed keeps the ids of the two rows it creates and injects them into the spec before the scan, because a per-item route scanned against a made-up id only ever exercises the 404.

From 10 requests to 3,010. On the item URLs alone: 418 PATCH on the expiry route, 50 DELETE, and another 62 GETs and POSTs probing them, with real payloads: PHP injection via auto_prepend_file, Spring’s classLoader, query-string fuzzing. The per-item routes, exercised for the first time. The DAST job went from about two minutes to over four, and the new scan started report-only, with fail_action: false and a date next to it, the same way the baseline had. Eleven days later it had gated nothing and it was four of a PR’s eight minutes, so it moved to the weekly run. On the box the pipeline runs on now (the fourth post), the API scan alone was 241 of the DAST job’s 468 seconds; without it, a PR’s DAST is 111.

And it found a bug on its first run. Server Error response code, 58 times. The app was answering 500 to requests Fastify had already classified as the client’s fault: a DELETE with Content-Type: application/json and no body (Fastify throws FST_ERR_CTP_EMPTY_JSON_BODY with statusCode: 400), an unsupported media type (415), malformed JSON (400). My error handler sent everything it didn’t recognise to a generic 500 without looking at the statusCode already on the error. It wasn’t exploitable, but every client mistake was being logged as a server failure, and the error handler is the one place that decides what a client gets to see, so it was deciding wrong. The fix is one branch: an error carrying a 4xx statusCode answers with that code and { error: <code> }, no message. Everything else stays a generic 500, and a test throws db password is hunter2 from a route and asserts the body says internal and nothing else.

The whole arc, with a number at each step:

Step Requests to the app Per-item routes
Spider, no header — bounces off 401
Spider with header (where the first post left it) 6 URLs, ZAP’s count 0, the page shows the login gate
Pantry seeded 6 URLs, ZAP’s count 0, same cause
HttpOnly cookie 8 URLs, 10 requests 0, renders, clicks once, stops
Three minutes of spider 10 requests 0, identical
API-definition scan 3,010 requests 530

What this adds to the two posts before it: DAST coverage is measured by counting requests at the server, not by reading the scanner’s report. Four rounds looked right from ZAP’s side (green, 63 rules passed) and none of them had touched the routes that mattered. And when something finally did, what it found wasn’t a vulnerability but an error handler confusing “you got it wrong” with “I got it wrong”, the kind of thing that only shows up when something sends three thousand malformed requests on purpose.

And then the SBOM blocked a deploy. The record I’d moved to the release so it would outlive the ninety-day artifact failed on the first two releases that tried to carry it. On v0.23.0 it failed on a documented permission the job didn’t have, and because I’d put the step before the deploy, in the same job, the release was tagged and never shipped: a record of what shipped had stopped the shipping. I moved the step after the deploy, and on v0.23.1 it failed again on something else entirely (gh looking for a git remote in a job with no checkout; one --repo flag fixes it), but this time the release was already live and the run went red so the missing record would show. Same step, one bug each, and the difference between them isn’t the bug but the order: a record of what ships goes after the shipping, and when it fails, it fails in red. I then went to backfill the two missing records by redeploying those tags, and stopped, because the server had moved on to v0.23.1 and redeploying v0.23.0 would have rolled production back. Neither record gets added, it’s written down that the two releases before v0.23.2 don’t have one, and v0.23.2 attached its SBOM, generated from the same image the deploy had just put on the server. Three releases to get one step working: a permission, an order and a flag.

A note on what this cost. The second post measured the pipeline in CI seconds: 45 seconds to 2m10s of wall-clock. Those were GitHub-hosted numbers, and they didn’t survive the month. On the 28th GitHub stopped starting jobs and a release sat waiting for an image that was never coming: two ZAP scans on every PR, and the same two again on every push to main, had used up the 2,000 free Actions minutes a private repo gets a month. GitHub’s own meter put September’s usage for this repo at $12.09, all of it covered by the free allowance, and none of it available on the 28th. DAST was cheap in engineering and expensive in minutes, and that’s the fourth post. Even then, minutes weren’t the real cost. Nothing the SBOM took, two broken releases and a rollback avoided by luck, shows up in a table of timings. If you’re going to build something like this, the budget that matters isn’t runner minutes. It’s evenings.

←All posts