From e29f854fe45b1b5a41676a6d512804d4d0ef8996 Mon Sep 17 00:00:00 2001 From: noelsaw1 Date: Tue, 26 May 2026 12:10:31 -0700 Subject: [PATCH 1/3] Update .gitignore --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index c23d97c..dd2612b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ /.claude +/vendor +PROJECT/.DS_Store +composer.lock +PROJECT/1-INBOX/.DS_Store +PROJECT/2-WORKING/.DS_Store +PROJECT/3-DONE/.DS_Store +PROJECT/4-MISC/.DS_Store +.phpunit.result.cache From 875e5518dfdd169d76584d604728a4a5cbfb9e4c Mon Sep 17 00:00:00 2001 From: noelsaw1 Date: Sat, 30 May 2026 20:39:43 -0700 Subject: [PATCH 2/3] feat: SQL classifier, 7 log fields, 10s admin_ajax ceiling, PHPUnit suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation ---------- On 2026-05-30 a production WooCommerce store suffered a DB saturation incident via admin-ajax.php. A `wp_comments WHERE comment_ID IN (… 10,639 IDs …)` query fired repeatedly through order-note loading, consumed large DB time per execution, and generated repeated "MySQL server has gone away" errors. This commit reduces blast radius for that exact class of failure and adds the logging fields needed to identify it in future incidents. Changes ------- * admin_ajax ceiling: 20 s → 10 s Halves per-execution DB exposure when concurrent ajax workers pile up. Checkout (60 s) and wp_admin (45 s) are unchanged. Operators can relax specific actions via hypercart_query_guard_limit_ms filter. * classify_sql( $sql ): array — public static Cheap, safe SQL shape detection (one anchored preg_match + substr_count). Returns: table_hint, is_comment_query, has_large_in_list, estimated_in_list_size, is_probable_woocommerce, is_probable_order_note_query. Only classifies SELECT; writes return all-default values. Threshold for "large" IN list: 200 items (LARGE_IN_LIST_THRESHOLD const). * is_admin_ajax_request( $uri ): bool — public static Simple strpos check; public for testability. * Both slow_query and query_killed events carry all 7 new fields: is_admin_ajax + the 6 classifier fields. * hypercart_query_guard_log_payload filter added inside log() so callers can add custom fields, route to extra sinks, or intercept payloads in integration tests. * PHPUnit suite: phpunit.xml, tests/bootstrap.php (WP function stubs), tests/SqlClassifierTest.php (22 tests), tests/PayloadFieldsTest.php (13 tests). 35 tests, 117 assertions. * README: updated admin_ajax limit table entry, expanded Logging section with classification field reference, added enforce-mode rollout guide. * CHANGELOG: Unreleased block documenting all changes. --- CHANGELOG.md | 22 ++- README.md | 61 +++++++- hypercart-query-guard.php | 166 ++++++++++++++++++-- phpunit.xml | 14 ++ tests/PayloadFieldsTest.php | 292 ++++++++++++++++++++++++++++++++++++ tests/SqlClassifierTest.php | 243 ++++++++++++++++++++++++++++++ tests/bootstrap.php | 153 +++++++++++++++++++ 7 files changed, 931 insertions(+), 20 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/PayloadFieldsTest.php create mode 100644 tests/SqlClassifierTest.php create mode 100644 tests/bootstrap.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c372f22..2b257bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,25 @@ All notable changes to Hypercart Query Guard are documented here. -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every merged build carries a version number. -## [Unreleased] +## [1.1.0] — 2026-05-30 + +### Changed + +- **`admin_ajax` execution-time ceiling tightened from 20 s to 10 s.** `admin-ajax.php` is the primary vector for runaway read queries (WC order-note loading, Facebook background sync, NoFraud). Halving the ceiling halves the per-execution DB exposure when concurrent workers pile up. Legitimate heavyweight reads that need > 10 s should use the REST API (30 s ceiling) or `wp-admin` (45 s ceiling). The `checkout` context (60 s) is unaffected — it is detected before `admin_ajax`. Operators can relax individual endpoints via the `hypercart_query_guard_limit_ms` filter. + +### Added + +- **SQL shape classifier (`classify_sql`).** A new `public static` method inspects a SQL string cheaply (plain string operations + one anchored regex for `IN (`) and returns six classification fields: `table_hint`, `is_comment_query`, `has_large_in_list`, `estimated_in_list_size`, `is_probable_woocommerce`, and `is_probable_order_note_query`. Only `SELECT` statements are classified; writes return all-default values. The classifier's large-IN threshold is 200 items (constant `LARGE_IN_LIST_THRESHOLD`). + +- **`is_admin_ajax_request` helper.** Public static method — takes a URI string and returns `true` when it routes through `admin-ajax.php`. Used for logging; no behaviour change. + +- **Seven new structured fields on `slow_query` and `query_killed` log events.** Both event types now carry `is_admin_ajax`, `table_hint`, `is_comment_query`, `has_large_in_list`, `estimated_in_list_size`, `is_probable_woocommerce`, and `is_probable_order_note_query`. These fields directly address the May 2026 production incident where a WooCommerce order-note query (`wp_comments … comment_ID IN (… 10,639 IDs …)`) running via `admin-ajax.php` consumed a large share of DB time and caused repeated `MySQL server has gone away` errors. + +- **`hypercart_query_guard_log_payload` filter.** Fires inside `log()` before emission. Allows callers to add custom fields, route to additional sinks, or inspect payloads in integration tests. + +- **PHPUnit test suite.** `phpunit.xml`, `tests/bootstrap.php` (WordPress function stubs), `tests/SqlClassifierTest.php` (22 tests covering classifier edge cases), and `tests/PayloadFieldsTest.php` (13 tests verifying both `query_killed` and `slow_query` payload shapes). 35 tests, 117 assertions. ### Fixed @@ -16,6 +32,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - **Every killed query in a request is now logged, not just the last one.** `detect_and_log_kill()` previously ran only on `shutdown` and read `$wpdb->last_error`, which gets overwritten by each subsequent query. A request that triggered N kills produced exactly one log line — the final one. Capture is now incremental: a `query` filter callback inspects `$wpdb->last_error` before each subsequent `wpdb::query()` clears it via `flush()`, and a high-water mark on `$wpdb->num_queries` makes the capture idempotent so duplicate error strings (the NoFraud thundering-herd pattern) are still counted as distinct kills. Shutdown remains the fallback for the request's final query. -## [1.0.0] +## [1.0.0] — 2026-05-01 Initial release. diff --git a/README.md b/README.md index a3f175f..a9d00e2 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Limits are applied per request context. Override via the `hypercart_query_guard_ | WP-CLI | unlimited | Long-running migrations, imports | | Action Scheduler | unlimited | Background workers, writes anyway | | `wp-cron.php` | 10 seconds | Cron should never be slow; cheap signal | -| `admin-ajax.php` | 20 seconds | Most spike vectors live here (FB sync, NoFraud) | +| `admin-ajax.php` | 10 seconds | Most spike vectors live here (FB sync, WC order-note IN lists, NoFraud). Tightened from 20 s to reduce per-execution DB exposure; heavyweight reads should use REST API or `wp-admin` instead. | | REST API | 30 seconds | Klaviyo polling, WC REST `/orders` | | Frontend (default) | 30 seconds | Product pages, my-account | | `wp-admin` | 45 seconds | Admin search/filter operations tolerated longer | @@ -78,9 +78,43 @@ If `Hypercart_Logger` (from the Hypercart Performance Monitor plugin) is present Two event types: -- **`slow_query`** *(warn)* — query exceeded 5s but completed; sampled in observe mode, always in enforce mode. +- **`slow_query`** *(warn)* — query exceeded 5 s but completed; sampled in observe mode, always in enforce mode. - **`query_killed`** *(error)* — MySQL killed the query for hitting the limit; only emitted in enforce mode. +### SQL classification fields + +Both events carry additional classification fields derived from cheap, safe string inspection of the SQL. These fields are designed for post-incident filtering and alerting. + +| Field | Type | Description | +|---|---|---| +| `is_admin_ajax` | bool | `true` when the request URI routes through `admin-ajax.php` | +| `table_hint` | string | `'wp_comments'` when that table appears in the SQL, else `''` | +| `is_comment_query` | bool | `true` when `table_hint === 'wp_comments'` | +| `has_large_in_list` | bool | `true` when the SQL contains an `IN (…)` list with ≥ 200 items | +| `estimated_in_list_size` | int | Comma count + 1 inside the first `IN (…)` found; `0` if none | +| `is_probable_woocommerce` | bool | `true` when WooCommerce table/keyword hints are present | +| `is_probable_order_note_query` | bool | `true` when the query targets `wp_comments` and mentions `order_note` (WC stores order notes as comment rows) | + +The classifier only runs on `SELECT` statements; writes return all-default values. No SQL mutation occurs. + +### Filtering the log payload + +A `hypercart_query_guard_log_payload` filter fires before each log emission. Use it to add custom fields, route to additional sinks, or tighten alerting: + +```php +add_filter( 'hypercart_query_guard_log_payload', function( $payload, $level ) { + // Page-level alert for killed order-note queries from admin-ajax. + if ( + $level === 'error' && + $payload['is_probable_order_note_query'] && + $payload['is_admin_ajax'] + ) { + my_pagerduty_alert( $payload ); + } + return $payload; +}, 10, 2 ); +``` + ## Limitations and caveats - **`init` priority 1 is not the earliest possible hook — early-boot queries are unprotected.** The `SET SESSION` is applied on `init` priority 1, so anything that queries the database before then runs without the ceiling. In practice the unprotected window contains: @@ -94,10 +128,33 @@ Two event types: - **WP Engine reconnects.** WPE's MySQL proxy occasionally rotates connections mid-request. The static `$last_dbh` identity check detects this and re-applies the limit automatically. - **Older MySQL.** `MAX_EXECUTION_TIME` requires MySQL 5.7.8+ or Percona/MariaDB equivalents. The plugin suppresses errors on the `SET SESSION` itself, so an unsupported server fails open (no protection, no breakage). +## Enforce-mode rollout recommendation + +For stores exposed to high-frequency admin-ajax traffic (WooCommerce order management, background syncs, order-note loading): + +1. **Start in observe mode for at least 48 hours.** Watch for `slow_query` events with `is_admin_ajax: true`, `is_comment_query: true`, or `has_large_in_list: true`. These identify the queries that enforce mode will kill. +2. **Check Action Scheduler detection.** If you see `slow_query` events with `context: admin_ajax` from queries you know are AS workers, verify the AS action names in `detect_context()` match your version. The static memo in `apply_session_timeout()` locks in the tier on first call; a mis-detected AS worker gets a 10 s ceiling for the rest of that request. +3. **Enable enforce mode.** Set `define( 'HYPERCART_QUERY_GUARD_MODE', 'enforce' )` in `wp-config.php`. Monitor `query_killed` events for 24 hours. Any killed query with `event: query_killed`, `is_probable_order_note_query: true`, `has_large_in_list: true`, and `is_admin_ajax: true` is the exact failure mode this plugin was hardened for. +4. **If a legitimate endpoint needs > 10 s,** use the limit filter to relax that specific action — do not raise the global `admin_ajax` ceiling: + +```php +add_filter( 'hypercart_query_guard_limit_ms', function( $ms, $context ) { + if ( $context === 'admin_ajax' && isset( $_REQUEST['action'] ) ) { + // Relax only for the specific WC AJAX action that legitimately needs more time. + if ( $_REQUEST['action'] === 'my_slow_but_necessary_action' ) { + return 30000; + } + } + return $ms; +}, 10, 2 ); +``` + ## Origin Built in response to a CPU-saturation incident on a high-volume WooCommerce store where a single Facebook background sync query (`SELECT … FROM wp_comments WHERE comment_ID IN (… 10,010 items …)`) running concurrently with itself took down the whole pod. The kill switch is the cheapest insurance against that class of failure: a few hours of work, indefinite payoff. +On 2026-05-30 a production store suffered the same shape of incident via WooCommerce order-note loading: `comment_ID IN (… 10,639 IDs …)` fired repeatedly through `admin-ajax.php`, consumed large DB time per execution, and generated repeated `MySQL server has gone away` errors. The SQL classification fields and 10 s `admin_ajax` ceiling were added in response. + ## License This plugin is licensed under the **GNU General Public License v2.0 or later** (GPL-2.0-or-later), the same license as WordPress itself. diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 19af369..27154e0 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -3,7 +3,7 @@ * Plugin Name: Hypercart Query Guard * Plugin URI: https://hypercart.io * Description: PHP-side circuit breaker that enforces MySQL MAX_EXECUTION_TIME on read queries to prevent runaway SELECTs from saturating the pod. Tiered limits per request context, observe-mode for safe rollout, automatic re-application on connection rotation, and admin-search timeout fallback. - * Version: 1.0.0 + * Version: 1.1.0 * Author: Hypercart / Neochrome * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -63,7 +63,16 @@ final class Hypercart_Query_Guard { 'wp_cli' => 0, 'action_scheduler' => 0, 'wp_cron' => 10000, - 'admin_ajax' => 20000, + // Tightened from 20 s → 10 s (2026-05-30). + // admin-ajax.php is the dominant vector for runaway read queries (Facebook + // background sync, WC order-note loading via oversized IN lists, NoFraud). + // Halving the ceiling halves the per-execution DB exposure when concurrent + // workers pile up. Legitimate heavyweight reads that truly need > 10 s should + // use the REST API (30 s ceiling) or be routed through WP-Admin (45 s ceiling). + // Operators can relax for specific actions via the hypercart_query_guard_limit_ms + // filter. The checkout context (60 s) is unchanged — it is detected before + // admin_ajax and covers both the AJAX and block REST checkout endpoints. + 'admin_ajax' => 10000, 'rest_api' => 30000, 'checkout' => 60000, 'wp_admin' => 45000, @@ -270,6 +279,14 @@ public static function apply_session_timeout() { $last_limit = $limit_ms; } + /** + * Threshold for classifying an IN-list as "large". Queries with an IN + * clause containing this many or more items are flagged in log payloads. + * Typical WordPress IN lists (get_posts with specific IDs, WC item rows) + * stay well under 50; anything above 200 is unusual enough to log. + */ + const LARGE_IN_LIST_THRESHOLD = 200; + /** * High-water mark for the last query number we've already inspected * for a kill. wpdb::$num_queries is monotonic, so comparing against @@ -332,14 +349,24 @@ private static function capture_pending_kill() { return; } + $uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; + $classification = self::classify_sql( (string) $wpdb->last_query ); + $payload = array( - 'event' => 'query_killed', - 'context' => self::detect_context(), - 'limit_ms' => self::get_limit_ms(), - 'last_query' => self::truncate( (string) $wpdb->last_query, 500 ), - 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '', - 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, - 'time' => time(), + 'event' => 'query_killed', + 'context' => self::detect_context(), + 'limit_ms' => self::get_limit_ms(), + 'last_query' => self::truncate( (string) $wpdb->last_query, 500 ), + 'uri' => $uri, + 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, + 'time' => time(), + 'is_admin_ajax' => self::is_admin_ajax_request( $uri ), + 'table_hint' => $classification['table_hint'], + 'is_comment_query' => $classification['is_comment_query'], + 'has_large_in_list' => $classification['has_large_in_list'], + 'estimated_in_list_size' => $classification['estimated_in_list_size'], + 'is_probable_woocommerce' => $classification['is_probable_woocommerce'], + 'is_probable_order_note_query' => $classification['is_probable_order_note_query'], ); self::log( 'error', $payload ); @@ -415,20 +442,119 @@ public static function log_slow_queries() { if ( ! isset( $row[1] ) || $row[1] < $threshold_s ) { continue; } + $query_sql = (string) $row[0]; + $classification = self::classify_sql( $query_sql ); self::log( 'warn', array( - 'event' => 'slow_query', - 'context' => $context, - 'duration_ms' => (int) ( $row[1] * 1000 ), - 'query' => self::truncate( (string) $row[0], 500 ), - 'caller' => isset( $row[2] ) ? self::truncate( (string) $row[2], 500 ) : '', - 'uri' => $uri, + 'event' => 'slow_query', + 'context' => $context, + 'duration_ms' => (int) ( $row[1] * 1000 ), + 'query' => self::truncate( $query_sql, 500 ), + 'caller' => isset( $row[2] ) ? self::truncate( (string) $row[2], 500 ) : '', + 'uri' => $uri, + 'is_admin_ajax' => self::is_admin_ajax_request( $uri ), + 'table_hint' => $classification['table_hint'], + 'is_comment_query' => $classification['is_comment_query'], + 'has_large_in_list' => $classification['has_large_in_list'], + 'estimated_in_list_size' => $classification['estimated_in_list_size'], + 'is_probable_woocommerce' => $classification['is_probable_woocommerce'], + 'is_probable_order_note_query' => $classification['is_probable_order_note_query'], ) ); } } + /** + * Classify a SQL string for incident-visibility fields. Returns an array + * of cheap, safe heuristics derived from plain string operations. + * Only classifies reads (SELECT); writes return all-default values because + * MAX_EXECUTION_TIME cannot kill writes by MySQL design. + * + * Returned keys: + * table_hint – 'wp_comments' when the table appears in the SQL, else '' + * is_comment_query – true when table_hint === 'wp_comments' + * has_large_in_list – true when an IN list has >= LARGE_IN_LIST_THRESHOLD items + * estimated_in_list_size – item count (commas + 1) of the first IN list found, else 0 + * is_probable_woocommerce – true when WC table / keyword hints are found + * is_probable_order_note_query – true when it's a wp_comments query with 'order_note' + * + * @param string $sql Raw SQL string (unsanitised, read-only inspection). + * @return array + */ + public static function classify_sql( $sql ) { + $result = array( + 'table_hint' => '', + 'is_comment_query' => false, + 'has_large_in_list' => false, + 'estimated_in_list_size' => 0, + 'is_probable_woocommerce' => false, + 'is_probable_order_note_query' => false, + ); + + if ( ! is_string( $sql ) || '' === $sql ) { + return $result; + } + + // Only classify reads; writes can't be killed by MAX_EXECUTION_TIME anyway. + if ( 0 !== strncasecmp( ltrim( $sql ), 'SELECT', 6 ) ) { + return $result; + } + + // ---- Table hint: wp_comments ---- + if ( false !== stripos( $sql, 'wp_comments' ) ) { + $result['table_hint'] = 'wp_comments'; + $result['is_comment_query'] = true; + } + + // ---- Large IN list ---- + // preg_match locates 'IN (' robustly with no backtracking risk; the body + // is then measured with substr_count (O(n), no regex engine needed). + if ( preg_match( '/\bIN\s*\(/i', $sql, $in_match, PREG_OFFSET_CAPTURE ) ) { + $open = $in_match[0][1] + strlen( $in_match[0][0] ); + $scan_limit = min( strlen( $sql ) - $open, 524288 ); // cap at 512 KB + $body_chunk = substr( $sql, $open, $scan_limit ); + $close = strpos( $body_chunk, ')' ); + if ( false !== $close ) { + $in_body = substr( $body_chunk, 0, $close ); + $commas = substr_count( $in_body, ',' ); + $result['estimated_in_list_size'] = $commas + 1; + $result['has_large_in_list'] = ( $result['estimated_in_list_size'] >= self::LARGE_IN_LIST_THRESHOLD ); + } + } + + // ---- WooCommerce heuristics ---- + foreach ( array( 'woocommerce', 'wc_order', "post_type = 'shop_order'", "post_type='shop_order'" ) as $needle ) { + if ( false !== stripos( $sql, $needle ) ) { + $result['is_probable_woocommerce'] = true; + break; + } + } + + // ---- Order-note heuristic ---- + // WC stores order notes as wp_comments rows with comment_type = 'order_note'. + // The query that caused the May 2026 production incident was exactly this shape. + if ( + $result['is_comment_query'] && + ( false !== stripos( $sql, 'order_note' ) || false !== stripos( $sql, 'order-note' ) ) + ) { + $result['is_probable_order_note_query'] = true; + $result['is_probable_woocommerce'] = true; + } + + return $result; + } + + /** + * Return true when the given request URI routes through admin-ajax.php. + * + * @param string $uri Value of $_SERVER['REQUEST_URI']. + * @return bool + */ + public static function is_admin_ajax_request( $uri ) { + return false !== strpos( (string) $uri, 'admin-ajax.php' ); + } + /** * Centralized log emitter. Prefers Hypercart_Logger if present (your * existing file-based logger from the Performance Monitor plugin), @@ -438,6 +564,16 @@ public static function log_slow_queries() { * @param array $payload Structured fields. */ private static function log( $level, array $payload ) { + /** + * Filter the structured log payload before emission. Use this hook to + * add custom fields, route to additional sinks, or intercept payloads + * in integration tests. + * + * @param array $payload Log fields. + * @param string $level 'warn' | 'error' + */ + $payload = (array) apply_filters( 'hypercart_query_guard_log_payload', $payload, $level ); + if ( class_exists( 'Hypercart_Logger' ) ) { if ( 'error' === $level && method_exists( 'Hypercart_Logger', 'error' ) ) { Hypercart_Logger::error( 'query_guard', $payload ); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9d37c6b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,14 @@ + + + + + tests + + + diff --git a/tests/PayloadFieldsTest.php b/tests/PayloadFieldsTest.php new file mode 100644 index 0000000..a9b3e97 --- /dev/null +++ b/tests/PayloadFieldsTest.php @@ -0,0 +1,292 @@ +captured = null; + // Clear any test-specific hook registered by a previous test. + remove_all_filters( 'hypercart_query_guard_log_payload' ); + // Reset SERVER URI so context detection doesn't bleed across tests. + $_SERVER['REQUEST_URI'] = '/'; + } + + protected function tearDown(): void { + // Remove the capture hook and unset the mock $wpdb global. + remove_all_filters( 'hypercart_query_guard_log_payload' ); + global $wpdb; + $wpdb = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride + $_SERVER['REQUEST_URI'] = '/'; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Install the log-payload capture hook and store the payload for assertion. + */ + private function capture_payload() { + add_filter( + 'hypercart_query_guard_log_payload', + function ( $payload ) { + $this->captured = $payload; + return $payload; + } + ); + } + + /** + * Reset the private static kill-detection high-water mark to -1. + */ + private function reset_kill_counter() { + $ref = new ReflectionClass( Hypercart_Query_Guard::class ); + $prop = $ref->getProperty( 'last_checked_query_num' ); + $prop->setAccessible( true ); + $prop->setValue( null, -1 ); + } + + /** + * Build a minimal $wpdb mock that looks like a killed query. + * + * @param string $last_query SQL of the killed query. + * @param int $num_queries Monotonic query counter value. + * @return stdClass + */ + private function mock_killed_wpdb( $last_query, $num_queries = 1 ) { + $mock = new stdClass(); + $mock->last_error = 'Query execution was interrupted, maximum statement execution time exceeded'; + $mock->last_query = $last_query; + $mock->num_queries = $num_queries; + return $mock; + } + + // ----------------------------------------------------------------------- + // query_killed event — classification fields present + // ----------------------------------------------------------------------- + + public function test_kill_payload_has_all_classification_fields() { + global $wpdb; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT comment_ID FROM wp_comments WHERE comment_ID IN ({$ids})", + 10 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured, 'log payload was not captured' ); + $expected_keys = array( + 'table_hint', + 'is_comment_query', + 'has_large_in_list', + 'estimated_in_list_size', + 'is_probable_woocommerce', + 'is_probable_order_note_query', + 'is_admin_ajax', + ); + foreach ( $expected_keys as $key ) { + $this->assertArrayHasKey( $key, $this->captured, "Missing key: {$key}" ); + } + } + + public function test_kill_payload_classifies_large_wp_comments_query() { + global $wpdb; + $ids = implode( ', ', range( 1, 500 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT comment_ID, comment_content FROM wp_comments WHERE comment_ID IN ({$ids})", + 20 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertSame( 'query_killed', $this->captured['event'] ); + $this->assertSame( 'wp_comments', $this->captured['table_hint'] ); + $this->assertTrue( $this->captured['is_comment_query'] ); + $this->assertTrue( $this->captured['has_large_in_list'] ); + $this->assertSame( 500, $this->captured['estimated_in_list_size'] ); + $this->assertFalse( $this->captured['is_probable_order_note_query'] ); + } + + public function test_kill_payload_classifies_order_note_query() { + global $wpdb; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT * FROM wp_comments WHERE comment_type = 'order_note' AND comment_ID IN ({$ids})", + 30 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertTrue( $this->captured['is_probable_order_note_query'] ); + $this->assertTrue( $this->captured['is_probable_woocommerce'] ); + $this->assertTrue( $this->captured['is_comment_query'] ); + } + + public function test_kill_payload_is_admin_ajax_true_for_ajax_uri() { + global $wpdb; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes'; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT * FROM wp_comments WHERE comment_ID IN ({$ids})", + 40 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertTrue( $this->captured['is_admin_ajax'] ); + $this->assertSame( '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes', $this->captured['uri'] ); + } + + public function test_kill_payload_is_admin_ajax_false_for_non_ajax_uri() { + global $wpdb; + $_SERVER['REQUEST_URI'] = '/wp-admin/edit.php?post_type=shop_order'; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT * FROM wp_comments WHERE comment_ID IN ({$ids})", + 50 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertFalse( $this->captured['is_admin_ajax'] ); + } + + public function test_kill_payload_clean_query_no_large_in_or_comments_flags() { + global $wpdb; + $wpdb = $this->mock_killed_wpdb( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 60 ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertSame( '', $this->captured['table_hint'] ); + $this->assertFalse( $this->captured['is_comment_query'] ); + $this->assertFalse( $this->captured['has_large_in_list'] ); + $this->assertSame( 0, $this->captured['estimated_in_list_size'] ); + $this->assertFalse( $this->captured['is_probable_woocommerce'] ); + $this->assertFalse( $this->captured['is_probable_order_note_query'] ); + } + + public function test_non_kill_error_does_not_emit_payload() { + global $wpdb; + $mock = new stdClass(); + $mock->last_error = 'Table wp_comments does not exist'; + $mock->last_query = 'SELECT * FROM wp_comments'; + $mock->num_queries = 70; + $wpdb = $mock; + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNull( $this->captured, 'Non-kill error should not emit a payload' ); + } + + // ----------------------------------------------------------------------- + // slow_query event — classification fields present + // ----------------------------------------------------------------------- + + public function test_slow_query_payload_has_classification_fields() { + global $wpdb; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = new stdClass(); + $wpdb->queries = array( + array( + "SELECT comment_ID FROM wp_comments WHERE comment_type = 'order_note' AND comment_ID IN ({$ids})", + 6.5, // 6.5 s > WARN_THRESHOLD_MS (5 s) + 'WC_Order_Notes::get_notes', + ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNotNull( $this->captured, 'slow_query payload was not captured' ); + $this->assertSame( 'slow_query', $this->captured['event'] ); + + foreach ( array( 'table_hint', 'is_comment_query', 'has_large_in_list', 'estimated_in_list_size', 'is_probable_woocommerce', 'is_probable_order_note_query', 'is_admin_ajax' ) as $key ) { + $this->assertArrayHasKey( $key, $this->captured, "Missing key: {$key}" ); + } + + $this->assertSame( 'wp_comments', $this->captured['table_hint'] ); + $this->assertTrue( $this->captured['is_comment_query'] ); + $this->assertTrue( $this->captured['has_large_in_list'] ); + $this->assertTrue( $this->captured['is_probable_order_note_query'] ); + $this->assertTrue( $this->captured['is_probable_woocommerce'] ); + } + + public function test_slow_query_payload_is_admin_ajax_for_ajax_uri() { + global $wpdb; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes'; + $wpdb = new stdClass(); + $wpdb->queries = array( + array( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 7.0, 'SomeClass::method' ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNotNull( $this->captured ); + $this->assertTrue( $this->captured['is_admin_ajax'] ); + } + + public function test_slow_query_below_threshold_not_emitted() { + global $wpdb; + $wpdb = new stdClass(); + $wpdb->queries = array( + array( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 1.5, 'SomeClass::method' ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNull( $this->captured, 'Sub-threshold query must not produce a payload' ); + } + + public function test_slow_query_normal_select_flags_are_false() { + global $wpdb; + $wpdb = new stdClass(); + $wpdb->queries = array( + array( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 6.0, 'SomeClass::method' ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNotNull( $this->captured ); + $this->assertSame( '', $this->captured['table_hint'] ); + $this->assertFalse( $this->captured['is_comment_query'] ); + $this->assertFalse( $this->captured['has_large_in_list'] ); + $this->assertFalse( $this->captured['is_probable_woocommerce'] ); + $this->assertFalse( $this->captured['is_probable_order_note_query'] ); + } +} diff --git a/tests/SqlClassifierTest.php b/tests/SqlClassifierTest.php new file mode 100644 index 0000000..20a15c8 --- /dev/null +++ b/tests/SqlClassifierTest.php @@ -0,0 +1,243 @@ +assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 0, $r['estimated_in_list_size'] ); + $this->assertFalse( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + public function test_non_string_returns_all_defaults() { + // @phpstan-ignore-next-line intentional wrong type for guard test + $r = Hypercart_Query_Guard::classify_sql( null ); + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['has_large_in_list'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — write queries are not classified + // ----------------------------------------------------------------------- + + public function test_update_query_not_classified() { + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "UPDATE wp_comments SET comment_content = 'x' WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 0, $r['estimated_in_list_size'] ); + } + + public function test_delete_query_not_classified() { + $sql = 'DELETE FROM wp_comments WHERE comment_ID IN (1, 2, 3)'; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['has_large_in_list'] ); + } + + public function test_insert_query_not_classified() { + $sql = "INSERT INTO wp_comments (comment_content) SELECT comment_content FROM wp_comments WHERE comment_ID IN (1,2,3)"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — normal SELECT with no notable characteristics + // ----------------------------------------------------------------------- + + public function test_normal_select_returns_clean_defaults() { + $sql = 'SELECT ID, post_title FROM wp_posts WHERE post_status = \'publish\''; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 0, $r['estimated_in_list_size'] ); + $this->assertFalse( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + public function test_small_in_list_is_not_flagged_as_large() { + // 10 IDs — well below the 200-item threshold. + $ids = implode( ', ', range( 1, 10 ) ); + $sql = "SELECT comment_ID FROM wp_comments WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 10, $r['estimated_in_list_size'] ); + // Table hint should still fire. + $this->assertSame( 'wp_comments', $r['table_hint'] ); + $this->assertTrue( $r['is_comment_query'] ); + } + + public function test_in_list_at_boundary_not_large() { + // 199 IDs — one below threshold, must not be flagged. + $ids = implode( ', ', range( 1, 199 ) ); + $sql = "SELECT * FROM wp_posts WHERE ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 199, $r['estimated_in_list_size'] ); + } + + public function test_in_list_at_threshold_is_large() { + // Exactly 200 IDs — at threshold, must be flagged. + $ids = implode( ', ', range( 1, 200 ) ); + $sql = "SELECT * FROM wp_posts WHERE ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 200, $r['estimated_in_list_size'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — wp_comments large IN list (the incident pattern) + // ----------------------------------------------------------------------- + + public function test_large_wp_comments_in_list_classified_correctly() { + // Mirrors the production-incident query shape: ~10 k IDs. + $ids = implode( ', ', range( 1, 500 ) ); + $sql = "SELECT comment_ID, comment_author, comment_content FROM wp_comments WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( 'wp_comments', $r['table_hint'] ); + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 500, $r['estimated_in_list_size'] ); + } + + public function test_large_wp_comments_in_list_without_space_before_paren() { + // Some ORMs emit IN( without a space. + $ids = implode( ',', range( 1, 250 ) ); + $sql = "SELECT comment_ID FROM wp_comments WHERE comment_ID IN({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 250, $r['estimated_in_list_size'] ); + } + + public function test_wp_comments_table_alias_still_detected() { + // Table present in FROM clause even with alias. + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT c.comment_ID FROM wp_comments AS c WHERE c.comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( 'wp_comments', $r['table_hint'] ); + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['has_large_in_list'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — WooCommerce / order-note heuristics + // ----------------------------------------------------------------------- + + public function test_order_note_query_flagged_as_woocommerce_and_order_note() { + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT * FROM wp_comments WHERE comment_type = 'order_note' AND comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['is_probable_order_note_query'] ); + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertTrue( $r['has_large_in_list'] ); + } + + public function test_order_note_keyword_without_large_in_still_flagged_as_wc() { + // Even a small order-note query should surface the WC heuristic. + $sql = "SELECT * FROM wp_comments WHERE comment_type = 'order_note' AND comment_post_ID = 42"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['is_probable_order_note_query'] ); + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['has_large_in_list'] ); + } + + public function test_woocommerce_order_table_detected_without_comments() { + $sql = "SELECT * FROM wp_wc_orders WHERE status = 'wc-processing'"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + public function test_shop_order_post_type_detected() { + $sql = "SELECT ID FROM wp_posts WHERE post_type = 'shop_order' AND post_status = 'wc-processing'"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_comment_query'] ); + } + + public function test_non_wc_comment_query_not_flagged_as_woocommerce() { + // A standard WordPress comment query with no WC signals. + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT * FROM wp_comments WHERE comment_post_ID IN ({$ids}) AND comment_approved = '1'"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertFalse( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — leading whitespace tolerance + // ----------------------------------------------------------------------- + + public function test_select_with_leading_whitespace_is_classified() { + $ids = implode( ', ', range( 1, 250 ) ); + $sql = " \n SELECT comment_ID FROM wp_comments WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['has_large_in_list'] ); + } + + // ----------------------------------------------------------------------- + // is_admin_ajax_request + // ----------------------------------------------------------------------- + + public function test_admin_ajax_php_uri_is_detected() { + $this->assertTrue( Hypercart_Query_Guard::is_admin_ajax_request( '/wp-admin/admin-ajax.php' ) ); + } + + public function test_admin_ajax_php_uri_with_query_string_is_detected() { + $this->assertTrue( + Hypercart_Query_Guard::is_admin_ajax_request( '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes' ) + ); + } + + public function test_non_ajax_admin_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '/wp-admin/post.php' ) ); + } + + public function test_frontend_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '/shop/my-product/' ) ); + } + + public function test_rest_api_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '/wp-json/wc/v3/orders' ) ); + } + + public function test_empty_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '' ) ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..48b0304 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,153 @@ +dbh connection object). +if ( ! defined( 'HYPERCART_QUERY_GUARD_MODE' ) ) { + define( 'HYPERCART_QUERY_GUARD_MODE', 'observe' ); +} + +// SAVEQUERIES must be true so log_slow_queries() processes the mock query list. +if ( ! defined( 'SAVEQUERIES' ) ) { + define( 'SAVEQUERIES', true ); +} + +// --------------------------------------------------------------------------- +// Lightweight filter/action registry. +// apply_filters and add_filter behave like WordPress so test code can use +// the hypercart_query_guard_log_payload hook to capture payloads. +// --------------------------------------------------------------------------- + +$GLOBALS['_qg_test_filters'] = array(); + +function add_filter( $hook, $callback, $priority = 10, $accepted_args = 1 ) { + $GLOBALS['_qg_test_filters'][ $hook ][] = $callback; +} + +function add_action( $hook, $callback, $priority = 10, $accepted_args = 1 ) { + add_filter( $hook, $callback, $priority, $accepted_args ); +} + +function apply_filters( $hook, $value ) { + $extra = array_slice( func_get_args(), 2 ); + if ( ! empty( $GLOBALS['_qg_test_filters'][ $hook ] ) ) { + foreach ( $GLOBALS['_qg_test_filters'][ $hook ] as $cb ) { + $value = $cb( $value, ...$extra ); + } + } + return $value; +} + +function remove_all_filters( $hook ) { + unset( $GLOBALS['_qg_test_filters'][ $hook ] ); +} + +// --------------------------------------------------------------------------- +// WordPress function stubs +// --------------------------------------------------------------------------- + +if ( ! function_exists( 'did_action' ) ) { + function did_action( $hook ) { + return 0; + } +} + +if ( ! function_exists( 'wp_doing_ajax' ) ) { + function wp_doing_ajax() { + return defined( 'DOING_AJAX' ) && DOING_AJAX; + } +} + +if ( ! function_exists( 'wp_doing_cron' ) ) { + function wp_doing_cron() { + return defined( 'DOING_CRON' ) && DOING_CRON; + } +} + +if ( ! function_exists( 'is_admin' ) ) { + function is_admin() { + return defined( 'WP_ADMIN' ) && WP_ADMIN; + } +} + +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id() { + return 0; + } +} + +if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, $options = 0, $depth = 512 ) { + return json_encode( $data, $options, $depth ); + } +} + +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( $str ) { + return $str; + } +} + +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( $str ) { + return $str; + } +} + +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $value ) { + return $value; + } +} + +if ( ! function_exists( 'set_transient' ) ) { + function set_transient( $key, $value, $expiration = 0 ) { + return true; + } +} + +if ( ! function_exists( 'get_transient' ) ) { + function get_transient( $key ) { + return false; + } +} + +if ( ! function_exists( 'delete_transient' ) ) { + function delete_transient( $key ) { + return true; + } +} + +if ( ! function_exists( 'esc_html__' ) ) { + function esc_html__( $text, $domain = 'default' ) { + return $text; + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $text ) { + return $text; + } +} + +if ( ! function_exists( '__' ) ) { + function __( $text, $domain = 'default' ) { + return $text; + } +} + +// Load the plugin (registers the class; calls init() which adds WordPress +// hooks via add_action / add_filter — both stubbed above). +require_once dirname( __DIR__ ) . '/hypercart-query-guard.php'; From b6025ae281babf34bd8b671472266a6d5c288873 Mon Sep 17 00:00:00 2001 From: noelsaw1 Date: Sat, 30 May 2026 21:16:53 -0700 Subject: [PATCH 3/3] fix: guard filter null-return, scan all IN lists for largest (not just first) - apply_filters null-return guard: replace bare (array) cast with is_array+!empty check so a hook callback that omits return $payload no longer silently reduces the log payload to []. - preg_match -> preg_match_all in classify_sql: scan every IN ( occurrence and keep the maximum list size, preventing a subquery IN earlier in the query from masking a large literal IN list. - tests/bootstrap.php: capture wp_json_encode argument in $GLOBALS['_qg_last_encoded'] for reliable emission-side assertions. - Add 3 regression tests: 2 for subquery masking (SqlClassifierTest), 1 for null-return filter guard (PayloadFieldsTest). --- hypercart-query-guard.php | 44 ++++++++++++++++++++++++++----------- tests/PayloadFieldsTest.php | 33 ++++++++++++++++++++++++++++ tests/SqlClassifierTest.php | 33 ++++++++++++++++++++++++++++ tests/bootstrap.php | 2 ++ 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 27154e0..3f1d53a 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -508,18 +508,28 @@ public static function classify_sql( $sql ) { } // ---- Large IN list ---- - // preg_match locates 'IN (' robustly with no backtracking risk; the body - // is then measured with substr_count (O(n), no regex engine needed). - if ( preg_match( '/\bIN\s*\(/i', $sql, $in_match, PREG_OFFSET_CAPTURE ) ) { - $open = $in_match[0][1] + strlen( $in_match[0][0] ); - $scan_limit = min( strlen( $sql ) - $open, 524288 ); // cap at 512 KB - $body_chunk = substr( $sql, $open, $scan_limit ); - $close = strpos( $body_chunk, ')' ); - if ( false !== $close ) { - $in_body = substr( $body_chunk, 0, $close ); - $commas = substr_count( $in_body, ',' ); - $result['estimated_in_list_size'] = $commas + 1; - $result['has_large_in_list'] = ( $result['estimated_in_list_size'] >= self::LARGE_IN_LIST_THRESHOLD ); + // preg_match_all finds every IN ( in the query so that an earlier + // subquery IN (...) cannot mask a later large literal list — the + // exact failure mode of the May 2026 incident pattern. We keep the + // largest list found. Each body is measured with substr_count (O(n), + // no regex backtracking). + if ( preg_match_all( '/\bIN\s*\(/i', $sql, $in_matches, PREG_OFFSET_CAPTURE ) ) { + $max_size = 0; + foreach ( $in_matches[0] as $in_match ) { + $open = $in_match[1] + strlen( $in_match[0] ); + $scan_limit = min( strlen( $sql ) - $open, 524288 ); // cap at 512 KB + $body_chunk = substr( $sql, $open, $scan_limit ); + $close = strpos( $body_chunk, ')' ); + if ( false !== $close ) { + $commas = substr_count( substr( $body_chunk, 0, $close ), ',' ); + if ( $commas + 1 > $max_size ) { + $max_size = $commas + 1; + } + } + } + if ( $max_size > 0 ) { + $result['estimated_in_list_size'] = $max_size; + $result['has_large_in_list'] = ( $max_size >= self::LARGE_IN_LIST_THRESHOLD ); } } @@ -569,10 +579,18 @@ private static function log( $level, array $payload ) { * add custom fields, route to additional sinks, or intercept payloads * in integration tests. * + * Callbacks MUST return $payload. A callback that omits the return + * would yield null here; (array) null === [] which silently drops + * every field. We guard against that: if the filtered value is not a + * non-empty array, the original $payload is used as-is. + * * @param array $payload Log fields. * @param string $level 'warn' | 'error' */ - $payload = (array) apply_filters( 'hypercart_query_guard_log_payload', $payload, $level ); + $filtered = apply_filters( 'hypercart_query_guard_log_payload', $payload, $level ); + if ( is_array( $filtered ) && ! empty( $filtered ) ) { + $payload = $filtered; + } if ( class_exists( 'Hypercart_Logger' ) ) { if ( 'error' === $level && method_exists( 'Hypercart_Logger', 'error' ) ) { diff --git a/tests/PayloadFieldsTest.php b/tests/PayloadFieldsTest.php index a9b3e97..088d6cf 100644 --- a/tests/PayloadFieldsTest.php +++ b/tests/PayloadFieldsTest.php @@ -289,4 +289,37 @@ public function test_slow_query_normal_select_flags_are_false() { $this->assertFalse( $this->captured['is_probable_woocommerce'] ); $this->assertFalse( $this->captured['is_probable_order_note_query'] ); } + + // ----------------------------------------------------------------------- + // Log filter safety — null-return regression (filter cast fix) + // A hook callback that forgets return $payload must not reduce the payload + // to [] and silently drop all structured fields. + // ----------------------------------------------------------------------- + + public function test_filter_callback_returning_null_preserves_original_payload() { + global $wpdb; + $wpdb = $this->mock_killed_wpdb( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 80 ); + $this->reset_kill_counter(); + $GLOBALS['_qg_last_encoded'] = null; + + // Register a badly-written filter that omits return $payload. + // This returns null; (array) null would have been [] before the fix. + add_filter( + 'hypercart_query_guard_log_payload', + function ( $payload ) { + // Intentionally omits: return $payload; + } + ); + + Hypercart_Query_Guard::detect_and_log_kill(); + + // wp_json_encode is our bootstrap stub and records the last argument it + // received — i.e. the payload $log() actually emitted. If the null-return + // guard is working, this must be the original payload, not []. + $emitted = $GLOBALS['_qg_last_encoded']; + $this->assertNotNull( $emitted, 'log() must still emit when a filter returns null' ); + $this->assertNotEmpty( $emitted, 'payload must not be reduced to [] by a null-return filter' ); + $this->assertArrayHasKey( 'event', $emitted ); + $this->assertSame( 'query_killed', $emitted['event'] ); + } } diff --git a/tests/SqlClassifierTest.php b/tests/SqlClassifierTest.php index 20a15c8..b38089e 100644 --- a/tests/SqlClassifierTest.php +++ b/tests/SqlClassifierTest.php @@ -211,6 +211,39 @@ public function test_select_with_leading_whitespace_is_classified() { $this->assertTrue( $r['has_large_in_list'] ); } + // ----------------------------------------------------------------------- + // classify_sql — subquery masking regression (preg_match_all fix) + // A query whose first IN ( is a small subquery must still surface the + // large literal list that follows it. + // ----------------------------------------------------------------------- + + public function test_large_in_list_not_masked_by_earlier_subquery_in() { + // First IN ( is a subquery with no commas; second IN ( is the 10k ID list. + // The old preg_match would stop at the subquery and return size=1. + $ids = implode( ', ', range( 1, 500 ) ); + $sql = "SELECT * FROM wp_comments + WHERE comment_type IN (SELECT slug FROM wp_term_taxonomy WHERE taxonomy = 'comment_type') + AND comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'], 'large literal list masked by earlier subquery IN' ); + $this->assertSame( 500, $r['estimated_in_list_size'] ); + $this->assertTrue( $r['is_comment_query'] ); + } + + public function test_small_subquery_in_before_large_list_reports_max_size() { + // Subquery IN with a small comma list (3 items), then a big literal list. + // estimated_in_list_size must reflect the larger one. + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT * FROM wp_comments + WHERE comment_type IN ('order_note', 'note', 'status') + AND comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 300, $r['estimated_in_list_size'] ); + } + // ----------------------------------------------------------------------- // is_admin_ajax_request // ----------------------------------------------------------------------- diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 48b0304..bd08777 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -89,7 +89,9 @@ function get_current_user_id() { } if ( ! function_exists( 'wp_json_encode' ) ) { + $GLOBALS['_qg_last_encoded'] = null; function wp_json_encode( $data, $options = 0, $depth = 512 ) { + $GLOBALS['_qg_last_encoded'] = $data; return json_encode( $data, $options, $depth ); } }