Writing vulnerability reports
A finding that isn't clearly reported doesn't get fixed. The job of a report is
to move a specific defect from your head into the maintainer's, with enough proof
that they can reproduce it in minutes and enough impact that they prioritize it,
and nothing else. This skill turns a schema-shaped finding into that report.
When to use
- A finding is
confirmed (see FINDING-SCHEMA.md) and
you're writing it up for a human: bounty submission, advisory, ticket, email.
- You have several findings and need consistent, triager-friendly writeups.
Scope check
Only report findings from authorized testing, to the party entitled to receive
them (the program's channel, the maintainer's security contact, your client).
Don't disclose someone else's data or a third party's system you weren't scoped
to touch. When in doubt about the channel, ask before sending - a report is
outward-facing and hard to unsend.
The structure that gets findings fixed
Lead with impact, prove it fast, make the fix obvious. Sections:
- Title - the specific defect and where. "IDOR in
admin_bulk_delete lets
any user delete any post," not "Access control issue."
- Summary (2 to 3 sentences) - what the bug is, who can trigger it, what they
get. A busy triager should grasp severity from this alone.
- Impact - the concrete consequence, tied to the target's threat model. Who
is harmed, what they lose, what precondition is needed (auth level, timing,
config). Don't inflate; don't undersell.
- Steps to reproduce - numbered, exact, copy-pasteable. Real request(s) with
method, path, headers that matter, and body. State the starting state (logged
in as user A, two accounts, a seeded record). A stranger must reproduce it
from these steps alone.
- Proof - the request/response pair (or crash/log) that shows it firing.
Redact secrets and unrelated PII. For whitebox findings, include the
source→sink path and the decisive code excerpt from the schema's
evidence.
- Root cause - the actual defect, at the decisive hop (the missing check,
the unbounded copy, the non-atomic act). One or two sentences.
- Remediation - the fix at that hop, plus the general pattern
("parameterize the query," "resolve against base and reject escapes," "atomic
conditional update"). Offer the fix; don't demand a specific patch.
- Severity - a rating with the reasoning. If the program uses CVSS, give the
vector and score; otherwise a justified low/med/high/critical. The reasoning
matters more than the number.
- References (optional) - CWE id, relevant advisory, similar prior fixes.
From schema to report
The finding schema already holds most of this. Map it: title→title;
source+sink+impact→summary/impact; path+evidence→reproduction/proof;
kill_reason never ships (killed findings aren't reported); remediation→
remediation; severity→severity; commit→pin the "affected version." If a field
is thin, that's a signal the finding isn't report-ready - go back and fill it,
don't paper over it.
Reproduction is the report
Most rejected or slow-triaged reports fail here. Rules:
- Reproduce it yourself from your own written steps, in a clean state, before
sending. If your steps don't reproduce for you, they won't for them.
- Minimize. Strip every step and header that isn't load-bearing. The shortest
path that still fires is the best path.
- Make preconditions explicit. "Two accounts," "a coupon that hasn't been
redeemed," "run the two requests concurrently." Hidden state is the #1 reason a
triager can't reproduce.
- Show, don't assert. "Returns 200 with user B's record" beats "it's
vulnerable."
Worked example (outline)
Title: IDOR in POST /api/reports/export - filename allows arbitrary
file write
Summary: An authenticated user can set export.filename to an absolute or
traversal path; the server writes the export there, overwriting arbitrary files
the service can write.
Impact: Arbitrary file write as the service account → RCE by dropping a
cron/config file. Precondition: any authenticated account.
Steps: 1. Log in as any user. 2. POST /api/reports/export with body
{"filename":"../../../../etc/cron.d/x", ...}. 3. Observe the file written
outside the export dir.
Proof: [request/response], plus source path handler.export_report → ReportJob → export_writer.write → open() with the unchecked filename.
Root cause: filename is used to build the output path with no
normalization or base-containment check.
Remediation: Resolve the path against the export base and reject if it
escapes; or ignore client filenames and generate one.
Severity: High (CVSS vector + reasoning).
Rationalizations to reject
- "They'll figure out the repro." → They won't; they'll close it. You reproduce
it from your own steps first.
- "Bigger impact wording = higher payout." → Inflation gets you dinged and
distrusted. Rate it honestly; let the facts carry it.
- "I'll include the whole capture/log." → Minimize and redact. Noise buries the
bug and can leak PII.
- "One report, many bugs." → One defect per report unless the program says
otherwise; bundled reports get partially triaged and partially lost.
Related
- FINDING-SCHEMA.md - the source record every report is
built from.
adjudicating-taint-paths, auditing-guard-gaps, detecting-memory-safety-bugs,
detecting-race-conditions - where confirmed findings come from.
1---2name: writing-vuln-reports3description: Turn a confirmed finding into a clear, reproducible vulnerability report a maintainer or triager can act on without a back-and-forth. Use after a finding is confirmed (via the finding schema) and you need a writeup - a bug-bounty submission, a security advisory, an internal ticket, or a disclosure email. Covers the report structure that gets findings fixed, writing a reproduction that actually reproduces, justifying severity honestly, and the disclosure etiquette that keeps you in bounds.4license: MIT5---67# Writing vulnerability reports89A finding that isn't clearly reported doesn't get fixed. The job of a report is10to move a specific defect from your head into the maintainer's, with enough proof11that they can reproduce it in minutes and enough impact that they prioritize it,12and nothing else. This skill turns a schema-shaped finding into that report.1314## When to use1516- A finding is `confirmed` (see [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md)) and17 you're writing it up for a human: bounty submission, advisory, ticket, email.18- You have several findings and need consistent, triager-friendly writeups.1920## Scope check2122Only report findings from authorized testing, to the party entitled to receive23them (the program's channel, the maintainer's security contact, your client).24Don't disclose someone else's data or a third party's system you weren't scoped25to touch. When in doubt about the channel, ask before sending - a report is26outward-facing and hard to unsend.2728## The structure that gets findings fixed2930Lead with impact, prove it fast, make the fix obvious. Sections:31321. **Title** - the specific defect and where. "IDOR in `admin_bulk_delete` lets33 any user delete any post," not "Access control issue."342. **Summary** (2 to 3 sentences) - what the bug is, who can trigger it, what they35 get. A busy triager should grasp severity from this alone.363. **Impact** - the concrete consequence, tied to the target's threat model. Who37 is harmed, what they lose, what precondition is needed (auth level, timing,38 config). Don't inflate; don't undersell.394. **Steps to reproduce** - numbered, exact, copy-pasteable. Real request(s) with40 method, path, headers that matter, and body. State the starting state (logged41 in as user A, two accounts, a seeded record). A stranger must reproduce it42 from these steps alone.435. **Proof** - the request/response pair (or crash/log) that shows it firing.44 Redact secrets and unrelated PII. For whitebox findings, include the45 source→sink path and the decisive code excerpt from the schema's `evidence`.466. **Root cause** - the actual defect, at the decisive hop (the missing check,47 the unbounded copy, the non-atomic act). One or two sentences.487. **Remediation** - the fix at that hop, plus the general pattern49 ("parameterize the query," "resolve against base and reject escapes," "atomic50 conditional update"). Offer the fix; don't demand a specific patch.518. **Severity** - a rating with the reasoning. If the program uses CVSS, give the52 vector and score; otherwise a justified low/med/high/critical. The reasoning53 matters more than the number.549. **References** (optional) - CWE id, relevant advisory, similar prior fixes.5556## From schema to report5758The finding schema already holds most of this. Map it: `title`→title;59`source`+`sink`+`impact`→summary/impact; `path`+`evidence`→reproduction/proof;60`kill_reason` never ships (killed findings aren't reported); `remediation`→61remediation; `severity`→severity; `commit`→pin the "affected version." If a field62is thin, that's a signal the finding isn't report-ready - go back and fill it,63don't paper over it.6465## Reproduction is the report6667Most rejected or slow-triaged reports fail here. Rules:6869- **Reproduce it yourself from your own written steps**, in a clean state, before70 sending. If your steps don't reproduce for you, they won't for them.71- **Minimize.** Strip every step and header that isn't load-bearing. The shortest72 path that still fires is the best path.73- **Make preconditions explicit.** "Two accounts," "a coupon that hasn't been74 redeemed," "run the two requests concurrently." Hidden state is the #1 reason a75 triager can't reproduce.76- **Show, don't assert.** "Returns 200 with user B's record" beats "it's77 vulnerable."7879## Worked example (outline)8081> **Title:** IDOR in `POST /api/reports/export` - `filename` allows arbitrary82> file write83> **Summary:** An authenticated user can set `export.filename` to an absolute or84> traversal path; the server writes the export there, overwriting arbitrary files85> the service can write.86> **Impact:** Arbitrary file write as the service account → RCE by dropping a87> cron/config file. Precondition: any authenticated account.88> **Steps:** 1. Log in as any user. 2. `POST /api/reports/export` with body89> `{"filename":"../../../../etc/cron.d/x", ...}`. 3. Observe the file written90> outside the export dir.91> **Proof:** [request/response], plus source path `handler.export_report →92> ReportJob → export_writer.write → open()` with the unchecked `filename`.93> **Root cause:** `filename` is used to build the output path with no94> normalization or base-containment check.95> **Remediation:** Resolve the path against the export base and reject if it96> escapes; or ignore client filenames and generate one.97> **Severity:** High (CVSS vector + reasoning).9899## Rationalizations to reject100101- *"They'll figure out the repro."* → They won't; they'll close it. You reproduce102 it from your own steps first.103- *"Bigger impact wording = higher payout."* → Inflation gets you dinged and104 distrusted. Rate it honestly; let the facts carry it.105- *"I'll include the whole capture/log."* → Minimize and redact. Noise buries the106 bug and can leak PII.107- *"One report, many bugs."* → One defect per report unless the program says108 otherwise; bundled reports get partially triaged and partially lost.109110## Related111112- [FINDING-SCHEMA.md](../../FINDING-SCHEMA.md) - the source record every report is113 built from.114- `adjudicating-taint-paths`, `auditing-guard-gaps`, `detecting-memory-safety-bugs`,115 `detecting-race-conditions` - where confirmed findings come from.