Most security findings are written for the person who found them. They name a vulnerability class, cite a CVSS score, link to an advisory, and assign a severity. Then they sit in a backlog because the engineer who received one cannot tell what to change.
Getting a finding fixed is a writing problem more than a detection problem. This post is what to put in one so it gets actioned, and what to leave out. For anyone who reports findings to engineers who do not report to them.
Why findings get ignored
Not because engineers do not care. Because the finding, as written, does not contain what they need to act.
It does not say what to change. "SQL injection in the reporting module" identifies a class and a location. The engineer still has to find the line, work out the fix, and decide whether it breaks anything.
The severity is contested and unexplained. A critical rating on something the engineer believes is unreachable reads as a tool being wrong, and once a reporter is seen as inflating severity, everything else they send is discounted.
It arrives with no context about cost. The engineer has to estimate the work themselves before they can schedule it, so it gets deferred to the sprint where someone has time to estimate it, which never arrives.
There are two hundred of them. A list nobody can triage is functionally the same as no list.
What a useful finding contains
Six parts. Together they are a page, and writing them takes about fifteen minutes.
What is wrong, in one sentence, in their vocabulary. Not the vulnerability class. The thing that is wrong in this code.
The
sortparameter on/api/reportsis interpolated into the ORDER BY clause without validation.
Where, exactly. File and line, or endpoint and parameter. If the reporter does not localise it, the engineer spends their first thirty minutes doing so, and that is the step that pushes it into next sprint.
How to reproduce it. A command they can paste. This is the single highest-leverage element, because a reproduction converts disagreement about severity into an observation:
curl -s "https://staging.example.com/api/reports?sort=id;SELECT%20pg_sleep(5)--" \
-H "Authorization: Bearer $TOKEN" -w "\ntime: %{time_total}s\n"
# returns in ~5s; the injected sleep executed
What an attacker gets. Concretely, in terms of this system. Not "could lead to data disclosure" but "can read any row in the reports table, which includes other tenants' data". Impact stated in their nouns is what makes severity credible.
A suggested fix, with code. Not mandatory to be right. The point is to show the shape and to demonstrate you understand the constraint:
ALLOWED_SORTS = {"id", "created_at", "name"}
if sort not in ALLOWED_SORTS:
raise BadRequest("invalid sort field")
# ORDER BY is a structural element; it cannot be parameterised,
# so an allowlist is the correct control here.
That last comment matters. A reporter who explains why the obvious fix (parameterisation) does not apply is a reporter the engineer will trust next time.
Why this severity. One line of reasoning, not a number from a calculator. "High: unauthenticated, reachable from the public API, reads across tenant boundaries." If any of those is wrong, the engineer can now say which, and you have a conversation instead of a standoff.
What to leave out
The educational preamble. The engineer does not need a paragraph explaining what SQL injection is. If they do, that is a training conversation, not a finding.
Raw scanner output. Nobody reads a stack trace and a rule ID. Translate it.
More than one issue. A finding with five things in it gets partially fixed and closed. One finding, one change, one review.
Severity you cannot defend. If you are unsure whether it is reachable, say so and say what would settle it. Overstating once costs more than the finding is worth.
Handling disagreement
You will be told a finding is not exploitable. Sometimes that is true, and the engineer usually knows something the scanner does not: the parameter is validated upstream, the endpoint is internal-only, the code path is dead.
Ask for the specific mechanism rather than defending the rating. "What stops the parameter reaching that line?" is a question with an answer, and it resolves the disagreement either way in a few minutes.
When they are right, write it down as a dismissal with their reasoning and the date, so the next scan does not restart the argument. When the reasoning depends on something that could change, such as a caller passing a constant, note that, because a refactor will silently invalidate it.
Being visibly willing to be wrong is what buys you credibility on the finding that actually matters.
Make the queue small
All of the above assumes the engineer has a list they can engage with. If you send two hundred findings, none of this helps.
Send the ones that are reachable, in code that ships, that a change would actually fix. Keep the rest in a backlog you own rather than pushing them into theirs. A list of five that someone works through beats a list of two hundred that is ignored in full, and the second one also teaches people that your lists can be ignored.
The concession
This is slow. Fifteen minutes per finding does not scale to a scanner's output, which is exactly why most findings are written badly: the volume makes it impossible to write any of them well.
The resolution is that the volume is the problem. If you cannot write a decent finding for each item you send, you are sending too many, and the filtering work you skipped has been transferred to someone with less context to do it. Automate the triage, not the writing.
The implication
A finding is a request for someone else's time, in competition with everything else on their list. It wins on clarity about what to change and honesty about why it matters, not on severity labels.
Write the one you would want to receive: one sentence on what is wrong, a command that shows it, and a diff that fixes it.