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
+16 -4
View File
@@ -204,12 +204,19 @@ def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointReco
# `wp_ajax_nopriv_*` is admin-ajax for logged-OUT users and is therefore declared public, while `wp_ajax_*`
# fires only for logged-in ones. That is a DECLARED access level, not a guess from an absent decorator, which
# is the distinction task 2.8 turns on.
# A callback is a bare string OR an array callable. `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 budget, so the entry point that carries
# CVE-2023-23488's source was never asked about.
_PHP_CALLABLE = (
r"""(?:['"](?P<handler>[A-Za-z_][A-Za-z0-9_]*)['"]|(?:array\s*\(|\[)\s*\$this\s*,\s*['"](?P<method>[A-Za-z_][A-Za-z0-9_]*)['"])"""
)
_PHP_REGISTRATION = re.compile(
r"""\b(?P<call>add_action|add_filter|add_shortcode)\s*\(\s*['"](?P<hook>[^'"]+)['"]\s*,\s*['"](?P<handler>[A-Za-z_][A-Za-z0-9_]*)['"]""",
r"""\b(?P<call>add_action|add_filter|add_shortcode)\s*\(\s*['"](?P<hook>[^'"]+)['"]\s*,\s*""" + _PHP_CALLABLE,
re.VERBOSE,
)
_PHP_REST_ROUTE = re.compile(r"\bregister_rest_route\s*\(")
_PHP_CALLBACK = re.compile(r"""['"]callback['"]\s*=>\s*['"](?P<handler>[A-Za-z_][A-Za-z0-9_]*)['"]""")
_PHP_CALLBACK = re.compile(r"""['"]callback['"]\s*=>\s*""" + _PHP_CALLABLE)
_PHP_PERMISSION = re.compile(r"""['"]permission_callback['"]\s*=>\s*(?P<value>[^,\)]+)""")
_PHP_FUNCTION = re.compile(r"^\s*function\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(")
@@ -226,7 +233,10 @@ def _php_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]:
for number, line in enumerate(lines, start=1):
for match in _PHP_REGISTRATION.finditer(line):
hook, handler = match.group("hook"), match.group("handler")
hook = match.group("hook")
handler = match.group("handler") or match.group("method")
if not handler:
continue
access, evidence = _wordpress_hook_access(match.group("call"), hook)
start, end = bounds.get(handler, (number, number))
records.append(
@@ -236,7 +246,9 @@ def _php_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]:
window = "\n".join(lines[number - 1 : number + 12])
callback = _PHP_CALLBACK.search(window)
if callback is not None:
handler = callback.group("handler")
handler = callback.group("handler") or callback.group("method")
if not handler:
continue
permission = _PHP_PERMISSION.search(window)
access, evidence = _rest_route_access(permission.group("value").strip() if permission else None)
start, end = bounds.get(handler, (number, number))