array( 'wpvulnerability-core-version' ), 'plugins' => array( 'wpvulnerability-plugins-signature', 'wpvulnerability-plugins-data', 'wpvulnerability-plugins-cache-data', ), 'themes' => array( 'wpvulnerability-themes-signature' ), ); if ( is_multisite() ) { delete_site_option( "wpvulnerability-{$type}" ); delete_site_option( "wpvulnerability-{$type}-vulnerable" ); delete_site_option( "wpvulnerability-{$type}-cache" ); if ( isset( $additional_keys[ $type ] ) ) { foreach ( $additional_keys[ $type ] as $option_name ) { delete_site_option( $option_name ); } } } else { delete_option( "wpvulnerability-{$type}" ); delete_option( "wpvulnerability-{$type}-vulnerable" ); delete_option( "wpvulnerability-{$type}-cache" ); if ( isset( $additional_keys[ $type ] ) ) { foreach ( $additional_keys[ $type ] as $option_name ) { delete_option( $option_name ); } } } } /** * Checks and validates user capabilities for managing vulnerability settings in a WordPress environment. * * This function verifies if the current user has the appropriate permissions to manage network settings * in a multisite installation or manage options in a single site installation. It ensures that only * Administrators in a single site and Super Administrators in multisite can access these settings. * * @since 3.0.0 * * @return bool Returns true if the current user has the required capabilities, false otherwise. */ function wpvulnerability_capabilities() { // Check if the user is logged in. if ( ! is_user_logged_in() ) { return false; } // Check if in a Multisite environment. if ( is_multisite() && is_super_admin() && ( is_network_admin() || is_main_site() ) ) { return true; } elseif ( is_admin() && current_user_can( 'manage_options' ) ) { return true; } // Return false if the user does not have the required capabilities. return false; } /** * Checks if the `shell_exec` function can be used. * * This function implements a 4-level security check system: * 1. Global disable via constant * 2. Security mode (strict/standard/disabled) * 3. Component-specific whitelist * 4. PHP configuration check * * @since 3.4.0 * @since 4.3.0 Enhanced with 4-level security checks and component-specific control. * @since 5.1.4 The live probe result is now cached per request. * * @param string $component Optional. Component name for granular control. * * @return bool True if `shell_exec` is available and allowed, false otherwise. */ function wpvulnerability_can_shell_exec( $component = '' ) { static $probe_result = null; // Level 1: Global disable via constant. if ( defined( 'WPVULNERABILITY_DISABLE_SHELL_EXEC' ) && WPVULNERABILITY_DISABLE_SHELL_EXEC ) { return false; } // Level 2: Security mode. $security_mode = wpvulnerability_get_security_mode(); if ( 'disabled' === $security_mode ) { return false; } if ( 'strict' === $security_mode ) { return false; } // Level 3: Component-specific whitelist (when specified). if ( ! empty( $component ) && defined( 'WPVULNERABILITY_SHELL_EXEC_WHITELIST' ) ) { $whitelist = WPVULNERABILITY_SHELL_EXEC_WHITELIST; if ( is_string( $whitelist ) ) { $whitelist = array_map( 'trim', explode( ',', $whitelist ) ); } if ( is_array( $whitelist ) && ! empty( $whitelist ) ) { if ( ! in_array( $component, $whitelist, true ) ) { return false; } } } // Level 4: PHP configuration check. if ( ! function_exists( 'shell_exec' ) ) { return false; } // Check if `shell_exec` is disabled in PHP configuration. if ( in_array( 'shell_exec', array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ), true ) ) { return false; } // Try to execute a simple command to confirm functionality. The probe // result is cached per request: PHP configuration cannot change mid-request // and this function runs several times per admin/cron cycle. if ( null === $probe_result ) { $probe_result = @shell_exec( escapeshellcmd( 'echo test' ) ); // phpcs:ignore } // If the command execution failed or returned null, shell_exec is not working. return null !== $probe_result; } /** * Conditionally log diagnostic messages for the plugin. * * This helper respects the WordPress debug mode and allows developers to hook into the * decision using the {@see 'wpvulnerability_should_log'} filter. Logged messages are * encoded as JSON when possible to provide structured context without breaking the * WordPress Coding Standards that discourage verbose debugging in production. * * @since 4.1.7 * * @param string $message Message to record in the debug log. * @param array $context Optional. Additional context about the message. Default empty array. * * @return void */ function wpvulnerability_maybe_log( $message, $context = array() ) { if ( empty( $message ) ) { return; } $should_log = defined( 'WP_DEBUG' ) && WP_DEBUG; /** * Filter whether a diagnostic message should be logged. * * @since 4.1.7 * * @param bool $should_log Whether the message should be logged. * @param string $message Message to log. * @param array $context Additional context data. */ $should_log = apply_filters( 'wpvulnerability_should_log', $should_log, $message, $context ); if ( ! $should_log ) { return; } $log_entry = array( 'plugin' => 'wpvulnerability', 'message' => (string) $message, ); if ( ! empty( $context ) ) { $log_entry['context'] = (array) $context; } $encoded_entry = wp_json_encode( $log_entry ); if ( false === $encoded_entry ) { $encoded_entry = sprintf( 'wpvulnerability: %s', sanitize_text_field( $log_entry['message'] ) ); } error_log( $encoded_entry ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } /** * Retrieve the cache expiration in hours. * * The value can be defined via the WPVULNERABILITY_CACHE_HOURS constant, * configured in the plugin settings, or falls back to 12 hours. * * @since 4.1.0 * * @return int Cache duration in hours. */ function wpvulnerability_cache_hours() { $default = 12; if ( defined( 'WPVULNERABILITY_CACHE_HOURS' ) && WPVULNERABILITY_CACHE_HOURS !== $default ) { return (int) WPVULNERABILITY_CACHE_HOURS; } $settings = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } if ( isset( $settings['cache'] ) ) { $cache_raw = $settings['cache']; $cache = is_scalar( $cache_raw ) ? (int) $cache_raw : 0; if ( in_array( $cache, array( 1, 6, 12, 24 ), true ) ) { return $cache; } } return $default; } /** * Retrieve a JSON-encoded vulnerability count option as an integer. * * Centralises the `(int) json_decode( get_*_option( ... ), true )` pattern used * in the admin dashboard and analysis tabs, providing a properly typed return * value that satisfies PHPStan level 9. * * @since 4.3.3 * * @param string $option_key Option name (without the `wpvulnerability-` prefix). * * @return int Decoded integer count, or 0 when the option is empty or invalid. */ function wpvulnerability_get_component_count( string $option_key ): int { $raw = is_multisite() ? get_site_option( 'wpvulnerability-' . $option_key . '-vulnerable', '0' ) : get_option( 'wpvulnerability-' . $option_key . '-vulnerable', '0' ); $decoded = json_decode( is_string( $raw ) ? $raw : '0', true ); return is_scalar( $decoded ) ? (int) $decoded : 0; } /** * Retrieve the plugin configuration array, always returning a typed array. * * Wraps `get_option`/`get_site_option` for the `wpvulnerability-config` key and * ensures the return value is an `array`, merging defaults so * that callers can safely access keys without additional type guards. * * @since 4.3.3 * * @param array $defaults Optional default values to merge. * * @return array Plugin configuration. */ function wpvulnerability_get_config( array $defaults = array() ): array { $raw = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() ); $config = is_array( $raw ) ? $raw : array(); return empty( $defaults ) ? $config : wp_parse_args( $config, $defaults ); } /** * Retrieve the supported log retention values. * * @since 4.2.0 * * @return int[] Valid log retention periods expressed in days. The value "0" disables retention. */ function wpvulnerability_get_log_retention_values() { return array( 0, 1, 7, 14, 28 ); } /** * Determine whether log retention is forced via a constant. * * @since 4.2.0 * * @return int|null Number of days when forced, or null when editable. */ function wpvulnerability_forced_log_retention() { if ( defined( 'WPVULNERABILITY_LOG_RETENTION_DAYS' ) ) { $forced = (int) WPVULNERABILITY_LOG_RETENTION_DAYS; if ( in_array( $forced, wpvulnerability_get_log_retention_values(), true ) ) { return $forced; } } return null; } /** * Retrieve the configured log retention period in days. * * The value can be defined through the WPVULNERABILITY_LOG_RETENTION_DAYS constant, * configured via the settings UI, or falls back to zero (disabled). * * @since 4.2.0 * * @return int Log retention in days. Zero disables retention. */ function wpvulnerability_log_retention_days() { $default = 0; $forced = wpvulnerability_forced_log_retention(); if ( null !== $forced ) { return $forced; } $settings = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } if ( isset( $settings['log_retention'] ) ) { $retention_raw = $settings['log_retention']; $retention = is_scalar( $retention_raw ) ? (int) $retention_raw : 0; if ( in_array( $retention, wpvulnerability_get_log_retention_values(), true ) ) { return $retention; } } return $default; } /** * Register the custom post type used to store API logs. * * @since 4.2.0 * * @return void */ function wpvulnerability_register_log_post_type() { register_post_type( 'wpvulnerability_log', array( 'labels' => array( 'name' => __( 'WPVulnerability Logs', 'wpvulnerability' ), 'singular_name' => __( 'WPVulnerability Log', 'wpvulnerability' ), ), 'public' => false, 'exclude_from_search' => true, 'publicly_queryable' => false, 'show_ui' => false, 'show_in_menu' => false, 'supports' => array( 'title', 'editor' ), ) ); } add_action( 'init', 'wpvulnerability_register_log_post_type' ); /** * Determine whether a URL should be logged as an API call. * * @since 4.2.0 * * @param string $url Requested URL. * * @return bool True when the URL targets the configured API host, false otherwise. */ function wpvulnerability_should_log_api_request( $url ) { if ( 0 >= wpvulnerability_log_retention_days() ) { return false; } $target_host = wp_parse_url( $url, PHP_URL_HOST ); $api_host = wp_parse_url( WPVULNERABILITY_API_HOST, PHP_URL_HOST ); if ( ! is_string( $target_host ) || '' === $target_host ) { return false; } return 0 === strcasecmp( $target_host, (string) $api_host ); } /** * Convert the HTTP response into a storable string for the log entry. * * @since 4.2.0 * * @param array|WP_Error $response Response returned by wp_remote_get(). * * @return string Encoded response body ready for storage. */ function wpvulnerability_prepare_log_body( $response ) { if ( is_wp_error( $response ) ) { $error_data = array( 'code' => $response->get_error_code(), 'message' => $response->get_error_message(), 'errors' => $response->errors, 'data' => $response->error_data, ); $body = wp_json_encode( $error_data ); if ( false === $body ) { $body = serialize( $error_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize } return $body; } $body = wp_remote_retrieve_body( $response ); if ( '' === $body ) { $encoded = wp_json_encode( array( 'message' => 'empty body' ) ); return false !== $encoded ? $encoded : '{"message":"empty body"}'; } return $body; } /** * Persist an API log entry when appropriate. * * @since 4.2.0 * * @param string $url Requested URL. * @param array|WP_Error $response Response returned by wp_remote_get(). * * @return void */ function wpvulnerability_maybe_log_api_response( $url, $response ) { if ( ! wpvulnerability_should_log_api_request( $url ) ) { return; } $log_id = wp_insert_post( array( 'post_type' => 'wpvulnerability_log', 'post_status' => 'publish', 'post_title' => wp_strip_all_tags( $url ), 'post_content' => wp_slash( wpvulnerability_prepare_log_body( $response ) ), 'post_author' => 0, ), true ); if ( is_wp_error( $log_id ) ) { return; } } /** * Retrieve the pagination sizes available for the logs table. * * @since 4.3.0 * * @return int[] Array of valid per-page options. */ function wpvulnerability_get_log_per_page_options() { return array( 10, 50, 100, 250, 1000 ); } /** * Retrieve the default pagination size for the logs table. * * @since 4.3.0 * * @return int Default per-page value. */ function wpvulnerability_get_default_log_per_page() { return 100; } /** * Retrieve log posts for the administration table. * * @since 4.2.0 * @since 4.3.0 Added the $paged argument and updated the default page size. * * @param int $per_page Optional. Number of logs to return per page. Default 100. * @param int $paged Optional. Page number to retrieve. Default 1. * * @return WP_Post[] Array of log posts. */ function wpvulnerability_get_api_logs( $per_page = 100, $paged = 1 ) { $per_page = max( 1, (int) $per_page ); $paged = max( 1, (int) $paged ); $offset = ( $paged - 1 ) * $per_page; return get_posts( array( 'post_type' => 'wpvulnerability_log', 'post_status' => 'publish', 'posts_per_page' => $per_page, 'orderby' => 'date', 'order' => 'DESC', 'offset' => $offset, 'no_found_rows' => true, ) ); } /** * Count the total amount of stored API log entries. * * @since 4.3.0 * * @return int Number of log posts. */ function wpvulnerability_count_api_logs() { $counts = wp_count_posts( 'wpvulnerability_log' ); if ( ! isset( $counts->publish ) ) { return 0; } return (int) $counts->publish; } /** * Retrieve a single log entry ensuring it belongs to the plugin log post type. * * @since 4.2.0 * * @param int $log_id Log post ID. * * @return WP_Post|null Post object on success, null otherwise. */ function wpvulnerability_get_api_log( $log_id ) { $log_id = (int) $log_id; if ( $log_id <= 0 ) { return null; } $log = get_post( $log_id ); if ( ! $log || 'wpvulnerability_log' !== $log->post_type ) { return null; } return $log; } /** * Format stored log content into a pretty printed JSON string when possible. * * @since 4.2.0 * * @param string $content Stored log body. * * @return string Formatted content. */ function wpvulnerability_format_log_content( $content ) { $content = (string) $content; if ( '' === $content ) { return ''; } $decoded = json_decode( $content, true ); if ( null === $decoded || JSON_ERROR_NONE !== json_last_error() ) { return $content; } $pretty = wp_json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); if ( false === $pretty ) { return $content; } return $pretty; } /** * Format the log date using the site's date and time settings. * * @since 4.2.0 * * @param WP_Post $log Log post. * * @return string Formatted date string. */ function wpvulnerability_format_log_date( WP_Post $log ) { $date_fmt = get_option( 'date_format' ); $time_fmt = get_option( 'time_format' ); $format = trim( ( is_scalar( $date_fmt ) ? (string) $date_fmt : '' ) . ' ' . ( is_scalar( $time_fmt ) ? (string) $time_fmt : '' ) ); if ( '' === $format ) { $format = 'Y-m-d H:i:s'; } return (string) mysql2date( $format, $log->post_date, true ); } /** * Delete logs that fall outside of the configured retention window. * * @since 4.2.0 * * @return void */ function wpvulnerability_delete_expired_logs() { $retention = wpvulnerability_log_retention_days(); if ( $retention <= 0 ) { return; } $threshold_ts = strtotime( '-' . $retention . ' days', time() ); $threshold = gmdate( 'Y-m-d H:i:s', false !== $threshold_ts ? $threshold_ts : time() ); do { $logs = get_posts( array( 'post_type' => 'wpvulnerability_log', 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => 100, 'orderby' => 'date', 'order' => 'ASC', 'no_found_rows' => true, 'cache_results' => false, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'date_query' => array( array( 'before' => $threshold, 'inclusive' => false, ), ), ) ); if ( empty( $logs ) ) { break; } foreach ( $logs as $log_id ) { wp_delete_post( $log_id, true ); } $logs_count = count( $logs ); } while ( $logs_count >= 100 ); } add_action( 'wpvulnerability_cleanup_logs', 'wpvulnerability_delete_expired_logs' ); /** * Delete all stored API logs. * * @since 4.3.0 * * @return void */ function wpvulnerability_delete_all_logs() { // The "delete logs" action runs at file scope during init, potentially // before this file's init callbacks register the post types: queries for // unregistered post types silently return nothing. if ( ! post_type_exists( 'wpvulnerability_log' ) ) { wpvulnerability_register_log_post_type(); } if ( ! post_type_exists( 'wpv_shell_log' ) ) { wpvulnerability_register_shell_log_post_type(); } // Purge both log stores: API request logs and shell audit logs. foreach ( array( 'wpvulnerability_log', 'wpv_shell_log' ) as $log_post_type ) { do { $logs = get_posts( array( 'post_type' => $log_post_type, 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => 100, 'orderby' => 'date', 'order' => 'ASC', 'no_found_rows' => true, 'cache_results' => false, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, ) ); if ( empty( $logs ) ) { break; } foreach ( $logs as $log_id ) { wp_delete_post( $log_id, true ); } $logs_count = count( $logs ); } while ( $logs_count >= 100 ); } } /** * Normalize various truthy and falsy values into the expected 'y' or 'n' format. * * This helper ensures that configuration options stored as booleans or integers * in previous plugin versions are converted into the new string-based format. * * @since 4.1.1 * * @param mixed $value Value to normalize. * * @return string Returns 'y' when enabled, 'n' otherwise. */ function wpvulnerability_normalize_yes_no( $value ) { if ( is_string( $value ) ) { $value = strtolower( trim( (string) $value ) ); } $truthy = array( 'y', 'yes', '1', 1, true, 'true', 'on' ); return in_array( $value, $truthy, true ) ? 'y' : 'n'; } /** * Determine if a stored yes/no value should be treated as enabled. * * @since 4.1.1 * * @param mixed $value Value to evaluate. * * @return bool True when enabled, false otherwise. */ function wpvulnerability_is_yes( $value ) { return 'y' === wpvulnerability_normalize_yes_no( $value ); } /** * Normalize the notification configuration array. * * @since 4.1.1 * * @param mixed $notify Notification configuration values. * * @return array Normalized notification configuration containing 'email', 'slack', 'teams', 'discord', and 'telegram'. */ function wpvulnerability_normalize_notify_settings( $notify ) { $defaults = array( 'email' => 'n', 'slack' => 'n', 'teams' => 'n', 'discord' => 'n', 'telegram' => 'n', ); $normalized = array(); if ( is_array( $notify ) ) { foreach ( $notify as $channel => $value ) { // Only the five known channels are stored; anything else from // a crafted request is discarded. if ( isset( $defaults[ (string) $channel ] ) ) { $normalized[ (string) $channel ] = wpvulnerability_normalize_yes_no( $value ); } } } return array_merge( $defaults, $normalized ); } /** * Sanitize a version string. * * This function removes any leading or trailing whitespace from the version string * and strips out any non-alphanumeric characters except for hyphens, underscores, and dots. * * @since 2.0.0 * * @param string|null $version The version string to sanitize. * * @return string The sanitized version string. */ function wpvulnerability_sanitize_version( $version ) { // Remove any leading or trailing whitespace. $version = trim( (string) $version ); // Strip out any non-alphanumeric characters except for hyphens, underscores, and dots. $replaced = preg_replace( '/[^a-zA-Z0-9_\-.]+/', '', $version ); $version = null !== $replaced ? $replaced : ''; // Normalize WordPress pre-release build suffixes such as "-beta1-12345" to "-beta1". if ( preg_match( '/^(\d+\.\d+(?:\.\d+)?-(?:beta|rc)\d+)(?:-\d+)$/i', $version, $matches ) ) { $version = $matches[1]; } return $version; } /** * Sanitize a version string and validate its format. * * This function sanitizes the input version string and checks it against a regular expression * to match the standard versioning format (major.minor[.patch[.build]]). It returns the matched version * if it conforms to the expected format; otherwise, it returns the original version. * * @since 3.5.0 Introduced. * * @param string|null $version The version string to sanitize and validate. * @return string|null The sanitized version string if it matches the standard format; otherwise, the original version string, or null when empty. */ function wpvulnerability_sanitize_and_validate_version( $version ) { if ( null === $version ) { return null; } // Sanitize the version string using the base sanitizer. $version = wpvulnerability_sanitize_version( $version ); if ( '' === $version ) { return null; } // Validate format (major.minor[.patch[.build]]) and sanitize. if ( preg_match( '/^\d+\.\d+(\.\d+){0,2}(\.\d+)?/', $version, $match ) ) { return trim( $match[0] ); } return $version; } /** * Detects the version of SQLite using the SQLite3 extension or system commands. * * Uses a hybrid detection approach: * 1. PHP SQLite3 extension (most secure, reliability 90) * 2. PDO SQLite (secondary secure method, reliability 90) * 3. shell_exec commands (most accurate, reliability 95) * 4. Binary existence check (basic fallback, reliability 30) * * @since 3.5.0 Introduced. * @since 4.3.0 Enhanced with hybrid detection and reliability scoring. * * @return array{version: string|null, method: string, reliability: int, attempts: list} */ function wpvulnerability_detect_sqlite() { $result = array( 'version' => null, 'method' => 'none', 'reliability' => 0, 'attempts' => array(), ); // Method 1: PHP SQLite3 extension (most secure). if ( class_exists( 'SQLite3' ) ) { $result['attempts'][] = 'sqlite3_extension'; try { $sqlite = new SQLite3( ':memory:' ); $version_info = $sqlite->version(); if ( isset( $version_info['versionString'] ) ) { $version = $version_info['versionString']; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'sqlite3_extension'; $result['reliability'] = 90; return $result; } } } catch ( Exception $e ) { wpvulnerability_maybe_log( 'SQLite3 extension detection failed', array( 'error' => $e->getMessage() ) ); } } // Method 2: PDO SQLite extension (secondary secure method). if ( ! class_exists( 'SQLite3' ) && class_exists( 'PDO' ) ) { $result['attempts'][] = 'pdo_sqlite'; $drivers = \PDO::getAvailableDrivers(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO if ( in_array( 'sqlite', $drivers, true ) ) { try { $pdo = new \PDO( 'sqlite::memory:' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO $pdo->setAttribute( \PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO $statement = $pdo->query( 'SELECT sqlite_version()' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO if ( $statement ) { $version_result = $statement->fetchColumn(); if ( false !== $version_result ) { $version = (string) $version_result; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'pdo_sqlite'; $result['reliability'] = 90; $pdo = null; return $result; } } } $pdo = null; } catch ( Exception $exception ) { wpvulnerability_maybe_log( 'PDO SQLite detection failed', array( 'error' => $exception->getMessage() ) ); if ( isset( $pdo ) ) { $pdo = null; } } } } // Method 3: shell_exec command (most accurate). if ( wpvulnerability_can_shell_exec( 'sqlite' ) ) { $result['attempts'][] = 'shell_sqlite3'; $version_output = wpvulnerability_safe_shell_exec( 'sqlite', 'sqlite3 --version' ); if ( ! empty( $version_output ) && preg_match( '/(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/', $version_output, $matches ) ) { $version = $matches[1]; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'shell_exec'; $result['reliability'] = 95; return $result; } } } // Method 4: Binary existence check (basic fallback). if ( wpvulnerability_can_shell_exec( 'sqlite' ) ) { $result['attempts'][] = 'which_sqlite3'; $which_output = wpvulnerability_safe_shell_exec( 'sqlite', 'which sqlite3' ); if ( ! empty( $which_output ) ) { $result['version'] = 'unknown'; $result['method'] = 'binary_exists'; $result['reliability'] = 30; return $result; } } return $result; } /** * Detects the version of Redis using the Redis extension or system commands. * * Uses a hybrid detection approach: * 1. PHP Redis extension (most secure, reliability 90) * 2. shell_exec commands (most accurate, reliability 95) * 3. Binary existence check (basic fallback, reliability 30) * * @since 3.5.0 Introduced. * @since 4.3.0 Enhanced with hybrid detection and reliability scoring. * * @return array{version: string|null, method: string, reliability: int, attempts: list} */ function wpvulnerability_detect_redis() { $result = array( 'version' => null, 'method' => 'none', 'reliability' => 0, 'attempts' => array(), ); // Method 1: PHP Redis extension (most secure). if ( class_exists( 'Redis' ) ) { $result['attempts'][] = 'redis_extension'; $redis_client = null; $temporary_connection = false; // Attempt to reuse an existing Redis connection from WordPress object cache implementations. $cache_instance = null; if ( function_exists( 'wp_cache_get_instance' ) ) { $cache_instance = wp_cache_get_instance(); } elseif ( isset( $GLOBALS['wp_object_cache'] ) && is_object( $GLOBALS['wp_object_cache'] ) ) { $cache_instance = $GLOBALS['wp_object_cache']; } if ( is_object( $cache_instance ) ) { foreach ( array( 'redis', 'redis_client', 'client', 'redis_instance', 'connection' ) as $property ) { if ( isset( $cache_instance->{$property} ) && $cache_instance->{$property} instanceof Redis ) { $redis_client = $cache_instance->{$property}; break; } } if ( ! $redis_client instanceof Redis ) { foreach ( array( 'get_redis', 'get_client', 'redis', 'redis_instance' ) as $method ) { if ( ! method_exists( $cache_instance, $method ) ) { continue; } try { $reflection_method = new ReflectionMethod( $cache_instance, $method ); if ( $reflection_method->getNumberOfRequiredParameters() > 0 || ! $reflection_method->isPublic() ) { continue; } } catch ( ReflectionException $exception ) { continue; } if ( ! is_callable( array( $cache_instance, $method ) ) ) { continue; } $maybe_client = $cache_instance->{$method}(); if ( $maybe_client instanceof Redis ) { $redis_client = $maybe_client; break; } } } } if ( ! $redis_client instanceof Redis ) { $redis_client = new Redis(); $temporary_connection = true; $host = defined( 'WP_REDIS_HOST' ) ? (string) WP_REDIS_HOST : '127.0.0.1'; $port = defined( 'WP_REDIS_PORT' ) ? (int) WP_REDIS_PORT : 6379; $timeout = defined( 'WP_REDIS_TIMEOUT' ) ? (float) WP_REDIS_TIMEOUT : 0.0; $connected = false; try { if ( defined( 'WP_REDIS_PATH' ) && '' !== (string) WP_REDIS_PATH ) { $connected = $redis_client->connect( (string) WP_REDIS_PATH ); } elseif ( $timeout > 0 ) { $connected = $redis_client->connect( $host, $port, $timeout ); } else { $connected = $redis_client->connect( $host, $port ); } if ( $connected ) { $username = defined( 'WP_REDIS_USERNAME' ) ? (string) WP_REDIS_USERNAME : ''; $password = null; if ( defined( 'WP_REDIS_PASSWORD' ) ) { $password = (string) WP_REDIS_PASSWORD; } elseif ( defined( 'WP_REDIS_AUTH' ) ) { $password = (string) WP_REDIS_AUTH; } if ( '' !== $username && null !== $password ) { $redis_client->auth( array( $username, $password ) ); } elseif ( null !== $password && '' !== $password ) { $redis_client->auth( $password ); } if ( defined( 'WP_REDIS_DATABASE' ) ) { $redis_client->select( (int) WP_REDIS_DATABASE ); } } else { $redis_client = null; $temporary_connection = false; } } catch ( RedisException $e ) { $redis_client = null; $temporary_connection = false; } catch ( Exception $e ) { $redis_client = null; $temporary_connection = false; } } if ( $redis_client instanceof Redis ) { try { $redis_info = $redis_client->info(); if ( isset( $redis_info['redis_version'] ) ) { $version = $redis_info['redis_version']; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'redis_extension'; $result['reliability'] = 90; return $result; } } } catch ( RedisException $e ) { wpvulnerability_maybe_log( 'Redis extension available but info() call failed', array( 'exception' => array( 'code' => $e->getCode(), 'message' => $e->getMessage(), ), ) ); } catch ( Exception $e ) { wpvulnerability_maybe_log( 'Redis detection failed', array( 'exception' => array( 'code' => $e->getCode(), 'message' => $e->getMessage(), ), ) ); } finally { if ( $temporary_connection ) { $redis_client->close(); } } } } // Method 2: shell_exec command (most accurate). if ( wpvulnerability_can_shell_exec( 'redis' ) ) { $result['attempts'][] = 'shell_redis_server'; $version_output = wpvulnerability_safe_shell_exec( 'redis', 'redis-server --version' ); if ( ! empty( $version_output ) && preg_match( '/redis-server\s+v=([\d.]+)/i', $version_output, $matches ) ) { $version = $matches[1]; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'shell_exec'; $result['reliability'] = 95; return $result; } } } // Method 3: Binary existence check (basic fallback). if ( wpvulnerability_can_shell_exec( 'redis' ) ) { $result['attempts'][] = 'which_redis_server'; $which_output = wpvulnerability_safe_shell_exec( 'redis', 'which redis-server' ); if ( ! empty( $which_output ) ) { $result['version'] = 'unknown'; $result['method'] = 'binary_exists'; $result['reliability'] = 30; return $result; } } return $result; } /** * Normalizes Memcached version information returned by PHP extensions. * * @since 4.1.7 * * @param mixed $version_info Version information as returned by Memcached::getVersion() or Memcache::getVersion(). * @return string|null Normalized version string or null when it cannot be determined. */ function wpvulnerability_normalize_memcached_version_info( $version_info ) { $reported_versions = array(); if ( is_array( $version_info ) ) { $reported_versions = $version_info; } elseif ( is_string( $version_info ) ) { $trimmed_version = trim( $version_info ); if ( '' !== $trimmed_version ) { $reported_versions = array( $trimmed_version ); } } foreach ( $reported_versions as $reported_version ) { if ( ! is_scalar( $reported_version ) ) { continue; } $reported_version = trim( (string) $reported_version ); if ( '' === $reported_version || '255.255.255' === $reported_version ) { continue; } if ( preg_match( '/-(\d+)$/', $reported_version, $suffix_matches ) ) { $reported_version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $reported_version ); } return $reported_version; } return null; } /** * Detects the version of Memcached using the Memcached extension or system commands. * * Uses a hybrid detection approach: * 1. PHP Memcached/Memcache extension (most secure, reliability 90) * 2. shell_exec commands (most accurate, reliability 95) * 3. Binary existence check (basic fallback, reliability 30) * * @since 3.5.0 Introduced. * @since 4.3.0 Enhanced with hybrid detection and reliability scoring. * * @return array{version: string|null, method: string, reliability: int, attempts: list} */ function wpvulnerability_detect_memcached() { $result = array( 'version' => null, 'method' => 'none', 'reliability' => 0, 'attempts' => array(), ); // Method 1: PHP Memcached extension (most secure). if ( class_exists( 'Memcached' ) ) { $result['attempts'][] = 'memcached_extension'; try { $memcached = new Memcached(); $version_info = $memcached->getVersion(); $version = wpvulnerability_normalize_memcached_version_info( $version_info ); if ( empty( $version ) ) { $servers = $memcached->getServerList(); if ( ! empty( $servers ) ) { $memcached->resetServerList(); foreach ( $servers as $server ) { if ( ! is_array( $server ) || empty( $server['host'] ) ) { continue; } $host_raw = $server['host']; $host = is_scalar( $host_raw ) ? (string) $host_raw : ''; $port_raw = $server['port'] ?? 11211; $port = is_scalar( $port_raw ) ? (int) $port_raw : 11211; $weight_raw = $server['weight'] ?? 0; $weight = is_scalar( $weight_raw ) ? (int) $weight_raw : 0; $memcached->addServer( $host, $port, $weight ); } $version_info = $memcached->getVersion(); $version = wpvulnerability_normalize_memcached_version_info( $version_info ); } } if ( $version ) { $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'memcached_extension'; $result['reliability'] = 90; return $result; } } } catch ( MemcachedException $e ) { // Extension available but service not running. unset( $memcached ); } catch ( Exception $e ) { unset( $memcached ); } } // Try legacy Memcache extension. if ( class_exists( 'Memcache' ) ) { $result['attempts'][] = 'memcache_extension'; try { $memcache = new Memcache(); $version_info = $memcache->getVersion(); $version = wpvulnerability_normalize_memcached_version_info( $version_info ); if ( $version ) { $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'memcache_extension'; $result['reliability'] = 90; return $result; } } } catch ( Exception $e ) { unset( $memcache ); } } // Method 2: shell_exec command (most accurate). if ( wpvulnerability_can_shell_exec( 'memcached' ) ) { $result['attempts'][] = 'shell_memcached'; $version_output = wpvulnerability_safe_shell_exec( 'memcached', 'memcached -h' ); if ( ! empty( $version_output ) && preg_match( '/memcached\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) { $version = $matches[1]; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'shell_exec'; $result['reliability'] = 95; return $result; } } } // Method 3: Binary existence check (basic fallback). if ( wpvulnerability_can_shell_exec( 'memcached' ) ) { $result['attempts'][] = 'which_memcached'; $which_output = wpvulnerability_safe_shell_exec( 'memcached', 'which memcached' ); if ( ! empty( $which_output ) ) { $result['version'] = 'unknown'; $result['method'] = 'binary_exists'; $result['reliability'] = 30; return $result; } } return $result; } /** * Detects the installed PHP version using available runtime information. * * @since 2.0.0 * * @return string|null The detected PHP version in N.n or N.n.n format, or null if unavailable. */ function wpvulnerability_detect_php() { // Initialize the version variable. $version = null; // First method: use the PHP_VERSION constant. if ( defined( 'PHP_VERSION' ) ) { $version = PHP_VERSION; } // First method: use the phpversion function. if ( empty( $version ) && function_exists( 'phpversion' ) ) { $version = phpversion(); } // Second method: use system commands if the first fails and shell_exec is available. if ( empty( $version ) && wpvulnerability_can_shell_exec() ) { // Command to check PHP version (routed through the safe wrapper for validation + audit logging). $version_output = wpvulnerability_safe_shell_exec( 'php', 'php -v' ); if ( ! empty( $version_output ) && preg_match( '/PHP\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) { $version = $matches[1]; // Replace "-N" at the end with ".N" if present. if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } } } // Return the sanitized and validated PHP version or null if it cannot be detected. return wpvulnerability_sanitize_and_validate_version( $version ); } /** * Detects the version of cURL using the cURL extension or system commands. * * @since 3.5.0 Introduced. * * @return string|null The version of cURL in the format N.n.n, N.n, etc., or null if it cannot be detected. */ function wpvulnerability_detect_curl() { // Product name for consistency. $version = null; // First method: use the cURL extension of PHP. if ( function_exists( 'curl_version' ) ) { $curl_info = curl_version(); $version = isset( $curl_info['version'] ) ? $curl_info['version'] : null; } // Second method: use system commands if the first fails and shell_exec is available. if ( empty( $version ) && wpvulnerability_can_shell_exec() ) { // Command to check cURL version (routed through the safe wrapper for validation + audit logging). $version_output = wpvulnerability_safe_shell_exec( 'curl', 'curl --version' ); if ( ! empty( $version_output ) && preg_match( '/curl\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) { $version = $matches[1]; // Replace "-N" at the end with ".N" if present. if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } } } // Return the sanitized and validated version or null if it cannot be detected. return wpvulnerability_sanitize_and_validate_version( $version ); } /** * Detects the version of ImageMagick using the Imagick extension or system commands. * * Uses a hybrid detection approach: * 1. PHP Imagick extension (most secure, reliability 90) * 2. shell_exec commands (most accurate, reliability 95-100) * 3. Binary existence check (basic fallback, reliability 30) * * @since 3.5.0 Introduced. * @since 4.3.0 Enhanced with hybrid detection and reliability scoring. * @since 5.0.0 Version regex updated to handle IMEI-installed ImageMagick builds * (format: `ImageMagick (IMEI - ...) 7.x.y-z`). * * @return array{version: string|null, method: string, reliability: int, attempts: list} */ function wpvulnerability_detect_imagemagick() { $result = array( 'version' => null, 'method' => 'none', 'reliability' => 0, 'attempts' => array(), ); // Method 1: PHP Imagick extension (most secure). if ( extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) { $result['attempts'][] = 'imagick_extension'; try { $imagick = new Imagick(); $version_info = $imagick->getVersion(); if ( preg_match( '/ImageMagick(?:\s*\([^)]+\))?\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_info['versionString'], $matches ) ) { $version = $matches[1]; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'imagick_extension'; $result['reliability'] = 90; return $result; } } } catch ( \ImagickException $exception ) { wpvulnerability_maybe_log( 'ImageMagick version detection via the Imagick PHP extension failed.', array( 'exception' => get_class( $exception ), 'error' => $exception->getMessage(), ) ); } } // Method 2: shell_exec commands (most accurate). if ( wpvulnerability_can_shell_exec( 'imagemagick' ) ) { $commands = array( 'magick -version', 'convert -version', 'identify -version' ); foreach ( $commands as $cmd ) { $result['attempts'][] = 'shell_' . explode( ' ', $cmd )[0]; $version_output = wpvulnerability_safe_shell_exec( 'imagemagick', $cmd ); if ( ! empty( $version_output ) && preg_match( '/ImageMagick(?:\s*\([^)]+\))?\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) { $version = $matches[1]; if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) { $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version ); } $version = wpvulnerability_sanitize_and_validate_version( $version ); if ( $version ) { $result['version'] = $version; $result['method'] = 'shell_exec'; $result['reliability'] = 95; return $result; } } } } // Method 3: Binary existence check (basic fallback). if ( wpvulnerability_can_shell_exec( 'imagemagick' ) ) { $binaries = array( 'convert', 'magick', 'identify' ); foreach ( $binaries as $binary ) { $result['attempts'][] = 'which_' . $binary; $which_output = wpvulnerability_safe_shell_exec( 'imagemagick', 'which ' . $binary ); if ( ! empty( $which_output ) ) { $result['version'] = 'unknown'; $result['method'] = 'binary_exists'; $result['reliability'] = 30; return $result; } } } return $result; } /** * Retrieves the Apache HTTP Server version using available PHP APIs. * * The version is first gathered using the {@see apache_get_version()} function if it exists. The detected * version is sanitized and validated to ensure it matches the expected `major.minor.patch` format. The numeric * portion is extracted before sanitization so that decorated version strings are normalized. A filter * allows overriding the detected version, which is helpful for testing environments where the Apache API is * unavailable. * * @since 4.1.7 * * @return string|null The sanitized Apache version or null when it cannot be determined. */ function wpvulnerability_get_apache_version() { $apache_version = null; $normalize_apache_version = static function ( $value ) { if ( ! is_string( $value ) ) { return null; } $value = trim( $value ); if ( '' === $value ) { return null; } if ( preg_match( '/(\d+\.\d+(?:\.\d+){0,2})/', $value, $matches ) ) { $value = $matches[1]; } $value = wpvulnerability_sanitize_and_validate_version( $value ); if ( null === $value ) { return null; } if ( ! preg_match( '/^\d/', $value ) ) { return null; } return $value; }; if ( function_exists( 'apache_get_version' ) ) { $raw_version = apache_get_version(); if ( is_string( $raw_version ) ) { $apache_version = $normalize_apache_version( $raw_version ); } } /** * Filter the detected Apache version. * * This filter allows overriding the detected Apache HTTP Server version. Returning a falsy value will cause * the detection routine to fall back to other discovery mechanisms. * * @since 4.1.7 * * @param string|null $apache_version The sanitized Apache version, or null if detection failed. */ $apache_version = apply_filters( 'wpvulnerability_detect_webserver_apache_version', $apache_version ); if ( null !== $apache_version ) { $apache_version = $normalize_apache_version( $apache_version ); } return $apache_version; } /** * Detects the web server software and version from the SERVER_SOFTWARE server variable. * * This function attempts to identify the web server software (e.g., Apache, nginx) and its version * based on the 'SERVER_SOFTWARE' environment variable provided by the server. It uses regular expressions * to parse the web server name and version. The function also sanitizes the detected version number * to a standard format (major.minor.patch). * * @since 3.2.0 Introduced. * * @return array{id: string|null, name: string|null, version: string|null} Web server information. */ function wpvulnerability_detect_webserver() { // Initialize an array to hold the web server information. $webserver = array( 'id' => null, 'name' => null, 'version' => null, ); $apache_version = wpvulnerability_get_apache_version(); if ( null !== $apache_version ) { $webserver['id'] = 'apache'; $webserver['name'] = 'Apache HTTPD'; $webserver['version'] = $apache_version; return $webserver; } // Check if the SERVER_SOFTWARE variable is set. if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) { // Trim and sanitize the server software string. $server_sw_raw = $_SERVER['SERVER_SOFTWARE']; // phpcs:ignore $webserver_software = trim( wp_kses( is_string( $server_sw_raw ) ? wp_unslash( $server_sw_raw ) : '', 'strip' ) ); // Use regular expressions to extract the web server name and version. if ( preg_match( '/^([^\s\/]+)\/?([^\s]*)/', $webserver_software, $matches ) ) { $webserver['name'] = trim( (string) $matches[1] ); $webserver['version'] = trim( (string) $matches[2] ); // Replace "-N" at the end of the version with ".N" if present. if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) { $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] ); } } } // Normalize and set the web server ID based on the detected name. if ( ! empty( $webserver['name'] ) ) { $normalized_name = strtolower( (string) $webserver['name'] ); $webserver['id'] = trim( (string) preg_replace( '/[^a-z0-9]+/', '-', $normalized_name ), '-' ); switch ( $normalized_name ) { case 'httpd': case 'apache': $webserver['id'] = 'apache'; $webserver['name'] = 'Apache HTTPD'; break; case 'nginx': $webserver['id'] = 'nginx'; $webserver['name'] = 'nginx'; break; case 'openresty': $webserver['id'] = 'nginx'; $webserver['name'] = 'OpenResty'; break; case 'tengine': $webserver['id'] = 'nginx'; $webserver['name'] = 'Tengine'; break; // Additional web servers can be added here. } } // If the version is not detected, try to get it from the OS. if ( empty( $webserver['version'] ) && wpvulnerability_can_shell_exec() ) { if ( 'apache' === $webserver['id'] && wpvulnerability_analyze_filter( 'apache' ) ) { $apache_version = wpvulnerability_safe_shell_exec( 'apache', 'apache2 -v' ); if ( empty( $apache_version ) ) { $apache_version = wpvulnerability_safe_shell_exec( 'apache', 'httpd -v' ); } if ( ! empty( $apache_version ) && preg_match( '/Apache\/([\d.]+)/', $apache_version, $version_matches ) ) { $webserver['version'] = $version_matches[1]; // Replace "-N" at the end with ".N" if present. if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) { $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] ); } } } elseif ( 'nginx' === $webserver['id'] && wpvulnerability_analyze_filter( 'nginx' ) ) { $nginx_version = wpvulnerability_safe_shell_exec( 'nginx', 'nginx -v' ); if ( ! empty( $nginx_version ) && preg_match( '/nginx\/([\d.]+)/', $nginx_version, $version_matches ) ) { $webserver['version'] = $version_matches[1]; // Replace "-N" at the end with ".N" if present. if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) { $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] ); } } else { $angie_version = wpvulnerability_safe_shell_exec( 'nginx', 'angie -v' ); if ( ! empty( $angie_version ) && preg_match( '/angie\/([\d.]+)/', $angie_version, $version_matches ) ) { $webserver['version'] = $version_matches[1]; // Replace "-N" at the end with ".N" if present. if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) { $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] ); } } } } } // Sanitize and validate the web server version format. if ( null !== $webserver['version'] && '' !== $webserver['version'] ) { // Sanitize the version number to ensure it's in a 'major.minor.patch' format. $webserver['version'] = wpvulnerability_sanitize_and_validate_version( $webserver['version'] ); if ( null !== $webserver['version'] && ! preg_match( '/^\d+(?:\.\d+)*$/', $webserver['version'] ) ) { $webserver['version'] = null; } } // Return the detected web server information. return $webserver; } /** * Fires plugin and legacy hooks when the database reports an error. * * @since 4.3.0 * * @param string $last_error The database error message. * * @return void */ function wpvulnerability_handle_wpdb_last_error( $last_error ) { $last_error = trim( (string) $last_error ); if ( '' === $last_error ) { return; } /** * Fires when WPVulnerability detects a database error while debugging is enabled. * * @since 4.3.0 * * @param string $last_error The database error message. */ do_action( 'wpvulnerability_wpdb_last_error', $last_error ); // Preserve backward compatibility with the legacy hook name. do_action_deprecated( 'wpdb_last_error', array( $last_error ), '4.3.0', 'wpvulnerability_wpdb_last_error' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound } /** * Detects the SQL server software and version from the database server. * * This function identifies the SQL server software (e.g., MariaDB, MySQL) and its version * by querying the database using the 'SHOW VARIABLES' command. It parses the server name * and version using the results and sanitizes the detected version number to a standard format (major.minor.patch). * * @since 3.4.0 * * @return array{id: string|null, name: string|null, version: string|null} SQL server information. */ function wpvulnerability_detect_sqlserver() { // Initialize an array to hold the SQL server information. $sqlserver = array( 'id' => null, 'name' => null, 'version' => null, ); global $wpdb; $version_source = ''; $server_info_string = ''; // Query to get the database server type (version_comment). $database_results = $wpdb->get_results( $wpdb->prepare( 'SHOW VARIABLES LIKE %s', 'version_comment' ) ); // phpcs:ignore if ( $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) { wpvulnerability_handle_wpdb_last_error( $wpdb->last_error ); } // Process the results to determine the database type. if ( ! empty( $database_results ) && isset( $database_results[0]->Value ) ) { $possible_database = trim( (string) $database_results[0]->Value ); if ( false !== stripos( $possible_database, 'mariadb' ) ) { $sqlserver['id'] = 'mariadb'; $sqlserver['name'] = 'MariaDB'; } elseif ( false !== stripos( $possible_database, 'mysql' ) ) { $sqlserver['id'] = 'mysql'; $sqlserver['name'] = 'MySQL'; } } // Query to get the database server version. $version_results = $wpdb->get_results( $wpdb->prepare( 'SHOW VARIABLES LIKE %s', 'version' ) ); // phpcs:ignore if ( $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) { wpvulnerability_handle_wpdb_last_error( $wpdb->last_error ); } if ( ! empty( $version_results ) && isset( $version_results[0]->Value ) ) { $version_source = trim( (string) $version_results[0]->Value ); } if ( empty( $sqlserver['id'] ) ) { $server_info_string = trim( (string) $wpdb->db_server_info() ); if ( '' === $server_info_string ) { $server_info_string = $wpdb->get_var( 'SELECT VERSION()' ); // phpcs:ignore if ( $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) { wpvulnerability_handle_wpdb_last_error( $wpdb->last_error ); } $server_info_string = is_string( $server_info_string ) ? trim( $server_info_string ) : ''; } if ( '' !== $server_info_string ) { if ( false !== stripos( $server_info_string, 'mariadb' ) ) { $sqlserver['id'] = 'mariadb'; $sqlserver['name'] = 'MariaDB'; } elseif ( false !== stripos( $server_info_string, 'mysql' ) ) { $sqlserver['id'] = 'mysql'; $sqlserver['name'] = 'MySQL'; } if ( '' === $version_source ) { $version_source = $server_info_string; } } } if ( '' !== $version_source ) { if ( preg_match( '/(\d+\.\d+\.\d+(?:-\d+)?)/', $version_source, $match ) || preg_match( '/(\d+\.\d+(?:-\d+)?)/', $version_source, $match ) ) { $sqlserver['version'] = $match[1]; } elseif ( 'mysql' === $sqlserver['id'] ) { // Fallback to the entire version string if regex doesn't match. $sqlserver['version'] = $version_source; } } // Replace "-N" at the end with ".N" if present. if ( ! empty( $sqlserver['version'] ) && preg_match( '/-(\d+)$/', $sqlserver['version'], $suffix_matches ) ) { $sqlserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $sqlserver['version'] ); } // Sanitize and validate the version format. if ( ! empty( $sqlserver['version'] ) ) { $sqlserver['version'] = wpvulnerability_sanitize_and_validate_version( $sqlserver['version'] ); } // Return the detected SQL server information. return $sqlserver; } /** * Returns a human-readable HTML entity for the given comparison operator. * * This function takes a comparison operator in string format and returns * its corresponding HTML entity for better readability in web contexts. * * @since 2.0.0 * * @param string $op The operator string to prettify. * * @return string The pretty operator HTML string. */ function wpvulnerability_pretty_operator( $op ) { // Normalize the operator string to lowercase and trim whitespace. $op = trim( strtolower( (string) $op ) ); // Define an associative array mapping operators to their HTML entities. $operator_map = array( 'lt' => '< ', // Less than. 'le' => '≤ ', // Less than or equal to. 'gt' => '> ', // Greater than. 'ge' => '≥ ', // Greater than or equal to. 'eq' => '= ', // Equal to. 'ne' => '≠ ', // Not equal to. ); // Return the corresponding HTML entity, or the original operator if not recognized. return isset( $operator_map[ $op ] ) ? $operator_map[ $op ] : $op; } /** * Returns a human-readable severity level. * * This function takes a severity string and returns a human-readable * severity level, localized for translation. * * @since 2.0.0 * * @param string $severity The severity string to prettify. * * @return string The human-readable severity string. */ function wpvulnerability_severity( $severity ) { // Normalize the severity string to lowercase and trim whitespace. $severity = trim( strtolower( (string) $severity ) ); // Define an associative array mapping severity codes to their human-readable equivalents. // Handles both legacy single-char codes (cvss.severity) and full-word values (cvss3.severity). $severity_map = array( 'n' => __( 'None', 'wpvulnerability' ), 'none' => __( 'None', 'wpvulnerability' ), 'l' => __( 'Low', 'wpvulnerability' ), 'low' => __( 'Low', 'wpvulnerability' ), 'm' => __( 'Medium', 'wpvulnerability' ), 'medium' => __( 'Medium', 'wpvulnerability' ), 'h' => __( 'High', 'wpvulnerability' ), 'high' => __( 'High', 'wpvulnerability' ), 'c' => __( 'Critical', 'wpvulnerability' ), 'critical' => __( 'Critical', 'wpvulnerability' ), ); // Return the corresponding human-readable severity, or the original if not recognized. return isset( $severity_map[ $severity ] ) ? $severity_map[ $severity ] : $severity; } /** * Retrieves vulnerabilities information from the API. * * This function fetches vulnerability information based on the provided type and slug. * It supports caching to minimize API requests and improve performance. * * @since 2.0.0 * * @param string $type The type of vulnerability. Can be 'core', 'plugin', or 'theme'. * @param string $slug The slug of the plugin or theme. For core vulnerabilities, it is the version string. * @param int $cache Optional. Whether to use cache. Default is 1 (true). * * @return array|false An array with the vulnerability information or false if there's an error. */ function wpvulnerability_get( $type, $slug = '', $cache = 1 ) { // Validate vulnerability type and normalize. $type = strtolower( trim( (string) $type ) ); $valid_types = array( 'core', 'plugin', 'theme' ); if ( ! in_array( $type, $valid_types, true ) ) { wp_die( 'Unknown vulnerability type sent.' ); } // Validate and normalize slug for plugin or theme. if ( 'plugin' === $type || 'theme' === $type ) { $slug = sanitize_title( (string) $slug ); if ( '' === $slug ) { return false; } } // Validate and normalize slug for core. if ( 'core' === $type ) { $slug = wpvulnerability_sanitize_version( (string) $slug ); if ( ! $slug ) { return false; } // The API only serves stable milestones: collapse pre-release suffixes // (e.g. 7.1-alpha-62421 or 6.9-beta1) to the milestone (7.1 / 6.9) so // core lookups keep working on development installations. $milestone = preg_replace( '/-(?:alpha|beta|rc).*$/i', '', $slug ); if ( is_string( $milestone ) && '' !== $milestone ) { $slug = $milestone; } } // Cache key. $key = 'wpvulnerability_' . $type . '_' . $slug; // Attempt to retrieve cached data. $vulnerability_data = $cache ? ( is_multisite() ? get_site_transient( $key ) : get_transient( $key ) ) : null; // If not cached, fetch updated data. if ( empty( $vulnerability_data ) ) { $url = WPVULNERABILITY_API_HOST . $type . '/' . $slug . '/'; $response = wp_remote_get( $url, array( 'timeout' => 2.5 ) ); wpvulnerability_maybe_log_api_response( $url, $response ); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { $body = wp_remote_retrieve_body( $response ); // Cache only valid JSON responses so error pages never poison the cache. if ( is_array( json_decode( $body, true ) ) && $cache ) { if ( is_multisite() ) { set_site_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() ); } else { set_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() ); } } $vulnerability_data = $body; // Use the fresh data. } } if ( ! is_string( $vulnerability_data ) || '' === $vulnerability_data ) { return false; } $decoded = json_decode( $vulnerability_data, true ); return is_array( $decoded ) ? $decoded : false; } /** * Retrieve vulnerabilities for a specific version of WordPress Core. * * This function fetches vulnerability information for a given version of WordPress Core. * If no version is provided, it retrieves vulnerabilities for the currently installed version. * It supports caching to minimize API requests and improve performance. * * @since 2.0.0 * * @param string|null $version The version number of WordPress Core. If null, retrieves for the installed version. * @param int $cache Optional. Whether to use cache. Default is 1 (true). * * @return list>|false Array of vulnerabilities, or false on error. */ function wpvulnerability_get_core( $version = null, $cache = 1 ) { // Sanitize the version number. if ( ! wpvulnerability_sanitize_version( $version ) ) { $version = null; // Reset version if sanitization fails. } // If version number is null, retrieve for the installed version. if ( is_null( $version ) ) { $version = get_bloginfo( 'version' ); } // Get vulnerabilities from the API. $response = wpvulnerability_get( 'core', $version, $cache ); // Check for errors in the response. if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) { return false; } $data_section = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(); $vuln_raw = isset( $data_section['vulnerability'] ) && is_array( $data_section['vulnerability'] ) ? $data_section['vulnerability'] : array(); if ( empty( $vuln_raw ) ) { return false; } // Process vulnerabilities and return as an array. $vulnerabilities = array(); foreach ( $vuln_raw as $v ) { if ( ! is_array( $v ) ) { continue; } $v_name = $v['name'] ?? null; $v_link = $v['link'] ?? null; $vulnerabilities[] = array( 'name' => is_scalar( $v_name ) ? wp_kses( (string) $v_name, 'strip' ) : null, 'link' => is_scalar( $v_link ) ? esc_url_raw( (string) $v_link ) : null, 'source' => isset( $v['source'] ) ? $v['source'] : null, 'impact' => isset( $v['impact'] ) ? $v['impact'] : null, 'uuid' => is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '', ); } return $vulnerabilities; } /** * Determines if a vulnerability applies to the specified version of the plugin. * * @since 3.5.0 Introduced. * * @param array $v The vulnerability data. * @param string $version The version of the plugin. * * @return bool True if the vulnerability applies, false otherwise. */ function wpvulnerability_is_vulnerability_applicable( $v, $version ) { $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array(); $min_op = isset( $op['min_operator'] ) && is_scalar( $op['min_operator'] ) ? (string) $op['min_operator'] : ''; $max_op = isset( $op['max_operator'] ) && is_scalar( $op['max_operator'] ) ? (string) $op['max_operator'] : ''; $min_ver = isset( $op['min_version'] ) && is_scalar( $op['min_version'] ) ? (string) $op['min_version'] : ''; $max_ver = isset( $op['max_version'] ) && is_scalar( $op['max_version'] ) ? (string) $op['max_version'] : ''; // Check if the vulnerability has minimum and maximum versions. if ( '' !== $min_op && '' !== $max_op ) { return version_compare( $version, $min_ver, $min_op ) && version_compare( $version, $max_ver, $max_op ); } // Check if the vulnerability has only a maximum version. if ( '' !== $max_op ) { return version_compare( $version, $max_ver, $max_op ); } // Check if the vulnerability has only a minimum version. if ( '' !== $min_op ) { return version_compare( $version, $min_ver, $min_op ); } return false; } /** * Retrieves vulnerabilities for a specified plugin, optionally returning general plugin data. * * This function sanitizes the plugin slug and verifies the version number before querying the vulnerability API. * If `$data` is set to 1, it returns general information about the plugin instead of vulnerabilities. * The function returns an array of vulnerabilities or plugin data based on the `$data` parameter, or `false` * if no vulnerabilities are found or the version number is invalid and `$data` is not set. * * @since 2.0.0 Introduced. * * @param string $slug The slug of the plugin to check for vulnerabilities. * @param string $version The version of the plugin to check. The function may return `false` if this is invalid and `$data` is not set. * @param int $data Optional. Set to 1 to return general plugin data instead of vulnerabilities. Default 0 (return vulnerabilities). * @param int $cache Optional. Whether to use cache. Default is 1 (true). * * @return list>|array|false An array of vulnerabilities or plugin data if `$data` is set to 1, or `false` if no vulnerabilities are found or the version number is invalid and `$data` is not set. */ function wpvulnerability_get_plugin( $slug, $version, $data = 0, $cache = 1 ) { // Sanitize the plugin slug. $slug = sanitize_title( $slug ); // If the version number is invalid, return false unless $data is set. if ( ! wpvulnerability_sanitize_version( $version ) && ! $data ) { return false; } // Get the response from the vulnerability API. $response = wpvulnerability_get( 'plugin', $slug, $cache ); // If $data is set to 1, return general plugin data. if ( 1 === $data && is_array( $response ) ) { $resp_data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(); $name_raw = $resp_data['name'] ?? ''; $link_raw = $resp_data['link'] ?? ''; $latest_raw = $resp_data['latest'] ?? 0; $closed_raw = $resp_data['closed'] ?? 0; return array( 'name' => wp_kses( is_scalar( $name_raw ) ? (string) $name_raw : '', 'strip' ), 'link' => esc_url( is_scalar( $link_raw ) ? (string) $link_raw : '' ), 'latest' => number_format( is_scalar( $latest_raw ) ? (int) $latest_raw : 0, 0, '.', '' ), 'closed' => number_format( is_scalar( $closed_raw ) ? (int) $closed_raw : 0, 0, '.', '' ), ); } // Check for errors in the response. if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) { return false; } $resp_data2 = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(); $vuln_list = isset( $resp_data2['vulnerability'] ) && is_array( $resp_data2['vulnerability'] ) ? $resp_data2['vulnerability'] : array(); if ( empty( $vuln_list ) ) { return false; } // Create an empty array to store vulnerabilities. $vulnerabilities = array(); // Loop through each vulnerability. foreach ( $vuln_list as $v ) { if ( ! is_array( $v ) ) { continue; } // Check version constraints and add vulnerabilities accordingly. if ( wpvulnerability_is_vulnerability_applicable( $v, $version ) ) { $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array(); $min_op_raw = $op['min_operator'] ?? ''; $max_op_raw = $op['max_operator'] ?? ''; $min_ver_raw = $op['min_version'] ?? ''; $max_ver_raw = $op['max_version'] ?? ''; $unfixed_raw = $op['unfixed'] ?? 0; $closed_raw2 = $op['closed'] ?? 0; $min_op = is_scalar( $min_op_raw ) ? (string) $min_op_raw : ''; $max_op = is_scalar( $max_op_raw ) ? (string) $max_op_raw : ''; $min_ver = is_scalar( $min_ver_raw ) ? (string) $min_ver_raw : ''; $max_ver = is_scalar( $max_ver_raw ) ? (string) $max_ver_raw : ''; $v_name_raw = $v['name'] ?? ''; $v_desc_raw = $v['description'] ?? ''; $vulnerabilities[] = array( 'name' => wp_kses( is_scalar( $v_name_raw ) ? (string) $v_name_raw : '', 'strip' ), 'description' => wp_kses_post( is_scalar( $v_desc_raw ) ? (string) $v_desc_raw : '' ), 'versions' => wp_kses( wpvulnerability_pretty_operator( $min_op ) . $min_ver . ' - ' . wpvulnerability_pretty_operator( $max_op ) . $max_ver, 'strip' ), 'version' => wp_kses( '' !== $min_ver ? $min_ver : $max_ver, 'strip' ), 'unfixed' => is_scalar( $unfixed_raw ) ? (int) $unfixed_raw : 0, 'closed' => is_scalar( $closed_raw2 ) ? (int) $closed_raw2 : 0, 'source' => isset( $v['source'] ) ? $v['source'] : null, 'impact' => isset( $v['impact'] ) ? $v['impact'] : null, 'uuid' => is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '', ); } } return $vulnerabilities; } /** * Get vulnerabilities for a specific theme. * * This function retrieves and sanitizes the theme slug and version before querying the vulnerability API. * It returns an array of vulnerabilities if any are found, or false if there are none. * * @since 3.5.0 * * @param string $slug Slug of the theme. * @param string $version Version of the theme. * @param int $cache Optional. Whether to use cache. Default is 1 (true). * * @return list>|false Returns an array of vulnerabilities, or false if there are none. */ function wpvulnerability_get_theme( $slug, $version, $cache = 1 ) { // Sanitize the theme slug. $slug = sanitize_title( $slug ); // Validate the version number. if ( ! wpvulnerability_sanitize_version( $version ) ) { return false; // Return false if the version is invalid. } // Get the response from the vulnerability API. $response = wpvulnerability_get( 'theme', $slug, $cache ); // Check for errors in the response. if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) { return false; } $theme_data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(); $vuln_list = isset( $theme_data['vulnerability'] ) && is_array( $theme_data['vulnerability'] ) ? $theme_data['vulnerability'] : array(); if ( empty( $vuln_list ) ) { return false; } // Process each vulnerability. $vulnerabilities = array(); foreach ( $vuln_list as $v ) { if ( ! is_array( $v ) ) { continue; } // Check if the version falls within the min and max operator range. if ( wpvulnerability_is_vulnerability_applicable( $v, $version ) ) { $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array(); $min_op_raw = $op['min_operator'] ?? ''; $max_op_raw = $op['max_operator'] ?? ''; $min_ver_raw = $op['min_version'] ?? ''; $max_ver_raw = $op['max_version'] ?? ''; $unfixed_raw = $op['unfixed'] ?? 0; $closed_raw = $op['closed'] ?? 0; $min_op = is_scalar( $min_op_raw ) ? (string) $min_op_raw : ''; $max_op = is_scalar( $max_op_raw ) ? (string) $max_op_raw : ''; $min_ver = is_scalar( $min_ver_raw ) ? (string) $min_ver_raw : ''; $max_ver = is_scalar( $max_ver_raw ) ? (string) $max_ver_raw : ''; $v_name_raw = $v['name'] ?? ''; $v_desc_raw = $v['description'] ?? ''; $vulnerabilities[] = array( 'name' => wp_kses( is_scalar( $v_name_raw ) ? (string) $v_name_raw : '', 'strip' ), 'description' => wp_kses_post( is_scalar( $v_desc_raw ) ? (string) $v_desc_raw : '' ), 'versions' => wp_kses( wpvulnerability_pretty_operator( $min_op ) . $min_ver . ' - ' . wpvulnerability_pretty_operator( $max_op ) . $max_ver, 'strip' ), 'version' => wp_kses( '' !== $min_ver ? $min_ver : $max_ver, 'strip' ), 'unfixed' => is_scalar( $unfixed_raw ) ? (int) $unfixed_raw : 0, 'closed' => is_scalar( $closed_raw ) ? (int) $closed_raw : 0, 'source' => isset( $v['source'] ) ? $v['source'] : null, 'impact' => isset( $v['impact'] ) ? $v['impact'] : null, 'uuid' => is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '', ); } } return $vulnerabilities; } /** * Get statistics. * * Returns an array with statistical information about vulnerabilities and their respective products. * * @since 2.0.0 * * @param int $cache Optional. Whether to use cache. Default is 1 (true). * * @return array|false Returns an array with the statistical information if successful, false otherwise. */ function wpvulnerability_get_statistics( $cache = 1 ) { $key = 'wpvulnerability_stats'; // Attempt to get cached statistics. $vulnerability = $cache ? ( is_multisite() ? get_site_transient( $key ) : get_transient( $key ) ) : null; // If cached statistics are not available, retrieve them from the API. if ( empty( $vulnerability ) ) { $url = WPVULNERABILITY_API_HOST; $response = wp_remote_get( $url, array( 'timeout' => 2.5 ) ); wpvulnerability_maybe_log_api_response( $url, $response ); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { $body = wp_remote_retrieve_body( $response ); // Cache only valid JSON responses so error pages never poison the cache. if ( is_array( json_decode( $body, true ) ) ) { if ( is_multisite() ) { set_site_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() ); } else { set_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() ); } $vulnerability = $body; // Use the fresh data. } } } // Decode the JSON response and check for statistics. $response = json_decode( is_string( $vulnerability ) ? $vulnerability : '', true ); if ( ! is_array( $response ) || ! isset( $response['stats'] ) || ! is_array( $response['stats'] ) ) { return false; } // Extract typed intermediate arrays to avoid mixed-type access errors. $stats = $response['stats']; $products = isset( $stats['products'] ) && is_array( $stats['products'] ) ? $stats['products'] : array(); $sources_raw = isset( $stats['vulnerabilities'] ) && is_array( $stats['vulnerabilities'] ) ? $stats['vulnerabilities'] : array(); $behind = isset( $response['behindtheproject'] ) && is_array( $response['behindtheproject'] ) ? $response['behindtheproject'] : array(); $updated_raw = $response['updated'] ?? 0; $updated = is_scalar( $updated_raw ) ? (int) $updated_raw : 0; $sponsors = array(); if ( isset( $behind['sponsors'] ) && is_array( $behind['sponsors'] ) && count( $behind['sponsors'] ) ) { foreach ( $behind['sponsors'] as $s ) { $sponsors[] = $s; } } $contributors = array(); if ( isset( $behind['contributors'] ) && is_array( $behind['contributors'] ) && count( $behind['contributors'] ) ) { foreach ( $behind['contributors'] as $s ) { $contributors[] = $s; } } /** * Cast a scalar value to int safely. * * @param mixed $x Value to cast. * @return int */ $to_int = static function ( $x ): int { return is_scalar( $x ) ? (int) $x : 0; }; // Build per-source vulnerability breakdown (stats.vulnerabilities.{source}). $sources_data = array(); $known_sources = array( 'cve', 'euvd', 'jvn', 'patchstack', 'wpscan', 'wordfence' ); foreach ( $known_sources as $src ) { if ( isset( $sources_raw[ $src ] ) && is_array( $sources_raw[ $src ] ) ) { $src_data = $sources_raw[ $src ]; $sources_data[ $src ] = array( 'core' => $to_int( $src_data['core'] ?? 0 ), 'plugins' => $to_int( $src_data['plugins'] ?? 0 ), 'themes' => $to_int( $src_data['themes'] ?? 0 ), ); } } // Return an array with statistical information. return array( 'core' => array( 'versions' => $to_int( $products['core'] ?? 0 ), ), 'plugins' => array( 'products' => $to_int( $products['plugins'] ?? 0 ), 'vulnerabilities' => $to_int( $stats['plugins'] ?? 0 ), ), 'themes' => array( 'products' => $to_int( $products['themes'] ?? 0 ), 'vulnerabilities' => $to_int( $stats['themes'] ?? 0 ), ), 'php' => array( 'vulnerabilities' => $to_int( $stats['php'] ?? 0 ), ), 'apache' => array( 'vulnerabilities' => $to_int( $stats['apache'] ?? 0 ), ), 'nginx' => array( 'vulnerabilities' => $to_int( $stats['nginx'] ?? 0 ), ), 'mariadb' => array( 'vulnerabilities' => $to_int( $stats['mariadb'] ?? 0 ), ), 'mysql' => array( 'vulnerabilities' => $to_int( $stats['mysql'] ?? 0 ), ), 'imagemagick' => array( 'vulnerabilities' => $to_int( $stats['imagemagick'] ?? 0 ), ), 'curl' => array( 'vulnerabilities' => $to_int( $stats['curl'] ?? 0 ), ), 'memcached' => array( 'vulnerabilities' => $to_int( $stats['memcached'] ?? 0 ), ), 'redis' => array( 'vulnerabilities' => $to_int( $stats['redis'] ?? 0 ), ), 'sqlite' => array( 'vulnerabilities' => $to_int( $stats['sqlite'] ?? 0 ), ), 'sources' => $sources_data, 'sponsors' => $sponsors, 'contributors' => $contributors, 'updated' => array( 'unixepoch' => $updated, 'datetime' => gmdate( 'Y-m-d H:i:s', $updated ), 'iso8601' => gmdate( 'c', $updated ), 'rfc2822' => gmdate( 'r', $updated ), ), ); } /** * Retrieves the latest vulnerability statistics. * * This function calls the wpvulnerability API to get fresh statistics related to vulnerabilities * and returns the updated information. * * @since 3.4.0 * * @return array|false The updated vulnerability statistics, or false on error. */ function wpvulnerability_get_fresh_statistics() { // Call the function to get the latest vulnerability statistics. $statistics_api_response = wpvulnerability_get_statistics(); // Return the response from the API. return $statistics_api_response; } /** * Retrieves and caches the latest vulnerability statistics. * * This function retrieves the most recent vulnerability statistics, caches the data, * and returns the information as a JSON-encoded array. The cache expiration timestamp is also updated. * * @since 3.4.0 * * @return string JSON-encoded array containing the vulnerability statistics, or empty string on encoding error. */ function wpvulnerability_statistics_get() { // Retrieve fresh statistics. $statistics = wpvulnerability_get_fresh_statistics(); // Cache the statistics data and the timestamp for cache expiration. $encoded_statistics = wp_json_encode( $statistics ); if ( false === $encoded_statistics ) { $encoded_statistics = ''; } $cache_expiration = number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ); if ( is_multisite() ) { update_site_option( 'wpvulnerability-statistics', $encoded_statistics ); update_site_option( 'wpvulnerability-statistics-cache', $cache_expiration ); } else { update_option( 'wpvulnerability-statistics', $encoded_statistics ); update_option( 'wpvulnerability-statistics-cache', $cache_expiration ); } // Return the JSON-encoded array of statistics data. return $encoded_statistics; } /** * Get vulnerabilities for a specific product version. * * This function retrieves vulnerability data for a specified product version. * It supports caching to minimize API requests and improve performance. * * @since 3.5.0 * * @param string $type The type of product (e.g., 'php', 'apache', 'nginx', 'mariadb', 'mysql'). * @param string $version The version of the product to check. * @param int $cache Optional. Whether to use cache. Default is 1 (true). * * @return list>|false Returns an array of vulnerabilities, or false if there are none. */ function wpvulnerability_get_vulnerabilities( $type, $version, $cache = 1 ) { // Validate vulnerability type and version before building the request URL. $type = strtolower( trim( (string) $type ) ); $version = wpvulnerability_sanitize_version( (string) $version ); $valid_types = array( 'php', 'apache', 'nginx', 'mariadb', 'mysql', 'imagemagick', 'curl', 'memcached', 'redis', 'sqlite' ); if ( ! in_array( $type, $valid_types, true ) || ! $version ) { return false; } $key = 'wpvulnerability_' . $type; $vulnerability_data = null; $vulnerability = array(); // Get cached statistics if available. if ( $cache ) { $vulnerability_data = is_multisite() ? get_site_transient( $key ) : get_transient( $key ); } // If cached statistics are not available, retrieve them from the API and store them in cache. if ( empty( $vulnerability_data ) ) { $url = WPVULNERABILITY_API_HOST . $type . '/' . $version . '/'; $response = wp_remote_get( $url, array( 'timeout' => 2.5 ) ); wpvulnerability_maybe_log_api_response( $url, $response ); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { $body = wp_remote_retrieve_body( $response ); // Cache only valid JSON responses so error pages never poison the cache. if ( is_array( json_decode( $body, true ) ) && $cache ) { if ( is_multisite() ) { set_site_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() ); } else { set_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() ); } } $vulnerability_data = $body; // Use the fresh data. } } // If the response does not contain vulnerabilities, return false. $response = json_decode( is_string( $vulnerability_data ) ? $vulnerability_data : '', true ); if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) { return false; } $resp_data2 = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array(); $vuln_list = isset( $resp_data2['vulnerability'] ) && is_array( $resp_data2['vulnerability'] ) ? $resp_data2['vulnerability'] : array(); if ( empty( $vuln_list ) ) { return false; } // Process each vulnerability. foreach ( $vuln_list as $v ) { if ( ! is_array( $v ) ) { continue; } $v_name_raw = $v['name'] ?? ''; $name = is_scalar( $v_name_raw ) ? wp_kses( (string) $v_name_raw, 'strip' ) : ''; $source = isset( $v['source'] ) ? $v['source'] : null; $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array(); $min_op_raw = $op['min_operator'] ?? ''; $max_op_raw = $op['max_operator'] ?? ''; $min_ver_raw = $op['min_version'] ?? ''; $max_ver_raw = $op['max_version'] ?? ''; $unfixed_raw = $op['unfixed'] ?? 0; $min_op = is_scalar( $min_op_raw ) ? (string) $min_op_raw : ''; $max_op = is_scalar( $max_op_raw ) ? (string) $max_op_raw : ''; $min_ver = is_scalar( $min_ver_raw ) ? (string) $min_ver_raw : ''; $max_ver = is_scalar( $max_ver_raw ) ? (string) $max_ver_raw : ''; $unfixed = is_scalar( $unfixed_raw ) ? (int) $unfixed_raw : 0; $impact = isset( $v['impact'] ) ? $v['impact'] : null; $uuid = is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : ''; // Check if the version falls within the specified min and max operator range. if ( '' !== $min_op && '' !== $max_op ) { if ( version_compare( $version, $min_ver, $min_op ) && version_compare( $version, $max_ver, $max_op ) ) { $vulnerability[] = array( 'name' => $name, 'versions' => wp_kses( wpvulnerability_pretty_operator( $min_op ) . $min_ver . ' - ' . wpvulnerability_pretty_operator( $max_op ) . $max_ver, 'strip' ), 'version' => wp_kses( $min_ver, 'strip' ), 'unfixed' => $unfixed, 'source' => $source, 'impact' => $impact, 'uuid' => $uuid, ); } } elseif ( '' !== $max_op ) { if ( version_compare( $version, $max_ver, $max_op ) ) { $vulnerability[] = array( 'name' => $name, 'versions' => wp_kses( wpvulnerability_pretty_operator( $max_op ) . $max_ver, 'strip' ), 'version' => wp_kses( $max_ver, 'strip' ), 'unfixed' => $unfixed, 'source' => $source, 'impact' => $impact, 'uuid' => $uuid, ); } } elseif ( '' !== $min_op ) { if ( version_compare( $version, $min_ver, $min_op ) ) { $vulnerability[] = array( 'name' => $name, 'versions' => wp_kses( wpvulnerability_pretty_operator( $min_op ) . $min_ver, 'strip' ), 'version' => wp_kses( $min_ver, 'strip' ), 'unfixed' => $unfixed, 'source' => $source, 'impact' => $impact, 'uuid' => $uuid, ); } } } return $vulnerability; } /** * Get the current security mode configuration. * * Returns the security mode that controls shell_exec usage. * Valid modes: 'standard', 'strict', 'disabled'. * * @since 4.3.0 * * @return string Current security mode. */ function wpvulnerability_get_security_mode() { if ( defined( 'WPVULNERABILITY_SECURITY_MODE' ) ) { $mode = strtolower( trim( (string) WPVULNERABILITY_SECURITY_MODE ) ); $valid_modes = array( 'standard', 'strict', 'disabled' ); if ( in_array( $mode, $valid_modes, true ) ) { return $mode; } } $settings = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() ); if ( ! is_array( $settings ) ) { $settings = array(); } if ( isset( $settings['security_mode'] ) ) { $security_mode_raw = $settings['security_mode']; $mode = strtolower( trim( is_scalar( $security_mode_raw ) ? (string) $security_mode_raw : '' ) ); $valid_modes = array( 'standard', 'strict', 'disabled' ); if ( in_array( $mode, $valid_modes, true ) ) { return $mode; } } return 'standard'; } /** * Validate a shell command before execution. * * Ensures commands match allowed patterns and don't contain dangerous characters. * * @since 4.3.0 * * @param string $command Command to validate. * * @return bool True if command is safe, false otherwise. */ function wpvulnerability_validate_shell_command( $command ) { $command = trim( (string) $command ); if ( '' === $command ) { return false; } // Whitelist of allowed command bases. $allowed_commands = array( 'convert', 'magick', 'identify', 'redis-server', 'memcached', 'sqlite3', 'which', 'apache2', 'httpd', 'nginx', 'angie', 'caddy', 'php', 'curl', 'gs', 'cwebp', 'avifenc', ); // Extract the base command (first word). $parts = explode( ' ', $command ); $base_command = $parts[0]; // Check if the base command is in the allowlist (exact match, not substring). $command_allowed = in_array( strtolower( $base_command ), $allowed_commands, true ); if ( ! $command_allowed ) { wpvulnerability_maybe_log( 'Shell command validation failed: command not in whitelist', array( 'command' => $command, 'base' => $base_command, ) ); return false; } // Dangerous patterns that should never appear in commands. $dangerous_patterns = array( ';', '|', '&', '`', '$(', '<', '>', "\n", "\r", "\0", ); foreach ( $dangerous_patterns as $pattern ) { if ( false !== strpos( $command, $pattern ) ) { wpvulnerability_maybe_log( 'Shell command validation failed: dangerous pattern detected', array( 'command' => $command, 'pattern' => $pattern, ) ); return false; } } return true; } /** * Register the custom post type used to store shell_exec logs. * * @since 4.3.0 * * @return void */ function wpvulnerability_register_shell_log_post_type() { register_post_type( 'wpv_shell_log', array( 'labels' => array( 'name' => __( 'WPVulnerability Shell Logs', 'wpvulnerability' ), 'singular_name' => __( 'WPVulnerability Shell Log', 'wpvulnerability' ), ), 'public' => false, 'exclude_from_search' => true, 'publicly_queryable' => false, 'show_ui' => false, 'show_in_menu' => false, 'supports' => array( 'title', 'editor' ), ) ); } add_action( 'init', 'wpvulnerability_register_shell_log_post_type' ); /** * Log a shell_exec execution attempt. * * Stores information about shell command execution for security auditing. * * @since 4.3.0 * * @param string $component Component making the request. * @param string $command Command that was executed or attempted. * @param string|null $output Command output (null if not executed). * @param bool $success Whether execution was successful. * @param string $reason Reason for success/failure. * * @return void */ function wpvulnerability_log_shell_exec( $component, $command, $output, $success, $reason ) { if ( 0 >= wpvulnerability_log_retention_days() ) { return; } $log_data = array( 'component' => sanitize_text_field( $component ), 'command' => sanitize_text_field( $command ), 'output' => $output ? substr( sanitize_textarea_field( $output ), 0, 1000 ) : null, 'success' => (bool) $success, 'reason' => sanitize_text_field( $reason ), 'timestamp' => current_time( 'mysql' ), 'user' => is_user_logged_in() ? wp_get_current_user()->user_login : 'system', 'ip' => ( isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown', // phpcs:ignore ); $log_id = wp_insert_post( array( 'post_type' => 'wpv_shell_log', 'post_status' => 'publish', 'post_title' => sprintf( '[%s] %s', $component, $reason ), 'post_content' => wp_slash( (string) wp_json_encode( $log_data ) ), 'post_author' => 0, ), true ); if ( is_wp_error( $log_id ) ) { wpvulnerability_maybe_log( 'Failed to create shell exec log entry', array( 'error' => $log_id->get_error_message(), ) ); } } /** * Retrieve shell execution logs for display in admin. * * @since 4.3.0 * * @param int $per_page Optional. Number of logs to return. Default 50. * @param int $paged Optional. Page number to retrieve. Default 1. * * @return WP_Post[] Array of log posts. */ function wpvulnerability_get_shell_exec_logs( $per_page = 50, $paged = 1 ) { $per_page = max( 1, (int) $per_page ); $paged = max( 1, (int) $paged ); $offset = ( $paged - 1 ) * $per_page; return get_posts( array( 'post_type' => 'wpv_shell_log', 'post_status' => 'publish', 'posts_per_page' => $per_page, 'orderby' => 'date', 'order' => 'DESC', 'offset' => $offset, 'no_found_rows' => true, ) ); } /** * Count the total amount of stored shell exec log entries. * * @since 4.3.0 * * @return int Number of log posts. */ function wpvulnerability_count_shell_exec_logs() { $counts = wp_count_posts( 'wpv_shell_log' ); if ( ! isset( $counts->publish ) ) { return 0; } return (int) $counts->publish; } /** * Delete shell exec logs that fall outside of the configured retention window. * * @since 4.3.0 * * @return void */ function wpvulnerability_delete_expired_shell_logs() { $retention = wpvulnerability_log_retention_days(); if ( $retention <= 0 ) { return; } $threshold_ts = strtotime( '-' . $retention . ' days', time() ); $threshold = gmdate( 'Y-m-d H:i:s', false !== $threshold_ts ? $threshold_ts : time() ); do { $logs = get_posts( array( 'post_type' => 'wpv_shell_log', 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => 100, 'orderby' => 'date', 'order' => 'ASC', 'no_found_rows' => true, 'cache_results' => false, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'date_query' => array( array( 'before' => $threshold, 'inclusive' => false, ), ), ) ); if ( empty( $logs ) ) { break; } foreach ( $logs as $log_id ) { wp_delete_post( $log_id, true ); } $logs_count = count( $logs ); } while ( $logs_count >= 100 ); } add_action( 'wpvulnerability_cleanup_logs', 'wpvulnerability_delete_expired_shell_logs' ); /** * Safe wrapper for shell_exec with validation and logging. * * This function wraps shell_exec calls with security checks and audit logging. * * @since 4.3.0 * * @param string $component Component requesting shell execution. * @param string $command Command to execute. * * @return string|null Command output on success, null on failure. */ function wpvulnerability_safe_shell_exec( $component, $command ) { // Check if shell_exec is allowed for this component. if ( ! wpvulnerability_can_shell_exec( $component ) ) { wpvulnerability_log_shell_exec( $component, $command, null, false, 'disabled' ); return null; } // Validate command structure. if ( ! wpvulnerability_validate_shell_command( $command ) ) { wpvulnerability_log_shell_exec( $component, $command, null, false, 'validation_failed' ); return null; } // Execute command. $output_raw = @shell_exec( escapeshellcmd( $command ) . ' 2>&1' ); // phpcs:ignore $output = ( false === $output_raw ) ? null : $output_raw; // Log execution. wpvulnerability_log_shell_exec( $component, $command, $output, ! empty( $output ), 'executed' ); return $output; }