docs(contributor-scan): 5.11 -- a detection strategy, not a verdict of impossible

"Structurally impossible" is a verdict, and a verdict is not a plan. Replacing
both structural-miss tasks with the one mechanism that covers them, because
they are the same shape: two halves of a path joined by a key that is a
literal in the source text.

    hook:   $_REQUEST -> ... -> return of set_current_page
            key: 'wp_statistics_current_page'   literal at both ends
            return of apply_filters(key) -> ... -> $wpdb->get_row

    field:  $_POST -> ... -> assignment to $this->attachments
            key: attachments                    a field name at both ends
            read of $this->attachments -> ... -> unlink

Neither half is missing from the graph. Only the join is. So this is not a
request for a frontend to draw an edge it cannot draw -- it is a second
question asked with the query that already exists:

  stage 1  the existing taint question against a synthetic endpoint: does
           untrusted data reach a callback's return, or an assignment to
           $this->F?
  table    built by reading literals, never inferring: add_filter('H', c)
           against apply_filters('H', ...); $this->F = against $this->F
  stage 2  does the apply_filters return, or the field read, reach a sink?
  join     on the key, with the RUNG carrying the join's uncertainty -- one
           registered callback and a literal key entails; several callbacks or
           a computed hook name corroborates, and the witness says which

"Any hook may reach any handler" is refused absolutely: a complete graph over
the plugin would entail everything.

No new dependency, no CPG mutation, no IR -- both stages ride the batch that
already runs, which is the point of expressing it as a query-level join.

