When queries return nothing

If you've written a CodeQL query that finds no results, the problem usually isn't the source or the sink themselves. Before digging deeper, verify that CodeQL can actually identify both endpoints by running quick evaluation on the predicates that define them. If both are found, the issue lies in taint propagation between them.

A good first step is to construct a minimal code example that triggers the vulnerability and build a dedicated database from it, as this reduces noise and lets you focus on the flow. In the example below, a Gradio app passes a user-uploaded file from a gr.File component via a gr.Button.click event handler into pickle.load:

import pickle
import gradio as gr

def load_config_from_file(config_file):
    """Load settings from a UUID.pkl file."""
    try:
        with open(config_file.name, 'rb') as f:
            settings = pickle.load(f)
        return settings
    except Exception as e:
        return f"Error loading configuration: {str(e)}"

with gr.Blocks(title="Configuration Loader") as demo:
    config_file_input = gr.File(label="Load Config File")

    load_config_button = gr.Button("Load Existing Config From File", variant="primary")

    config_status = gr.Textbox(label="Status")

    load_config_button.click(
        fn=load_config_from_file,
        inputs=[config_file_input],
        outputs=[config_status]
    )

demo.launch()

To reproduce the environment for testing, save the example as example.py and run:

python -m venv venv
source venv/bin/activate
pip install gradio
python example.py

The corresponding taint tracking query defines sources as parameters of functions passed to gr.Button.click, and sinks as any Decoding sink. In CodeQL for Python, the Decoding type captures unsafe deserialization calls including the first argument to pickle.load. This query returns no results on the minimal example. After simplifying the query into predicates for source and sink, quick evaluation reveals both are found correctly — but the sink result highlights the entire pickle.load(f) call rather than the argument. The Decoding abstract sinks expose a getAnInput predicate that specifies the argument to a sink call, so it's better to sink on that argument directly. To separate sinks that may execute input from ordinary decoding sinks like json.loads, the query can also use the mayExecuteInput predicate:

predicate isSink(DataFlow::Node sink) { 
    exists(Decoding d | d.mayExecuteInput() | sink = d.getAnInput()) }

With the sink now correctly reported as an argument, the query still fails to connect source to sink. A partial path graph, which traces taint forward or backward from a chosen starting point, shows exactly where propagation stops.

Useful inspection tools

When you can't determine the right node or type for a given code element, two built-in tools help in different ways. The abstract syntax tree (AST) viewer shows the full parse tree for a file: right-click an element in the editor and choose CodeQL: View AST. The viewer displays the element's type and its relationships to neighboring nodes, making it easier to write accurate queries. Alternatively, the getAQlClass predicate reports all CodeQL classes a node belongs to. For example, to check the types of a parameter to a function passed to gr.Button.click:

/**
 * @name getAQlClass on Gradio Button input source
 * @description This query reports on a code element's types.
 * @id 5/2
 * @severity error
 * @kind problem
 */

import python
import semmle.python.ApiGraphs
import semmle.python.Concepts
import semmle.python.dataflow.new.RemoteFlowSources

from DataFlow::Node node
where node = API::moduleImport("gradio").getMember("Button").getReturn()
        .getMember("click").getACall().getParameter(0, "fn").getParameter(_).asSource()
select node, node.getAQlClass()

The results return a list including ExprNode and ParameterNode, among others, which helps you target your source definitions with more precision. Quick evaluation on any of these predicates provides immediate confirmation of what CodeQL sees.

Using partial flow to trace the gap

Since the source and sink are correctly identified but no path connects them, the next step is to use a partial path graph, which shows flow from a given source to any sink, and identifies where each flow stops. The exploration depth is controlled with an explorationLimit() predicate; a small number like 10 is suitable for minimal examples, while a cap of 3 works for targeted checks in larger codebases. Forward tracing from the source uses FlowExplorationFwd, while tracing backward from a sink uses FlowExplorationRev. Public templates for most languages are available in the CodeQL Community Packs. A forward partial path query would look like this:

/**
 * @name Gradio Button partial path graph
 * @description This query tracks data flow from inputs passed to a Gradio's Button component to any sink.
 * @kind path-problem
 * @problem.severity warning
 * @id 5/3
 */

import python
import semmle.python.ApiGraphs
import semmle.python.Concepts
import semmle.python.dataflow.new.RemoteFlowSources
import semmle.python.dataflow.new.TaintTracking

// import MyFlow::PathGraph
import PartialFlow::PartialPathGraph

class GradioButton extends RemoteFlowSource::Range {
    GradioButton() {
        exists(API::CallNode n |
        n = API::moduleImport("gradio").getMember("Button").getReturn()
        .getMember("click").getACall() |
        this = n.getParameter(0, "fn").getParameter(_).asSource())
    }

    override string getSourceType() { result = "Gradio untrusted input" }
}

private module MyConfig implements DataFlow::ConfigSig {
    predicate isSource(DataFlow::Node source) { source instanceof GradioButton }

    predicate isSink(DataFlow::Node sink) { exists(Decoding d | d.mayExecuteInput() | sink = d.getAnInput()) }

}

module MyFlow = TaintTracking::Global<MyConfig>;
int explorationLimit() { result = 10 }
module PartialFlow = MyFlow::FlowExplorationFwd<explorationLimit/0>;

from PartialFlow::PartialPathNode source, PartialFlow::PartialPathNode sink
where PartialFlow::partialFlow(source, sink, _)
select sink.getNode(), source, sink, "Partial Graph $@.", source.getNode(), "user-provided value."

For this example, the result shows taint stops at config_file inside the with open(config_file.name, 'rb') as f: line, meaning the attribute access config_file.name is not reachable. CodeQL does not propagate taint from an object to its attributes unless told to, and an instance of gr.File stores the uploaded file's path in its name attribute. The fix is to add an isAdditionalFlowStep predicate to the taint tracking configuration, connecting the two nodes — the object accessing name and the attribute read itself:

predicate isAdditionalFlowStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    exists(DataFlow::AttrRead attr |
        attr.accesses(nodeFrom, "name")
        and nodeTo = attr
    )
}

Plugging this taint step into the partial flow query produces two results; the second path shows taint now propagates to config_file.name. The step is broad — it allows any object's name attribute to be tainted — so consider whether to keep it in production queries or reserve it for testing hypotheses during research. The remaining gap in propagation, if any, can be uncovered by checking where the next partial flow stops and writing corresponding steps. This iterative debugging approach — identifying nodes, verifying types with tools, and using partial graphs to reveal missing steps — solves most cases where queries return no results. If it doesn't, the CodeQL engineers monitor GitHub Security Lab's public Slack instance for questions.

Building the Missing Taint Step

This is a "second order" vulnerability: a malicious file is uploaded first, then loaded later. In most cases, the file path is considered tainted rather than the file contents, so CodeQL won't propagate here by default. But in Gradio, we control the file being loaded, so we need to add a taint step that propagates from config_file.name to open(config_file.name, 'rb').

The predicate below propagates taint from the argument to open() to its result, and similarly for os.open:

predicate osOpenStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    // Connects the argument to `open()` to the result of `open()`
    // And argument to `os.open()` to the result of `os.open()`
    exists(API::CallNode call |
        call = API::moduleImport("os").getMember("open").getACall() and
        nodeFrom = call.getArg(0) and
        nodeTo = call)
    or
    exists(API::CallNode call |
        call = API::builtin("open").getACall() and
        nodeFrom = call.getArg(0) and
        nodeTo = call)
}

Next, add this taint step to isAdditionalFlowStep:

predicate isAdditionalFlowStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    nameAttrRead(nodeFrom, nodeTo)
    or
    osOpenStep(nodeFrom, nodeTo)
}

Now convert the query to a regular taint tracking query and run it. The result is the vulnerability we were looking for:

/**
 * @name Gradio File Input Flow
 * @description This query tracks data flow from Gradio's Button component to a Decoding sink.
 * @kind path-problem
 * @problem.severity warning
 * @id 5/5
 */

import python
import semmle.python.ApiGraphs
import semmle.python.Concepts
import semmle.python.dataflow.new.RemoteFlowSources
import semmle.python.dataflow.new.TaintTracking

import MyFlow::PathGraph

class GradioButton extends RemoteFlowSource::Range {
    GradioButton() {
        exists(API::CallNode n |
        n = API::moduleImport("gradio").getMember("Button").getReturn()
        .getMember("click").getACall() |
        this = n.getParameter(0, "fn").getParameter(_).asSource())
    }

    override string getSourceType() { result = "Gradio untrusted input" }
}
predicate nameAttrRead(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    // Connects an attribute read of an object's `name` attribute to the object itself
    exists(DataFlow::AttrRead attr |
      attr.accesses(nodeFrom, "name")
      and nodeTo = attr
    )
}

predicate osOpenStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    // Connects the argument to `open()` to the result of `open()`
    // And argument to `os.open()` to the result of `os.open()`
    exists(API::CallNode call |
        call = API::moduleImport("os").getMember("open").getACall() and
        nodeFrom = call.getArg(0) and
        nodeTo = call)
    or
    exists(API::CallNode call |
        call = API::builtin("open").getACall() and
        nodeFrom = call.getArg(0) and
        nodeTo = call)
}

private module MyConfig implements DataFlow::ConfigSig {
    predicate isSource(DataFlow::Node source) { source instanceof GradioButton }

    predicate isSink(DataFlow::Node sink) {
        exists(Decoding d | d.mayExecuteInput() | sink = d.getAnInput()) }

    predicate isAdditionalFlowStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
        nameAttrRead(nodeFrom, nodeTo)
        or
        osOpenStep(nodeFrom, nodeTo)
        }
}
module MyFlow = TaintTracking::Global<MyConfig>;

from MyFlow::PathNode source, MyFlow::PathNode sink
where MyFlow::flowPath(source, sink)
select sink.getNode(), source, sink, "Data Flow from a Gradio source to decoding"
VS Code screenshot of a code path from `def load_config_from_file(config_file)` to `f` in `pickle.load(f)` sink

Generalizing the Taint Step

The taint step above is specific to Gradio and unlikely to appear in other frameworks. For a more maintainable solution, we need to refine it. Propagating taint through a name attribute read on any object is a blunt instrument: not every object that reads name leads to a vulnerability. Ideally we'd restrict propagation to gr.File types only.

The challenge is that Gradio sources are modeled as parameters passed to functions in gr.Button.click event handlers, so CodeQL can't determine the type of each argument. We need to "look back" to where the source was instantiated, verify its type, then connect that object to a name attribute read.

import pickle
import gradio as gr

def load_config_from_file(config_file):
    """Load settings from a UUID.pkl file."""
    try:
        with open(config_file.name, 'rb') as f:
            settings = pickle.load(f)
        return settings
    except Exception as e:
        return f"Error loading configuration: {str(e)}"

with gr.Blocks(title="Configuration Loader") as demo:
    config_file_input = gr.File(label="Load Config File")

    load_config_button = gr.Button("Load Existing Config From File", variant="primary")

    config_status = gr.Textbox(label="Status")

    load_config_button.click(
        fn=load_config_from_file,
        inputs=[config_file_input],
        outputs=[config_status]
    )

demo.launch()

Taint steps create edges between nodes. Here we need two connections along the same path. First, connect variables passed to inputs (like config_file_input) in gr.Button.click to the parameter config_file in load_config_from_file, allowing propagation back to config_file_input = gr.File(...). Second, propagate from confirmed gr.File nodes to cases where they read the name attribute.

The ListTaintStep logic from the previous article already implements the tracking back to instantiations. We can reuse it by modifying the nameAttrRead predicate:

predicate nameAttrRead(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    // Connects an attribute read of an object's `name` attribute to the object itself
    exists(DataFlow::AttrRead attr |
      attr.accesses(nodeFrom, "name")
      and nodeTo = attr
    )
    and
    exists(API::CallNode node, int i, DataFlow::Node n1, DataFlow::Node n2 |
		node = API::moduleImport("gradio").getAMember().getReturn().getAMember().getACall() and
        n2 = node.getParameter(0, "fn").getParameter(i).asSource()
        and n1.asCfgNode() =
          node.getParameter(1, "inputs").asSink().asCfgNode().(ListNode).getElement(i)
        and n1.getALocalSource() = API::moduleImport("gradio").getMember("File").getReturn().asSource()
        and (DataFlow::localFlow(n2, nodeFrom) or DataFlow::localFlow(nodeTo, n1))
        )
}

This taint step first connects any object to its name read, as before. It then finds the function passed to fn and the variables passed to inputs in gr.Button.click, connecting them to the function parameters using an integer index i to track position. This check verifies the node is of gr.File type:

nodeFrom.getALocalSource()
        = API::moduleImport("gradio").getMember("File").getReturn().asSource()
and (DataFlow::localFlow(n2, nodeFrom) or DataFlow::localFlow(nodeTo, n1)

Finally, we confirm there is local flow between the function parameter n2 and the attribute read nodeFrom, or between the name attribute read nodeTo and a variable in inputs. Essentially, we combine two taint steps into one via localFlow, which connects the sets when multiple steps lie between the input variables and the eventual name read. This construction works because one condition can't stand alone; localFlow bridges the gap.

The complete query:

/**
 * @name Gradio File Input Flow
 * @description This query tracks data flow from Gradio's Button component to a Decoding sink.
 * @kind path-problem
 * @problem.severity warning
 * @id 5/6
 */

import python
import semmle.python.dataflow.new.DataFlow
import semmle.python.dataflow.new.TaintTracking
import semmle.python.Concepts
import semmle.python.dataflow.new.RemoteFlowSources
import semmle.python.ApiGraphs

class GradioButton extends RemoteFlowSource::Range {
    GradioButton() {
        exists(API::CallNode n |
        n = API::moduleImport("gradio").getMember("Button").getReturn()
        .getMember("click").getACall() |
        this = n.getParameter(0, "fn").getParameter(_).asSource())
    }

    override string getSourceType() { result = "Gradio untrusted input" }
}

predicate nameAttrRead(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    // Connects an attribute read of an object's `name` attribute to the object itself
    exists(DataFlow::AttrRead attr |
      attr.accesses(nodeFrom, "name")
      and nodeTo = attr
    )
    and
    exists(API::CallNode node, int i, DataFlow::Node n1, DataFlow::Node n2 |
		node = API::moduleImport("gradio").getAMember().getReturn().getAMember().getACall() and
        n2 = node.getParameter(0, "fn").getParameter(i).asSource()
        and n1.asCfgNode() =
          node.getParameter(1, "inputs").asSink().asCfgNode().(ListNode).getElement(i)
        and n1.getALocalSource() = API::moduleImport("gradio").getMember("File").getReturn().asSource()
        and (DataFlow::localFlow(n2, nodeFrom) or DataFlow::localFlow(nodeTo, n1))
        )
}

predicate osOpenStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    exists(API::CallNode call |
        call = API::moduleImport("os").getMember("open").getACall() and
        nodeFrom = call.getArg(0) and
        nodeTo = call)
    or
    exists(API::CallNode call |
        call = API::builtin("open").getACall() and
        nodeFrom = call.getArg(0) and
        nodeTo = call)
}

module MyConfig implements DataFlow::ConfigSig {
  predicate isSource(DataFlow::Node source) { source instanceof GradioButton }

  predicate isSink(DataFlow::Node sink) {
    exists(Decoding d | d.mayExecuteInput() | sink = d.getAnInput())
  }

  predicate isAdditionalFlowStep(DataFlow::Node nodeFrom, DataFlow::Node nodeTo) {
    nameAttrRead(nodeFrom, nodeTo)
    or
    osOpenStep(nodeFrom, nodeTo)
   }
}

import MyFlow::PathGraph

module MyFlow = TaintTracking::Global<MyConfig>;

from MyFlow::PathNode source, MyFlow::PathNode sink
where MyFlow::flowPath(source, sink)
select sink.getNode(), source, sink, "Data Flow from a Gradio source to decoding"

Running this taint step yields the full path from gr.File to pickle.load(f). While a taint step like this could be contributed upstream, it's specific to certain vulnerability classes. For instance, it suits unsafe deserialization but would cause false positives for path injection sinks like open(file.name, 'r'), since a second-order vulnerability here controls the file contents, not the path.

Lessons for Debugging Taint

Difficulties tracking taint are common enough that the GitHub Security Lab Slack hears about them regularly. These cases are infrequent but worth documenting when they surface. If your own queries still misbehave after trying the approaches covered here, help is available in the GitHub Security Lab Slack instance and github/codeql discussions.