db = $db; } public static function decodeValue( string $raw ) { if ( strlen( $raw ) > self::MAX_VALUE_BYTES ) { throw new \RuntimeException( 'Serialized value exceeds inspection budget.' ); } if ( ! preg_match( '/^(?:a|s|i|b|d|N|O|C|R|r):|^N;$/D', $raw ) ) { return $raw; } // Reject object markers before unserialization, then disable all classes as defense in depth. // A string containing an object-looking serialized marker may be conservatively skipped. if ( preg_match( '/(?:^|[;{}])(?:O|C):[0-9]+:/', $raw ) ) { throw new \RuntimeException( 'Object serialization is not inspected.' ); } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Temporarily convert unserialize warnings into a bounded inspection failure; the prior handler is restored in finally. set_error_handler( static function () { throw new \RuntimeException( 'Invalid or over-depth serialized value.' ); } ); try { $value = unserialize( $raw, array( 'allowed_classes' => false, 'max_depth' => 16, ) ); } finally { restore_error_handler(); } if ( false === $value && 'b:0;' !== $raw ) { throw new \RuntimeException( 'Invalid serialized value.' ); } $count = 0; self::validateDecoded( $value, $count, 0 ); return $value; } private static function validateDecoded( $value, int &$count, int $depth ): void { if ( ++$count > 10000 || $depth > 16 || is_object( $value ) || is_resource( $value ) ) { throw new \RuntimeException( 'Unsupported serialized value or structure budget.' ); } if ( is_array( $value ) ) { foreach ( $value as $item ) { self::validateDecoded( $item, $count, $depth + 1 ); } } } public function option( string $name, $default = null ) { if ( ! $this->db ) { return $default; } $row = $this->db->get_row( $this->db->prepare( "SELECT OCTET_LENGTH(option_value) AS bytes,CASE WHEN OCTET_LENGTH(option_value)<=%d THEN option_value ELSE NULL END AS value FROM {$this->db->options} WHERE option_name=%s LIMIT 1", self::MAX_VALUE_BYTES, $name ), ARRAY_A ); if ( ! empty( $this->db->last_error ) ) { throw new \RuntimeException( 'Inventory option query failed; coverage incomplete.' ); } if ( null === $row ) { return $default; } if ( ! is_array( $row ) || ! is_string( $row['value'] ?? null ) || (int) ( $row['bytes'] ?? PHP_INT_MAX ) > self::MAX_VALUE_BYTES ) { throw new \RuntimeException( 'Inventory option exceeds the bounded read budget or has an unsupported value.' ); } return self::decodeValue( $row['value'] ); } public function inspectPage( array $cursor, int $limit = 25 ): array { $limit = max( 1, min( 25, $limit ) ); $phases = array( 'options', 'posts', 'postmeta', 'usermeta' ); $phase = (string) ( $cursor['phase'] ?? 'options' ); $after = max( 0, (int) ( $cursor['after'] ?? 0 ) ); if ( ! in_array( $phase, $phases, true ) ) { throw new \InvalidArgumentException( 'Invalid database scan phase.' ); } $result = array( 'findings' => array(), 'cursor' => array( 'phase' => $phase, 'after' => $after, ), 'done' => false, 'checked' => 0, 'skipped' => 0, 'locations' => array(), 'limitations' => array( 'Database coverage: selected settings/widgets, post/page/block/template text, selected rendering metadata, and site role metadata; excludes orders, arbitrary custom tables, passwords, and unrelated customer records.' ), ); if ( ! $this->db ) { $result['done'] = true; $result['skipped'] = 1; $result['limitations'][] = 'WordPress database adapter unavailable.'; return $result; } switch ( $phase ) { case 'options': $sql = $this->db->prepare( "SELECT option_id AS id,option_name AS name,OCTET_LENGTH(option_value) AS bytes,CASE WHEN OCTET_LENGTH(option_value)<=%d THEN option_value ELSE NULL END AS value FROM {$this->db->options} WHERE option_id>%d AND (option_name IN ('siteurl','home','active_plugins','template','stylesheet','users_can_register','default_role','cron') OR option_name LIKE %s) ORDER BY option_id LIMIT %d", self::MAX_VALUE_BYTES, $after, $this->db->esc_like( 'widget_' ) . '%', $limit ); break; case 'posts': $sql = $this->db->prepare( "SELECT ID AS id,post_type AS name,OCTET_LENGTH(post_content) AS bytes,CASE WHEN OCTET_LENGTH(post_content)<=%d THEN post_content ELSE NULL END AS value FROM {$this->db->posts} WHERE ID>%d AND post_type IN ('post','page','wp_block','wp_template','wp_template_part','custom_css') ORDER BY ID LIMIT %d", self::MAX_VALUE_BYTES, $after, $limit ); break; case 'postmeta': $sql = $this->db->prepare( "SELECT meta_id AS id,meta_key AS name,OCTET_LENGTH(meta_value) AS bytes,CASE WHEN OCTET_LENGTH(meta_value)<=%d THEN meta_value ELSE NULL END AS value FROM {$this->db->postmeta} WHERE meta_id>%d AND meta_key IN ('_elementor_data','_wp_page_template','_wp_attachment_metadata') ORDER BY meta_id LIMIT %d", self::MAX_VALUE_BYTES, $after, $limit ); break; default: $sql = $this->db->prepare( "SELECT umeta_id AS id,meta_key AS name,OCTET_LENGTH(meta_value) AS bytes,CASE WHEN OCTET_LENGTH(meta_value)<=%d THEN meta_value ELSE NULL END AS value FROM {$this->db->usermeta} WHERE umeta_id>%d AND meta_key=%s ORDER BY umeta_id LIMIT %d", self::MAX_VALUE_BYTES, $after, $this->db->prefix . 'capabilities', $limit ); break; } $rows = $this->db->get_results( $sql, ARRAY_A ); if ( ! is_array( $rows ) || ! empty( $this->db->last_error ) ) { throw new \RuntimeException( 'Database inspection query failed; coverage incomplete.' ); } foreach ( $rows as $row ) { $id = (int) $row['id']; $result['cursor']['after'] = $id; $location = 'database:' . $phase . '/' . $id . '/' . $row['name']; if ( null === $row['value'] || (int) $row['bytes'] > self::MAX_VALUE_BYTES ) { ++$result['skipped']; $result['limitations'][] = $location . ' exceeds the value budget.'; continue; } try { $decoded = self::decodeValue( (string) $row['value'] ); } catch ( \Throwable $e ) { ++$result['skipped']; $result['limitations'][] = $location . ': ' . $e->getMessage(); continue; } ++$result['checked']; $hash = hash( 'sha256', (string) $row['value'] ); $base = array( 'location' => $location, 'content_hash' => $hash, 'scope' => array( 'type' => 'database', 'table' => $phase, 'record_id' => $id, ), 'component_version' => 'database-scope-v1', ); try { $found = self::inspectStrings( $decoded, $base ); } catch ( \Throwable $error ) { --$result['checked']; ++$result['skipped']; $result['limitations'][] = $location . ': bounded structural inspection was incomplete.'; continue; } if ( 'options' === $phase && 'default_role' === $row['name'] && 'administrator' === $decoded ) { $found[] = Finding::make( $base + array( 'detector_id' => 'database.default-admin-role', 'category' => 'configuration', 'severity' => 'high', 'confidence' => 'informational', 'reason' => 'The default new-user role is administrator. If public registration is enabled, new registrations may receive administrative access.', 'evidence' => array( array( 'type' => 'option_value', 'name' => 'default_role', 'value' => 'administrator', ), ), 'limitations' => array( 'This setting is an exposure, not evidence of infection. Public registration may be disabled.' ), 'recommended_action' => 'Review registration and default role settings. Change the role manually if unintended.', ) ); } if ( 'options' === $phase && 'cron' === $row['name'] && is_array( $decoded ) ) { foreach ( self::cronAnomalies( $decoded ) as $anomaly ) { $found[] = Finding::make( $base + array( 'detector_id' => 'database.cron-anomaly', 'category' => 'configuration', 'severity' => 'medium', 'confidence' => 'informational', 'reason' => 'The saved scheduled-task data contains an unusually frequent task or malformed entry.', 'evidence' => array( $anomaly ), 'limitations' => array( 'Plugins can intentionally schedule frequent work. A hook name does not prove malicious intent.' ), 'recommended_action' => 'Review the responsible plugin and task schedule. No tasks were altered.', ) ); break; } } $result['findings'] = array_merge( $result['findings'], $found ); $result['locations'][] = array( 'location' => $location, 'hash' => $hash, 'detectors' => array_merge( Detector::ids(), array( 'database.default-admin-role', 'database.cron-anomaly' ) ), ); } if ( count( $rows ) < $limit ) { $index = array_search( $phase, $phases, true ) + 1; if ( $index === count( $phases ) ) { $result['done'] = true; } else { $result['cursor'] = array( 'phase' => $phases[ $index ], 'after' => 0, ); } } $result['limitations'] = array_slice( array_unique( $result['limitations'] ), 0, 40 ); return $result; } /** Inspect decoded string values directly; JSON-encoding widgets hides their script strings. */ public static function inspectStrings( mixed $decoded, array $base ): array { if ( is_string( $decoded ) && preg_match( '/^\s*[\[{]/', $decoded ) ) { try { $json = json_decode( $decoded, true, 16, JSON_THROW_ON_ERROR ); if ( is_array( $json ) ) { $decoded = $json; } } catch ( \JsonException $ignored ) { if ( JSON_ERROR_DEPTH === $ignored->getCode() ) { throw new \RuntimeException( 'Database JSON nesting budget exceeded; content remains unassessed.' ); } // Ordinary text, styles and malformed JSON remain eligible as plain text. } } $pending = array( array( $decoded, 0 ) ); $nodes = 0; $strings = 0; $bytes = 0; $findings = array(); while ( $pending ) { list( $value, $depth ) = array_pop( $pending ); if ( ++$nodes > 10000 || $depth > 16 || is_object( $value ) || is_resource( $value ) ) { throw new \RuntimeException( 'Database string inspection structure budget exceeded.' ); } if ( is_array( $value ) ) { if ( count( $pending ) + count( $value ) > 10000 ) { throw new \RuntimeException( 'Database string inspection node budget exceeded.' ); } foreach ( $value as $child ) { $pending[] = array( $child, $depth + 1 ); } } elseif ( is_string( $value ) ) { $bytes += strlen( $value ); if ( ++$strings > 1024 || $bytes > self::MAX_VALUE_BYTES ) { throw new \RuntimeException( 'Database string inspection byte or leaf budget exceeded.' ); } foreach ( Detector::inspect( $value, $base ) as $finding ) { if ( ! isset( $findings[ $finding['detector_id'] ] ) ) { $finding['evidence'][] = array( 'type' => 'database_string_leaf', 'ordinal' => $strings ); $finding['limitations'][] = 'The pattern occurs in stored content. Stored PHP text is not necessarily executed; tutorials and intentional custom scripts can match.'; $findings[ $finding['detector_id'] ] = $finding; } } } } return array_values( $findings ); } public static function cronAnomalies( array $cron ): array { $out = array(); $checked = 0; foreach ( $cron as $time => $hooks ) { if ( 'version' === $time ) { continue; } if ( ! is_array( $hooks ) || ! ctype_digit( (string) $time ) ) { $out[] = array( 'type' => 'malformed_cron', 'timestamp' => (string) $time, ); continue; } foreach ( $hooks as $hook => $events ) { if ( ++$checked > 2000 ) { return $out; } if ( ! is_array( $events ) ) { $out[] = array( 'type' => 'malformed_cron', 'hook' => substr( (string) $hook, 0, 200 ), ); continue; } foreach ( $events as $event ) { if ( ! is_array( $event ) ) { $out[] = array( 'type' => 'malformed_cron', 'hook' => substr( (string) $hook, 0, 200 ), ); } elseif ( isset( $event['interval'] ) && (int) $event['interval'] > 0 && (int) $event['interval'] < 60 ) { $out[] = array( 'type' => 'frequent_cron', 'hook' => substr( (string) $hook, 0, 200 ), 'interval_seconds' => (int) $event['interval'], ); } if ( count( $out ) >= 10 ) { return $out; } } } } return $out; } }