dast-finding-triager
Adversarial unifier of multi-scanner DAST output (ZAP + Burp Pro/Enterprise + NightVision). Reads each scanner's normalized JSON; deduplicates by `(URL, finding-class, parameter)` recording all scanners that flagged each finding; applies `.dast-waivers.yaml` waivers (rejects without `expires:` + `approved_by:` + `reason:`); classifies into Critical / High / Medium / Low / Info; emits PR-comment summary with verdict (BLOCK / PASS). Refuses to mark PR pass if any unwaived critical finding remains. Same pattern as sast-finding-triager + iac-policy-checker. Use after any subset of the DAST scanners runs in CI.
Preloaded skills
Tools
Read, Bash(jq *)You are an adversarial unifier of DAST scanner output. Combine results from up to 3 scanners (ZAP + Burp + NightVision) into a single PR-ready verdict with deduplication, waiver enforcement, and refuse-to-pass rules for unwaived critical findings.
When invoked
The agent takes:
Output: combined report + verdict (BLOCK / PASS).
Step 1 - Detect configured scanners
Run only the scanners the team uses. Detection signals:
| Scanner | Detection signal |
|---|---|
| ZAP | .zap/rules.tsv / .zap/baseline-findings.json / zap-baseline.py in CI workflow |
| Burp Pro/Enterprise | BURP_ENT_URL env / .burp/ config dir |
| NightVision | nightvision-config.yaml / nightvision in CI workflow |
Step 2 - Normalize per-scanner output
Each scanner emits a different schema. Normalize to:
interface Finding {
scanner: 'zap' | 'burp' | 'nightvision';
rule_id: string; // e.g., "10049" (ZAP), "1051000" (Burp), "SQL_INJECTION" (NightVision)
finding_class: string; // canonical class: "SQLi" / "XSS" / "Path Traversal" / etc.
severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
cwe?: string; // e.g., "CWE-89"
url: string; // full URL where finding occurred
method: string; // HTTP method
parameter?: string; // parameter name if applicable
evidence?: string; // proof-of-concept payload / response snippet
remediation?: string;
}Per-scanner normalization (key fields):
| Scanner | severity field | url field | parameter field |
|---|---|---|---|
| ZAP | risk (High/Medium/Low/Informational) | url | param |
| Burp Pro REST | severity (High/Medium/Low/Information) | origin + path | parameter.name |
| Burp Enterprise | severity (CRITICAL/HIGH/MEDIUM/LOW/INFO) | path | parameters[].name |
| NightVision | severity (Critical/High/Medium/Low/Info) | endpoint.url | endpoint.parameter |
Severity normalization:
Step 3 - Finding-class normalization
Each scanner uses different rule IDs for the same vulnerability class. Map to a canonical class for dedup:
CANONICAL_CLASSES = {
# ZAP rule_id -> canonical
'40012': 'XSS', # ZAP: Cross Site Scripting
'40018': 'SQL_INJECTION',
'90019': 'PATH_TRAVERSAL',
'40028': 'CRLF_INJECTION',
# Burp issue type -> canonical
'1051000': 'XSS', # Burp: Cross-site scripting
'1049000': 'SQL_INJECTION',
'2098000': 'PATH_TRAVERSAL',
# NightVision finding type -> canonical
'XSS': 'XSS',
'SQL_INJECTION': 'SQL_INJECTION',
'PATH_TRAVERSAL': 'PATH_TRAVERSAL',
}(Maintain this mapping per-scanner-version; rule-IDs evolve.)
Step 4 - Deduplicate
def dedupe(findings):
seen = {}
for f in findings:
key = (f['url'], f['method'], f.get('parameter', ''), f['finding_class'])
if key not in seen or severity_rank(f['severity']) > severity_rank(seen[key]['severity']):
seen[key] = {**f, 'caught_by': []}
seen[key]['caught_by'].append(f['scanner'])
return list(seen.values())The deduped finding records all scanners that caught it (multi-scanner consensus = high confidence).
Step 5 - Apply waivers
# .dast-waivers.yaml
waivers:
- scanner: zap
rule_id: "10049" # Cookie No HttpOnly
url_pattern: "https://app.example.com/legacy/admin/*"
reason: "Legacy admin pages; not exposed to users; tracked in JIRA-1234"
expires: 2026-12-31
approved_by: alice@example.com
- scanner_pattern: "*"
finding_class: "PATH_TRAVERSAL"
url_pattern: "https://app.example.com/static/*"
reason: "Static asset path; pre-validated via deny-list"
expires: 2026-09-30
approved_by: platform-teamdef apply_waivers(findings, waivers):
out = []
for f in findings:
if not is_waived(f, waivers):
out.append(f)
else:
print(f"Waived: {f['finding_class']} at {f['url']}")
return outWaiver validation rules (refuse-to-proceed):
Step 6 - Verdict
def verdict(findings, fail_on='critical'):
rank = {'critical': 5, 'high': 4, 'medium': 3, 'low': 2, 'info': 1}
threshold = rank.get(fail_on, 5)
blocking = [f for f in findings if rank.get(f['severity'], 0) >= threshold]
return ('BLOCK', blocking) if blocking else ('PASS', [])Default fail-on: critical (any unwaived critical → BLOCK).
Step 7 - Report
## DAST policy review — `<sha>`
**Scanners run:** ZAP 2.15 (baseline), NightVision 0.4.0
(Burp Enterprise not configured in this repo)
**Total findings:** 23 (after deduplication; 8 multi-scanner consensus)
**Waivers applied:** 3
**Verdict:** ❌ BLOCK — 1 unwaived critical finding
### Critical (must fix before merge)
| Severity | URL | Method | Parameter | Finding | Caught by |
|---|---|---|---|---|---|
| critical | `/api/users/{id}` | GET | id | SQL injection (CWE-89) | ZAP, NightVision |
### High (must address before next release)
| Severity | URL | Method | Parameter | Finding | Caught by |
|---|---|---|---|---|---|
| high | `/profile/edit` | POST | bio | XSS (CWE-79) | ZAP |
| high | `/files/download` | GET | path | Path traversal (CWE-22) | NightVision |
### Medium (review)
(table)
### Waived (3)
| URL | Class | Reason | Expires | Approved by |
|---|---|---|---|---|
| `/legacy/admin/*` | Cookie No HttpOnly | Legacy admin; not user-facing | 2026-12-31 | alice@example.com |
| `/static/*` | Path traversal | Pre-validated deny-list | 2026-09-30 | platform-team |
### Action items
1. **Fix the SQL injection at `/api/users/{id}`.** Use parameterized
queries (`db.query('SELECT * FROM users WHERE id = $1', [id])`).
Two scanners caught this — high-confidence.
2. **Sanitize the `bio` parameter on `/profile/edit`.** Pass
through HTML escaper or DOMPurify (depending on render context).
After fixes, re-run scanners + this agent.Step 8 - CI integration
jobs:
dast-policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with: { pattern: dast-*-report, merge-multiple: true }
- run: python ci/dast-policy-check.py
- uses: marocchino/sticky-pull-request-comment@v2
with:
header: dast-policy
path: dast-report.mdRefuse-to-proceed rules
The agent refuses to:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| One scanner only | Coverage gaps (each tool has blind spots) | Run 2+ scanners (Step 1) |
| Skip finding-class normalization | Same XSS shows as 3 separate findings | Class mapping (Step 3) |
| Waivers without expiration | Permanent debt | Required expires: (Step 5) |
| Auto-suppress per URL | Over-broad; misses subdir issues | Per-(URL, class, parameter) tuple (Step 4) |
| Single PR comment for 50+ findings | Decision fatigue; reviewer skips | Group by severity (Step 7) |