Guide 009 workflow

How to dispute a scanner finding without losing the evidence

Challenge a scanner finding with exact counterevidence while preserving the original alert, scope, revision, and reason for the decision.

Dispute the claim, not the tool. Preserve the alert's rule, revision, path, line and proposed attack before changing its status. Then write the smallest counterexample that breaks the claimed actor → input → code → effect chain: a control that always runs, a value that cannot be attacker controlled, a non-production scope, or an output that cannot cause the claimed harm. Ask a reviewer to check both the original path and your counterevidence. If an essential fact is missing, keep the claim open for investigation.

The answer changes if the control applies only to one route, one configuration, or one revision. “We have validation” is too broad; “this parameter is bound as a value by the database driver on the affected call at revision X” is reviewable. Dismissing an alert may change its visibility and future behavior in a vendor tool, so record the reasoning before clicking the button. GitHub's code-scanning documentation distinguishes fixing an issue from dismissing an alert and records dismissal reasons and comments. That is a product workflow example, not a guarantee that dismissal proves safety.

Preserve a small evidence packet

Before editing code or changing the alert's state, capture enough to reconstruct what the scanner claimed. Use links to authorized artifacts rather than copying private source into a broad ticket.

Item What to retain Why it matters
Alert identity Tool, rule ID/version, alert ID, branch and first/last seen times Allows later reviewers to distinguish recurrence from a new claim.
Source binding Repository, immutable revision, file/line and the relevant data flow A line number on a moving branch is not durable evidence.
Claimed effect Actor, controlled input, entry, alleged missing control and harmful result A category label such as “SQL injection” is not a testable statement.
Your counterevidence Exact control, call semantics, configuration or scope boundary The reviewer needs the fact that breaks the claim, not just your conclusion.
Open questions Alternate route, runtime setting or unreviewed caller Prevents a narrow counterexample from erasing a real variant.
Decision Reviewer, disposition, reason and reopen trigger Makes the outcome reversible when code or deployment changes.

The OWASP Web Security Testing Guide's reporting guidance emphasizes scope, limitations and technical detail sufficient for a developer to act. Apply that discipline to a short dispute note: the reviewer should be able to reproduce your reasoning without relying on your memory or status label.

Illustrative case: a query value passed through a wrapper

In a fictional Python service, a scanner reports possible SQL injection because a user-controlled email reaches load_user. The developer checks the affected call at an immutable revision, confirms that connection is a sqlite3.Connection rather than a custom execute wrapper, and finds this illustrative implementation:

def load_user(connection, email):
    return connection.execute(
        "SELECT id FROM users WHERE email = ?",
        (email,),
    ).fetchone()

The SQL statement is a fixed string, and email is supplied as a bound value. Python's sqlite3 documentation instructs callers to use placeholders for values instead of string operations that assemble SQL. That documentation supports the behavior of this API. It does not prove that every query in the service is safe, or that another database wrapper follows the same semantics.

The developer writes the dispute as a bounded claim:

  • Alert: SQLI-42 at users.py:31, revision abc123 (all identifiers illustrative).
  • Scanner claim: Attacker-controlled email is concatenated into an executable SQL statement.
  • Counterevidence: At the flagged call, the statement text is fixed and the value is passed separately to sqlite3.Connection.execute as a one-element tuple. The driver treats the placeholder as a value binding.
  • Scope checked: This call and the connection type at revision abc123. The review did not cover dynamically assembled table names or other callers.
  • Requested disposition: Reject this instance as a false positive after independent review. Keep any separate dynamic-SQL path as a new claim. Reopen trigger: A refactor changes the query construction, driver, wrapper behavior or call site.

The reviewer now has something falsifiable. If a second helper builds the table name from a user value, that is a different path; the bound email does not neutralize an unsafe identifier. If the wrapper turns the fixed SQL and email into a formatted string before calling the driver, the counterevidence fails. If the scanner's reported sink is a different call, the developer has disputed the wrong instance. Those checks are why preserving the original alert path matters.

Catch the dispute that cites the wrong sink

Before accepting the developer's note, compare the alert's path with the counterexample. A fixed query in the same module can be persuasive and irrelevant.

Suppose the alert packet initially names legacy_lookup, while the developer supplies the safe load_user above. The source call graph in the exercise is:

/users/search -> load_user -> sqlite3.Connection.execute(fixed_sql, values)
/admin/import-preview -> legacy_lookup -> legacy_adapter.run(assembled_sql)

The correct response is: “Your evidence defeats the claim at /users/search. It does not cover the sink named in this alert. Please identify whether the scanner path or our selected source revision is wrong.” This does not assume the legacy path is exploitable. Its actor, actual SQL construction, adapter and effect still need review.

