Here is a line of Java that appears, in some form, in essentially every web application ever written:
param = java.net.URLDecoder.decode(param, "UTF-8");
Sanitise in place. Reassign the variable to a transformation of itself. s = s.trim(), sql = sql + clause, path = normalize(path) — same shape.
It is also, for a certain very common design of taint engine, a line that never terminates.
How a taint engine resolves a variable
A source-to-sink dataflow analysis walking an AST hits an identifier and has to answer one question: where did this value come from? The usual approach is to search backwards for the most recent definition of that name that lexically precedes the use, then recurse into whatever that definition assigns.
sql = "SELECT ... '" + param + "'"; <- use of param, resolve it
param = decode(param, "UTF-8"); <- most recent preceding definition
So the engine takes the value expression — decode(param, "UTF-8") — and analyses it. That expression contains param. Resolve that one too.
And here is the trap. The occurrence of param inside the decode call starts later in the file than the assignment statement it sits within. So when the engine asks "what is the most recent definition preceding this use", the answer it computes is the very assignment it is already inside.
It recurses into the same node. Forever.
Why it is worse in Go than it looks
Infinite recursion is a bug in any language. In Go it is a specific kind of bad.
A goroutine stack grows until it hits a limit — 1 GB by default on 64-bit — and then the runtime raises fatal error: stack overflow. That is a fatal error, not a panic. recover() does not catch it. There is no deferred cleanup, no per-file error handling, no "skip this file and carry on".
A scanner that analyses files in a worker pool, wrapping each one in a recover so a single bad parse cannot take down the run, still dies completely the first time one file contains x = f(x). The blast radius is not the file. It is the process.
And the failure mode is silent in the way that matters: the scan does not report a partial result with a warning. It stops.
The wrong fix
The obvious patch is a visited set — remember which definition nodes are currently being resolved, and refuse to re-enter one.
That stops the crash. It also gets the answer wrong.
With the guard alone, the engine gives up on param at the point of recursion and returns "not tainted". The flow dies there. The SQL injection that was genuinely present — user input, decoded, concatenated into a query — becomes a false negative. You have traded a loud failure for a quiet one, which on a security tool is the worse trade.
The right fix
The semantic error is in the definition lookup, not the recursion.
In param = decode(param, ...), the right-hand side is evaluated before the assignment takes effect. The param inside the call therefore refers to the previous definition — the one before this statement — not to the assignment being computed.
So the rule is: a candidate definition that lexically contains the use is not a preceding definition for that use. Skip it, and the lookup walks back to the real one:
param = headers.nextElement(); // <- what the inner param means
param = java.net.URLDecoder.decode(param, "UTF-8");
Now the recursion terminates and the taint keeps flowing. The engine correctly reports that untrusted input reaches the query, through the decode, exactly as a human reader would trace it.
Keep the visited-set guard as well, but as a backstop rather than as the fix. Termination should be a structural property of the algorithm, not something that depends on every future edit to the definition lookup preserving a subtle invariant. When the failure mode is an uncatchable crash, belt and braces is cheap.
How to test for it
Three cases, and all three matter:
// 1. self-referential through a call argument
p = decode(p, "UTF-8");
// 2. self-referential through a binary operator — even more common
sql = sql + " ORDER BY id";
// 3. mutual: a defined from b, b defined from a
String b = a + "1";
a = b + "2";
Each should terminate, and each should still report the flow if one exists. A test that only asserts "does not hang" will pass against the wrong fix, so assert both properties separately: the analysis finishes, and the finding is still there.
Add a watchdog around the analysis in the test rather than relying on a timeout at the CI level. A fatal stack overflow takes the test binary with it, so the watchdog cannot save you from that — but it converts "exponential but finite" into a visible failure instead of a hung job, and that is the failure you are more likely to introduce next.
The general lesson
Backwards definition lookup is a reasonable design. It is simple, it needs no control-flow graph, and it handles the overwhelming majority of real code. The bug is not the approach; it is a single missing containment check in the comparison that decides what "precedes" means.
Which is worth remembering when reading any dataflow engine: the interesting failures are rarely in the analysis. They are in the small predicate that decides which nodes the analysis is allowed to look at.
Related reading: taint analysis versus reachability, taint analysis for zero-day discovery, and what SAST actually does.