Triage a single security finding — from a scanner, audit, advisory, report, or a findings.json record — to a defensible disposition with calibrated severity, a mitigation plan, false-positive justification, or accepted-risk writeup. Use when the user mentions 'triage this finding,' 'is this a real vulnerability,' 'mitigation plan,' 'false positive,' 'accept this risk,' 'compensating controls,' 'risk justification,' 'security ticket,' 'CVSS this,' 'what severity is this,' 'is this really high,' 'severity calibration,' 'needs validation,' 'hardening note,' 'findings.json,' 'should we fix this,' 'disposition,' 'sign off on,' or has a single security finding and needs to decide what to do.
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Every other skill in this repo generates findings. This skill closes the loop — for a single finding, walk through whether it's real, what severity it deserves in your context, and what to do about it. Output is a complete ticket-ready writeup with the right fields, the right justification, and an audit trail that survives a regulator reading it six months later.
A confirmed finding ends in one of five dispositions: Fixed, Deferred, Accepted Risk, Mitigated (platform/config), or Requires major migration. False Positive isn't a disposition for a real finding — it's a determination that there is no finding. A gap with no traced impact ends as a Hardening note: not a finding, no severity.
This skill works on findings from any source: SAST output, DAST scanner, dependency advisory, manual audit, threat-hunt hit, pentest report, vendor disclosure, internal red-team writeup, bug bounty submission — or a structured record from another skill's findings.json.
Cross-references:
vuln-research for the technical CVE deep-dive that feeds reachability assessment hereowasp-audit / api-audit disposition rule (the framework this implements per-finding) and their per-finding verification pass (references/verification.md)scanner-pipeline for bulk scanner output — triage the survivors here, one at a timevariant-analysis when a confirmed finding probably has siblings: encode it as a rule and sweep the repo or orgsecurity-comms for translating the disposition writeup into stakeholder-readable language when the finding has to leave the security contextfindings.json record formatConfirm the user owns or is authorized to triage this finding and the system it lives in. Triage reads code, configs and sometimes runs a reproduction — only do that against systems in scope. If a reproduction would touch production or someone else's system, stop and get explicit approval first.
Accept the finding in whatever form it arrives. Two forms get special handling.
A findings.json record. If the user points at a findings.json (from owasp-audit, api-audit, dependency-audit, secrets-audit, scanner-pipeline, business-logic-testing, or agent-supply-chain-audit), ask for the fingerprint, or list the records and let them pick. Validate the file first:
node references/validate-findings.mjs findings.jsonThen use the record's fields directly instead of re-deriving them: location, trace[] (entrypoint → sink), evidence[], counterevidence, conditions[], and severity if present. The format is in references/findings-format.md.
A verdict. Findings from this repo's audit skills arrive with one of three verdicts. Start triage according to the verdict:
| Verdict | What it means | Where triage starts |
|---|---|---|
confirmed | Full trace or PoC; has a severity | Step 2 (re-check), then Step 3 (re-calibrate severity), then Step 4 |
needs_validation | Code supports it, but a decisive fact lives outside the code. Has blockers[] + validation_plan, no severity | Run or hand off the validation plan. Resolve every blocker before rating. Result: confirmed (go to Step 3) or rejected (False Positive template). Don't assign a severity while blockers are open |
rejected | A named control (ruled_out_by) runs on every reachable path | Verify the control still exists at ruled_out_by.location and still runs on every path. If yes, write it up as False Positive. If it moved or was removed, re-open as a candidate |
The old "Potential" label is retired; treat anything still labelled that way as needs_validation and ask for the blocker.
When triage changes a record, write the result back to the record — verdict, severity, disposition — so the JSON stays the source of truth. Re-run the validator after editing. Changing the verdict upward (to confirmed, or to a higher severity) needs fresh evidence, not just a different opinion.
The agent works through these steps with the user. Stop and ask clarifying questions where the user has context the finding alone doesn't reveal.
If the finding came from a scanner, restate what's actually being claimed. Scanners produce noise; restating filters out the boilerplate.
A good restatement names:
If you can't restate it clearly, you don't understand it yet. Ask the user for context.
Half of automated-scanner findings are false positives by volume. The triage:
| Question | If yes | If no |
|---|---|---|
| Does the vulnerable code path exist as described? | Continue | False positive — scanner found a phantom |
| Is the code path reachable from any attacker-controllable input? | Continue | Continue, but severity drops |
| Does the exploit precondition match your environment? | Continue | Severity drops or false positive |
| Is there a public PoC, or has anyone confirmed this in the wild? | Severity stays / rises | Severity may drop |
| Are existing controls (WAF, auth, network segmentation) preventing exploitation? | Severity drops; controls become the mitigation | Severity stays |
Boundary + result gate. A finding must name all six of these. Write them down:
Missing one? Then it isn't a finding yet:
Common false-positive patterns:
devDependencies only — runtime-unreachable (see dependency-audit reachability column)pk_…, Firebase web config, Supabase anon). Check what the key can do before rating it — see references/severity-calibration.md §6A false positive must name the control. "Looks fine" isn't a determination. Name the specific control and where it runs (file:line, config key), and confirm it runs on every path an attacker can reach before the sink. A control on a different path, a library you trust generically, or "it's internal" doesn't rule anything out.
Document a false-positive determination as carefully as a real finding. If a future scanner or auditor flags the same thing, the prior false-positive note saves them the work.
Get an independent read. Before confirming at High or Critical, moving a verdict up, or writing a scanner finding off as a false positive, have something other than the finder try to refute it. Scanner batches go through the fixed exclusion rules first (test, generated, vendored, docs). In Claude Code with this plugin, hand the finding to the finding-verifier subagent: one finding per call, the record and the code only, never your notes. In Cursor, Codex and other tools, run the same pass inline as a separate step from references/verification.md, and check your reasoning against its "Rationalizations to reject" table. When to run it, turning a scanner finding into a candidate, and merging the result: references/independent-verification.md.
The scanner's CVSS or severity rating is a starting point, not the answer. Rate likelihood and impact separately, each with a one-line reason, then pick an overall.
Rule: overall severity ≤ demonstrated impact. High likelihood of a low-impact bug is a low finding. If you can't state the concrete damage, the severity is lower than it feels.
Factors that increase severity beyond the rating:
Factors that decrease severity below the rating:
vuln-research)Severity anchors:
| Level | Anchor |
|---|---|
| Critical | Fully defeats a core control with broad, severe consequences and little or no precondition — pre-auth RCE, takeover of any account, cross-tenant access to all data, leaked production secret with write authority |
| High | Fully defeats an explicit security control, with real consequences — IDOR reading or changing another user's data, privilege escalation, SSRF to cloud credentials |
| Medium | Weakens a control without fully defeating it, or defeats one with limited consequences |
| Low | A real boundary crossing with minor consequences, or one needing unlikely preconditions |
The High/Medium question: does it fully defeat the control, and can you name the damage? Both yes → High is on the table. Otherwise Medium or lower.
Defense-in-depth is not Low. A gap with no traced impact — if layer A already prevents the attack, the missing layer B — is a Hardening note: no severity, no SLA, reported separately (Step 4).
Usually NOT High or Critical — rate these up only with a traced, specific, severe consequence:
High/Critical acceptance checklist. All six, or it isn't High:
file:line trace or request/response)If a check fails, downgrade rather than delete. The finding stays, at the level you can defend.
Missing deployment evidence lowers confidence, not the severity floor. If you can't see whether production has the WAF rule, either make it needs_validation with that as the blocker, or keep the traced severity with lower confidence. Don't silently rate a traced High as Low because production is out of view.
SLA is separate from severity. Severity describes the bug; the remediation deadline is your org's policy for that severity. Keep them in separate columns, and use your org's SLAs if it has them. A common default:
| Severity | Remediation SLA (default) |
|---|---|
| Critical | 24–72 hours |
| High | 1–2 weeks |
| Medium | 30 days |
| Low | 90 days |
| Hardening note | No SLA — backlog, fix when convenient |
Full rules, examples and the publishable-vs-secret key table: references/severity-calibration.md.
| Disposition | When to choose | Required fields |
|---|---|---|
| Fix now (open until shipped, then Fixed) | Patch / mitigation deployable within the severity's SLA | Fix description, deploy plan, verification method, owner, target date |
| Defer (Deferred) | Severity warrants action, but operational constraints make immediate fix infeasible | Reason for deferral, new deadline, who owns, alerting if conditions change before deadline |
| Accept risk (Accepted Risk) | Fix isn't planned at current configuration | (1) Why fix doesn't apply, (2) Compensating controls, (3) Re-evaluation trigger |
| Mitigated (platform/config) | The code is unchanged but a platform or configuration control now blocks the path (WAF rule, egress policy, IdP setting, feature flag off, network policy) | The control, where it's configured, evidence it's active, what would remove it, owner of that config |
| Requires major migration | The real fix is an architectural change — framework or major-version upgrade, auth model rework, data-model change — that can't land in the SLA | Migration scope, interim mitigation, target milestone, owner, re-evaluation date |
| False positive | Not actually a vulnerability (verdict rejected) | Evidence for the determination, the named control + location, scanner rule ID to suppress (with care) |
| Hardening note | No traced impact — not a finding | One line: what's missing, why it would help, effort. No severity, no SLA |
On Fix now: a decision to fix is not a fix. Leave disposition unset in findings.json while the fix is planned or in review. The record stays open, and to-sarif.mjs keeps alerting on it. Set Fixed only once the change has shipped and the fix check in Step 7 passed against it; record the commit or release in the writeup and in ext.fixed_in. Writing Fixed early makes the SARIF drop the alert while the bug is still live.
On Defer: severity does NOT change because you decided to defer. Recording a "High deferred to Q3" is honest; downgrading a High to Medium because Q3 is far away is risk-laundering.
On Accept Risk: all three fields are required. An "Accepted Risk" without all three is a real finding being silently dropped. The re-evaluation trigger is the most-skipped field — name a specific condition (plan upgrade, dependency bump, traffic pattern change, audit anniversary).
On Mitigated (platform/config): the vulnerability is still in the code. If someone removes the WAF rule or flips the flag, it's back. Record what would undo the mitigation, and prefer a code fix eventually. Impact doesn't change. You may re-score likelihood, but only with evidence the control is active on every path, and say so in the likelihood reason.
On Requires major migration: this isn't a polite Accept Risk. It needs an interim mitigation now and a dated milestone, and the finding stays open until the migration ships.
Produce a ticket-ready writeup. Use one of these templates depending on disposition.
## Finding: [Title]
**Source:** [scanner / audit / advisory] **Fingerprint:** [if from findings.json]
**Severity:** [contextual] (Scanner reported: [original]) **SLA:** [date]
**Likelihood / Impact:** [score — reason] / [score — reason]
**CWE / CVE:** [if applicable]
**Location:** [file:line / endpoint / resource]
### What
[Plain-English description — what the issue is]
### Boundary + result
[Principal → input → control defeated → boundary → affected resource → observed result]
### Why this severity
[Contextual reasoning — what the scanner missed, what your environment adds]
### Fix
[Specific change — code diff, config update, dependency upgrade]
### Verification
[Concrete test — adversarial input + observed result that proves the fix holds]
- Run [command / test case]
- Observe [response / behavior]
### Owner: [name]
### Target deploy: [date]## Finding: [Title] — DEFERRED
**Severity:** [unchanged]
**Original target:** [original SLA date]
**New target:** [date]
### Why deferred
[Operational constraint — release freeze, dependency on third party, etc.]
### Risk during deferral window
[What's the exposure? What controls reduce it?]
### Alerting / conditions that would escalate
[What would force action sooner than the new target?]
### Owner: [name]
### Re-evaluation: [date — usually before new target]## Finding: [Title] — ACCEPTED RISK
**Severity:** [contextual]
**Approver:** [name + role]
**Date accepted:** [date]
### Why fix doesn't apply
[Cost tier, dependency version constraint, deployment topology, vendor limitation, etc. — be specific]
### Compensating controls
- [Control 1 — what it is, why it reduces impact / likelihood]
- [Control 2]
- ...
### Re-evaluation trigger
[Specific condition — plan upgrade, dependency bump, traffic pattern change, calendar anniversary]
- Trigger: [what would change this decision]
- Calendar review: [date — at minimum, annually]
### Approvals
- [ ] Engineering owner: [name, date]
- [ ] Security: [name, date]
- [ ] (if required) Compliance / Legal: [name, date]## Finding: [Title] — MITIGATED (PLATFORM/CONFIG)
**Severity:** [as rated] **Code still vulnerable:** yes
**Control:** [WAF rule / egress policy / IdP setting / flag — exact name or ID]
**Configured in:** [system + path / console location / IaC file:line]
### Evidence the control is active
[Request blocked with response X; config export; screenshot reference; date checked]
### What would undo it
[Rule removal, config drift, region/environment without the control]
### Long-term fix
[Code change, and whether it's planned]
### Owner of the control: [name / team]
### Re-check: [date or trigger — e.g. every WAF policy change]## Finding: [Title] — REQUIRES MAJOR MIGRATION
**Severity:** [unchanged] **Original SLA:** [date]
### Why a normal fix doesn't apply
[Framework EOL, major-version upgrade, auth or data model change — scope it]
### Interim mitigation (in place now)
[What reduces exposure until the migration ships]
### Migration plan
- Milestone: [target date / release]
- Owner: [name]
- Tracking: [epic / ticket]
### Re-evaluation: [date]## Finding: [Title] — FALSE POSITIVE
**Scanner:** [name + rule ID]
**Original severity:** [as reported]
### What the scanner claimed
[Restate the claim]
### Why it's not real
[Specific evidence — code path not reachable, version contains the fix, etc.]
**Ruled out by:** [control] at [file:line / config] — runs on every reachable path
### Suppression decision
- [ ] Suppress this exact finding (location + rule ID)
- [ ] Add allow-list rule (with care — broad suppression breeds blind spots)
- [ ] No suppression — re-evaluate if it returns
### Determination by: [name, date]
### Reviewed by: [name, date — for non-trivial suppressions]## Hardening note: [Title] (not a finding — no severity)
- **Gap:** [what's missing]
- **Why no finding:** [which control already prevents it / which gate element is missing]
- **Would help because:** [defense-in-depth value]
- **Effort:** [S / M / L]Before submitting:
findings.json record: did you write back the verdict, severity and disposition, and does the validator still pass?Fixed whose fix hasn't shipped and been verified? Clear the disposition until it has.A merged fix is a claim. Before a Fix now finding becomes Fixed, check it: the fix is verified when the original claim, re-checked against the fixed code, would close as rejected with the fix as ruled_out_by.
trace[] walked again (the finding-verifier subagent can do this read-only). Confirm the legitimate request still works.Fixed — only after 1–4 pass and the change is live everywhere the finding applied. Keep the fingerprint, verdict and severity unchanged; add ext.fixed_in, ext.fix_verified_on and ext.regression_test, then re-run the validator. If a later run finds the same fingerprint again, reopen it as a regression.Full procedure, the "moved the sink" table and the record example: references/fix-verification.md.
Findings that should NOT be triaged unilaterally by a single engineer:
These get a second reviewer (Tier 3 / security team / approver named in policy).
The primary output is the disposition writeup itself (templates above). For a triage session that covers multiple findings or a batch import, summarize:
# Finding Triage Session
## Source: [scanner / audit / report / findings.json path]
## Date: [date]
## Triaged by: [name]
### Summary
| Finding | Fingerprint | Verdict | Original severity | Contextual severity | SLA | Disposition |
|---------|-------------|---------|-------------------|---------------------|-----|-------------|
### Detail
[Per-finding writeup using the appropriate template]
### Needs validation (no severity yet)
| Finding | Blocker | Validation plan | Owner |
|---------|---------|-----------------|-------|
### Hardening notes (not findings)
- [one line each — gap, why it would help, effort]
### Related candidates (not triaged here)
| Candidate | Location | Why it's related | Hand to |
|-----------|----------|------------------|---------|
Things you noticed while triaging: the same pattern on a sibling route, a second call site of the vulnerable function, the control that would have caught it missing elsewhere. They have no verdict yet, so don't give them a severity or a `findings.json` record in this session. Name the skill that should review them (`owasp-audit`, `api-audit`, `dependency-audit`, ...), or triage each one separately.
### Escalations
[Findings that need senior review or approval]incident-triage — disposition triage is for steady-state findings, not firesfindings.json verdicts and fields; validate-findings.mjs, to-sarif.mjsFixedfinding-verifier subagent or the inline passfirst.org/cvssbriiirussell/cybersecurity-skills
c79bb8b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.