Search by

enshrined / wp-taint

darylldoyle

Interprocedural taint analysis for WordPress PHP. Finds real XSS, SQL injection and authorization bugs.

Package info

github.com/darylldoyle/wp-taint

pkg:composer/enshrined/wp-taint

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.3.1 2026-09-02 12:57 UTC

README

Interprocedural taint analysis for WordPress PHP. Finds real XSS, SQL injection and broken authorization, with a source-to-sink trace on every finding.

composer require --dev enshrined/wp-taint
vendor/bin/wp-taint scan ./src
HIGH      wp.xss.unescaped-output
  includes/class-report-renderer.php:47:10  echo $this->build_header( $filter );

    source    :42:24  $filter = wp_unslash( $_GET['report_filter'] );
    sink      :47:10  echo $this->build_header( $filter );

  Untrusted input reaches output without HTML escaping.
  Run with --verbose for the full path.
─────────────────────────────────────────────────────────────
  0 critical   1 high   0 medium   0 low
  1 finding in 1 file · 18 files scanned · 0.2s
─────────────────────────────────────────────────────────────

  Why is a value tainted? Ask about the line:
  wp-taint explain ./src/includes/class-report-renderer.php:47 --scope=./src

explain is a command, not a scan flag, and --scope is not optional: without it the file is analysed alone, and anything whose taint arrives through an include or a hook comes back clean.

Documentation

Guide For
Getting started Install, first scan, reading a finding
Scanning a WordPress project Reporting on your code among thousands of third-party files
How it works Sources, sinks, taint kinds and the decisions behind them
CLI reference Every command, option, config key and rule
Troubleshooting A scan that looks stuck, wrong or too loud
Known limitations What the engine does at the edges, and why

Why this exists

Every existing option fails on WordPress in a specific way.

Tool Where it falls down on WP
Psalm taint analysis Precision depends on type inference. Plugin code is untyped array soup, so inference degrades and the taint graph degrades with it. Also needs a coherent autoloader, which themes do not have.
Semgrep / Opengrep Ignores types, which suits WP, but cross-function taint is limited to a single file. Interfile taint is roadmap, not shipped.
PHPStan No dataflow engine. Taint would have to be rebuilt on collectors, fighting per-file caching and parallelism.
WPCS sniffs Encode the right catalogue of escapers and sinks, but are token-based and intraprocedural, so they are noisy and miss anything crossing a function boundary.

wp-taint takes the SSA/CFG approach (which gives interprocedural taint cheaply) and the WPCS catalogue, which is the expensive, already-curated asset.

What it finds

Injection, via dataflow from source to sink:

  • Reflected and stored XSS, including across one or more function boundaries
  • Escaping undone before output, a value escaped and then passed through a filter, or through one of the 569 core functions that return a filtered value
  • The wrong escaper for the context: esc_html() inside <script>, esc_attr() on an href, any escaper in an unquoted attribute
  • Input sanitising mistaken for output escaping: sanitize_text_field() strips tags but leaves both quote characters, so a value it "cleaned" still ends a quoted attribute and turns the rest of the payload into onmouseover=
  • CSV formula injection, where no HTML escaper helps and the fix is a prefix
  • SQL injection, including $wpdb->prepare() called with a non-literal format string, and esc_sql() used where there are no quotes for it to escape
  • Local file inclusion, arbitrary file read and write
  • Command injection and eval()
  • PHP object injection through unserialize(), including from stored data, reported separately, because that one needs an attacker who can write the option or meta first
  • Open redirects and HTTP header injection

Calls are followed through the value that names them: a callable in a variable, call_user_func(), array_map(), and hook dispatch. A callback registered with add_filter() that reads $_GET taints the filter's result at every apply_filters() site; one that escapes is credited.

Method calls resolve through the class hierarchy, so a method inherited from a parent or pulled in from a trait is followed to the body PHP would actually run, and parent:: dispatches past the override to the parent's.

Writes that go back through an argument are followed too, preg_match() into $matches, parse_str() into an array, a user function's &$out parameter, and the aliases created by $a = &$b and by-reference foreach.

And include/require join the two files' scopes, so the theme shape works: $title = $_GET['title']; include 'header.php'; connects to the template that echoes it. Paths fold from __DIR__, constants, and the pure path helpers WordPress builds them with. --no-follow-includes turns it off.

