Manual Test Runbook — I4: Dynamic Application Security Testing (DAST)¶
Owner: Sagar | Time: ~3 min (Part A offline) · +5 min (Part B dry) · +15 min (Part C live) | Cloud: none · $0
Promotes I4 (
.github/workflows/dast-scan.yml) from 🟦 Code Complete → 🟩 Shipped. Reusableworkflow_callDAST gate (OWASP ZAP). The dynamic layer of the vuln suite: it attacks a running app over HTTP, whereas I1/I2/I3 inspect artifacts and source at rest. Part A is offline (YAML lint + rules-file check). Part B is a wiring dry-run. Part C is a live scan against a deliberately-vulnerable target.
Prerequisites¶
- Local tooling:
python3(YAML lint),awk/python3(tsv check) - (Part C only)
ghauthenticated; the workflow present on the default branch - (Part C only, optional)
dockerto stand up a local juice-shop target - Working directory: repo root
Steps¶
Part A — workflow lint + rules-file check (offline, ~3 min)¶
- Parse the workflow YAML:
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/dast-scan.yml')); print('dast-scan.yml parses OK')"
# Optional, if installed:
actionlint .github/workflows/dast-scan.yml
Expected: dast-scan.yml parses OK. Confirm workflow_call inputs
(target_url required, scan_type default baseline, fail_on default
High, allow_url_failure, rules_file_path default .zap/rules.tsv) and
that permissions: is contents: read only (no issues: write, because
allow_issue_writing: false).
- Confirm the rules file is present and well-formed (tab-separated, 3 fields/row):
python3 - <<'PY'
rows = [l.rstrip("\n") for l in open(".zap/rules.tsv") if l.strip()]
bad = [(i+1, r) for i, r in enumerate(rows) if len(r.split("\t")) != 3]
print(f"{len(rows)} rules; malformed rows: {bad or 'none'}")
PY
Expected: all rows have 3 fields; malformed rows: none.
- How DAST (I4) differs from I1 / I3:
| What it inspects | When | Tool | |
|---|---|---|---|
| I1 image-scan | container image CVEs (at rest) | build / re-scan | Trivy |
| I2 dependency-review | dependency advisories (at rest) | PR | dep-review |
| I3 codeql | first-party source (SAST, at rest) | PR / push | CodeQL |
| I4 dast-scan | a running, deployed app (over HTTP) | after deploy | OWASP ZAP |
I4 is the only gate that needs a live URL — it spiders the app and sends real requests, catching runtime issues (reflected XSS, injection, missing security headers) that static analysis cannot see. It must therefore run after a deploy to a reachable environment, not at code-time.
Part B — caller wiring dry-run (~5 min, $0)¶
- A downstream repo wires the gate after its staging deploy. The caller snippet:
# .github/workflows/deploy-and-dast.yml in a downstream repo
jobs:
deploy-staging:
# ... deploys the PR build, outputs the staging URL ...
outputs:
url: ${{ steps.deploy.outputs.url }}
dast:
needs: deploy-staging
uses: snowops/snowops-automation/.github/workflows/dast-scan.yml@main
with:
target_url: ${{ needs.deploy-staging.outputs.url }}
scan_type: baseline # "full" for the intrusive active scan
fail_on: High
# allow_url_failure: true # set while the env is still flaky
Walk through it: scan_type: baseline selects the baseline job (the full
job is gated off via its if:); ZAP reads .zap/rules.tsv; on a High finding
the gate fails; either way the zap-baseline-report artifact is uploaded.
Part C — live scan against a vulnerable target (~15 min, $0)¶
- Stand up (or point at) a deliberately-vulnerable target. OWASP Juice Shop is the canonical choice:
# Local option (runner must reach it — for a hosted runner use a public URL):
docker run --rm -d -p 3000:3000 bkimminich/juice-shop
For the GitHub-hosted runner, use a publicly reachable vulnerable demo URL
(e.g. a deployed juice-shop instance or http://testphp.vulnweb.com) since
the runner cannot reach your laptop's localhost.
- Dispatch the scan against the vulnerable target:
Confirm:
- the zap (baseline) job runs and fails on High-risk findings
(a FAIL-mapped rule such as SQL injection / XSS fired);
- the zap-baseline-report artifact uploaded (download it; open
report_html.html).
-
Re-run against a clean/benign URL (e.g.
https://example.com) and confirm the gate is green (noFAILrules fire), proving the cutoff wiring. -
(Optional) Try
scan_type: fullagainst the throwaway target to confirm thefulljob (active attack scanner) selects instead ofbaseline. Never point the full scan at production.
Sign-Off¶
| Field | Value |
|---|---|
| Part A (lint + rules) | ☐ PASS |
| Part B (wiring dry-run) | ☐ PASS |
| Part C (live scan) | ☐ PASS / ☐ skipped |
| Tester | |
| Date | |
| Result | ☐ PASS |