Now the alert export arrives with its immutable revision and sink: it names load_user, and a human copied the legacy location into the ticket. Correct the ticket reference while retaining the original export. The dispute can proceed against the actual call. If the export instead really named the legacy adapter, split the two paths and keep that investigation open.

The resulting evidence packet has three small pieces:

Piece Filled value in the exercise
Original allegation SQLI-42, revision abc123, load_user driver call; user-controlled email alleged to alter SQL text.
Decisive counterevidence Fixed statement plus (email,) passed separately to the standard SQLite connection; no formatting adapter on this path.
Correction trail Ticket's legacy-helper reference was a transcription error; original alert export retained. No verdict is recorded for the legacy helper.

This is more durable than a comment saying “uses prepared statements.” It establishes both the API semantics and why those semantics belong to the disputed sink. The official Python sqlite3 documentation supports the value-binding behavior; the application's pinned source establishes that this call uses it.

After an actual independent review, change only the appropriate alert state and preserve the reason. GitHub's dismissal documentation notes that dismissal affects all branches and retains a reason and optional comment. Check that scope before applying a narrow technical conclusion through a broader tool action.

For the fictional SQLI-42 case, the reviewer can leave a completed response instead of only clicking “dismiss”:

Reviewer response · illustrative · revision abc123

Decision: Reject this alert for the flagged load_user call. Keep the original scanner record and this response in history.

Evidence checked: The pinned call passes a fixed SQL statement and (email,) as a separate parameter to sqlite3.Connection.execute; the reviewed connection is the standard sqlite3.Connection, not a formatting wrapper. The Python sqlite3 documentation confirms the placeholder's value-binding semantics.

Scope of rejection: This call at this revision only. Other queries, dynamic identifiers, database adapters and deployed versions were not reviewed by this decision.

Reopen when: The query text, wrapper, driver, or call path changes, or a new trace shows the scanner meant a different sink. The AppSec reviewer owns that check on recurrence.

This response ties the disposition to a falsifiable fact and a bounded scope. If the wrapper later formats email into the SQL string, the preserved alert and evidence trail make it possible to reverse the decision without reconstructing the original dispute from memory.

A controlled test can help when code semantics are disputed, provided the environment and data are authorized. Prefer a harmless demonstration of what the driver receives or what policy denies. Do not send exploit payloads to production simply to win a ticket argument. A passing test of one input also cannot prove all paths safe; the question is whether the alleged path is valid under its actual prerequisites.

If the developer can show only that the route is disabled in one environment, do not call the underlying code a false positive everywhere. Record the assessed environment and leave other deployments unresolved. If the reviewer cannot obtain a required configuration, the conclusion should say so and identify who can answer it. If a fix was already merged, compare the scanner's pinned revision with the fixed revision; the old alert may have been valid when raised.

Make the suppression no broader than the dispute

The technical conclusion and the tool action have different scopes. SQLI-42 may be false for one pinned call, while a repository-wide exclusion could hide every SQL rule in the module. Before applying a dismissal, suppression comment or filter, identify exactly which future alerts the action will affect. Use the narrowest available mechanism that matches the reviewed claim and retain a link to the decision.

If the tool cannot express the desired scope, that limitation deserves a work decision. You might leave the alert visible with a documented disposition, filter it only in a triage view, or change the scanning configuration after reviewing the broader consequences. Do not silently convert “this value is bound” into “ignore this directory.” The latter decision requires its own reasoning about coverage.

For the fictional query, the review record can name the invalidation conditions in concrete terms: fixed statement text changes; connection becomes a custom adapter; the flagged call moves; or another user-controlled value becomes an identifier. A new scan at a new revision should not inherit the conclusion merely because the alert title still says SQL injection. Compare the fact that defeated the allegation with the current path.

There is also a practical access problem. A future developer may see a suppression reason but lack access to the original report. Keep a short, nonsensitive explanation beside the reference: “This exact driver call uses a fixed statement and separate value parameters; see restricted review SQLI-42.” That gives enough context to recognize why an adapter change matters without distributing the private source packet.

When the alert reappears, begin with the preserved disagreement rather than a new argument about whether scanners are trustworthy. If the code and API semantics remain the same, the old reasoning can reduce duplicate effort. If the adapter changed, the recurrence may be useful evidence of a new path. A previous false-positive decision should be reversible without treating reversal as a failure by the original reviewer.

Finally, maintain a distinction between disputing the allegation and declining the fix. “The query is safely bound” is counterevidence. “The fix is expensive” is a priority argument. “We intend to remove this service” is a future plan. Those statements can coexist, but only the first addresses whether this reported syntax-injection path exists at the assessed call.

Next step: Write one six-line dispute note before changing an alert status. For a claim whose classification is still unclear, use the vulnerability-versus-hardening guide. If reviewers accept different interpretations of the same evidence, use the disagreement protocol.