Broken authorization, via structural rules, which taint analysis structurally cannot find:

  • register_rest_route() with no permission_callback
  • permission_callback => '__return_true' on a route that writes
  • permission_callback that resolves but reaches no authorization check
  • wp_ajax_* handlers with no capability check and no nonce check
  • admin_post_* handlers with no capability check, a nonce is not one
  • A guard that redirects without exit, so the code it guards runs anyway
  • A nonce created or verified with no action, so it is the shared -1 token
  • A nonce check behind isset() on the same parameter, which omitting it skips
  • update_option() whose option name comes from the request, which is privilege escalation rather than stored data
  • A request-chosen post, user, term or comment id reaching a delete or update when the capability check guards the action but not the row: the classic WordPress IDOR, where the check is present and correctly named but scoped to a role rather than that object

These walk the call graph rather than matching names, so a check delegated to a helper counts and a helper merely named like a check does not. A nonce is recorded as proving intent rather than entitlement, which is why it satisfies the AJAX rule and not the admin-post one.

Taint is never a boolean

esc_html() clears HTML taint and does nothing whatsoever for SQL. $wpdb->prepare() clears SQL and nothing else. esc_url_raw() is for redirects and database storage, not for HTML.

Every value carries a set of taint kinds, and every sanitizer clears a specific subset. That is the single most important modelling decision in the project, and it is why this reports the SQL injection in:

$slug = esc_html( $_GET['slug'] );
$wpdb->query( "SELECT * FROM wp_items WHERE slug = '{$slug}'" );

…while reporting nothing at all in:

$wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}items WHERE slug = %s", $_GET['slug'] ) );

Usage

wp-taint init /path/to/wp-content         # detect the project, write wp-taint.toml
wp-taint scan ./src                       # find bugs
wp-taint explain ./src/file.php:47 --scope=./src   # ask why
wp-taint registry:dump                    # print the catalogue

Common options:

Option Effect
--format=json Full traces, for another tool or an agent
--fail-on=SEVERITY Exit 1 at or above this level. Default high.
--include-path=PATH Analyse for symbols, never report. Repeatable.
--exclude=GLOB Skip a tree. Repeatable.
--jobs=N Worker processes. Needs ext-pcntl.
--baseline=PATH Silence everything already known

Exit codes: 0 clean, 1 findings at or above --fail-on, 2 execution error, including any file that failed to parse. A file the scanner could not read is a file it could not clear, and a green build over an unread file is a lie.

Every command, option, config key and rule id is in the CLI reference.

Handing results to an agent

Use --format=json. Console output is lossy by design: colour codes, collapsed traces, truncated spans. The JSON carries the full trace, the taint kinds at every step, and the rule definition inline on each finding, so a reader coming to it cold needs no other context.

wp-taint scan ./src --format=json --output=findings.json

Why was this not flagged?

The failure mode a security scanner most needs to defend against is silence. explain turns "I don't trust it" into a specific, checkable statement:

wp-taint explain includes/class-report-renderer.php:58 --kind=html
includes/class-report-renderer.php:58

  return '<h2>' . $label . '</h2>';

  Taint at this point: (none)

  Why:
    - sanitize: esc_html() clears html. (none) survives.

The important case is the third one, where the engine says a path was abandoned, a call it could not resolve, rather than proved clean. --dynamic-calls=tainted then gives an upper bound on what might be missing, which is what you want when auditing the auditor. --dynamic-calls=clean is the other end: no assumptions, and a documented false negative wherever the engine lost the thread. See KNOWN_LIMITATIONS.md § dynamic calls.

Suppressing findings

Inline, with a mandatory reason:

// wp-taint-ignore-next-line wp.xss.unescaped-output -- output is admin-only, reviewed 2026-08
echo $markup;

A suppression without a reason is reported as an error of its own: "someone silenced this and nobody knows why" is a worse state than the original finding.

Or by baseline, for adopting the tool on an existing codebase:

wp-taint scan ./src --generate-baseline
wp-taint scan ./src --baseline=wp-taint-baseline.json

Fingerprints deliberately exclude the line number, so a baseline survives unrelated edits above a finding. The suppressed count is always printed, so the debt stays visible.

The catalogue is data