It also recovers the precision fix 5.8 had to refuse. With the field half
working, _delete_files() is reached through $this->attachments rather than
through $this being tainted wholesale, so the receiver can then leave
parameter sources and take PMPro's 11 receiver-sourced findings with it.
5.8's numbers are the before-side of that measurement, and both observables
are pairs already pinned.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-09 18:45:55 +02:00
co-authored by Claude Opus 5
parent 2ba1aaf649
commit e41d6dd7ea
2 changed files with 57 additions and 38 deletions
+46 -38
View File
@@ -537,7 +537,8 @@
yet nothing is reported on either side, because the only edge between the two files is yet nothing is reported on either side, because the only edge between the two files is
`add_filter('...', array($this, 'method'))` / `apply_filters('...')` -- both ends naming the callback with `add_filter('...', array($this, 'method'))` / `apply_filters('...')` -- both ends naming the callback with
a **string**. No frontend can draw that edge. Most WordPress plugin data travels this way, which makes it a **string**. No frontend can draw that edge. Most WordPress plugin data travels this way, which makes it
the largest structural gap for PHP and not a fact-table problem. Recorded as 5.11. the largest structural gap for PHP and not a fact-table problem. The detection strategy for it is
5.11, which handles it and the `$this` field case with one mechanism.
- _Requirements: 4.1, 4.4, 9.1_ - _Requirements: 4.1, 4.4, 9.1_
- _Depends: 5.7_ - _Depends: 5.7_
@@ -589,44 +590,51 @@
- _Requirements: 4.2, 4.3, 9.1_ - _Requirements: 4.2, 4.3, 9.1_
- _Depends: 5.7_ - _Depends: 5.7_
- [ ] 5.11 The WordPress hook edge, or an honest statement that it is not drawn - [ ] 5.11 Two-stage taint with a link table — the detection strategy for both structural misses
- `add_filter('name', array($this, 'method'))` and `apply_filters('name', $value)` are a data-flow edge that - "Structurally impossible" is a verdict, not a plan, and both PHP structural misses turn out to be the
no CPG frontend draws, because both ends name the callback with a string. 5.8 measured the cost: WP SAME shape: two halves of a path, joined by a key that is a literal in the source text.
Statistics' CVE-2022-25148 has a modelled source, a modelled sink and a modelled fix, and is invisible on
both sides purely because the path runs through this edge.
- The registry is static and small. Every `add_action`/`add_filter` in a plugin names a hook and a callback,
and the mapper already parses both for entry points -- so the pairs can be built without any inference,
and the question is only where to apply them: a synthesized call edge in a preprocessing pass, or a
source/sink pairing the taint query is told about.
- Do NOT approximate it with "any hook may reach any handler". That is a complete graph over the plugin and
would entail everything.
- **The neighbouring string-named forms were checked and are sound**, so this task is the hook registry and
nothing else. Measured on probes, 2026-09-09:
- taint SURVIVES a string-named callable -- `array_map('trim', $_GET['a'])` and
`call_user_func('trim', $_GET['a'])` both reach the sink and are entailed, like a plain call;
- a sanitizer named by a string IS recognised -- `array_map('esc_sql', ...)` and
`call_user_func('esc_sql', ...)` both suppress, matching PMPro's own `array_map('esc_sql', $status)`;
- the textual sanitizer test (`taint.sc`: does a path element's code mention the name) does NOT
over-suppress. A query where a SIBLING value is `esc_sql`-wrapped is still entailed for the unwrapped
one, and a decoy `'esc_sql'` literal flowing to the same sink does not suppress either -- because
`flow.elements` are the nodes on that value's own path.
The hook is different in kind: `add_filter` and `apply_filters` are not one expression with a callback in
it, they are two statements in two files joined only through a hashtable in WordPress core.
- Observable: CVE-2022-25148 entailed on `wpstatistics`'s vulnerable side and absent on its fixed side --
the pair already pinned, already measured as a double miss, so the number moves or it does not.
- _Requirements: 4.4, 8.1, 9.1_
- _Depends: 5.8_
- [ ] 5.12 Taint a field where it is assigned ```
- Excluding `$this` from parameter sources cuts PMPro's order class from 16 entailed findings to 6, all hook: $_REQUEST -> ... -> return of set_current_page [half 1]
named-parameter sourced, and makes its pair separate cleanly -- and loses CVE-2023-6559 outright, because key: 'wp_statistics_current_page' literal at both ends
MW WP Form's `_delete_files()` takes no parameters and its attacker-controlled path arrives as return of apply_filters(key) -> ... -> $wpdb->get_row [half 2]
`$this->attachments`. The receiver was kept and the measurement written next to it in `taint.sc`.
- The right fix is field-sensitivity: `$this->attachments` should be tainted at the assignment that fills field: $_POST -> ... -> assignment to $this->attachments [half 1]
it, so a method with no parameters is not either universally tainted or universally clean. key: attachments a field name at both ends
- Observable: CVE-2023-6559 still entailed with the receiver excluded, and PMPro's receiver-sourced count read of $this->attachments -> ... -> unlink [half 2]
down from 11. ```
- _Requirements: 6.1, 9.1_
Neither half is missing from the graph. Only the join is. So this is not a request to make a CPG
frontend draw an edge it cannot draw; it is a second question asked with the query we already have.
- **Stage 1 -- summaries.** The existing taint question, pointed at a synthetic endpoint instead of a
modelled sink: does untrusted data reach the RETURN of a registered callback, or an ASSIGNMENT to
`$this->F`? One extra request per endpoint, on the batch that already runs.
- **The link table.** Built by reading literals, never by inference: every
`add_action`/`add_filter('H', callable)` against every `apply_filters`/`do_action('H', ...)`; every
`$this->F =` against every read of `$this->F` in the same class. The mapper already parses the first of
those for entry points.
- **Stage 2 -- the second half.** Again the existing query: does the `apply_filters` return, or the field
read, reach a modelled sink?
- **Join on the key**, and let the RUNG carry the join's uncertainty, which is what keeps this honest:
- exactly one callback registered for the hook, string literal at both ends -> `model_entailed`;
- several callbacks, or a computed hook name (`"save_post_" . $type`) -> `model_corroborated`, because
the join is a match rather than a resolution, and the witness must say which.
Refuse absolutely the approximation "any hook may reach any handler": that is a complete graph over the
plugin and would entail everything.
- Costs no new dependency, no CPG mutation and no IR. Both stages ride the batch that already runs, which
is the whole reason to express this as a query-level join rather than a graph rewrite.
- **The two compose into a precision win already measured and refused.** With the field half working,
`_delete_files()` is reached through `$this->attachments` rather than through `$this` being tainted
wholesale -- so the receiver can then be dropped from parameter sources, taking PMPro's 11
receiver-sourced findings with it. The fix rejected in 5.8 becomes available once the mechanism under it
is right, and 5.8's numbers are the before-side of that measurement.
- Observable, and both are already-pinned pairs so the numbers move or they do not:
- CVE-2022-25148 entailed on `wpstatistics`'s vulnerable side, absent on its fixed side;
- CVE-2023-6559 still entailed on `mwwpform` WITH the receiver excluded, and PMPro's receiver-sourced
count down from 11 to 0 with its CVE still found.
- Order: the field half first. It is the smaller link table, it has a pinned pair of its own, and it is
what unlocks the receiver change; the hook half then reuses the same two-stage machinery.
- _Requirements: 4.4, 6.1, 8.1, 9.1_
- _Depends: 5.8_ - _Depends: 5.8_
## Group 6 — Regression baselines ## Group 6 — Regression baselines
+11
View File
@@ -113,6 +113,17 @@ cross it, and the honest description is not "we missed it" but "the graph does n
plugin data travels this way. It is the single largest structural gap for PHP, and it is not a fact-table plugin data travels this way. It is the single largest structural gap for PHP, and it is not a fact-table
problem. problem.
It is also not a permanent one, and "structurally impossible" would be the wrong thing to leave here. Note
what is and is not missing: `$_REQUEST` reaching `set_current_page`'s return is a path the graph *has*, and
`apply_filters`' return reaching `$wpdb->get_row` is a path the graph *has*. Only the join between them is
absent — and the key it joins on, `'wp_statistics_current_page'`, is a string literal written at both ends.
The same is true of `$this->attachments` in MW WP Form, where the key is a field name.
So the strategy is two-stage taint with a link table: ask the existing query for each half against a
synthetic endpoint, build the table by reading literals rather than inferring anything, and join. The rung
carries the join's uncertainty — one registered callback and a literal key entails, several callbacks or a
computed hook name corroborates. Task 5.11 states it, with both pinned pairs as its observable.
What the engine reports on that plugin instead is two other sites — `getTop:406` and `TotalCount:443` — the What the engine reports on that plugin instead is two other sites — `getTop:406` and `TotalCount:443` — the
same weakness class at the wrong lines, identical on both sides, so the pair does not separate. They are not same weakness class at the wrong lines, identical on both sides, so the pair does not separate. They are not
noise: upstream now annotates both with `// phpcs:ignore WordPress.DB.PreparedSQL...`, and later rewrote noise: upstream now annotates both with `// phpcs:ignore WordPress.DB.PreparedSQL...`, and later rewrote