dast-baseline-runner
Designs an end-to-end DAST cadence for teams adopting dynamic scanning: ZAP passive baseline (PR-blocking) then ZAP full active scan (nightly on staging) then optional Burp Pro deep scan (per-release). Handles the baseline-finding ratchet for legacy apps so pre-existing findings do not immediately block PRs, plus per-tool per-run deduplication and CI workflow YAML. Use when the team is setting up DAST from scratch or restructuring scan cadence, not when tools are already running and you need to merge their output (see dast-finding-triager for cross-tool aggregation of existing independent runs).
dast-baseline-runner
Overview
DAST tools alone don't make a coverage strategy. A team running ZAP baseline once per PR catches 30% of what ZAP can find; running ZAP full + Burp + NightVision together at the right cadence catches ~80%. This skill is a build-an-X workflow - the per-team DAST cadence and aggregation strategy.
When to use
Step 1 - Layer the scans by intrusiveness
Three scan types stack:
| Layer | Scan type | Cadence | Target | Risk |
|---|---|---|---|---|
| 1 | Passive baseline (ZAP baseline) | Per-PR (blocking) | Staging | Safe - passive only |
| 2 | Active full scan (ZAP full / NightVision) | Nightly | Staging | Active probes - pollute staging data |
| 3 | Deep paid scan (Burp Pro / Enterprise) | Per-release / weekly | Staging | Active + extension-driven |
PR-blocking layer is intentionally narrow - only fail on findings that didn't exist before. This requires baseline ratchet (Step 3).
Step 2 - Baseline-finding ratchet
The first scan against a legacy app surfaces 100s of pre-existing findings; if they all block PRs, the team disables DAST. The ratchet pattern:
# pr-gate.py
import json
def diff_findings(current, baseline):
baseline_keys = {(f['file'], f['rule_id']) for f in baseline}
new = [f for f in current if (f['file'], f['rule_id']) not in baseline_keys]
return new
with open('current.json') as f:
current = json.load(f)
with open('baseline.json') as f:
baseline = json.load(f)
new_findings = diff_findings(current, baseline)
if any(f['severity'] in ['critical', 'high'] for f in new_findings):
print(f"FAIL: {len(new_findings)} new finding(s) on PR; not in baseline")
exit(1)ZAP baseline natively supports this via the -c config.tsv rule file; mirror the pattern for the cross-tool aggregation layer.
Step 3 - Alert deduplication across runs
Consecutive PR-runs catch the same vulnerability multiple times; each PR comment shows duplicate noise. Dedupe by (rule_id, url, parameter) tuple:
def dedupe_findings(findings):
seen = set()
deduped = []
for f in findings:
key = (f['rule_id'], f['url'], f.get('parameter', ''))
if key not in seen:
seen.add(key)
deduped.append(f)
return dedupedFor dast-finding-triager integration, the triager handles cross-tool dedup; this skill's dedup is per-tool per-run.
Step 4 - CI cadence
# .github/workflows/dast.yml
on:
pull_request:
branches: [main]
jobs:
zap-baseline-pr:
name: DAST baseline (PR-blocking)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: zaproxy/action-baseline@v0.13.0
with:
target: ${{ secrets.STAGING_URL }}
rules_file_name: '.zap/rules.tsv'
- run: python ci/dast-pr-gate.py current.json .zap/baseline-findings.json# .github/workflows/dast-nightly.yml
on:
schedule:
- cron: '0 2 * * *' # 2 AM daily
workflow_dispatch:
jobs:
zap-full-scan:
name: DAST active full scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: zaproxy/action-full-scan@v0.13.0
with:
target: ${{ secrets.STAGING_URL }}
- name: Upload report
uses: actions/upload-artifact@v4
with: { name: zap-full-report, path: report_html.html }
nightvision:
name: DAST API spec scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: ./ci/run-nightvision.sh# .github/workflows/dast-release.yml
on:
release:
types: [created]
workflow_dispatch:
jobs:
burp-deep:
name: Burp deep scan (per-release)
runs-on: self-hosted # Burp Enterprise lives on internal infra
steps:
- run: ./ci/burp-enterprise-trigger.shStep 5 - Per-finding triage workflow
When a new finding appears in a PR-blocking scan, the team has 4 options:
Each option requires reviewer + reason + Re-review-date in commit message or PR comment. No silent suppression.
Step 6 - Coverage measurement
Post-scan, measure coverage to detect blind spots:
# How many endpoints did the scan cover?
jq '.spider_results.urls | length' report.json
# How many endpoints did the OpenAPI spec define?
jq '.paths | length' openapi.yaml
# Coverage ratioIf coverage < 80% of API surface, the spider missed routes; investigate auth flows, JS-heavy SPAs, route-discovery gaps.
Step 7 - Aggregate via dast-finding-triager
Once 2+ tools run, ingest each tool's JSON output into the triager:
zap-baseline.py -t $URL -J zap.json
nightvision scan results $SCAN_ID --output json > nightvision.json
# Burp Enterprise: download via API
curl ... -o burp.json
# Agent consumes all three + emits unified verdictThe agent handles cross-tool dedup, severity normalization, waiver enforcement.
Step 8 - Anti-patterns specific to DAST cadence
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Run full active scans on every PR | Scan time blows out CI; staging data corrupted | Baseline-only on PR; full nightly (Step 4) |
| Skip baseline ratchet | Legacy findings block every PR | Baseline + diff (Step 2) |
| Ignore coverage measurement | Missing endpoints unscanned silently | Step 6 weekly check |
| One scan per app, never re-baseline | Baseline grows stale; misses regressions in old code | Quarterly re-baseline + waiver review |
| Run ZAP + Burp + NightVision without dedup | Same finding shows 3x | Aggregate via triager (Step 7) |
Step 9 - End-to-end test recipe
For each app's DAST coverage: