Source to Sink Analysis Explained

Source to Sink Analysis Explained

When a scanner flags a line of code, the first question that matters is simple: can an attacker actually reach it? Source to sink analysis is how you answer that. It traces a value from the place attacker input enters your app, the source, to the place that value gets used in a dangerous way, the sink, and it only rings the alarm when a real path connects the two.

What source to sink analysis actually means

Two words carry the whole idea. A source is any value an attacker controls. A sink is any operation that becomes dangerous when fed the wrong value.

Common sources in a web app or API:

  • Request query parameters, like ?id=42.
  • Request body fields from JSON or form posts.
  • HTTP headers, including Cookie, User-Agent, and X-Forwarded-For.
  • Path segments in the URL.
  • Uploaded file names and file contents.

Common sinks, where a tainted value turns into a bug:

  • A raw SQL query string passed to the database.
  • A shell command run with os.system or exec.
  • HTML written straight into a response, which is how reflected injection and cross site scripting happen.
  • A file path opened on disk.
  • The destination of an outbound HTTP request, the classic setup for SSRF.

A source on its own is harmless. A sink on its own is often fine. The bug lives in the line that connects them, and that connection is exactly what dataflow analysis follows.

Following a value from source to sink

Data rarely goes straight from the request into the sink. It gets assigned to variables, passed into functions, concatenated, and returned. A tool doing source to sink analysis walks each of those steps and asks, at every hop, does the tainted value still flow forward?

Here is a short flow with the taint marked at each step:

def get_user(request):
    uid = request.args.get("id")        # source: attacker controls uid
    return lookup(uid)                   # taint passed into a function

def lookup(value):
    query = "SELECT * FROM users WHERE id = " + value   # taint reaches the string
    return db.execute(query)             # sink: raw SQL runs

The value starts life in request.args.get("id"). It travels as the argument uid, crosses a function boundary into lookup as value, lands inside a concatenated SQL string, and finally hits db.execute. That unbroken chain is a real path from source to sink. An attacker who sends ?id=1 OR 1=1 changes the meaning of the query, so this is not just scary looking code, it is exploitable.

Why a validator breaks the chain

Not every value that touches a sink is dangerous. If something on the path forces the value into a safe shape, the flow is broken and there is no bug to report. This is the difference between a finding and a false alarm.

Take almost the same code, with one guard added:

def get_user(request):
    uid = request.args.get("id")        # source
    if not uid.isdigit():               # validator: rejects anything but digits
        abort(400)
    return lookup(int(uid))             # value is now a safe integer

The isdigit check rejects 1 OR 1=1 before it ever reaches the query, and int(uid) makes it impossible for text to survive. A tool that understands this sees the tainted string die at the validator. The path from source to sink is cut, so no alert fires. The same logic applies to a proper sanitizer, a parameterized query, an allowlist, or an escaping function that the analysis recognizes.

This is also where these tools earn their keep or fail. If the analysis does not recognize your cleaning function, it will either miss a real bug or cry wolf on a safe one. Recognizing which functions genuinely break the flow is most of the hard work.

Pattern grep versus real dataflow

The plainest way to see the value of source to sink analysis is to compare it with a text search.

A grep style scanner looks for the shape of a sink. Search for db.execute( and it flags every call, whether or not attacker input reaches it. So it fires on this line:

db.execute("SELECT count(*) FROM users")   # constant string, no input, still flagged

Nothing an attacker sends can change that query. There is no source, so there is no bug, but the pattern matcher does not know the difference. It flags the sink because the sink exists.

Pattern matching asks whether a dangerous function is present. Dataflow asks whether attacker input can actually reach it. Only the second question tells you if you have a real bug.

Dataflow inverts the logic. It starts from the sources, follows the taint, and reports the sink only when a live path connects the two. The constant query above gets no alert because no source reaches it. The concatenated query from earlier does, because one does. That is how you turn a wall of maybes into a short list of paths worth fixing.

Doing this across a whole codebase

Tracing one function by hand is easy. Doing it across thousands of files, through imports, class methods, and callbacks, is where it gets hard. The taint might enter in a route handler, pass through three helper modules, and reach a sink defined in a fourth. You cannot hold that in your head, and grep cannot see across those hops at all.

This is what a code property graph is for. It models the code as a graph of declarations, calls, and data edges, so a query can walk from a source, across every assignment and function call, to a sink, and report the exact path it found. Because the graph is built from a real parse of the language rather than string matching, a rename or an import alias does not hide a caller. UnboundCompute builds an open code property graph for this kind of source reasoning, lachesis on GitHub, so the same walk that is tedious by hand becomes a single query over the whole tree.

A graph also makes the negative answer trustworthy. When it reports no path from a given source to a given sink, that is a considered result, not a search that happened to miss. For more on how this differs from tools that only match known patterns, see scanners vs research.

From reachable to exploitable

Source to sink analysis draws the line between two very different statements. “This code contains a SQL call” is almost never useful on its own. “This request parameter reaches that SQL call with no validation in between” is a bug you can prove and fix. The path is the proof.

Finding that path is one half of the work. Confirming that it is truly exploitable, and not blocked by some condition the graph could not see, is the other half. That pairing, reason about the path in the source, then verify it against the running app, is exactly the kind of work UnboundCompute is built to do. You can read more on our about page.

Frequently asked questions

What is a source and a sink in dataflow analysis?

A source is any value an attacker controls, such as a request parameter, a header, a request body field, or an uploaded file name. A sink is an operation that becomes dangerous with the wrong input, like a raw SQL query, a shell command, an HTML response, or the destination of an outbound HTTP request. Source to sink analysis reports a bug only when a value flows from one to the other.

How is source to sink analysis different from grep?

A grep style scanner flags a sink wherever it appears, even a constant query like db.execute("SELECT count(*) FROM users") that no attacker can influence. Source to sink analysis starts from the attacker controlled sources and only reports the sink when a real path of assignments and function calls connects the two. That is the difference between a wall of maybes and a short list of paths worth fixing.

Why does a validator stop a finding from being reported?

If a check on the path forces the value into a safe shape, the tainted value never reaches the sink in a dangerous form. An isdigit guard followed by int(uid) makes SQL injection impossible, so the flow is broken and no alert should fire. Parameterized queries, allowlists, and escaping functions break the chain the same way, as long as the analysis recognizes them.

Why use a code property graph for this?

Tracing one function by hand is easy, but real bugs cross many files, imports, and helper calls. A code property graph models declarations, calls, and data edges from a real parse, so a single query can walk from a source to a sink across the whole tree without a rename or alias hiding a caller. You can see one open implementation in lachesis on GitHub.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.