Taint analysis
How the AST-level static analyzer traces untrusted input from WordPress's seven superglobals through function calls, into dangerous sinks, and decides whether the flow is sanitized along the way.
Data flow, not pattern match
file:line for each node in the flow. WordPress plugins fail in predictable ways: untrusted HTTP input reaches a dangerous sink - $wpdb->query(), eval(), include, echo of raw HTML - without passing through an escape or sanitize call on the way. That pattern is the root cause of most of the SQL injection, XSS, LFI, and RCE CVEs in the WordPress ecosystem.
Pattern-matching misses the real signal. Grepping for $wpdb->query turns up hundreds of call sites per plugin, most of them safe (prepared statements, integer casts, internal-only IDs). Grepping for $_GET finds the sources but doesn't tell you which ones reach a sink. What matters is the path: does untrusted data flow from a source to a sink, and is there a sanitizer somewhere along the way?
Taint analysis makes that question decidable at the AST level. Every variable that receives user input becomes "tainted;" taint propagates through assignments and function calls; passing through a known sanitizer clears it. A flow that lands at a sink while still tainted is the signal. That's what the tracker in server/analysis/ast/taint-tracker.ts does across every plugin version in the corpus.
7 sources, 31 sinks, 47 sanitizers
update_option" aren't treated equivalently. Two phases: summarize, then resolve
Where naïve taint analysis gets WordPress wrong
$wpdb->prepare The format string passed as the first argument to $wpdb->prepare() is a SQLi sink (tainted format strings let attackers smuggle placeholders), but every substitution parameter after it is safe (prepare escapes them). The tracker treats only argument 0 as a sink on this call, everything else as sanitized.
array_map / array_walk Higher-order functions that apply a callback to every element. Naïvely treating the output as tainted misses that array_map('intval', $_POST['ids']) is, in fact, sanitized - the callback is a known integer-coercion sanitizer. The tracker recognises this pattern and marks the result as cleared for SQLi + XSS + LFI categories.
A flow into a sink is dangerous in proportion to who can trigger it. If the enclosing function checks wp_verify_nonce or current_user_can somewhere in its body, the tracker downgrades the flow's severity one tier - critical to high, high to medium, and so on. The vulnerability isn't cleared, but it's weighted by the real-world probability of exploitation.
A flow, as the analyzer records it
Abridged but structurally real shape of one row in plugin_taint_flows. A single TaintFlowResult entry ties a source to a sink with the call-chain that carries the taint between them and the sanitizer check at each step.
{
"plugin_slug": "example-plugin",
"plugin_version": "3.4.1",
"flow_id": "flw_8f3c2a1",
"source": {
"kind": "GET",
"variable": "$_GET['id']",
"file": "admin/handlers.php",
"line": 148
},
"sink": {
"kind": "sql",
"callable": "$wpdb->query",
"file": "lib/query-runner.php",
"line": 64,
"severity": "critical"
},
"path": [
{ "file": "admin/handlers.php", "line": 148, "op": "source" },
{ "file": "admin/handlers.php", "line": 161, "op": "call", "callee": "acme\\build_filter" },
{ "file": "lib/filter-builder.php", "line": 22, "op": "assign", "var": "$where" },
{ "file": "lib/filter-builder.php", "line": 44, "op": "return" },
{ "file": "lib/query-runner.php", "line": 64, "op": "sink" }
],
"sanitizers_seen": [],
"guards": {
"nonce_verified": false,
"capability_required": null
},
"severity_final": "critical",
"confidence": 0.93,
"notes": "no sanitizer on path; unauthenticated reach; standard SQLi shape"
}Why the call chain lives in the row. The point of an AST tracker is not "there's a vulnerability" - that's table stakes. The point is showing which specific lines the taint moved through so a reviewer can walk the analyzer's reasoning top-to-bottom and either confirm or reject it. Every path step carries a file and a line number so the report is reviewable against the actual plugin source, not just against a black-box verdict.
Severity is derived, not claimed.severity_final is computed from the sink's baseline severity, downgraded by any nonce / capability guards found on the path (guards block), and up-weighted if the source is reachable without authentication. Confidence reflects how narrow the analyzer's assumptions had to be to trace the path - a flow that threaded through two helper functions with static arguments is higher confidence than one that required an inference about an object's runtime type.
Running this on every plugin version ever shipped
svn exportsvn export · per versionrate-limit · RTT · TLS setup- Net I/O per sweep~GBs
- Per-version latency~100-500 ms
- Concurrency ceilingthrottled
svnsync keeps the mirror fresh against upstream on a scheduled tick - not on the analyzer's hot path. - Net I/O per sweep0
- Per-version latency< 1 ms
- Concurrency ceilingdisk-bound
svn export today, and the mirror layers in beside it as it fills. Once coverage is complete, the analyzer switches to the local path by default and the on-demand fallback is retained only for versions the mirror hasn't synchronised yet. The taxonomy above defines what the tracker does on one file. The interesting part is running it on every plugin version ever published - not just latest. A plugin's security score benefits from looking at how its taint surface changed over time: did this release add new unsanitized flows? Did a patched CVE genuinely remove the source-to-sink path, or did the fix only plug one reachable branch? Those questions need the AST for arbitrary version pairs.
Source code comes from WordPress.org's SVN repositories - every plugin hosted on .org has a full SVN history, which is the ground truth for "what did the code look like in version X." In day-to-day operation the analyzer fetches specific version tags on demand, which works but has two downsides at scale: SVN rate-limits unauthenticated pulls, and the round-trip latency per svn export dominates wall-clock time once you're analyzing thousands of plugin-versions.
A full local mirror of the .org SVN is the unlock. With every plugin's complete history on local disk, the AST analyzer can run against any version pair without network I/O, enqueue bulk analysis runs that would be prohibitive against the live SVN, and derive ecosystem-scale statistics like "fraction of plugins whose last release introduced a new unsanitized flow." The standard pattern is a straightforward svnsync-style mirror, which is what the pipeline is converging on.
Status: work in progress. The mirror is being built out incrementally; the on-demand SVN path still backs production analysis runs today, and the mirror is layered in beside it rather than as a wholesale swap. The public architecture page notes "plugin SVN mirror" as a Layer 1 ingestion source; the taint analyzer is the biggest downstream consumer once the mirror is fully filled.
Once the mirror is complete, the same taint tracker running against it produces the dataset for the planned "ecosystem-scale static-analysis findings" preprint - see Further reading below.
What the analyzer can and can't catch
False negatives are under-counted in the code-signal score; false positives land as downgraded severity through the guard detection. The aggregate signal holds across the corpus even when individual findings need a human look.
Dynamic dispatch. Static taint analysis is fundamentally bounded by what the analyzer can resolve without running the code. A plugin that builds a function name at runtime and calls it via $fn = $_GET['method']; $fn($userInput) is visible as a sink (the dynamic call site itself is tracked) but the tracker can't statically prove which concrete function is invoked. These flows land in the results tagged with an inferred-sink severity that the risk-assessment layer treats conservatively.
String-built SQL. When a plugin builds a SQL string by concatenating many small fragments across many functions - $sql = $a . $b; $sql .= $c; over five call sites - the per-function summary cache can outpace the resolver. The tracker detects the final sink but may miss that fragment N in the middle was tainted-but-then-concatenated-with-literal-text. These are caught on the code-signals layer rather than the taint layer (they show up as "raw SQL detected"), so the score still reflects the risk.
Type coercion edge cases. Some PHP coercions clean taint for certain categories but not others - (array)$input prevents SQL injection (it's no longer a string) but doesn't prevent LFI (the array's contents are still tainted). The sanitizer → category mapping has to be careful about these; the current mapping covers the common cases and adds entries as we find ones that slip through.
Prior art & primary sources
- FlowDroid - precise context/flow/field/object-sensitive taint analysis for Android (Arzt et al., PLDI 2014)The canonical inter-procedural taint analyzer in academic literature. Our two-phase design - per-function summaries, then cross-function resolution against the cache - maps cleanly onto the summary-based approach in this paper. The WordPress-specific adaptation is the sink / sanitizer / guard taxonomy, not the traversal machinery.
- CodeQL - semantic code analysis via QL queries (Avgustinov et al., ECOOP 2016)A general-purpose framework for writing taint analyses as declarative queries over a code database. CodeQL's published rule-set for PHP includes WordPress-specific sinks; our tracker is narrower in scope (single-language, single-framework) but trades generality for very specific modelling of WordPress idioms like
$wpdb->prepareand nonce guards. - RIPS - static source code analyzer for vulnerabilities in PHP scripts (Dahse & Holz, USENIX Security 2014)The reference static taint analyzer purpose-built for PHP. RIPS models PHP semantics directly rather than lowering to a general IR; our tracker takes the same route (walking the php-parser AST directly) and uses the same broad sink taxonomy, with additions for the WordPress core API.
- PHP Parser (nikic)The parsing library the analyzer uses under the hood to turn PHP source into an AST. Standard across the PHP static-analysis ecosystem and the reason single-pass walks are cheap enough to run across every plugin version once the SVN mirror is in place.
- Planned arXiv preprint - taint analysis at WordPress ecosystem scaleOnce the full WP.org SVN mirror is operational, running this tracker across every plugin version in the ecosystem will produce a labelled corpus of source-to-sink flows annotated with CVE ground truth. The planned preprint reports precision, recall, and prevalence statistics - treating the tracker as a detector and comparing its labels against the existing CVE database for the same versions. Working title: "Static-analysis findings at ecosystem scale: precision, recall, and prevalence of source-to-sink flows across a full WordPress plugin corpus."In progress
Sibling deep-dives
The CVE-reproduction cascade that consumes the taint-analyzer's output. Lightweight LLM → frontier LLM, ephemeral WordPress, independent frontier judge.
How WAT-first narrowing and byte-range fetches turn petabytes into tens of GB moved; the per-worker SQLite → merged DuckDB pattern that feeds the analyzer.
See the flows in the wild.
Every plugin page shows its detected taint flows - sources, sinks, whether sanitized, and which entry point the flow starts from.
