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
+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
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
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