wpvulnerability/wpvulnerability-general.php
2026-08-23 07:08:42 +00:00

2938 lines
94 KiB
PHP

<?php
/**
* General functions
*
* @package WPVulnerability
*
* @since 2.0.0
*/
defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
/**
* Clear the existing cache for the specified type.
*
* @since 2.0.0
*
* @param string $type The type of cache to clear (core, plugins, themes).
* @return void
*/
function wpvulnerability_clear_cache( $type ) {
$additional_keys = array(
'core' => 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<string, mixed> $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<string, mixed>`, merging defaults so
* that callers can safely access keys without additional type guards.
*
* @since 4.3.3
*
* @param array<string, mixed> $defaults Optional default values to merge.
*
* @return array<string, mixed> 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<string, mixed>|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<string, mixed>|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<string, string> 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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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' => '&lt;&nbsp;', // Less than.
'le' => '&le;&nbsp;', // Less than or equal to.
'gt' => '&gt;&nbsp;', // Greater than.
'ge' => '&ge;&nbsp;', // Greater than or equal to.
'eq' => '&equals;&nbsp;', // Equal to.
'ne' => '&ne;&nbsp;', // 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<mixed>|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<array<string, mixed>>|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<mixed> $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<string, mixed>>|array<string, mixed>|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<array<string, mixed>>|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<string, mixed>|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<string, mixed>|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<array<string, mixed>>|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;
}