Part 6 — Run DAST Against Staging
Every earlier capstone part checked Vulnerly from the inside — its source, its dependencies, its container, its Terraform. This part checks it the way an attacker actually would: from the outside, against a running deployment, with no idea what the code looks like. You'll stand up a staging namespace, teach OWASP ZAP how to log in as two different merchants, throw a full active scan at it, and use both merchant identities to independently confirm the reflected XSS and the IDOR that Part 1's threat model already predicted. By the end, you'll have two saved reports — one before the fix, one after — and the after one will show zero High alerts.
Reading the blueprint for a house tells you where the doors are supposed to be. Walking up to the actual house and trying every door and window is a different test — and it's the only one that tells you whether the door someone installed actually locks. That's the difference between everything you've done so far in this capstone and what you're doing today: today you stop reading the blueprint and start rattling the doorknobs, as two different visitors, to see what each one can actually get into.
Arriving: a signed, non-root Vulnerly image that Kyverno will only admit into the vulnerly namespace if cosign verifies it (Part 4), and a Terraform plan that Conftest now blocks before apply if it reopens the public S3 bucket or the 0.0.0.0/0 security group (Part 5). Leaving this page: a new staging namespace running that same signed image behind two authenticated ZAP identities, a before-fix report with two High alerts saved to disk, the reflected XSS and the IDOR fixed in application source, a re-signed image redeployed, and an after-fix report sitting right next to the first one showing zero Highs. Part 7 picks up exactly here and imports both reports into DefectDojo as evidence.
What this part assumes, and what it adds
☺ Like you're 10: Everything from Parts 1–5 stays true — you're not rebuilding anything, just adding one new room and one new set of keys.
You need the same vulnerly-dev kind cluster from Part 1, still running, with Part 4's signed image sitting in your registry and Part 5's Kyverno and Conftest policies still enforced. You also need OWASP ZAP (the Docker image zaproxy/zap-stable is the least fuss — no local Java install) and jq for reading tokens out of JSON responses on the command line. Nothing from Parts 1–5 gets rebuilt or replaced today — this part only adds new names to the world model:
| Thing | Name | Introduced |
|---|---|---|
| Scan target namespace | staging — a deploy of the signed image, isolated from vulnerly | Part 6 — this page |
| Merchant identities | merchant-a@vulnerly.test, merchant-b@vulnerly.test — two tenants, used to prove the IDOR | Part 6 |
| ZAP config | security/zap/zap.yaml (Automation Framework plan) + replacer-rules.yaml | Part 6 |
| Evidence artifacts | security/zap/reports/staging-before.html, staging-after.html (+ matching .json) | Part 6 — Part 7 imports both |
Extend the repo tree the lab track introduced with exactly these additions — nothing existing moves:
vulnerly/ ├── app/src/routes/transactions.js # the two routes this part fixes ├── Dockerfile # unchanged since Part 4 ├── infra/main.tf # unchanged since Part 5 ├── k8s/ │ ├── deploy.yaml # base Deployment + Service — Parts 4–6 all apply this │ └── overlays/ │ └── staging/ # NEW — Kustomize overlay: same base, staging namespace │ ├── kustomization.yaml │ └── postgres-seed-job.yaml ├── security/ │ └── zap/ # NEW │ ├── zap.yaml # the Automation Framework plan this part runs │ ├── replacer-rules.yaml # injects each merchant's Bearer token │ └── reports/ # staging-before.* and staging-after.* land here └── .github/workflows/ci.yml
Standing up the staging namespace
☺ Like you're 10: You don't attack the room people are already living in — you build an identical spare room and attack that one instead.
Scanning vulnerly directly would be a mistake even in a throwaway lab: an active scan sends real attack payloads, some of which write data, and you want a target you can tear down and reseed without worrying about anything else that depends on it. Create a dedicated namespace and deploy the exact signed image Part 4 produced, by Kustomize overlay rather than a second copy of the manifest:
# k8s/overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: staging
resources:
- ../../deploy.yaml
- postgres-seed-job.yaml
images:
- name: ghcr.io/YOU/vulnerly
newTag: "sha-<the digest Part 4 signed>"kubectl create namespace staging kubectl apply -k k8s/overlays/staging kubectl -n staging rollout status deploy/vulnerly
It's tempting to skip signature verification for a "throwaway" namespace. Don't. Copy Part 4's Kyverno Policy into staging too — kubectl -n staging apply -f k8s/policy/require-signed-images.yaml — so an unsigned or hand-edited image can't sneak into the exact environment you're about to point an attacker-grade scanner at. If staging can run anything, your DAST results are testing a system nobody's promised will ever match what ships.
Seed two merchants with distinct transactions so there's something real to steal in the IDOR test later — a Kubernetes Job that runs once against the staging Postgres, referenced in the overlay above:
# k8s/overlays/staging/postgres-seed-job.yaml (excerpt — the SQL it runs)
# INSERT INTO merchants (id, email, password_hash) VALUES
# ('m-a', 'merchant-a@vulnerly.test', '$2b$12$...'),
# ('m-b', 'merchant-b@vulnerly.test', '$2b$12$...');
# INSERT INTO transactions (id, merchant_id, amount_cents, status) VALUES
# (101, 'm-a', 250000, 'settled'),
# (102, 'm-a', 75000, 'pending'),
# (201, 'm-b', 400000, 'settled');Port-forward the Service so both curl and ZAP can reach it from your laptop:
kubectl -n staging port-forward svc/vulnerly 8080:80 & # target for the rest of this page: http://localhost:8080
Getting ZAP past login
☺ Like you're 10: A scanner that can't log in only ever sees the "please sign in" page — it has no idea what's actually behind the door.
Vulnerly's POST /api/auth/login takes { "email", "password" } and returns a JWT: { "token": "eyJhbG..." }. Every other route expects that token as Authorization: Bearer <token>. An unauthenticated ZAP scan against this app finds almost nothing interesting — every protected route just returns 401, and a wall of identical 401s is not a security posture, it's a scan that never got in the door. Confirm the two identities work by hand first, the same way Rocky would, before you ever hand them to a tool:
TOKEN_A=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"merchant-a@vulnerly.test","password":"REPLACE_ME"}' | jq -r .token)
TOKEN_B=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"merchant-b@vulnerly.test","password":"REPLACE_ME"}' | jq -r .token)
curl -s http://localhost:8080/api/transactions/201 -H "Authorization: Bearer $TOKEN_A"
# 200 OK — and 201 belongs to merchant-b. That's the IDOR, confirmed by hand,
# before ZAP even runs. What ZAP adds isn't discovery — it's proof this isn't
# the only door like it, and a report you can hand to someone else.For a scan that runs for minutes at a time, don't fight ZAP's session-management machinery over a token that might expire mid-run — use the Replacer add-on to stamp the header onto every outgoing request instead. It's the simplest mechanism that actually works for a short-lived bearer-token API, and it's exactly what "wire up authentication" means in practice for most JWT-secured services:
# security/zap/replacer-rules.yaml — one Replacer rule per merchant scan run - description: "Inject merchant-a bearer token" url: "^http://localhost:8080/.*" matchType: REQ_HEADER matchString: "Authorization" replacementString: "Bearer $TOKEN_A" # substitute the real token before loading this file tokenProcessing: false initiators: []
A DAST scan is only as authenticated as the weakest link in its own login chain. Getting ZAP "past login" isn't a checkbox — it's making sure every single request in the scan, including ones fired minutes into a long active scan after a short-lived JWT has expired, still carries a header that gets a 200 instead of a 401. A scan that silently degrades to unauthenticated halfway through will still finish and still produce a report — it just won't be testing anything.
Baseline pass, then the full active scan
☺ Like you're 10: First a quick, gentle walk-through that only looks — then a real, hands-on attempt to open every door and window.
Run the baseline first — spider plus passive scan only, no attack payloads sent, safe against anything: it maps the app's routes and flags the cheap, obvious stuff (missing security headers, verbose error pages) in under a minute.
docker run --rm -v "$(pwd)/security/zap:/zap/wrk:rw" -t zaproxy/zap-stable \
zap-baseline.py -t http://host.docker.internal:8080 \
-z "-config replacer.full_list(0).description=merchant-a \
-config replacer.full_list(0).url='http://host.docker.internal:8080/.*' \
-config replacer.full_list(0).matchtype=REQ_HEADER \
-config replacer.full_list(0).matchstr=Authorization \
-config replacer.full_list(0).replacement='Bearer '$TOKEN_A \
-config replacer.full_list(0).enabled=true"Then run the real thing — a full Automation Framework plan that spiders, waits for the passive scan to finish, and launches an authenticated active scan that actually sends injection payloads. (ZAP's Automation Framework schema has moved release to release — treat the field names below as the shape of it, and check zap.sh -cmd -autogenkey or the in-app plan editor against whichever ZAP version you've actually installed before copying this verbatim.)
# security/zap/zap.yaml
env:
contexts:
- name: "vulnerly-staging"
urls: ["http://host.docker.internal:8080"]
includePaths: ["http://host.docker.internal:8080/.*"]
parameters:
failOnError: true
progressToStdout: true
jobs:
- type: replacer
parameters: {}
rules: !include replacer-rules.yaml
- type: spider
parameters: { context: "vulnerly-staging", maxDuration: 5 }
- type: passiveScan-wait
parameters: { maxDuration: 5 }
- type: activeScan
parameters: { context: "vulnerly-staging", policy: "Default Policy" }
- type: report
parameters:
template: "traditional-html"
reportDir: "/zap/wrk/reports"
reportFile: "staging-before"
risks: ["high", "medium", "low", "info"]
- type: report
parameters:
template: "traditional-json"
reportDir: "/zap/wrk/reports"
reportFile: "staging-before"docker run --rm -v "$(pwd)/security/zap:/zap/wrk:rw" -t zaproxy/zap-stable \ zap.sh -cmd -autorun /zap/wrk/zap.yaml
The before-fix run comes back with 18 alerts. Two of them are High — everything else is worth a look, but nothing else blocks the merge:
| Risk | Count | Examples |
|---|---|---|
| High | 2 | Reflected Cross-Site Scripting (transaction search) · Access Control Issue (see next section) |
| Medium | 3 | CSP Header Not Set · X-Frame-Options Not Set · Missing Anti-CSRF Tokens |
| Low | 5 | Server header leaks version · X-Content-Type-Options missing · Timestamp Disclosure · Private IP Disclosure · Cookie without SameSite |
| Informational | 8 | Storable/Cacheable content · Suspicious comments · Modern Web App detected · User agent fuzzer notes |
Confirming the IDOR with Access Control Testing
☺ Like you're 10: A regular scan checks whether a door opens. It doesn't ask whose room is behind it — for that you need to actually swap visitors and compare what each one sees.
The active scan's injection rules found the reflected XSS on their own — that's exactly the class of bug they're built for. They will not, on their own, find the IDOR. ZAP's standard active-scan rules test payloads against a single authenticated identity; they have no idea that transaction 201 is supposed to belong to merchant-b and not merchant-a. That's a business-logic, authorization question, and it needs a second identity in the loop to even be askable.
ZAP's Access Control Testing feature exists for exactly this: define the two users (merchant-a, merchant-b) on the context, mark which URLs each one should and shouldn't be able to reach, and let ZAP replay the site's URLs under both identities and diff the results. Point it at the two transaction IDs you already confirmed by hand:
Context: vulnerly-staging
Users: merchant-a, merchant-b
Access rules:
GET /api/transactions/101 → allowed for merchant-a, denied for merchant-b
GET /api/transactions/201 → allowed for merchant-b, denied for merchant-a
Result: GET /api/transactions/201 as merchant-a → 200 (expected 403/404)
→ flagged as an Access Control Issue, rolled into the same report as the XSSIt's easy to read "0 alerts" from a single-user active scan as "the app is secure" and stop there. It only means the app is secure against the one identity you scanned as. Any endpoint that returns someone else's data to a different, equally-valid identity is invisible until you scan with at least two.
Triage: fix, defer, or accept
☺ Like you're 10: Not every mess needs cleaning today — but every mess needs someone to look at it and decide, out loud, what happens to it.
Triage means every one of the 18 alerts gets a decision, not that every one gets a code change. Work risk-first, same order the vulnerability management & triage page teaches: both Highs get fixed today, full stop. Of the three Mediums, two get fixed cheaply (X-Frame-Options and a CSRF exemption note for the API-only routes); the CSP header gets deliberately deferred and documented — Vulnerly's staging deploy sits behind an internal load balancer with no public rendering surface yet, and a rushed CSP on an API that returns only JSON is more likely to break a legitimate client than stop an attacker. The Lows and Informational items get a pass this cycle; none of them are exploitable on their own.
Both Highs get fixed in application source. The reflected XSS was a debug-era search endpoint that never should have rendered HTML at all:
// app/src/routes/transactions.js — BEFORE: q is echoed straight into HTML
router.get('/api/transactions/search', (req, res) => {
const q = req.query.q || '';
res.send(`<p>Results for: ${q}</p>` + renderResultsHtml(q));
});
// AFTER — this is an API. Stop returning HTML from it at all; nothing
// gets concatenated into markup because there's no markup being built.
router.get('/api/transactions/search', (req, res) => {
const q = String(req.query.q || '').slice(0, 128);
res.set('Content-Type', 'application/json');
res.json({ query: q, results: findTransactions(q) });
});The IDOR was a query that never checked ownership — the fix scopes it to the authenticated merchant and returns 404, not 403, so a probing request can't even confirm the ID exists:
// app/src/routes/transactions.js — BEFORE: any authenticated user, any ID
router.get('/api/transactions/:id', requireAuth, async (req, res) => {
const tx = await db.query('SELECT * FROM transactions WHERE id = $1', [req.params.id]);
res.json(tx.rows[0]);
});
// AFTER — the row has to belong to req.user, not just exist
router.get('/api/transactions/:id', requireAuth, async (req, res) => {
const tx = await db.query(
'SELECT * FROM transactions WHERE id = $1 AND merchant_id = $2',
[req.params.id, req.user.merchantId]
);
if (!tx.rows[0]) return res.status(404).json({ error: 'Not found' });
res.json(tx.rows[0]);
});Re-scan and save the after report
☺ Like you're 10: You don't get to say the door is fixed until you've gone back and tried opening it again, the exact same way you did the first time.
Commit both fixes, let Part 2's SAST/secrets gates and Part 3's SCA gate wave the change through, and let the pipeline build, sign, and push a new image digest exactly the way Part 4 set up. Redeploy that new digest into staging — same overlay, new newTag — and re-run the identical zap.yaml, changing only the two report filenames to staging-after:
kubectl set image -n staging deploy/vulnerly vulnerly=ghcr.io/YOU/vulnerly:sha-<new digest> kubectl -n staging rollout status deploy/vulnerly docker run --rm -v "$(pwd)/security/zap:/zap/wrk:rw" -t zaproxy/zap-stable \ zap.sh -cmd -autorun /zap/wrk/zap.yaml # zap.yaml's report jobs now target staging-after
The after-fix report comes back with 13 alerts — the two Highs are gone, one Medium (the deferred CSP header) is now explicitly flagged in your own notes as accepted risk rather than an open item, and the Low count drops by one now that a version-leaking header got cleaned up alongside the real fixes:
| Risk | Before | After | What changed |
|---|---|---|---|
| High | 2 | 0 | XSS fixed (API stopped rendering HTML) · Access Control Issue fixed (ownership check added) |
| Medium | 3 | 1 | X-Frame-Options and CSRF-exemption fixed; CSP deferred and documented as accepted risk |
| Low | 5 | 4 | Server version header removed from responses |
| Informational | 8 | 8 | Unchanged — none were exploitable on their own |
Save both HTML reports and both JSON reports under security/zap/reports/ and commit them — they're evidence, not scratch output, and Part 7 imports the JSON straight into DefectDojo as two findings-import events on the same engagement, so the before/after diff survives as an auditable record rather than a claim in a commit message. To stop this from being a one-time check, add the same zap.yaml run as a required job in .github/workflows/ci.yml that deploys to staging on every merge to main and fails the build on any new High — see security in CI/CD for how the earlier gates in this pipeline are wired the same way.
What "done" looks like for Part 6
☺ Like you're 10: Two reports sitting side by side, one with red in it and one without, both saved where anyone can go check them.
At the end of this part you have a staging namespace running the same signature-enforced image standard as vulnerly, two merchant identities ZAP can authenticate as on demand, a staging-before report with two documented High alerts, both root causes fixed directly in application source, and a staging-after report proving zero Highs against the same target, same context, same two identities. Nothing here gets thrown away — Part 7 starts by pulling both reports into DefectDojo alongside every finding from Parts 2 through 5, so the whole capstone's evidence trail lives in one place before you wire up the monitoring rule that would catch a regression of any of it.
Timmy: Staging's up, both merchant tokens are wired into Replacer. I'm not trusting a scan that only ever logged in as one person.
Rocky: Then let me in as merchant-a and go after merchant-b's transaction first, by hand, before the tooling even runs. ...Got it. Same door the threat model already flagged.
Timmy: Which is exactly why I don't sign off on "the scan came back clean" until Access Control Testing has run with both of you logged in — the injection rules alone would never have caught that one.
Rocky: The XSS I found even faster — the search box just prints back whatever I send it. Ninety seconds, no tooling required.
Pip: Before either of you touches the fix — the image Kyverno's about to admit into staging. Same signature check as vulnerly, or this whole exercise is testing a namespace nothing else has to live up to.
Timmy: Already copied the policy over. Both alerts go in the before report exactly as found — I want proof of the broken state, not just proof of the fixed one.
Milestones
☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."
Work these in order — each depends on the state left by the one before. Progress saves in this browser.
staging and deploy Part 4's signed image into itkubectl create namespace staging, then kubectl apply -k k8s/overlays/staging.kubectl -n staging rollout status deploy/vulnerly reports success.stagingkubectl -n staging apply -f k8s/policy/require-signed-images.yaml.staging is rejected at admission, same as it already is in vulnerly.merchant-a and merchant-b each own transactions that belong only to them.GET /api/transactions list returns only their own rows.curl, before any tool runs200 with merchant-b's data — the bug, confirmed with your own hands.replacer-rules.yaml with the live token, then send one authenticated request through ZAP's proxy and check it returns 200, not 401.zap-baseline.py against staging with the Replacer config attached.zap.sh -cmd -autorun /zap/wrk/zap.yaml against the vulnerable staging deploy.staging-before.html exists and the reflected XSS on the search endpoint appears in it.staging-before has a one-line disposition — fix, defer, or accept — not just the two Highs.req.user.merchantId and return 404 on a mismatch.curl IDOR check from step 4 now returns 404, and the search endpoint returns JSON only.staging, then re-run the identical zap.yaml targeting staging-after.staging-after.html exists and shows zero High alerts.staging-before.html/json and staging-after.html/json under security/zap/reports/ and push.git log — this is the actual done-when for the whole part, not the eleven checkboxes above it.1. Why does this part deploy Vulnerly to a brand-new staging namespace instead of scanning the existing vulnerly deployment directly? 2. Why doesn't the active scan's standard rule set find the IDOR on its own, and what feature does find it? 3. What's the difference between the Replacer approach used here and letting a scan run unauthenticated? 4. What exactly is the done-when for this part — the checklist above, or something else?
Check your answers
- An active scan sends real, sometimes state-changing attack payloads. A dedicated, throwaway
stagingnamespace means nothing else depends on the target, and it can be reseeded freely — scanningvulnerlydirectly would risk data other parts of the capstone rely on, for no benefit. - The active scan's injection rules test payloads against a single authenticated identity and have no concept of which resource is supposed to belong to which user — that's a business-logic/authorization question, not an injection question. ZAP's Access Control Testing feature finds it, by replaying the same URLs under two different user identities and diffing what each one is allowed to see.
- Replacer stamps a fixed
Authorizationheader onto every outgoing request for the duration of the scan, which is simple and reliable for a short-lived bearer token. An unauthenticated scan would hit401on almost every protected route and report almost nothing — a wall of identical 401s, not a real security posture. - A saved
staging-beforereport showing the original High alerts, and a savedstaging-afterreport — same target, same context, same two identities — showing zero Highs. The twelve milestone checkboxes track your own progress toward that; the two committed reports are the actual proof.
Part 6 gave you an outside-in view of Vulnerly and closed the two threats Part 1's model predicted, proven by an authenticated scan rather than a guess. Continue to Capstone Part 7 — Ship Compliance Evidence & Monitor, where both reports get imported as evidence and a monitoring rule goes up to catch a regression of any of it. Or step back to the full lab track, and revisit Dynamic Analysis in Practice and Offensive Security for DevSecOps for the concepts behind what you just ran.