A dataflow trace is a proposed route by which a value moves through code. Read it from the source of the value to the sink where its use could be dangerous, then inspect the real code and controls between them. The trace helps you locate a question; it does not prove that a user can reach the path, that a dangerous value survives validation, or that the deployed application behaves the same way. Your decision changes when a branch cannot execute, a reliable control changes the value before the sink, or the sink treats the value safely. Keep the exact revision and rule with your notes so another developer can check the same claim.
In a typical static-analysis result, the source might be an HTTP parameter and the sink might be a database call. CodeQL's dataflow documentation explains how an analyzer models possible value movement; its path-query documentation describes paths from defined sources to sinks. Those are model paths. You still have to decide what the application does.
First identify what the trace actually asserts
Open the result at the pinned commit. Record the rule ID and version, language, alert location, source and sink. Check whether the displayed path crosses files or stops inside one function. The alert line is often only the sink. If you start there and read one line, you may miss the entry point, a protective wrapper, or the branch that makes the operation unreachable.
Read every step as one of four things:
| Trace element | Question to ask in code |
|---|---|
| Source | Who controls this value, and through which request, event or stored record? |
| Propagation step | Is it the same value, a transformed value, or merely a value the analyzer approximates as related? |
| Guard or branch | Under what condition is the next call made, and can that condition hold for the proposed actor? |
| Sink | Which argument reaches which operation, and does the API treat that argument as data or as part of a command? |
Some result formats can carry an ordered code path. The SARIF 2.1.0 specification defines codeFlow and threadFlow locations for this purpose, while explicitly allowing a path to omit locations the tool visited. A six-step display is therefore not a complete execution transcript. Follow calls between displayed steps yourself, especially where a security check might live.
Work through three files, then open the missing adapter
Synthetic code and analysis; no example was executed. Ledgerline's trace names three locations at A17C2:
# routes.py
name = request.query["customerName"]
return find_invoices(name)
# search.py
query = make_filter(name)
return db.run(query)
# filters.py
return Query("SELECT id FROM invoices WHERE customer_name = ?", [name])
The path reaches a database operation. Does it show SQL injection? Not yet. The last line creates two separate pieces: statement structure and a values collection. The missing edge is what db.run does with them.
Adapter A preserves the separation:
# SQLite-style explanatory fragment
return connection.execute(query.text, query.values)
Adapter B destroys it:
# Defective illustrative wrapper
statement = query.text.replace("?", "'" + query.values[0] + "'")
return connection.execute(statement)
For Adapter A, the value reaches a query as data, so the shown value-injection claim is defeated for that call. For Adapter B, the wrapper manufactures SQL text from the caller's string. An input such as O'Neil already exposes why the statement structure can be broken; no attack payload is needed to understand the boundary. Review the real driver before relying on a placeholder convention. Python's sqlite3 documentation documents passing values separately; other adapters have different interfaces.
The same visible three-step trace can therefore lead to opposite dispositions. The decisive fact is not how many arrows the UI draws, but the semantics of the omitted edge. Also distinguish value flow from taint tracking: the latter can follow transformed data that is still influenced by the input. A changed string is not automatically safe, and a tainted argument passed as a bound value is not automatically SQL syntax.
Annotate edges rather than copying the scanner's story
| Edge | Source fact in this example | Interpretation |
|---|---|---|
Request → name |
Signed-in caller chooses a string | Authentication does not remove caller control |
name → Query.values[0] |
Value remains a separate list member | No SQL syntax construction at this edge |
Query → Adapter A |
Driver receives text and values separately | Defeats this value-injection mechanism |
Query → Adapter B |
Wrapper replaces placeholder with quoted text | Source-supported unsafe construction |
| Query result → tenant disclosure | Not shown | Impact needs query scope, privileges and returned fields |
A length validator would bound the string but would not change Adapter B's construction. An account predicate would restrict the intended result set but would not, by itself, make string interpolation safe. Conversely, parameter binding says nothing about whether the account predicate is present. Keep each proposed control attached to the mechanism it actually addresses.
CodeQL's dataflow documentation distinguishes flow models and library modeling limitations. A displayed path may omit the implementation needed for this decision. Record “adapter unresolved” until it is read; do not fill the gap with a function name such as safe_query.
Stored input keeps its history
Add a scheduled report: the customer saves customerName today, and tomorrow a worker reads it from the database and invokes the same search helper. The immediate source in the worker is saved_filter.name, but the original writer is still the customer. Storage has changed the timing and entry point, not automatically the value's trust.
If both live and scheduled routes use Adapter A, the binding counterevidence applies to both calls once their routing is established. If the worker uses Adapter B for compatibility, the live route's rejection cannot close the stored-input path. The filled review note becomes:
Live search at
A17C2: value-injection claim rejected by the bound adapter. Scheduled search: same customer-controlled value reaches the legacy interpolating adapter; unsafe string construction supported in source; deployment applicability pending. Repair the wrapper while checking that applicability. Cross-tenant impact not established by the trace alone.
That note gives a developer a specific wrapper to repair and a permitted behavior to preserve: searching for a name containing an apostrophe must remain legitimate. It does not claim any query or exploit was run.
Check which value reaches each use
The same variable name can hide different questions at different locations. Extend Ledgerline's fictional handler: it stores the original customer name for an audit event, creates a normalized copy for searching, and passes a constant sort field chosen from a server-defined map. A trace for the original string entering an audit record is not automatically a trace for SQL structure. A trace ending at a sort expression may concern a different argument from the bound search value. Read the actual assignment and argument position instead of joining the results by the word name.
Use a small value ledger when the function has several transformations:
| Value at a location | Where it came from | Question this value raises |
|---|---|---|
| Original customer name | Request parameter or saved filter | Which caller could write it, and where is it used? |
| Normalized search value | Transformation of the original | Does the final adapter still bind it as data? |
| Selected sort expression | Lookup in a fixed server-defined map | Can an unknown key reach any fallback that uses raw input? |
| Audit record field | Copy of the original name | Who can read the log, and how is that field rendered? |
This table describes separate review questions, not four established vulnerabilities. Normalization may change case or spacing without eliminating input influence. A fixed map may constrain an identifier only if every accepted value goes through that mapping and the failure path does not restore raw input. The audit copy needs its own sink and output context. Neither a SQL verdict nor a function name settles those other uses.
For a rejection, identify the precise edge the control defeats. “The value is sanitized” is too vague to recheck after a refactor. “The search value is passed as the second argument to the bound SQLite adapter, with the query text built independently” points to an inspectable contract. For a supported source flaw, name the edge that breaks that contract: “The legacy wrapper inserts the value into statement text before execution.” These statements remain useful even when the scanner chooses a different alert line next time.
Read branches in the caller's context
Consider a schematic alternative to the earlier handler:
if request asks for scheduled delivery:
require current export permission for the selected account
queue the saved filter for the worker
else:
run immediate search through Adapter A
The immediate and scheduled calls share a filter but have different entry conditions and adapters. To review the scheduled path, identify who can satisfy the export permission and which account the job carries. Do not copy the immediate route's SQL conclusion into the worker record. Equally, do not assume that merely asking for scheduled delivery bypasses the permission check. The proposed actor must be able to reach the relevant branch under the actual guard contract.
If the trace skips the permission helper, inspect its implementation and caller rather than treating the gap as proof of a bypass. Record whether failure stops the queue operation, whether the chosen account is checked against the actor, and what later authority checks the worker performs. These questions concern authorization and timing. They are distinct from whether Adapter B constructs SQL unsafely; a privileged scheduled caller could still supply a malformed search value. State the actor prerequisite instead of making either issue disappear behind the other.
When preparing the developer comment, keep the smallest source excerpts needed to show the decisive edges and link them to the pinned revision. Include the permitted behavior to preserve, such as immediate searches using a legitimate apostrophe-containing name. A reviewer should be able to see what correction changes the unsafe construction without assuming that every related search feature must be disabled. Where a required adapter or guard remains unavailable, assign that missing artifact explicitly and keep the conclusion bounded.
If the path is broken or incomplete
Do not silently fill gaps. A trace may stop at a framework boundary, omit a callback, or point to generated code while the real route uses another implementation. A missing source step can mean the analyzer has no model for the framework; a missing guard in the display does not prove the guard is absent. Record the last verified edge and name the missing artifact: library implementation, middleware registration, build configuration, or deployed revision. If the evidence cannot be recovered, describe the alert as unresolved for that scope rather than converting it to either a vulnerability or a false positive.
For your next alert, annotate the source fact and interpretation for every edge, including the final adapter. If another scanner produces a different result on the same path, use the scanner-disagreement workflow. To decide which remaining questions need a controlled running system, continue with source review versus runtime validation.