To validate a security scanner alert, rewrite it as a statement that another reviewer could support or defeat: a specified actor controls a specified input, that input reaches a named operation through a path that is in scope, a named control does not stop it, and a concrete harmful effect follows. Bind the statement to the code revision and deployment being assessed. Then search for counterevidence as deliberately as supporting evidence. If the path or effect remains unknown, retain an investigation; if a verified control breaks the path, reject the claim with the reason. A vulnerability finding needs a corroborated conclusion within its stated scope; use independent challenge for consequential or disputed decisions.
This conversion is useful because a rule title such as “possible SQL injection” compresses several separate questions. Did the input come from a user? Was it converted to a fixed allowed value? Did it become SQL syntax or remain a bound parameter? Did the flagged helper run in the selected deployment? A testable claim exposes those questions instead of borrowing the scanner's confidence.
Use a claim with six parts
Write the claim before requesting more evidence. A concise form is:
- Actor can set input at entry point. The value follows path to sensitive operation. Expected control is absent or ineffective, so harmful effect may occur under named conditions.
Record a matching falsifier:
The claim fails if the input is replaced by a fixed allowed value, safely bound as data, blocked by an effective control on every relevant path, or never reaches the operation in the assessed deployment.
The falsifier turns “review this alert” into a bounded investigation. It also prevents a reviewer from treating a matching line of code as proof of the entire path. OWASP's reporting guidance calls for clear scope, limitations and technical detail that lets a reader understand and act on a finding. Record which path and revision the claim covers, and which checks remain open.
Illustrative case: the sort parameter in two report routes
Illustrative codebase and all names below are invented. At revision GHI789, a scanner flags a function in Alder Reports that appends a column name to an ORDER BY clause. It labels the result “SQL injection” and points to the shared query builder. The alert does not show where the column value came from.
The engineer traces two callers:
| Caller | How the column is selected | Initial interpretation |
|---|---|---|
| Interactive reports page | The user's sort choice is looked up in a server-side map of three fixed database column names. Unknown choices select a fixed default. | The request text does not reach SQL syntax on this path. |
| Scheduled CSV export | The request's sort string is passed directly to the same query builder. A role and feature gate exist, but no fixed-value mapping is visible before the builder. | A plausible path needs deeper review. |
The scanner was right to notice string assembly and wrong to treat both callers as one verdict. OWASP's SQL Injection Prevention Cheat Sheet explains why parameterized values keep data separate from SQL code and why identifiers or sort direction, which often cannot be bound as ordinary values, require an allow-list or query redesign. A parameterized WHERE value elsewhere in the query does not by itself make a dynamic ORDER BY identifier safe.
The engineer writes two conclusions about one alert. For the interactive page: “At GHI789, the selected sort value becomes one of three literal column names before it reaches the builder; the proposed user-controlled SQL-syntax path is defeated.” For the CSV export: “A user with the export capability may control the sort string that reaches the ORDER BY construction. If the deployed export route and database driver use this generated text as SQL syntax without a later constraint, the user may change the query beyond the intended sort.”
The second sentence is still conditional. The engineer must check the export request schema, transformation at the service boundary, driver's actual query API, downstream sanitizer or allow-list, and deployed route. Do not label it a confirmed injection solely because the helper concatenates a string.
Trace the data, including the boring controls
For each caller, make a five-column trace. Avoid jumping from input to sink:
| Boundary | Input and who controls it | Transformation | Control that may matter | Evidence location |
|---|---|---|---|---|
| Request | Sort string from authenticated export user | Schema parses the field | Role and feature gates | Route and schema at GHI789 |
| Service | Export job receives normalized request | May set a default | Any allow-list or enum conversion? | Service and job payload |
| Builder | Sort value becomes ORDER BY text | String assembly | Fixed mapping or quoting? | Builder and caller |
| Driver | Query object is executed | Driver may bind values but not identifiers | Actual API semantics | Driver call and official documentation |
| Deployment | Scheduled export path is enabled | Queue and worker version | Feature flag, selected artifact | Deployment/configuration record |
This table is an investigation artifact, not a claim that every boundary is unsafe. Fill the “control” column even when it weakens the alert. Ask the code owner which component is authoritative if the same value is normalized twice. If the reviewer finds a fixed enum transformation in the export worker, capture it and reject or narrow the claim. If no transformation exists but the route was not deployed, separate the code weakness from current production exposure.
Follow one value through the missing worker
The most useful next artifact is the worker implementation, because it can defeat the claim before driver behavior or impact needs investigation. The following pseudocode is a constructed continuation of Alder's case, not executed code:
COLUMNS = {"date": "created_at", "name": "report_name", "size": "byte_count"}
interactive(request):
column = COLUMNS.get(request.sort, "created_at")
return builder(column)
export_worker(job):
if job.sort not in COLUMNS:
reject_job("unsupported sort")
else:
return builder(COLUMNS[job.sort])
The HTTP export handler does put raw sort in the job. That intermediate observation was correct. The worker replaces it before query construction. Walk two harmless values through the complete path:
| Input | Interactive builder receives | Export builder receives |
|---|---|---|
name |
Literal report_name |
Literal report_name |
unexpected-column |
Literal created_at |
Nothing: job is rejected before the builder |
This exercise reasons about all possible map outputs, rather than using one successful request as proof. No request text becomes the identifier: the only values the builder receives from these callers are the three literals. The distinction matters because value placeholders do not authorize arbitrary column identifiers; the fixed mapping supplies that constraint.
One more artifact closes applicability: the constructed deployment record binds the enabled queue consumer to the reviewed worker revision and shows no alternate consumer for this job type. If that record instead names an older worker without the mapping, the new code cannot defeat the old deployment's claim.
Completed decision: reject the injection allegation for both reviewed callers at GHI789 and the matching worker deployment. Preserve the earlier investigation and the worker evidence that resolved it. This does not approve unrelated queries or establish that SQL string assembly is always safe. A new caller that passes arbitrary text directly to builder reopens the question.
If the missing worker had no mapping, the next check would be the actual driver submission and permitted query effect. “Raw text reaches the builder” would support continued investigation, not an invented claim of database takeover.
Record a verdict that survives a handoff
A useful output for Alder is:
- Alert ID: AR-42, rule “possible SQL injection,” revision GHI789.
- Claim A, reports page: rejected. The user sort text is replaced by one of three fixed column identifiers before query construction; the selected route always uses that mapping at this revision.
- Claim B, CSV export: initially investigation; now rejected for the reviewed deployment. The supplied worker rejects unknown choices and maps accepted choices to fixed identifiers before the builder.
- Reopen condition: the report-service owner changes the worker mapping, consumer deployment or caller set; review the affected path again.
- Potential effect: query manipulation if untrusted text becomes SQL syntax; no executed exploit or data access has been observed.
The record is short enough to act on and precise enough to challenge. It also shows why a safe reports-page path could not settle the export claim until its own worker was inspected. If the export path is later supported and approved, publish a new finding version with its own scope and evidence. If two reviewers disagree, list the exact control or driver behavior at issue and resolve it with bounded fresh evidence; do not settle factual disagreement by averaging confidence scores.
OWASP's reporting guidance asks technical findings to explain the affected function, impact and remedy. A testable claim is the bridge from a scanner's pattern to that level of report. It lets the developer see which path needs a change and lets the AppSec lead see which uncertainty still blocks a verdict.
Choose a check that can distinguish the two explanations
A testable claim should lead to a discriminating check, not merely to an activity. For Alder, “run the report” is weak because both the safe mapping and unsafe direct-string branch may produce a normal report when the value is name. A successful ordinary request establishes very little about which explanation is correct. Reading the worker assignment that selects the builder argument is more decisive for this question.
Write the competing explanations explicitly before asking for validation:
| Explanation | Expected evidence | Decision if supported |
|---|---|---|
| Raw sort text reaches the builder | The worker passes the original field or a transformation that preserves arbitrary syntax | Continue to the driver and possible effect; the mapping counterargument fails. |
| Sort choice becomes a fixed identifier | Every accepted branch selects a literal from the map and rejects or defaults all other choices | Reject this particular user-controlled identifier path for those callers. |
| Different consumers apply different rules | Deployed workers do not share the same reviewed mapping | Split the claim by consumer instead of giving the whole queue one verdict. |
This comparison prevents an evidence request from expanding indefinitely. Once the reviewed worker establishes a fixed output set, researching dramatic database consequences for that defeated path adds no value. Conversely, discovering an unreviewed consumer is a reason to investigate that consumer, not to discard the safe caller's evidence.
A useful handoff asks the engineer to supply the one artifact that chooses between rows: “Show how the deployed export consumer derives the argument passed to builder, including the unknown-value branch.” The requested artifact is small enough to review and includes the branch most likely to undermine a broad safety claim. A screenshot of a successful export does not answer it.
If a controlled observation is needed, agree on the expected observation and its limits first. A harmless instrumented fixture might expose the argument delivered to a substitute builder without sending a query to a production database. That observation would describe the exercised fixture and branch; the source and deployment mapping still determine whether it represents the assessed worker. No such fixture was executed for this article.
Stop when the decision is supported at its stated scope. The objective is not to collect every possible artifact about SQL injection. It is to determine whether the particular caller-controlled value can cross the particular syntax boundary in the selected implementation.
For the next scanner alert, write one claim and one falsifier before reading the severity badge. Use the triage gate to decide when it becomes a ticket. If the disputed step is whether a path can run, continue with checking code-path reachability.