fix(mapping): an array callable registers a handler too

`array( $this, 'method' )` is the normal way a class-based WordPress plugin
registers a callback, and the recogniser only matched the bare string form.

The cost was precise. Paid Memberships Pro registers every REST route that
way, so `pmpro_rest_api_get_order` -- the entry point that receives
CVE-2023-23488's tainted `code` parameter -- was classified as a plain
function at rank 0.30, position 1036 of 1577, below the 500-region budget.
It was never asked about. Meanwhile `getMemberOrderByCode`, the sink two
calls away, WAS scanned at position 450 and found nothing, because its
`$code` is an ordinary parameter and only an entry point's parameters are
treated as untrusted.

So the scan examined the sink, missed the source, and reported the plugin
clean. Recognising the array callable puts the handler at position 290 with
rank 0.80 and `source=entry_point`, which is what gives it the
interprocedural question that task 2.4 built for exactly this shape.

Gates byte-identical.

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 16:36:12 +02:00
co-authored by Claude Opus 5
parent 92677136d7
commit 9020318a83
2 changed files with 46 additions and 4 deletions
+30
View File
@@ -302,3 +302,33 @@ def test_every_php_function_becomes_a_region(tmp_path: Path) -> None:
regions = regions_for(analyze_entry_points(tmp_path, targets), targets)
assert {region.function for region in regions} >= {"a", "b"}
def test_an_array_callable_is_a_handler_too(tmp_path: Path) -> None:
"""`array( $this, 'method' )` is the normal form in a class-based plugin.
Paid Memberships Pro registers every REST route that way, and matching only the string form left its
handlers ranked as plain functions below the scan budget -- so the entry point carrying CVE-2023-23488's
source was never asked about, while the sink two calls away was.
"""
from openultrasast.mapping import analyze_entry_points
from openultrasast.preprocess import preprocess_repository
(tmp_path / "api.php").write_text(
"<?php\n"
"register_rest_route( $ns, '/order', array( array(\n"
" 'methods' => 'GET',\n"
" 'callback' => array( $this, 'get_order' ),\n"
" 'permission_callback' => array( $this, 'permissions_check' )\n"
") ) );\n"
"add_action('wp_ajax_nopriv_fetch', array( $this, 'fetch' ));\n"
"function get_order($request) { echo 1; }\n"
"function fetch() { echo 2; }\n"
)
_, targets = preprocess_repository(tmp_path)
by_name = {entry.function_name: entry for entry in analyze_entry_points(tmp_path, targets)}
assert by_name["get_order"].kind == "route", "an array callable registers a route"
assert by_name["get_order"].access_level == "authenticated", "its permission_callback is not __return_true"
assert by_name["fetch"].access_level == "public", "nopriv, through an array callable"