If a scanner reports SQL injection in code that uses placeholders, find which parts of the final statement are bound values and which are assembled as SQL text. Correct binding usually protects the values passed through it; it does not make a dynamically chosen table, column, sort expression, or raw SQL fragment safe. Trace the exact request through the query builder and driver call. Reject the alert only when all attacker-influenced pieces on that path remain values or are mapped to fixed application-owned SQL tokens. Keep the claim open when the final statement or a caller is missing.
That distinction is more useful than “we use prepared statements.” OWASP's SQL injection prevention guidance recommends parameterized queries for values and redesign or allowlisting for structural parts that cannot use bind variables. Python's sqlite3 guide similarly warns against string assembly for variable values. These controls answer a specific syntax question; they do not establish that the query's account or permission policy is correct. Bound values may also have intentional meaning inside an operator: a parameter used with LIKE can contain wildcards. Unexpectedly broad matching then requires a search-contract review, not an automatic SQL-injection verdict.
Work one alert through two paths
Illustrative repository and code; no query was run and no exploit is claimed. Cedar Reports has an invoice list for account members. The endpoint accepts status and sort fields. The scanner highlights the final execute call because it sees a formatted statement:
SORTS = {
"newest": "created_at DESC",
"amount": "amount_cents ASC",
}
def list_invoices(db, account_id, status, sort_key):
order = SORTS.get(sort_key)
if order is None:
raise ValueError("unsupported sort")
statement = (
"SELECT id, amount_cents FROM invoices "
"WHERE account_id = ? AND status = ? ORDER BY " + order
)
return db.execute(statement, (account_id, status)).fetchall()
On this path, status and account_id are bound values, and order comes from two fixed strings in a server-owned mapping. The user's sort key only selects one of those strings; it does not become SQL text itself. If the route really calls this function, and neither the mapping nor statement is mutated from untrusted configuration, the alleged sort-key SQL injection is not supported on this route. Keep the review scoped: whether account_id is authorized, whether there are other callers, and whether the function is deployed are separate questions.
Now imagine an older export route in the same illustrative repository. This fragment is intentionally unsafe when sort_key is caller-controlled:
def export_invoices(db, account_id, status, sort_key):
statement = (
"SELECT id, amount_cents FROM invoices "
"WHERE account_id = ? AND status = ? ORDER BY " + sort_key
)
return db.execute(statement, (account_id, status)).fetchall()
The WHERE values are still parameterized, but the sort key is inserted into SQL grammar. The first route's fixed mapping does not protect this export route unless it actually calls that mapper. The code supports an injection concern if an actor can control sort_key and reach this method. It is not yet proof of a particular database effect: a reviewer must confirm route wiring, driver behavior, privileges, and any unseen wrapper. MITRE's CWE-89 description concerns externally influenced input changing an SQL command, rather than the mere presence of a string operation.
Trace the statement that reaches the driver
Start with the scanner's file and exact revision, then work from both ends. From the entry point, find where each value comes from. From the execute call, trace backward through formatters, query builders, stored procedures, helpers, and raw-query escape hatches. A value can cross several layers before reaching SQL; review the last layer that decides whether it is syntax or data.
Use this compact review sheet:
| Question | Cedar list route | Cedar export route |
|---|---|---|
| Who chooses sort? | Account member supplies a key. | Possibly an account member; route link still to verify. |
| What reaches SQL text? | One fixed value from SORTS. | The supplied sort string. |
| What is bound? | account_id and status. | account_id and status. |
| What rejects unsupported values? | Mapping lookup raises for an unknown key. | No local guard is visible. |
| What remains unknown? | Whether another caller bypasses the mapping; account authorization. | Route reachability, upstream validation, final driver path and privileges. |
| Current decision | Reject the sort-key claim for this proven route only. | Investigate and independently verify the path. |
The result could change if sort_key is first converted by a shared schema, a router bypasses the function, or the export route was removed from deployment. Capture the actual route mapping and call trace rather than assuming a function name reflects current behavior. If the scanner sampled only one file, record that limitation. A precise rejection is “not SQL injection through sort_key on the list route at revision X,” not “no SQL injection in invoices.”
Inspect the actual statement-and-values pair
For the list request status=open&sort=amount, with authoritative account Cedar, the expected adapter inputs are:
statement:
SELECT id, amount_cents FROM invoices
WHERE account_id = ? AND status = ? ORDER BY amount_cents ASC
parameters:
("Cedar", "open")
The word amount never reaches SQL grammar. It selects the application constant amount_cents ASC. Changing the requested status to O'Neil changes only the second parameter. This benign punctuation is useful for explaining the API boundary; an error-free result would not, by itself, prove injection impossible.
Now inspect the wrapper. These implementations are materially different despite sharing a method name:
# Binding survives to the driver.
def query(statement, values):
return connection.execute(statement, values)
# Illustrative bad wrapper: parameters become statement text again.
def query_bad(statement, values):
for value in values:
statement = statement.replace("?", str(value), 1)
return connection.execute(statement)
The second fragment is intentionally broken, not an alternative implementation. A caller passing a tuple does not establish driver binding when the adapter rebuilds text. Read the final driver API and placeholder convention; do not infer them from the facade's signature.
Resolve the unknown-sort and export branches
The list mapper raises for sort=recent-ish. The useful review follows the exception: the route should return the defined client error without executing a fallback query. A handler that catches the error and calls the legacy export with the original string reopens the path. A handler returning a documented default constant can be safe for syntax, although silently changing the sort may be undesirable product behavior.
In this synthetic repository, the owner supplies two missing artifacts: the export route passes the raw sort field, and its adapter delegates directly to the driver. That completes the source path into SQL grammar. The disposition becomes “source-supported structural SQL injection path in export; list-route allegation rejected.” Database authority, reachable deployment and concrete effect still determine the severity and verification work. No query has been executed here.
The repair can share resolve_sort(key) between both routes or remove the obsolete export option. Either way, inspect the worker too: a queued export containing the original raw sort field may bypass a fixed web handler. The closure packet should show the chosen constant in the final statement, the unchanged bound values, unknown-key behavior, and all relevant callers at the new revision. That is stronger than a screenshot of placeholders in one query.
What a useful fix and validation look like
For the illustrative export route, replace the raw sort fragment with a fixed choice mapping like the list route. Keep values bound through the database API. Confirm that every supported sort choice yields the expected statement shape and that an unsupported key is rejected before SQL assembly. Review the actual export caller and compare the old and new pinned revisions. If the route was removed, find whether another entry point still calls the same helper; deletion from a search result is not proof of remediation.
An authorized team may validate behavior with synthetic records in a controlled development or staging database. That can check normal list/export behavior, unknown-key rejection and account separation. This article supplies no attack payload and claims no such test was performed. If safe validation is unavailable, retain the source-supported conclusion and state the missing behavior evidence. A merged PR or a scanner that no longer flags the line is insufficient to close a previously verified issue.
Counterevidence deserves equal space in the ticket. If a higher-level query builder proves that all structural tokens are chosen from constants, cite its implementation and the call edge. If a direct export path bypasses it, cite that route too. The developer should not have to guess which of two apparently similar calls the reviewer examined.
Review the query family, not just the highlighted call
Cedar's list and export functions illustrate why a shared feature name is not a shared control. Build a small inventory of callers that assemble the same invoice query: the browser list, scheduled export, administrator report, and any legacy adapter actually present in the assessed source. Record which caller selects a fixed sort token and which supplies statement text. Do not claim an exhaustive inventory if route generation or another repository is missing.
The inventory should include the source of the mapping itself. In the example, SORTS is application-owned and fixed. If a real system loads query fragments from tenant-editable configuration, the same dictionary lookup would not establish the same boundary. The question becomes who can modify those entries and what the receiving layer permits. A mapping is only as constrained as the values it can return.
Also inspect default behavior. An unsupported sort can be rejected, or a documented fixed default can be chosen. It should not trigger a compatibility fallback that inserts the original request into SQL. Follow the exception handler to the final driver call before recording rejection of the claim.
Separate matching behavior from query construction
A parameterized search can return more records than a product owner expected without allowing SQL syntax injection. The retained LIKE example is one such distinction: the bound value can have meaning within the search operator. Ask whether the product intended literal matching, wildcard search, or a restricted pattern language. Then review that contract and the account scope separately.
For Cedar, a user selecting status=open should affect the status value, not the account predicate. A broad result caused by an incorrect account ID remains an authorization problem even when every value is bound correctly. A result caused by an intentionally broad search pattern may be normal behavior. The evidence should explain which boundary failed before choosing a weakness label or a patch.
This distinction protects useful counterevidence. A developer can correctly demonstrate that the driver binds values while the security reviewer still has a valid concern about account selection or structural sort input. Neither person needs to reject the other's entire argument. Split the claims and identify the exact final inputs that decide each one.
The repair record should end with the driver statement shape, bound-value list, fixed structural choices, and the relevant caller paths. That is enough for another reviewer to understand why the list allegation was rejected and why the export needed a different control, without executing a query or inferring a production impact from a formatted string alone.
For the next SQL alert, copy the review sheet and annotate the exact driver call before changing code. For the broader interpreter-boundary method, read how to review injection from input to operation. If you have a supported finding and need to choose a repair, continue with choosing safe injection remediation.