Sources, sinks, sanitizers and propagators live in registries/*.toml. Adding a WordPress escaper never requires a code change.

[[sanitizers]]
function = "esc_attr"
clears = ["html", "html_attr"]

[[propagators]]
function = "wp_unslash"
note = "Strips slashes only. Pure pass-through. NOT a sanitizer."

A project-local wp-taint.toml in the scan root is loaded last and can add or override anything. Unknown keys are a hard error, not a warning: a typo in a security catalogue silently creates false negatives.

Stored sources, get_option(), get_post_meta() and friends, are on by default, because stored XSS is most of the WordPress CVE population. --no-stored-taint turns them off if you want to triage reflected issues first.

A value written to an option and read back from the same key connects, so a second-order flow with both ends in the scan (update_option( 'k', $_POST['x'] ) here, echo get_option( 'k' ) there) is followed the whole way rather than left at "an option might hold anything". Post and user meta stay unconnected per key.

Read the resolved catalogue with wp-taint registry:dump.

Design principles

  1. Deterministic. Same input, byte-identical output. Findings sorted by file, line, column, rule id. No randomness, no hash-order iteration, no timestamps in machine-readable output.
  2. Fail loudly, never silently. A file that fails to parse is a reported error, not a skipped file.
  3. Structure carries the value. The catalogue is data, not code.
  4. False positives are the product risk. A tool that cries wolf gets muted and then deleted. When in doubt, take the documented false negative.
  5. No LLM, no network, no telemetry on the analysis path. Fully deterministic and offline. The only thing that touches the network is tools/fetch-corpus.php, a developer tool.

Speed

A plugin is the unit: interprocedural taint crosses files, so the whole scan is parsed and held in memory at once.

Plugin Lines Time Peak RSS
akismet 7,678 2s 73 MB
advanced-custom-fields 83,330 21s 429 MB
wordpress-seo 209,674 40s 602 MB
woocommerce 786,566 256s 2,373 MB

Single-threaded, on macOS 15 (arm64), PHP 8.3.32. Roughly 10 seconds per 50k lines. --jobs=4 roughly halves that (parsing stays serial, so it is not linear) and produces byte-identical output, which is enforced by a test rather than hoped for.

A first-party plugin or theme is a much smaller unit than any of these: a client theme of 926 files scans in 15 seconds.

Memory is the real constraint. bin/wp-taint raises the limit to 2 GB, which covers everything in the WordPress.org top fifty except WooCommerce; for a tree that size set WP_TAINT_MEMORY_LIMIT=4G.

Scored against two suites written elsewhere

Two labelled fixture suites, neither written here, each with its own scorer and its own vocabulary for what a finding is called.

composer suite:check

wp-taint-fixtures carries 109 annotations across 18 rule ids in Semgrep's ruleid: / ok: grammar, and reports precision and recall per rule. That framing matters: a tool that flags everything scores perfect recall and is useless.

default --no-unknown-provenance
Precision 0.98 1.00
Recall 0.94 0.77
F1 0.96 0.87

One false positive across 44 labelled-safe cases. --no-unknown-provenance trades eleven true positives for it, which is the wrong way round for a tool whose low band cannot fail a build.

ideas/wp-taint-analyser-fixtures pairs 36 scenarios as vulnerable and safe variants, cross-file and cross-plugin. Zero findings on all 36 safe variants, which is the strongest single result here: independent code, written correctly by someone else, and the tool says nothing about any of it.

They cover semantics the other benchmarks do not, context correctness, escape invalidation, weak sanitisers posing as real ones, where the corpus covers volume and the CVE set covers incidents.

Scored against real CVEs, both sides of the fix

47 published CVEs across 24 WordPress.org plugins, each pinned to the last vulnerable release and the release that fixed it. Scanning both is what makes it sharp: a finding that disappears when the bug is fixed is attributable in a way that a finding in a known-vulnerable plugin is not.

composer cve:fetch
composer cve:check

2 confirmed · 4 attributed · 20 reported · 20 silent, across the 46 pairs close enough to score (one CVE's vulnerable and fixed releases are five major versions apart, and a diff across that says nothing).

Four outcomes rather than two, because plenty of real fixes do not remove the flow. Confirmed means the findings vanish at the fix and the fixed release comes back clean; attributed means findings vanish while others remain, which a refactor in the fix release also produces. Reported is a finding on the vulnerable release that survives the fix, CVE-2022-2593 in Better Search Replace is SQL injection through unvalidated table names; we report 'DESCRIBE ' . $table in both releases, and the fix is array_map( 'trim', ... ). Scoring that a miss understates the engine as badly as scoring it a hit would flatter it, so it is counted apart and never added to the headline.

Where it fails, by class:

confirmed attributed reported silent
Code injection (CWE-94) 0 0 1 4
Deserialization (CWE-502) 1 0 0 2
Open redirect (CWE-601) 0 0 1 2
XSS (CWE-79) 0 2 7 3
Authorization (CWE-862/863) 0 2 5 5
Path traversal (CWE-22) 0 0 3 1
SQL injection (CWE-89) 1 0 2 2
SSRF (CWE-918) 0 0 1 1

Deserialization was a zero until this benchmark said so: stored data did not carry object-injection taint, on reasoning that holds for filesystem and URL sinks and not for this one. Fixing it caught CVE-2023-1196 in Advanced Custom Fields, now a confirmed hit, because the false positives that used to linger on the fixed release were themselves fixed and the release came back clean.

The CWE-94 cases are plugins whose purpose is executing admin-supplied code, so those CVEs are privilege-boundary bugs wearing a code-injection label. Two of the three open redirects still are not wp_redirect() flows the analysis can see; the third is now reported. Both are worth knowing before reading the column as a verdict on the rules.

"Attributed" is evidence, not proof. A finding can vanish because the fix refactored the line rather than because it was the bug. This is the sharpest measurement here and it is still not a confirmed hit.

Scored against an answer key we did not write

The fixture suite is ours, and 37 of its cases were written after the behaviour they test. The corpus is third-party code with no ground truth. Both flatter this tool in the same place.

The WordPress plugin review team published an intentionally vulnerable plugin in 2013 to teach plugin authors what their code does wrong, and a companion post enumerating every flaw in it. Somebody else's test, somebody else's answer key, written for a purpose unrelated to this tool.

It finds all 12. Three of them (the CSRF and control-flow bugs) the taint engine cannot see at all, and are caught by structural rules; filing them under "not a dataflow problem" would have been the easy way out.

composer vulnerable:fetch
composer vulnerable:check

The score is committed in tests/Fixtures/vulnerable-plugin-truth.json and checked in CI. It fails in both directions: a caught issue going quiet is a regression, and a missed one starting to fire means somebody fixed something without noticing and the file is now lying about where the tool stands.

How it compares

Against the same fixture suite, each fixture analysed on its own, with no project-specific tuning for anyone:

wp-taint Semgrep 1.174 Psalm 6.16
Vulnerable caught, all 184 92 / 92 67 / 92 38 / 92
Vulnerable caught, original 147 69 / 69 64 / 69 33 / 69
False positives, original 147 0 / 78 12 / 78 0 / 78

Quote the second row, not the first. 37 of these fixtures were added while fixing bugs the corpus exposed, and each pins a shape that work had just taught this engine to handle: dynamic calls, hook dispatch, include scope, by-reference writes. Semgrep gets 3 of 23 on them because they are a catalogue of what its analysis model does not attempt. On the suite that predates the work, it finds 93% of the vulnerable fixtures. It is a capable tool.

What separates them there is the safe half: 12 false positives against zero, clustered on values that were escaped by a route Semgrep cannot follow: an escaper applied through array_map(), an allowlist regex, a $wpdb table identifier that is not data.

Psalm is precise where it fires (zero false positives) and misses every SQL injection because nothing tells it what $wpdb is, which is a catalogue gap a plugin would close, and every authorization bug because those have no source and no sink, which taint analysis structurally cannot find.

Full working, including what the comparison does not prove and why the fixtures flatter us, in docs/benchmark.md.

Where it is imprecise

Honestly, in KNOWN_LIMITATIONS.md. Findings that crossed something the engine could not resolve carry imprecise: true so you can filter on it.

Development

composer install
composer test        # Pest
composer analyse     # PHPStan, level max + strict rules
composer lint        # PHP_CodeSniffer, PSR-12 + Slevomat
composer check       # all three

The fixture suite in tests/Fixtures/ is the regression net: 145 vulnerable files and 149 safe ones that are superficially similar. The safe half matters more: a single false positive there fails the build.

Expectations are written as inline // wp-taint-expect <rule-id> <kind> annotations on the sink line and generated into .expected.json siblings by composer fixtures:build. CI fails if the two drift apart.

composer corpus:fetch    # ~50 plugins from WordPress.org, gitignored
vendor/bin/wp-taint scan tests/Fixtures/corpus --parse-report

Requirements

PHP 8.2 or newer. No WordPress installation needed, the tool analyses source, it does not run it.

Licence

MIT.