feat(cpg): the hook half — CVE-2022-25148 found across an add_filter/apply_filters edge

The last of the three WordPress flaw classes 5.8 measured as missed. The
value crosses a registry inside WordPress core:

    add_filter('wp_statistics_current_page', array($this, 'set_current_page'));
    return apply_filters('wp_statistics_current_page', $current_page);

php2cpg drops the callback argument entirely -- the CPG holds
`add_filter("wp_statistics_current_page", )` -- so the edge cannot come from
the graph at any price. It comes from the source text instead, where both ends
are literals, via `mapping.php_hook_callbacks`.

                 vulnerable                              fixed
  wpstatistics   3 entailed, incl. pages.php:225:record  2, that one absent
  mwwpform       1 ($this->attachments)                  0
  pmpro          18, incl. CVE at :936                   1
  VAmPI          4                                       unchanged

The correctly-prepared sibling query one line below the CVE is not flagged on
either side.

Four things this cost, each measured rather than guessed:

  * Half one must be asked per ARRAY KEY, not per callback. "Does an
    unsanitized value reach this callback's return" is TRUE on both sides: the
    fix escapes `type` and `id` and leaves `search_query` alone. A
    callback-level answer flags the fixed side as readily as the vulnerable
    one, which is a leak, not a detection. The key is a literal at both ends,
    so it joins the way the hook name and the field name do -- the same idea
    for the third time.

  * The sanitizer test had to stop crediting a sibling's cleansing. The
    vulnerable query is ONE concatenation holding both `esc_sql($page_uri)`
    and the injectable `{$current_page['id']}`, so asking whether a
    sanitizer's name appears in a path element's text marked the whole flow
    sanitized. An element must now BE the cleansing -- a call to it, or its
    name as a string literal, which keeps `array_map('esc_sql', ...)` working.

  * Hook seeds are repository-wide where field seeds are file-scoped. A hook
    registry is global by construction: the callback is in one file and its
    `apply_filters` in another. The query returned nothing until those two
    scopes were separated.

  * `sourceKind` had to be REPORTED, not inferred. By the time a row reaches
    Python a hook source reads as `$current_page["id"]` and a parameter as
    `$args`, and `_usable_flows` discards parameter flows whenever a modelled
    one is present -- so seven sanitized `$_SERVER["REQUEST_URI"]` flows
    displaced the CVE between the query and the verdict. That preference then
    had to move PER SINK SITE: applied across a region it decides one site's
    question with another site's evidence, and PMPro's CVE at :936 vanished
    from a region still reporting fifteen other findings.

`verdict` now delegates to `verdicts` rather than running its own selection
over the union, because two copies of "which flow decides" is exactly the
duplication this module has been bitten by before -- and had been again: the
per-site preference did not reach the single-verdict path, so the two entry
points disagreed about the same rows.

841 tests pass; gates byte-identical; VAmPI unchanged.

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 20:42:35 +02:00
co-authored by Claude Opus 5
parent dcdbe322a1
commit d6908f18ba
6 changed files with 362 additions and 40 deletions
+30 -2
View File
@@ -146,6 +146,12 @@ def scan_repository(
# spent in belongs to whoever holds the budget.
ordered_regions = sorted(regions, key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
# The hook link table, built once for the whole scan. It is a property of the repository rather than of
# any region, and it is read from the source text because the graph does not carry it: php2cpg drops a
# registration's callback argument, so `add_filter("hook", array($this, "m"))` reaches the CPG as
# `add_filter("hook", )`. See `mapping.php_hook_callbacks`.
hooks = _hook_callbacks(root, regions)
# Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet.
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
for region in ordered_regions[: limits.max_regions]:
@@ -162,7 +168,7 @@ def scan_repository(
per_kind: dict[str, float] = {}
census_reported = False
query_started = time.monotonic()
for kind, requests in _grouped(work).items():
for kind, requests in _grouped(work, hook_callbacks=hooks).items():
kind_started = time.monotonic()
batch = getattr(cpg, "run_batch", None)
if callable(batch):
@@ -276,7 +282,9 @@ def _prefetched(rows: list[object]): # type: ignore[no-untyped-def]
return run
def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, dict[str, Mapping[str, object]]]:
def _grouped(
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], *, hook_callbacks: str = ""
) -> dict[str, dict[str, Mapping[str, object]]]:
"""The work, keyed by query kind, with each request built by the arbiter's OWN parameter function.
Building the parameters here instead would be a second copy that drifts from the arbiter's -- the exact
@@ -299,12 +307,32 @@ def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, d
file=region.path,
parameter_sources=entry,
call_depth=ENTRY_POINT_CALL_DEPTH if entry else 0,
hook_callbacks=hook_callbacks,
),
)
grouped.setdefault(kind, {})[rid] = params
return grouped
def _hook_callbacks(root: Path, regions: Sequence[ScanRegion]) -> str:
"""``hook:callback;hook:callback`` for the PHP files this scan is about, or ``""``.
Rendered here rather than in the query because a CPGQL parameter is a string, and rendered from the
regions rather than by walking the tree because the regions already name every file in scope.
"""
paths = sorted({region.path for region in regions if region.language == "php" and region.path})
if not paths:
return ""
from ..mapping import php_hook_callbacks
from ..preprocess import FileTarget
targets = [
FileTarget(path=path, absolute_path=str(root / path), language="php", loc=0, tags=[], has_fuzz_entry_point=False) for path in paths
]
table = php_hook_callbacks(root, targets)
return ";".join(f"{hook}:{name}" for hook, names in table.items() for name in names)
def _is_entry_point(region: ScanRegion) -> bool:
"""Is this region a place untrusted input actually enters?