Hands-On Labs · The Capstone · Part 6 of 7

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.

☺ Explain it like I'm 10

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.

🐢🦝Your hosts for this part: Timmy the Turtle & Rocky the Raccoon — Timmy won't call a scan trustworthy until it ran authenticated, and Rocky is the one who actually tries the door before anyone else is allowed to believe it's locked.
⚠ Where you are arriving from, and where you're headed

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:

ThingNameIntroduced
Scan target namespacestaging — a deploy of the signed image, isolated from vulnerlyPart 6 — this page
Merchant identitiesmerchant-a@vulnerly.test, merchant-b@vulnerly.test — two tenants, used to prove the IDORPart 6
ZAP configsecurity/zap/zap.yaml (Automation Framework plan) + replacer-rules.yamlPart 6
Evidence artifactssecurity/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
⚠ Don't give staging a lower bar than production

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: []
◆ Key idea

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:

RiskCountExamples
High2Reflected Cross-Site Scripting (transaction search) · Access Control Issue (see next section)
Medium3CSP Header Not Set · X-Frame-Options Not Set · Missing Anti-CSRF Tokens
Low5Server header leaks version · X-Content-Type-Options missing · Timestamp Disclosure · Private IP Disclosure · Cookie without SameSite
Informational8Storable/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 XSS
⚠ A clean active-scan report is not the same claim as "authorization works"

It'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:

RiskBeforeAfterWhat changed
High20XSS fixed (API stopped rendering HTML) · Access Control Issue fixed (ownership check added)
Medium31X-Frame-Options and CSRF-exemption fixed; CSP deferred and documented as accepted risk
Low54Server version header removed from responses
Informational88Unchanged — none were exploitable on their own
Before: two Highs Login (2 merchants)Replacer injects token Spider Active Scan +Access Control Testing staging-before2 High alerts fix XSS + IDOR in source, rebuild, re-sign, redeploy After: zero Highs Login (2 merchants)same context, new image Spider Active Scan +Access Control Testing staging-after0 High alerts

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.

🎬 At the Shift-Left Squad
🐢

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.

0 / 12 milestones complete
1Create staging and deploy Part 4's signed image into it
kubectl create namespace staging, then kubectl apply -k k8s/overlays/staging.
Done when: kubectl -n staging rollout status deploy/vulnerly reports success.
2Copy the signed-image Kyverno policy into staging
kubectl -n staging apply -f k8s/policy/require-signed-images.yaml.
Done when: deploying a hand-built, unsigned test image to staging is rejected at admission, same as it already is in vulnerly.
Concept: Kyverno
3Seed two merchant identities with distinct transactions
Apply the Postgres seed Job so merchant-a and merchant-b each own transactions that belong only to them.
Done when: both logins succeed and each merchant's GET /api/transactions list returns only their own rows.
4Confirm the IDOR by hand with curl, before any tool runs
Log in as both merchants, then request merchant-b's transaction ID using merchant-a's token.
Done when: the request returns 200 with merchant-b's data — the bug, confirmed with your own hands.
5Wire the Replacer rule and confirm ZAP gets past login
Set replacer-rules.yaml with the live token, then send one authenticated request through ZAP's proxy and check it returns 200, not 401.
Done when: a manually-proxied authenticated request through ZAP succeeds.
Concept: OWASP ZAP
6Run the baseline pass
zap-baseline.py against staging with the Replacer config attached.
Done when: the baseline completes and lists the app's mapped routes with no active-scan payloads sent.
7Run the full active scan via the Automation Framework plan
zap.sh -cmd -autorun /zap/wrk/zap.yaml against the vulnerable staging deploy.
Done when: staging-before.html exists and the reflected XSS on the search endpoint appears in it.
8Run Access Control Testing across both merchant identities
Configure the two users and the expected-access rules on the context, then run the comparison.
Done when: the IDOR surfaces in the report as an Access Control Issue — found by the tool, matching what you already confirmed by hand in step 4.
9Triage all 18 alerts and write down a decision for each
Fix both Highs and two of the three Mediums; document the CSP deferral as an accepted risk with a stated reason.
Done when: every alert in staging-before has a one-line disposition — fix, defer, or accept — not just the two Highs.
10Fix the reflected XSS and the IDOR in source
Stop rendering HTML from the search endpoint; scope the transaction lookup to req.user.merchantId and return 404 on a mismatch.
Done when: the manual curl IDOR check from step 4 now returns 404, and the search endpoint returns JSON only.
11Rebuild, re-sign, redeploy, and re-scan
Let the pipeline build and sign the new image, deploy it to staging, then re-run the identical zap.yaml targeting staging-after.
Done when: staging-after.html exists and shows zero High alerts.
12Commit both reports as evidence
Add staging-before.html/json and staging-after.html/json under security/zap/reports/ and push.
Done when: both report pairs are in git log — this is the actual done-when for the whole part, not the eleven checkboxes above it.
🐢 Timmy's checkpoint

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
  1. An active scan sends real, sometimes state-changing attack payloads. A dedicated, throwaway staging namespace means nothing else depends on the target, and it can be reseeded freely — scanning vulnerly directly would risk data other parts of the capstone rely on, for no benefit.
  2. 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.
  3. Replacer stamps a fixed Authorization header 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 hit 401 on almost every protected route and report almost nothing — a wall of identical 401s, not a real security posture.
  4. A saved staging-before report showing the original High alerts, and a saved staging-after report — 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.