fix(cpg): a graph the frontend emptied must not read as a clean repository

Scanning a two-file WordPress slice returned 64 regions examined, zero
findings and zero degradations. The graph it examined had no methods in it.

php2cpg 4.0.623 feeds several files to one `php-parse` process and reads its
stdout and stderr merged. php-parse writes a `====> File <next>:` banner to
stderr the moment it starts the next file, while the previous file's JSON --
megabytes of it -- is still draining from a block-buffered stdout. The banner
lands inside the JSON, ujson reports

    expected json value or } got "=" at index 5011645

and the frontend drops that file. Reproducible on real plugin code, and the
size that matters is one file's AST dump, not the repository's:

    class.memberorder.php alone (1834 lines)     -> 142KB CPG, no failures
    the same file + a two-line file              ->   5.7KB CPG, BOTH dropped

One oversized file empties the whole graph. `joern-parse` then does not
propagate any of the frontend's warnings: 13 lines of output, exit status 0,
"Successfully wrote graph". This is what made a 637-file plugin unscannable
and reported it as clean -- it was never a scale limit.

Two fixes, because the failure arrives on two different paths:

  * `_unparsed_files` reads the per-file warnings the frontend does emit, and
    the scan reports them as `files_unparsed`. This covers the direct-frontend
    path where the warnings are visible.

  * The batched query now carries a `__census__` of the graph -- method and
    file counts -- under a key no request id can collide with, and a scan over
    a graph with no methods degrades as `cpg_empty`. This covers joern-parse,
    which shows nothing. It rides the batch rather than costing its own
    invocation, because avoiding a JVM start is the entire reason batching
    exists.

Neither invents a verdict. There is no rung for "the graph was empty" because
the ladder labels verdicts and none was produced, which is exactly why it has
to be a degradation.

    pmpro two-file slice  before: 0 findings, degradations=[]
                          after:  0 findings, degradations=[cpg_empty]

Gates byte-identical; 840 tests pass.

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:07:28 +02:00
co-authored by Claude Opus 5
parent 37a6594703
commit e0442d146c
7 changed files with 179 additions and 10 deletions
+48
View File
@@ -0,0 +1,48 @@
# WP Statistics 13.1.5 -- a WordPress plugin with an unauthenticated SQL injection reached through a hook.
#
# The third WordPress checkout, and the one chosen because it is NOT expected to be found. PMPro's CVE is a
# concatenation inside the function that receives the value; this one crosses WordPress's filter dispatcher,
# which is the shape most plugin data actually travels in. A table that only lists hits teaches nothing about
# where the engine stops.
#
# Verified in the code. At this commit `includes/class-wp-statistics-pages.php:225` reads:
#
# $exist = $wpdb->get_row("SELECT `page_id` FROM ... AND `id` = {$current_page['id']}", ARRAY_A);
#
# and one line below, the SAME value is used correctly:
#
# $wpdb->query($wpdb->prepare("UPDATE ... AND `id` = %d", $current_page['id']));
#
# The value arrives from an unauthenticated REST hit. `Hits::rest_params()` copies every `$_REQUEST` key into
# `$this->rest_hits`; `Hits::set_current_page()` returns `'id' => $this->rest_hits->current_page_id`; and it
# is bound to the sink not by a call but by
#
# add_filter('wp_statistics_current_page', array($this, 'set_current_page')); # hits.php:43
# return apply_filters('wp_statistics_current_page', $current_page); # pages.php:105
#
# Both ends of that edge name the callback with a STRING, so no frontend can draw it. The fix (bc98b414)
# wraps both values in `esc_sql`, which is a modelled sanitizer -- so if the flow were visible at all, the
# pair would separate.
name = "wpstatistics"
url = "https://github.com/wp-statistics/wp-statistics.git"
commit = "ca66e7b89587dce3ee189f1eb42211ad111db4b7"
language = "php"
license = "GPL-2.0"
license_file = "license.txt"
measures = ["detection"]
note = """
The parent of the fix commit bc98b414 ("Sanitize current_page_id & current_page_type in rest request",
2022-02-13), released in the 13.1.x line. Nothing is vendored -- the recipe fetches on request and CI never
does.
"""
[[known]]
id = "CVE-2022-25148"
family = "injection"
weakness = "unauthenticated SQL injection: current_page_id from an unauthenticated REST hit is interpolated into a $wpdb->get_row query, having crossed the wp_statistics_current_page filter"
file = "includes/class-wp-statistics-pages.php"
function = "record"
line = 225
reference = "fixed in bc98b4146afd6f4b29fda1136772dcf1a42f26e6, which applies esc_sql to current_page_type and current_page_id at their source; the vulnerable line is verified in the checkout"
in_scope = true