Safeguard
AppSec

What Tree-sitter Taint Analysis Actually Catches (and What It Cannot)

Following untrusted data from source to sink across a real codebase is a solved problem right up until reflection, dynamic dispatch and an ORM turn up. Knowing where the analysis stops is what makes it usable.

Marcus Chen
Staff Security Engineer
5 min read

A grep for eval( finds every call to eval. A taint analysis finds the calls to eval whose argument came from an HTTP request. The difference is most of the value in static analysis, and all of the difficulty.

Tree-sitter is a reasonable foundation for building it: incremental parsers for most languages, concrete syntax trees with real node types, error recovery that keeps producing a usable tree when the file does not compile. That last property matters more than it sounds — a scanner that requires a successful build cannot scan a repository whose dependencies are not installed, which is most repositories most of the time.

The three pieces

Sources are where untrusted data enters: request parameters and bodies, headers, cookies, CLI arguments, environment variables, file contents, database rows in some threat models, and message queue payloads.

Sinks are where it becomes dangerous: query execution, command execution, template rendering, file path construction, deserialisation, redirect targets, eval and its relatives.

Propagation is everything in between — assignment, string concatenation and interpolation, collection insertion and retrieval, function arguments and returns, field access. Taint spreads along these, and the analysis is a reachability question over the resulting graph.

Sanitisers cut the edge. Recognising them is what separates a tool people use from a tool people mute, because a parameterised query, a shell-escaped argument or a context-correct HTML escape genuinely removes the vulnerability — and an engine that does not know the framework's escaping helpers by name will report every safe template as an XSS.

What it finds reliably

Injection with a direct path. Request parameter into string concatenation into query execution, within one function or across a couple of calls. This is the canonical case and a competent implementation gets it consistently.

Command injection. Same shape, and often easier, because the sink APIs are a short list per language and the safe alternatives are structurally distinct — an argument array rather than a shell string.

Path traversal into file operations. Concatenating user input into a path and opening it. Reliable when the concatenation is visible.

Hardcoded secrets and weak cryptography. Not taint analysis at all — API presence and literal matching — but they ride on the same parse and account for a large share of what teams actually remediate.

Cross-file paths through explicit calls. With a call graph over the whole program, taint follows an argument into a helper in another module and back out through its return value. This is where an engine earns its keep, because it is exactly the case a reviewer misses.

Where the analysis ends

Be specific about these, in the product and not only in the documentation.

Reflection and dynamic dispatch. getattr(obj, name)(arg) in Python, Method.invoke in Java, a function looked up in a map by a runtime string. The callee is not knowable statically, so the edge cannot be drawn. Most engines stop; some over-approximate by assuming every candidate, which trades a false negative for a pile of false positives.

ORM and query builder internals. Taint entering .where(userInput) may be parameterised safely or interpolated raw, depending on the builder, the method, and sometimes the argument type. Without a model of that specific library the analysis is guessing, and the guess determines whether you report or stay silent.

Serialisation boundaries. Data written to a queue, a cache or a database and read back later is a taint path across time and process. Static analysis of one program cannot see it. This is where the largest real-world injection bugs hide.

Framework-implicit dataflow. Dependency injection, middleware chains, decorator-based routing, template rendering that binds a context object. The connection between a route handler and the request is made by the framework at runtime, and without a framework model the source is invisible.

Callbacks and higher-order functions. Taint through a function passed as a value is tractable but expands the graph quickly, and most engines bound the depth. Where they bound it is a tuning decision that directly trades recall against runtime.

The cost nobody mentions: cgo

Tree-sitter is a C library. Using it from Go means cgo, and cgo means the build needs a C toolchain.

This has consequences that surface late and hurt:

  • cross-compiled binaries built with CGO_ENABLED=0 silently exclude the engine, so the same version of your CLI has the feature on one platform and not another
  • CI images need a compiler, which makes them larger and slower
  • a developer machine without build tools cannot run or test that code path at all

The standard mitigation is a build-tagged stub that returns a clear error — this engine requires the full build — rather than a nil pointer or a silent zero findings. Get that wrong and the failure mode is a scan that completes successfully having analysed nothing, which is the worst possible outcome because it looks like a clean bill of health.

Being honest in the output

If you can build all this, you can also afford to state what it did not cover. A finding should carry the path it followed; an absence of findings should carry what the analysis could not follow.

"No taint paths found" and "no taint paths found, but this file uses reflection at line 88 and dynamic calls were not resolved" are different results. The second is what lets a reviewer decide where to spend their own attention — which, given the list above, is still the mechanism that catches the interesting bugs.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.