PluginProbe
WPVulnerability / 5.1.2
WPVulnerability v5.1.2
5.1.6 5.1.2 5.1.1 5.0.1 5.0.0 trunk 0.1 0.2 1.0 1.0.1 1.1 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 All 57 releases
wpvulnerability / wpvulnerability-general.php

wpvulnerability-general.php in WPVulnerability 5.1.2, at wpvulnerability-general.php

2,879 lines 91.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * General functions
4 *
5 * @package WPVulnerability
6 *
7 * @since 2.0.0
8 */
9
10 defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
11
12 /**
13 * Clear the existing cache for the specified type.
14 *
15 * @since 2.0.0
16 *
17 * @param string $type The type of cache to clear (core, plugins, themes).
18 * @return void
19 */
20 function wpvulnerability_clear_cache( $type ) {
21 $additional_keys = array(
22 'core' => array( 'wpvulnerability-core-version' ),
23 'plugins' => array(
24 'wpvulnerability-plugins-signature',
25 'wpvulnerability-plugins-data',
26 'wpvulnerability-plugins-cache-data',
27 ),
28 'themes' => array( 'wpvulnerability-themes-signature' ),
29 );
30
31 if ( is_multisite() ) {
32 delete_site_option( "wpvulnerability-{$type}" );
33 delete_site_option( "wpvulnerability-{$type}-vulnerable" );
34 delete_site_option( "wpvulnerability-{$type}-cache" );
35
36 if ( isset( $additional_keys[ $type ] ) ) {
37 foreach ( $additional_keys[ $type ] as $option_name ) {
38 delete_site_option( $option_name );
39 }
40 }
41 } else {
42 delete_option( "wpvulnerability-{$type}" );
43 delete_option( "wpvulnerability-{$type}-vulnerable" );
44 delete_option( "wpvulnerability-{$type}-cache" );
45
46 if ( isset( $additional_keys[ $type ] ) ) {
47 foreach ( $additional_keys[ $type ] as $option_name ) {
48 delete_option( $option_name );
49 }
50 }
51 }
52 }
53
54 /**
55 * Checks and validates user capabilities for managing vulnerability settings in a WordPress environment.
56 *
57 * This function verifies if the current user has the appropriate permissions to manage network settings
58 * in a multisite installation or manage options in a single site installation. It ensures that only
59 * Administrators in a single site and Super Administrators in multisite can access these settings.
60 *
61 * @since 3.0.0
62 *
63 * @return bool Returns true if the current user has the required capabilities, false otherwise.
64 */
65 function wpvulnerability_capabilities() {
66 // Check if the user is logged in.
67 if ( ! is_user_logged_in() ) {
68 return false;
69 }
70
71 // Check if in a Multisite environment.
72 if ( is_multisite() && is_super_admin() && ( is_network_admin() || is_main_site() ) ) {
73 return true;
74 } elseif ( is_admin() && current_user_can( 'manage_options' ) ) {
75 return true;
76 }
77
78 // Return false if the user does not have the required capabilities.
79 return false;
80 }
81
82 /**
83 * Checks if the `shell_exec` function can be used.
84 *
85 * This function implements a 4-level security check system:
86 * 1. Global disable via constant
87 * 2. Security mode (strict/standard/disabled)
88 * 3. Component-specific whitelist
89 * 4. PHP configuration check
90 *
91 * @since 3.4.0
92 * @since 4.3.0 Enhanced with 4-level security checks and component-specific control.
93 *
94 * @param string $component Optional. Component name for granular control.
95 *
96 * @return bool True if `shell_exec` is available and allowed, false otherwise.
97 */
98 function wpvulnerability_can_shell_exec( $component = '' ) {
99 // Level 1: Global disable via constant.
100 if ( defined( 'WPVULNERABILITY_DISABLE_SHELL_EXEC' ) && WPVULNERABILITY_DISABLE_SHELL_EXEC ) {
101 return false;
102 }
103
104 // Level 2: Security mode.
105 $security_mode = wpvulnerability_get_security_mode();
106 if ( 'disabled' === $security_mode ) {
107 return false;
108 }
109
110 if ( 'strict' === $security_mode ) {
111 return false;
112 }
113
114 // Level 3: Component-specific whitelist (when specified).
115 if ( ! empty( $component ) && defined( 'WPVULNERABILITY_SHELL_EXEC_WHITELIST' ) ) {
116 $whitelist = WPVULNERABILITY_SHELL_EXEC_WHITELIST;
117
118 if ( is_string( $whitelist ) ) {
119 $whitelist = array_map( 'trim', explode( ',', $whitelist ) );
120 }
121
122 if ( is_array( $whitelist ) && ! empty( $whitelist ) ) {
123 if ( ! in_array( $component, $whitelist, true ) ) {
124 return false;
125 }
126 }
127 }
128
129 // Level 4: PHP configuration check.
130 if ( ! function_exists( 'shell_exec' ) ) {
131 return false;
132 }
133
134 // Check if `shell_exec` is disabled in PHP configuration.
135 if ( in_array( 'shell_exec', array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ), true ) ) {
136 return false;
137 }
138
139 // Try to execute a simple command to confirm functionality.
140 $test = @shell_exec( escapeshellcmd( 'echo test' ) ); // phpcs:ignore
141
142 // If the command execution failed or returned null, shell_exec is not working.
143 return null !== $test;
144 }
145
146 /**
147 * Conditionally log diagnostic messages for the plugin.
148 *
149 * This helper respects the WordPress debug mode and allows developers to hook into the
150 * decision using the {@see 'wpvulnerability_should_log'} filter. Logged messages are
151 * encoded as JSON when possible to provide structured context without breaking the
152 * WordPress Coding Standards that discourage verbose debugging in production.
153 *
154 * @since 4.1.7
155 *
156 * @param string $message Message to record in the debug log.
157 * @param array<string, mixed> $context Optional. Additional context about the message. Default empty array.
158 *
159 * @return void
160 */
161 function wpvulnerability_maybe_log( $message, $context = array() ) {
162 if ( empty( $message ) ) {
163 return;
164 }
165
166 $should_log = defined( 'WP_DEBUG' ) && WP_DEBUG;
167
168 /**
169 * Filter whether a diagnostic message should be logged.
170 *
171 * @since 4.1.7
172 *
173 * @param bool $should_log Whether the message should be logged.
174 * @param string $message Message to log.
175 * @param array $context Additional context data.
176 */
177 $should_log = apply_filters( 'wpvulnerability_should_log', $should_log, $message, $context );
178
179 if ( ! $should_log ) {
180 return;
181 }
182
183 $log_entry = array(
184 'plugin' => 'wpvulnerability',
185 'message' => (string) $message,
186 );
187
188 if ( ! empty( $context ) ) {
189 $log_entry['context'] = (array) $context;
190 }
191
192 $encoded_entry = wp_json_encode( $log_entry );
193
194 if ( false === $encoded_entry ) {
195 $encoded_entry = sprintf(
196 'wpvulnerability: %s',
197 sanitize_text_field( $log_entry['message'] )
198 );
199 }
200
201 error_log( $encoded_entry ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
202 }
203
204 /**
205 * Retrieve the cache expiration in hours.
206 *
207 * The value can be defined via the WPVULNERABILITY_CACHE_HOURS constant,
208 * configured in the plugin settings, or falls back to 12 hours.
209 *
210 * @since 4.1.0
211 *
212 * @return int Cache duration in hours.
213 */
214 function wpvulnerability_cache_hours() {
215 $default = 12;
216
217 if ( defined( 'WPVULNERABILITY_CACHE_HOURS' ) && WPVULNERABILITY_CACHE_HOURS !== $default ) {
218 return (int) WPVULNERABILITY_CACHE_HOURS;
219 }
220
221 $settings = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() );
222 if ( ! is_array( $settings ) ) {
223 $settings = array();
224 }
225 if ( isset( $settings['cache'] ) ) {
226 $cache_raw = $settings['cache'];
227 $cache = is_scalar( $cache_raw ) ? (int) $cache_raw : 0;
228 if ( in_array( $cache, array( 1, 6, 12, 24 ), true ) ) {
229 return $cache;
230 }
231 }
232
233 return $default;
234 }
235
236 /**
237 * Retrieve a JSON-encoded vulnerability count option as an integer.
238 *
239 * Centralises the `(int) json_decode( get_*_option( ... ), true )` pattern used
240 * in the admin dashboard and analysis tabs, providing a properly typed return
241 * value that satisfies PHPStan level 9.
242 *
243 * @since 4.3.3
244 *
245 * @param string $option_key Option name (without the `wpvulnerability-` prefix).
246 *
247 * @return int Decoded integer count, or 0 when the option is empty or invalid.
248 */
249 function wpvulnerability_get_component_count( string $option_key ): int {
250 $raw = is_multisite()
251 ? get_site_option( 'wpvulnerability-' . $option_key . '-vulnerable', '0' )
252 : get_option( 'wpvulnerability-' . $option_key . '-vulnerable', '0' );
253
254 $decoded = json_decode( is_string( $raw ) ? $raw : '0', true );
255 return is_scalar( $decoded ) ? (int) $decoded : 0;
256 }
257
258 /**
259 * Retrieve the plugin configuration array, always returning a typed array.
260 *
261 * Wraps `get_option`/`get_site_option` for the `wpvulnerability-config` key and
262 * ensures the return value is an `array<string, mixed>`, merging defaults so
263 * that callers can safely access keys without additional type guards.
264 *
265 * @since 4.3.3
266 *
267 * @param array<string, mixed> $defaults Optional default values to merge.
268 *
269 * @return array<string, mixed> Plugin configuration.
270 */
271 function wpvulnerability_get_config( array $defaults = array() ): array {
272 $raw = is_multisite()
273 ? get_site_option( 'wpvulnerability-config', array() )
274 : get_option( 'wpvulnerability-config', array() );
275
276 $config = is_array( $raw ) ? $raw : array();
277
278 return empty( $defaults ) ? $config : wp_parse_args( $config, $defaults );
279 }
280
281 /**
282 * Retrieve the supported log retention values.
283 *
284 * @since 4.2.0
285 *
286 * @return int[] Valid log retention periods expressed in days. The value "0" disables retention.
287 */
288 function wpvulnerability_get_log_retention_values() {
289 return array( 0, 1, 7, 14, 28 );
290 }
291
292 /**
293 * Determine whether log retention is forced via a constant.
294 *
295 * @since 4.2.0
296 *
297 * @return int|null Number of days when forced, or null when editable.
298 */
299 function wpvulnerability_forced_log_retention() {
300 if ( defined( 'WPVULNERABILITY_LOG_RETENTION_DAYS' ) ) {
301 $forced = (int) WPVULNERABILITY_LOG_RETENTION_DAYS;
302 if ( in_array( $forced, wpvulnerability_get_log_retention_values(), true ) ) {
303 return $forced;
304 }
305 }
306
307 return null;
308 }
309
310 /**
311 * Retrieve the configured log retention period in days.
312 *
313 * The value can be defined through the WPVULNERABILITY_LOG_RETENTION_DAYS constant,
314 * configured via the settings UI, or falls back to zero (disabled).
315 *
316 * @since 4.2.0
317 *
318 * @return int Log retention in days. Zero disables retention.
319 */
320 function wpvulnerability_log_retention_days() {
321 $default = 0;
322
323 $forced = wpvulnerability_forced_log_retention();
324 if ( null !== $forced ) {
325 return $forced;
326 }
327
328 $settings = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() );
329 if ( ! is_array( $settings ) ) {
330 $settings = array();
331 }
332 if ( isset( $settings['log_retention'] ) ) {
333 $retention_raw = $settings['log_retention'];
334 $retention = is_scalar( $retention_raw ) ? (int) $retention_raw : 0;
335 if ( in_array( $retention, wpvulnerability_get_log_retention_values(), true ) ) {
336 return $retention;
337 }
338 }
339
340 return $default;
341 }
342
343 /**
344 * Register the custom post type used to store API logs.
345 *
346 * @since 4.2.0
347 *
348 * @return void
349 */
350 function wpvulnerability_register_log_post_type() {
351 register_post_type(
352 'wpvulnerability_log',
353 array(
354 'labels' => array(
355 'name' => __( 'WPVulnerability Logs', 'wpvulnerability' ),
356 'singular_name' => __( 'WPVulnerability Log', 'wpvulnerability' ),
357 ),
358 'public' => false,
359 'exclude_from_search' => true,
360 'publicly_queryable' => false,
361 'show_ui' => false,
362 'show_in_menu' => false,
363 'supports' => array( 'title', 'editor' ),
364 )
365 );
366 }
367 add_action( 'init', 'wpvulnerability_register_log_post_type' );
368
369 /**
370 * Determine whether a URL should be logged as an API call.
371 *
372 * @since 4.2.0
373 *
374 * @param string $url Requested URL.
375 *
376 * @return bool True when the URL targets the configured API host, false otherwise.
377 */
378 function wpvulnerability_should_log_api_request( $url ) {
379 if ( 0 >= wpvulnerability_log_retention_days() ) {
380 return false;
381 }
382
383 $target_host = wp_parse_url( $url, PHP_URL_HOST );
384 $api_host = wp_parse_url( WPVULNERABILITY_API_HOST, PHP_URL_HOST );
385
386 if ( ! is_string( $target_host ) || '' === $target_host ) {
387 return false;
388 }
389
390 return 0 === strcasecmp( $target_host, (string) $api_host );
391 }
392
393 /**
394 * Convert the HTTP response into a storable string for the log entry.
395 *
396 * @since 4.2.0
397 *
398 * @param array<string, mixed>|WP_Error $response Response returned by wp_remote_get().
399 *
400 * @return string Encoded response body ready for storage.
401 */
402 function wpvulnerability_prepare_log_body( $response ) {
403 if ( is_wp_error( $response ) ) {
404 $error_data = array(
405 'code' => $response->get_error_code(),
406 'message' => $response->get_error_message(),
407 'errors' => $response->errors,
408 'data' => $response->error_data,
409 );
410 $body = wp_json_encode( $error_data );
411 if ( false === $body ) {
412 $body = serialize( $error_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
413 }
414 return $body;
415 }
416
417 $body = wp_remote_retrieve_body( $response );
418 if ( '' === $body ) {
419 $encoded = wp_json_encode( array( 'message' => 'empty body' ) );
420 return false !== $encoded ? $encoded : '{"message":"empty body"}';
421 }
422
423 return $body;
424 }
425
426 /**
427 * Persist an API log entry when appropriate.
428 *
429 * @since 4.2.0
430 *
431 * @param string $url Requested URL.
432 * @param array<string, mixed>|WP_Error $response Response returned by wp_remote_get().
433 *
434 * @return void
435 */
436 function wpvulnerability_maybe_log_api_response( $url, $response ) {
437 if ( ! wpvulnerability_should_log_api_request( $url ) ) {
438 return;
439 }
440
441 $log_id = wp_insert_post(
442 array(
443 'post_type' => 'wpvulnerability_log',
444 'post_status' => 'publish',
445 'post_title' => wp_strip_all_tags( $url ),
446 'post_content' => wp_slash( wpvulnerability_prepare_log_body( $response ) ),
447 'post_author' => 0,
448 ),
449 true
450 );
451
452 if ( is_wp_error( $log_id ) ) {
453 return;
454 }
455 }
456
457 /**
458 * Retrieve the pagination sizes available for the logs table.
459 *
460 * @since 4.3.0
461 *
462 * @return int[] Array of valid per-page options.
463 */
464 function wpvulnerability_get_log_per_page_options() {
465 return array( 10, 50, 100, 250, 1000 );
466 }
467
468 /**
469 * Retrieve the default pagination size for the logs table.
470 *
471 * @since 4.3.0
472 *
473 * @return int Default per-page value.
474 */
475 function wpvulnerability_get_default_log_per_page() {
476 return 100;
477 }
478
479 /**
480 * Retrieve log posts for the administration table.
481 *
482 * @since 4.2.0
483 * @since 4.3.0 Added the $paged argument and updated the default page size.
484 *
485 * @param int $per_page Optional. Number of logs to return per page. Default 100.
486 * @param int $paged Optional. Page number to retrieve. Default 1.
487 *
488 * @return WP_Post[] Array of log posts.
489 */
490 function wpvulnerability_get_api_logs( $per_page = 100, $paged = 1 ) {
491 $per_page = max( 1, (int) $per_page );
492 $paged = max( 1, (int) $paged );
493 $offset = ( $paged - 1 ) * $per_page;
494
495 return get_posts(
496 array(
497 'post_type' => 'wpvulnerability_log',
498 'post_status' => 'publish',
499 'posts_per_page' => $per_page,
500 'orderby' => 'date',
501 'order' => 'DESC',
502 'offset' => $offset,
503 'no_found_rows' => true,
504 )
505 );
506 }
507
508 /**
509 * Count the total amount of stored API log entries.
510 *
511 * @since 4.3.0
512 *
513 * @return int Number of log posts.
514 */
515 function wpvulnerability_count_api_logs() {
516 $counts = wp_count_posts( 'wpvulnerability_log' );
517
518 if ( ! isset( $counts->publish ) ) {
519 return 0;
520 }
521
522 return (int) $counts->publish;
523 }
524
525 /**
526 * Retrieve a single log entry ensuring it belongs to the plugin log post type.
527 *
528 * @since 4.2.0
529 *
530 * @param int $log_id Log post ID.
531 *
532 * @return WP_Post|null Post object on success, null otherwise.
533 */
534 function wpvulnerability_get_api_log( $log_id ) {
535 $log_id = (int) $log_id;
536 if ( $log_id <= 0 ) {
537 return null;
538 }
539
540 $log = get_post( $log_id );
541 if ( ! $log || 'wpvulnerability_log' !== $log->post_type ) {
542 return null;
543 }
544
545 return $log;
546 }
547
548 /**
549 * Format stored log content into a pretty printed JSON string when possible.
550 *
551 * @since 4.2.0
552 *
553 * @param string $content Stored log body.
554 *
555 * @return string Formatted content.
556 */
557 function wpvulnerability_format_log_content( $content ) {
558 $content = (string) $content;
559 if ( '' === $content ) {
560 return '';
561 }
562
563 $decoded = json_decode( $content, true );
564 if ( null === $decoded || JSON_ERROR_NONE !== json_last_error() ) {
565 return $content;
566 }
567
568 $pretty = wp_json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
569 if ( false === $pretty ) {
570 return $content;
571 }
572
573 return $pretty;
574 }
575
576 /**
577 * Format the log date using the site's date and time settings.
578 *
579 * @since 4.2.0
580 *
581 * @param WP_Post $log Log post.
582 *
583 * @return string Formatted date string.
584 */
585 function wpvulnerability_format_log_date( WP_Post $log ) {
586 $date_fmt = get_option( 'date_format' );
587 $time_fmt = get_option( 'time_format' );
588 $format = trim( ( is_scalar( $date_fmt ) ? (string) $date_fmt : '' ) . ' ' . ( is_scalar( $time_fmt ) ? (string) $time_fmt : '' ) );
589 if ( '' === $format ) {
590 $format = 'Y-m-d H:i:s';
591 }
592
593 return (string) mysql2date( $format, $log->post_date, true );
594 }
595
596 /**
597 * Delete logs that fall outside of the configured retention window.
598 *
599 * @since 4.2.0
600 *
601 * @return void
602 */
603 function wpvulnerability_delete_expired_logs() {
604 $retention = wpvulnerability_log_retention_days();
605 if ( $retention <= 0 ) {
606 return;
607 }
608
609 $threshold_ts = strtotime( '-' . $retention . ' days', time() );
610 $threshold = gmdate( 'Y-m-d H:i:s', false !== $threshold_ts ? $threshold_ts : time() );
611
612 do {
613 $logs = get_posts(
614 array(
615 'post_type' => 'wpvulnerability_log',
616 'post_status' => 'publish',
617 'fields' => 'ids',
618 'posts_per_page' => 100,
619 'orderby' => 'date',
620 'order' => 'ASC',
621 'no_found_rows' => true,
622 'cache_results' => false,
623 'update_post_term_cache' => false,
624 'update_post_meta_cache' => false,
625 'date_query' => array(
626 array(
627 'before' => $threshold,
628 'inclusive' => false,
629 ),
630 ),
631 )
632 );
633
634 if ( empty( $logs ) ) {
635 break;
636 }
637
638 foreach ( $logs as $log_id ) {
639 wp_delete_post( $log_id, true );
640 }
641 $logs_count = count( $logs );
642 } while ( $logs_count >= 100 );
643 }
644
645 add_action( 'wpvulnerability_cleanup_logs', 'wpvulnerability_delete_expired_logs' );
646
647 /**
648 * Delete all stored API logs.
649 *
650 * @since 4.3.0
651 *
652 * @return void
653 */
654 function wpvulnerability_delete_all_logs() {
655 do {
656 $logs = get_posts(
657 array(
658 'post_type' => 'wpvulnerability_log',
659 'post_status' => 'publish',
660 'fields' => 'ids',
661 'posts_per_page' => 100,
662 'orderby' => 'date',
663 'order' => 'ASC',
664 'no_found_rows' => true,
665 'cache_results' => false,
666 'update_post_term_cache' => false,
667 'update_post_meta_cache' => false,
668 )
669 );
670
671 if ( empty( $logs ) ) {
672 break;
673 }
674
675 foreach ( $logs as $log_id ) {
676 wp_delete_post( $log_id, true );
677 }
678 $logs_count = count( $logs );
679 } while ( $logs_count >= 100 );
680 }
681
682 /**
683 * Normalize various truthy and falsy values into the expected 'y' or 'n' format.
684 *
685 * This helper ensures that configuration options stored as booleans or integers
686 * in previous plugin versions are converted into the new string-based format.
687 *
688 * @since 4.1.1
689 *
690 * @param mixed $value Value to normalize.
691 *
692 * @return string Returns 'y' when enabled, 'n' otherwise.
693 */
694 function wpvulnerability_normalize_yes_no( $value ) {
695 if ( is_string( $value ) ) {
696 $value = strtolower( trim( (string) $value ) );
697 }
698
699 $truthy = array( 'y', 'yes', '1', 1, true, 'true', 'on' );
700
701 return in_array( $value, $truthy, true ) ? 'y' : 'n';
702 }
703
704 /**
705 * Determine if a stored yes/no value should be treated as enabled.
706 *
707 * @since 4.1.1
708 *
709 * @param mixed $value Value to evaluate.
710 *
711 * @return bool True when enabled, false otherwise.
712 */
713 function wpvulnerability_is_yes( $value ) {
714 return 'y' === wpvulnerability_normalize_yes_no( $value );
715 }
716
717 /**
718 * Normalize the notification configuration array.
719 *
720 * @since 4.1.1
721 *
722 * @param mixed $notify Notification configuration values.
723 *
724 * @return array<string, string> Normalized notification configuration containing 'email', 'slack', 'teams', 'discord', and 'telegram'.
725 */
726 function wpvulnerability_normalize_notify_settings( $notify ) {
727 $defaults = array(
728 'email' => 'n',
729 'slack' => 'n',
730 'teams' => 'n',
731 'discord' => 'n',
732 'telegram' => 'n',
733 );
734 $normalized = array();
735
736 if ( is_array( $notify ) ) {
737 foreach ( $notify as $channel => $value ) {
738 $normalized[ $channel ] = wpvulnerability_normalize_yes_no( $value );
739 }
740 }
741
742 return array_merge( $defaults, $normalized );
743 }
744
745 /**
746 * Sanitize a version string.
747 *
748 * This function removes any leading or trailing whitespace from the version string
749 * and strips out any non-alphanumeric characters except for hyphens, underscores, and dots.
750 *
751 * @since 2.0.0
752 *
753 * @param string|null $version The version string to sanitize.
754 *
755 * @return string The sanitized version string.
756 */
757 function wpvulnerability_sanitize_version( $version ) {
758 // Remove any leading or trailing whitespace.
759 $version = trim( (string) $version );
760
761 // Strip out any non-alphanumeric characters except for hyphens, underscores, and dots.
762 $replaced = preg_replace( '/[^a-zA-Z0-9_\-.]+/', '', $version );
763 $version = null !== $replaced ? $replaced : '';
764
765 // Normalize WordPress pre-release build suffixes such as "-beta1-12345" to "-beta1".
766 if ( preg_match( '/^(\d+\.\d+(?:\.\d+)?-(?:beta|rc)\d+)(?:-\d+)$/i', $version, $matches ) ) {
767 $version = $matches[1];
768 }
769
770 return $version;
771 }
772
773 /**
774 * Sanitize a version string and validate its format.
775 *
776 * This function sanitizes the input version string and checks it against a regular expression
777 * to match the standard versioning format (major.minor[.patch[.build]]). It returns the matched version
778 * if it conforms to the expected format; otherwise, it returns the original version.
779 *
780 * @since 3.5.0 Introduced.
781 *
782 * @param string|null $version The version string to sanitize and validate.
783 * @return string|null The sanitized version string if it matches the standard format; otherwise, the original version string, or null when empty.
784 */
785 function wpvulnerability_sanitize_and_validate_version( $version ) {
786 if ( null === $version ) {
787 return null;
788 }
789
790 // Sanitize the version string using the base sanitizer.
791 $version = wpvulnerability_sanitize_version( $version );
792
793 if ( '' === $version ) {
794 return null;
795 }
796
797 // Validate format (major.minor[.patch[.build]]) and sanitize.
798 if ( preg_match( '/^\d+\.\d+(\.\d+){0,2}(\.\d+)?/', $version, $match ) ) {
799 return trim( $match[0] );
800 }
801
802 return $version;
803 }
804
805 /**
806 * Detects the version of SQLite using the SQLite3 extension or system commands.
807 *
808 * Uses a hybrid detection approach:
809 * 1. PHP SQLite3 extension (most secure, reliability 90)
810 * 2. PDO SQLite (secondary secure method, reliability 90)
811 * 3. shell_exec commands (most accurate, reliability 95)
812 * 4. Binary existence check (basic fallback, reliability 30)
813 *
814 * @since 3.5.0 Introduced.
815 * @since 4.3.0 Enhanced with hybrid detection and reliability scoring.
816 *
817 * @return array{version: string|null, method: string, reliability: int, attempts: list<string>}
818 */
819 function wpvulnerability_detect_sqlite() {
820 $result = array(
821 'version' => null,
822 'method' => 'none',
823 'reliability' => 0,
824 'attempts' => array(),
825 );
826
827 // Method 1: PHP SQLite3 extension (most secure).
828 if ( class_exists( 'SQLite3' ) ) {
829 $result['attempts'][] = 'sqlite3_extension';
830
831 try {
832 $sqlite = new SQLite3( ':memory:' );
833 $version_info = $sqlite->version();
834
835 if ( isset( $version_info['versionString'] ) ) {
836 $version = $version_info['versionString'];
837 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
838 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
839 }
840 $version = wpvulnerability_sanitize_and_validate_version( $version );
841
842 if ( $version ) {
843 $result['version'] = $version;
844 $result['method'] = 'sqlite3_extension';
845 $result['reliability'] = 90;
846 return $result;
847 }
848 }
849 } catch ( Exception $e ) {
850 wpvulnerability_maybe_log( 'SQLite3 extension detection failed', array( 'error' => $e->getMessage() ) );
851 }
852 }
853
854 // Method 2: PDO SQLite extension (secondary secure method).
855 if ( ! class_exists( 'SQLite3' ) && class_exists( 'PDO' ) ) {
856 $result['attempts'][] = 'pdo_sqlite';
857
858 $drivers = \PDO::getAvailableDrivers(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO
859
860 if ( in_array( 'sqlite', $drivers, true ) ) {
861 try {
862 $pdo = new \PDO( 'sqlite::memory:' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO
863 $pdo->setAttribute( \PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO
864 $statement = $pdo->query( 'SELECT sqlite_version()' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.RestrictedClasses.mysql__PDO
865
866 if ( $statement ) {
867 $version_result = $statement->fetchColumn();
868
869 if ( false !== $version_result ) {
870 $version = (string) $version_result;
871 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
872 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
873 }
874 $version = wpvulnerability_sanitize_and_validate_version( $version );
875
876 if ( $version ) {
877 $result['version'] = $version;
878 $result['method'] = 'pdo_sqlite';
879 $result['reliability'] = 90;
880
881 $pdo = null;
882 return $result;
883 }
884 }
885 }
886
887 $pdo = null;
888 } catch ( Exception $exception ) {
889 wpvulnerability_maybe_log( 'PDO SQLite detection failed', array( 'error' => $exception->getMessage() ) );
890 if ( isset( $pdo ) ) {
891 $pdo = null;
892 }
893 }
894 }
895 }
896
897 // Method 3: shell_exec command (most accurate).
898 if ( wpvulnerability_can_shell_exec( 'sqlite' ) ) {
899 $result['attempts'][] = 'shell_sqlite3';
900
901 $version_output = wpvulnerability_safe_shell_exec( 'sqlite', 'sqlite3 --version' );
902
903 if ( ! empty( $version_output ) && preg_match( '/(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/', $version_output, $matches ) ) {
904 $version = $matches[1];
905 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
906 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
907 }
908 $version = wpvulnerability_sanitize_and_validate_version( $version );
909
910 if ( $version ) {
911 $result['version'] = $version;
912 $result['method'] = 'shell_exec';
913 $result['reliability'] = 95;
914 return $result;
915 }
916 }
917 }
918
919 // Method 4: Binary existence check (basic fallback).
920 if ( wpvulnerability_can_shell_exec( 'sqlite' ) ) {
921 $result['attempts'][] = 'which_sqlite3';
922
923 $which_output = wpvulnerability_safe_shell_exec( 'sqlite', 'which sqlite3' );
924
925 if ( ! empty( $which_output ) ) {
926 $result['version'] = 'unknown';
927 $result['method'] = 'binary_exists';
928 $result['reliability'] = 30;
929 return $result;
930 }
931 }
932
933 return $result;
934 }
935
936 /**
937 * Detects the version of Redis using the Redis extension or system commands.
938 *
939 * Uses a hybrid detection approach:
940 * 1. PHP Redis extension (most secure, reliability 90)
941 * 2. shell_exec commands (most accurate, reliability 95)
942 * 3. Binary existence check (basic fallback, reliability 30)
943 *
944 * @since 3.5.0 Introduced.
945 * @since 4.3.0 Enhanced with hybrid detection and reliability scoring.
946 *
947 * @return array{version: string|null, method: string, reliability: int, attempts: list<string>}
948 */
949 function wpvulnerability_detect_redis() {
950 $result = array(
951 'version' => null,
952 'method' => 'none',
953 'reliability' => 0,
954 'attempts' => array(),
955 );
956
957 // Method 1: PHP Redis extension (most secure).
958 if ( class_exists( 'Redis' ) ) {
959 $result['attempts'][] = 'redis_extension';
960
961 $redis_client = null;
962 $temporary_connection = false;
963
964 // Attempt to reuse an existing Redis connection from WordPress object cache implementations.
965 $cache_instance = null;
966 if ( function_exists( 'wp_cache_get_instance' ) ) {
967 $cache_instance = wp_cache_get_instance();
968 } elseif ( isset( $GLOBALS['wp_object_cache'] ) && is_object( $GLOBALS['wp_object_cache'] ) ) {
969 $cache_instance = $GLOBALS['wp_object_cache'];
970 }
971
972 if ( is_object( $cache_instance ) ) {
973 foreach ( array( 'redis', 'redis_client', 'client', 'redis_instance', 'connection' ) as $property ) {
974 if ( isset( $cache_instance->{$property} ) && $cache_instance->{$property} instanceof Redis ) {
975 $redis_client = $cache_instance->{$property};
976 break;
977 }
978 }
979
980 if ( ! $redis_client instanceof Redis ) {
981 foreach ( array( 'get_redis', 'get_client', 'redis', 'redis_instance' ) as $method ) {
982 if ( ! method_exists( $cache_instance, $method ) ) {
983 continue;
984 }
985
986 try {
987 $reflection_method = new ReflectionMethod( $cache_instance, $method );
988 if ( $reflection_method->getNumberOfRequiredParameters() > 0 || ! $reflection_method->isPublic() ) {
989 continue;
990 }
991 } catch ( ReflectionException $exception ) {
992 continue;
993 }
994
995 if ( ! is_callable( array( $cache_instance, $method ) ) ) {
996 continue;
997 }
998
999 $maybe_client = $cache_instance->{$method}();
1000 if ( $maybe_client instanceof Redis ) {
1001 $redis_client = $maybe_client;
1002 break;
1003 }
1004 }
1005 }
1006 }
1007
1008 if ( ! $redis_client instanceof Redis ) {
1009 $redis_client = new Redis();
1010 $temporary_connection = true;
1011
1012 $host = defined( 'WP_REDIS_HOST' ) ? (string) WP_REDIS_HOST : '127.0.0.1';
1013 $port = defined( 'WP_REDIS_PORT' ) ? (int) WP_REDIS_PORT : 6379;
1014 $timeout = defined( 'WP_REDIS_TIMEOUT' ) ? (float) WP_REDIS_TIMEOUT : 0.0;
1015 $connected = false;
1016
1017 try {
1018 if ( defined( 'WP_REDIS_PATH' ) && '' !== (string) WP_REDIS_PATH ) {
1019 $connected = $redis_client->connect( (string) WP_REDIS_PATH );
1020 } elseif ( $timeout > 0 ) {
1021 $connected = $redis_client->connect( $host, $port, $timeout );
1022 } else {
1023 $connected = $redis_client->connect( $host, $port );
1024 }
1025
1026 if ( $connected ) {
1027 $username = defined( 'WP_REDIS_USERNAME' ) ? (string) WP_REDIS_USERNAME : '';
1028 $password = null;
1029 if ( defined( 'WP_REDIS_PASSWORD' ) ) {
1030 $password = (string) WP_REDIS_PASSWORD;
1031 } elseif ( defined( 'WP_REDIS_AUTH' ) ) {
1032 $password = (string) WP_REDIS_AUTH;
1033 }
1034
1035 if ( '' !== $username && null !== $password ) {
1036 $redis_client->auth( array( $username, $password ) );
1037 } elseif ( null !== $password && '' !== $password ) {
1038 $redis_client->auth( $password );
1039 }
1040
1041 if ( defined( 'WP_REDIS_DATABASE' ) ) {
1042 $redis_client->select( (int) WP_REDIS_DATABASE );
1043 }
1044 } else {
1045 $redis_client = null;
1046 $temporary_connection = false;
1047 }
1048 } catch ( RedisException $e ) {
1049 $redis_client = null;
1050 $temporary_connection = false;
1051 } catch ( Exception $e ) {
1052 $redis_client = null;
1053 $temporary_connection = false;
1054 }
1055 }
1056
1057 if ( $redis_client instanceof Redis ) {
1058 try {
1059 $redis_info = $redis_client->info();
1060
1061 if ( isset( $redis_info['redis_version'] ) ) {
1062 $version = $redis_info['redis_version'];
1063 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1064 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1065 }
1066 $version = wpvulnerability_sanitize_and_validate_version( $version );
1067
1068 if ( $version ) {
1069 $result['version'] = $version;
1070 $result['method'] = 'redis_extension';
1071 $result['reliability'] = 90;
1072
1073 return $result;
1074 }
1075 }
1076 } catch ( RedisException $e ) {
1077 wpvulnerability_maybe_log(
1078 'Redis extension available but info() call failed',
1079 array(
1080 'exception' => array(
1081 'code' => $e->getCode(),
1082 'message' => $e->getMessage(),
1083 ),
1084 )
1085 );
1086 } catch ( Exception $e ) {
1087 wpvulnerability_maybe_log(
1088 'Redis detection failed',
1089 array(
1090 'exception' => array(
1091 'code' => $e->getCode(),
1092 'message' => $e->getMessage(),
1093 ),
1094 )
1095 );
1096 } finally {
1097 if ( $temporary_connection ) {
1098 $redis_client->close();
1099 }
1100 }
1101 }
1102 }
1103
1104 // Method 2: shell_exec command (most accurate).
1105 if ( wpvulnerability_can_shell_exec( 'redis' ) ) {
1106 $result['attempts'][] = 'shell_redis_server';
1107
1108 $version_output = wpvulnerability_safe_shell_exec( 'redis', 'redis-server --version' );
1109
1110 if ( ! empty( $version_output ) && preg_match( '/redis-server\s+v=([\d.]+)/i', $version_output, $matches ) ) {
1111 $version = $matches[1];
1112 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1113 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1114 }
1115 $version = wpvulnerability_sanitize_and_validate_version( $version );
1116
1117 if ( $version ) {
1118 $result['version'] = $version;
1119 $result['method'] = 'shell_exec';
1120 $result['reliability'] = 95;
1121 return $result;
1122 }
1123 }
1124 }
1125
1126 // Method 3: Binary existence check (basic fallback).
1127 if ( wpvulnerability_can_shell_exec( 'redis' ) ) {
1128 $result['attempts'][] = 'which_redis_server';
1129
1130 $which_output = wpvulnerability_safe_shell_exec( 'redis', 'which redis-server' );
1131
1132 if ( ! empty( $which_output ) ) {
1133 $result['version'] = 'unknown';
1134 $result['method'] = 'binary_exists';
1135 $result['reliability'] = 30;
1136 return $result;
1137 }
1138 }
1139
1140 return $result;
1141 }
1142
1143 /**
1144 * Normalizes Memcached version information returned by PHP extensions.
1145 *
1146 * @since 4.1.7
1147 *
1148 * @param mixed $version_info Version information as returned by Memcached::getVersion() or Memcache::getVersion().
1149 * @return string|null Normalized version string or null when it cannot be determined.
1150 */
1151 function wpvulnerability_normalize_memcached_version_info( $version_info ) {
1152 $reported_versions = array();
1153
1154 if ( is_array( $version_info ) ) {
1155 $reported_versions = $version_info;
1156 } elseif ( is_string( $version_info ) ) {
1157 $trimmed_version = trim( $version_info );
1158 if ( '' !== $trimmed_version ) {
1159 $reported_versions = array( $trimmed_version );
1160 }
1161 }
1162
1163 foreach ( $reported_versions as $reported_version ) {
1164 if ( ! is_scalar( $reported_version ) ) {
1165 continue;
1166 }
1167 $reported_version = trim( (string) $reported_version );
1168
1169 if ( '' === $reported_version || '255.255.255' === $reported_version ) {
1170 continue;
1171 }
1172
1173 if ( preg_match( '/-(\d+)$/', $reported_version, $suffix_matches ) ) {
1174 $reported_version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $reported_version );
1175 }
1176
1177 return $reported_version;
1178 }
1179
1180 return null;
1181 }
1182
1183 /**
1184 * Detects the version of Memcached using the Memcached extension or system commands.
1185 *
1186 * Uses a hybrid detection approach:
1187 * 1. PHP Memcached/Memcache extension (most secure, reliability 90)
1188 * 2. shell_exec commands (most accurate, reliability 95)
1189 * 3. Binary existence check (basic fallback, reliability 30)
1190 *
1191 * @since 3.5.0 Introduced.
1192 * @since 4.3.0 Enhanced with hybrid detection and reliability scoring.
1193 *
1194 * @return array{version: string|null, method: string, reliability: int, attempts: list<string>}
1195 */
1196 function wpvulnerability_detect_memcached() {
1197 $result = array(
1198 'version' => null,
1199 'method' => 'none',
1200 'reliability' => 0,
1201 'attempts' => array(),
1202 );
1203
1204 // Method 1: PHP Memcached extension (most secure).
1205 if ( class_exists( 'Memcached' ) ) {
1206 $result['attempts'][] = 'memcached_extension';
1207
1208 try {
1209 $memcached = new Memcached();
1210 $version_info = $memcached->getVersion();
1211 $version = wpvulnerability_normalize_memcached_version_info( $version_info );
1212
1213 if ( empty( $version ) ) {
1214 $servers = $memcached->getServerList();
1215
1216 if ( ! empty( $servers ) ) {
1217 $memcached->resetServerList();
1218
1219 foreach ( $servers as $server ) {
1220 if ( ! is_array( $server ) || empty( $server['host'] ) ) {
1221 continue;
1222 }
1223
1224 $host_raw = $server['host'];
1225 $host = is_scalar( $host_raw ) ? (string) $host_raw : '';
1226 $port_raw = $server['port'] ?? 11211;
1227 $port = is_scalar( $port_raw ) ? (int) $port_raw : 11211;
1228 $weight_raw = $server['weight'] ?? 0;
1229 $weight = is_scalar( $weight_raw ) ? (int) $weight_raw : 0;
1230
1231 $memcached->addServer( $host, $port, $weight );
1232 }
1233
1234 $version_info = $memcached->getVersion();
1235 $version = wpvulnerability_normalize_memcached_version_info( $version_info );
1236 }
1237 }
1238
1239 if ( $version ) {
1240 $version = wpvulnerability_sanitize_and_validate_version( $version );
1241 if ( $version ) {
1242 $result['version'] = $version;
1243 $result['method'] = 'memcached_extension';
1244 $result['reliability'] = 90;
1245 return $result;
1246 }
1247 }
1248 } catch ( MemcachedException $e ) {
1249 // Extension available but service not running.
1250 unset( $memcached );
1251 } catch ( Exception $e ) {
1252 unset( $memcached );
1253 }
1254 }
1255
1256 // Try legacy Memcache extension.
1257 if ( class_exists( 'Memcache' ) ) {
1258 $result['attempts'][] = 'memcache_extension';
1259
1260 try {
1261 $memcache = new Memcache();
1262 $version_info = $memcache->getVersion();
1263 $version = wpvulnerability_normalize_memcached_version_info( $version_info );
1264
1265 if ( $version ) {
1266 $version = wpvulnerability_sanitize_and_validate_version( $version );
1267 if ( $version ) {
1268 $result['version'] = $version;
1269 $result['method'] = 'memcache_extension';
1270 $result['reliability'] = 90;
1271 return $result;
1272 }
1273 }
1274 } catch ( Exception $e ) {
1275 unset( $memcache );
1276 }
1277 }
1278
1279 // Method 2: shell_exec command (most accurate).
1280 if ( wpvulnerability_can_shell_exec( 'memcached' ) ) {
1281 $result['attempts'][] = 'shell_memcached';
1282
1283 $version_output = wpvulnerability_safe_shell_exec( 'memcached', 'memcached -h' );
1284
1285 if ( ! empty( $version_output ) && preg_match( '/memcached\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) {
1286 $version = $matches[1];
1287 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1288 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1289 }
1290 $version = wpvulnerability_sanitize_and_validate_version( $version );
1291
1292 if ( $version ) {
1293 $result['version'] = $version;
1294 $result['method'] = 'shell_exec';
1295 $result['reliability'] = 95;
1296 return $result;
1297 }
1298 }
1299 }
1300
1301 // Method 3: Binary existence check (basic fallback).
1302 if ( wpvulnerability_can_shell_exec( 'memcached' ) ) {
1303 $result['attempts'][] = 'which_memcached';
1304
1305 $which_output = wpvulnerability_safe_shell_exec( 'memcached', 'which memcached' );
1306
1307 if ( ! empty( $which_output ) ) {
1308 $result['version'] = 'unknown';
1309 $result['method'] = 'binary_exists';
1310 $result['reliability'] = 30;
1311 return $result;
1312 }
1313 }
1314
1315 return $result;
1316 }
1317
1318 /**
1319 * Detects the installed PHP version using available runtime information.
1320 *
1321 * @since 2.0.0
1322 *
1323 * @return string|null The detected PHP version in N.n or N.n.n format, or null if unavailable.
1324 */
1325 function wpvulnerability_detect_php() {
1326 // Initialize the version variable.
1327 $version = null;
1328
1329 // First method: use the PHP_VERSION constant.
1330 if ( defined( 'PHP_VERSION' ) ) {
1331 $version = PHP_VERSION;
1332 }
1333
1334 // First method: use the phpversion function.
1335 if ( empty( $version ) && function_exists( 'phpversion' ) ) {
1336 $version = phpversion();
1337 }
1338
1339 // Second method: use system commands if the first fails and shell_exec is available.
1340 if ( empty( $version ) && wpvulnerability_can_shell_exec() ) {
1341 // Command to check PHP version (routed through the safe wrapper for validation + audit logging).
1342 $version_output = wpvulnerability_safe_shell_exec( 'php', 'php -v' );
1343
1344 if ( ! empty( $version_output ) && preg_match( '/PHP\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) {
1345 $version = $matches[1];
1346 // Replace "-N" at the end with ".N" if present.
1347 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1348 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1349 }
1350 }
1351 }
1352
1353 // Return the sanitized and validated PHP version or null if it cannot be detected.
1354 return wpvulnerability_sanitize_and_validate_version( $version );
1355 }
1356
1357 /**
1358 * Detects the version of cURL using the cURL extension or system commands.
1359 *
1360 * @since 3.5.0 Introduced.
1361 *
1362 * @return string|null The version of cURL in the format N.n.n, N.n, etc., or null if it cannot be detected.
1363 */
1364 function wpvulnerability_detect_curl() {
1365 // Product name for consistency.
1366 $version = null;
1367
1368 // First method: use the cURL extension of PHP.
1369 if ( function_exists( 'curl_version' ) ) {
1370 $curl_info = curl_version();
1371 $version = isset( $curl_info['version'] ) ? $curl_info['version'] : null;
1372 }
1373
1374 // Second method: use system commands if the first fails and shell_exec is available.
1375 if ( empty( $version ) && wpvulnerability_can_shell_exec() ) {
1376 // Command to check cURL version (routed through the safe wrapper for validation + audit logging).
1377 $version_output = wpvulnerability_safe_shell_exec( 'curl', 'curl --version' );
1378
1379 if ( ! empty( $version_output ) && preg_match( '/curl\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) {
1380 $version = $matches[1];
1381 // Replace "-N" at the end with ".N" if present.
1382 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1383 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1384 }
1385 }
1386 }
1387
1388 // Return the sanitized and validated version or null if it cannot be detected.
1389 return wpvulnerability_sanitize_and_validate_version( $version );
1390 }
1391
1392 /**
1393 * Detects the version of ImageMagick using the Imagick extension or system commands.
1394 *
1395 * Uses a hybrid detection approach:
1396 * 1. PHP Imagick extension (most secure, reliability 90)
1397 * 2. shell_exec commands (most accurate, reliability 95-100)
1398 * 3. Binary existence check (basic fallback, reliability 30)
1399 *
1400 * @since 3.5.0 Introduced.
1401 * @since 4.3.0 Enhanced with hybrid detection and reliability scoring.
1402 * @since 5.0.0 Version regex updated to handle IMEI-installed ImageMagick builds
1403 * (format: `ImageMagick (IMEI - ...) 7.x.y-z`).
1404 *
1405 * @return array{version: string|null, method: string, reliability: int, attempts: list<string>}
1406 */
1407 function wpvulnerability_detect_imagemagick() {
1408 $result = array(
1409 'version' => null,
1410 'method' => 'none',
1411 'reliability' => 0,
1412 'attempts' => array(),
1413 );
1414
1415 // Method 1: PHP Imagick extension (most secure).
1416 if ( extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) {
1417 $result['attempts'][] = 'imagick_extension';
1418
1419 try {
1420 $imagick = new Imagick();
1421 $version_info = $imagick->getVersion();
1422
1423 if ( preg_match( '/ImageMagick(?:\s*\([^)]+\))?\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_info['versionString'], $matches ) ) {
1424 $version = $matches[1];
1425 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1426 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1427 }
1428 $version = wpvulnerability_sanitize_and_validate_version( $version );
1429
1430 if ( $version ) {
1431 $result['version'] = $version;
1432 $result['method'] = 'imagick_extension';
1433 $result['reliability'] = 90;
1434 return $result;
1435 }
1436 }
1437 } catch ( \ImagickException $exception ) {
1438 wpvulnerability_maybe_log(
1439 'ImageMagick version detection via the Imagick PHP extension failed.',
1440 array(
1441 'exception' => get_class( $exception ),
1442 'error' => $exception->getMessage(),
1443 )
1444 );
1445 }
1446 }
1447
1448 // Method 2: shell_exec commands (most accurate).
1449 if ( wpvulnerability_can_shell_exec( 'imagemagick' ) ) {
1450 $commands = array( 'magick -version', 'convert -version', 'identify -version' );
1451
1452 foreach ( $commands as $cmd ) {
1453 $result['attempts'][] = 'shell_' . explode( ' ', $cmd )[0];
1454
1455 $version_output = wpvulnerability_safe_shell_exec( 'imagemagick', $cmd );
1456
1457 if ( ! empty( $version_output ) && preg_match( '/ImageMagick(?:\s*\([^)]+\))?\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) {
1458 $version = $matches[1];
1459 if ( preg_match( '/-(\d+)$/', $version, $suffix_matches ) ) {
1460 $version = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $version );
1461 }
1462 $version = wpvulnerability_sanitize_and_validate_version( $version );
1463
1464 if ( $version ) {
1465 $result['version'] = $version;
1466 $result['method'] = 'shell_exec';
1467 $result['reliability'] = 95;
1468 return $result;
1469 }
1470 }
1471 }
1472 }
1473
1474 // Method 3: Binary existence check (basic fallback).
1475 if ( wpvulnerability_can_shell_exec( 'imagemagick' ) ) {
1476 $binaries = array( 'convert', 'magick', 'identify' );
1477
1478 foreach ( $binaries as $binary ) {
1479 $result['attempts'][] = 'which_' . $binary;
1480
1481 $which_output = wpvulnerability_safe_shell_exec( 'imagemagick', 'which ' . $binary );
1482
1483 if ( ! empty( $which_output ) ) {
1484 $result['version'] = 'unknown';
1485 $result['method'] = 'binary_exists';
1486 $result['reliability'] = 30;
1487 return $result;
1488 }
1489 }
1490 }
1491
1492 return $result;
1493 }
1494
1495 /**
1496 * Retrieves the Apache HTTP Server version using available PHP APIs.
1497 *
1498 * The version is first gathered using the {@see apache_get_version()} function if it exists. The detected
1499 * version is sanitized and validated to ensure it matches the expected `major.minor.patch` format. The numeric
1500 * portion is extracted before sanitization so that decorated version strings are normalized. A filter
1501 * allows overriding the detected version, which is helpful for testing environments where the Apache API is
1502 * unavailable.
1503 *
1504 * @since 4.1.7
1505 *
1506 * @return string|null The sanitized Apache version or null when it cannot be determined.
1507 */
1508 function wpvulnerability_get_apache_version() {
1509 $apache_version = null;
1510 $normalize_apache_version = static function ( $value ) {
1511 if ( ! is_string( $value ) ) {
1512 return null;
1513 }
1514
1515 $value = trim( $value );
1516
1517 if ( '' === $value ) {
1518 return null;
1519 }
1520
1521 if ( preg_match( '/(\d+\.\d+(?:\.\d+){0,2})/', $value, $matches ) ) {
1522 $value = $matches[1];
1523 }
1524
1525 $value = wpvulnerability_sanitize_and_validate_version( $value );
1526
1527 if ( null === $value ) {
1528 return null;
1529 }
1530
1531 if ( ! preg_match( '/^\d/', $value ) ) {
1532 return null;
1533 }
1534
1535 return $value;
1536 };
1537
1538 if ( function_exists( 'apache_get_version' ) ) {
1539 $raw_version = apache_get_version();
1540
1541 if ( is_string( $raw_version ) ) {
1542 $apache_version = $normalize_apache_version( $raw_version );
1543 }
1544 }
1545
1546 /**
1547 * Filter the detected Apache version.
1548 *
1549 * This filter allows overriding the detected Apache HTTP Server version. Returning a falsy value will cause
1550 * the detection routine to fall back to other discovery mechanisms.
1551 *
1552 * @since 4.1.7
1553 *
1554 * @param string|null $apache_version The sanitized Apache version, or null if detection failed.
1555 */
1556 $apache_version = apply_filters( 'wpvulnerability_detect_webserver_apache_version', $apache_version );
1557
1558 if ( null !== $apache_version ) {
1559 $apache_version = $normalize_apache_version( $apache_version );
1560 }
1561
1562 return $apache_version;
1563 }
1564
1565 /**
1566 * Detects the web server software and version from the SERVER_SOFTWARE server variable.
1567 *
1568 * This function attempts to identify the web server software (e.g., Apache, nginx) and its version
1569 * based on the 'SERVER_SOFTWARE' environment variable provided by the server. It uses regular expressions
1570 * to parse the web server name and version. The function also sanitizes the detected version number
1571 * to a standard format (major.minor.patch).
1572 *
1573 * @since 3.2.0 Introduced.
1574 *
1575 * @return array{id: string|null, name: string|null, version: string|null} Web server information.
1576 */
1577 function wpvulnerability_detect_webserver() {
1578 // Initialize an array to hold the web server information.
1579 $webserver = array(
1580 'id' => null,
1581 'name' => null,
1582 'version' => null,
1583 );
1584
1585 $apache_version = wpvulnerability_get_apache_version();
1586
1587 if ( null !== $apache_version ) {
1588 $webserver['id'] = 'apache';
1589 $webserver['name'] = 'Apache HTTPD';
1590 $webserver['version'] = $apache_version;
1591
1592 return $webserver;
1593 }
1594
1595 // Check if the SERVER_SOFTWARE variable is set.
1596 if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) {
1597 // Trim and sanitize the server software string.
1598 $server_sw_raw = $_SERVER['SERVER_SOFTWARE']; // phpcs:ignore
1599 $webserver_software = trim( wp_kses( is_string( $server_sw_raw ) ? wp_unslash( $server_sw_raw ) : '', 'strip' ) );
1600
1601 // Use regular expressions to extract the web server name and version.
1602 if ( preg_match( '/^([^\s\/]+)\/?([^\s]*)/', $webserver_software, $matches ) ) {
1603 $webserver['name'] = trim( (string) $matches[1] );
1604 $webserver['version'] = trim( (string) $matches[2] );
1605
1606 // Replace "-N" at the end of the version with ".N" if present.
1607 if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) {
1608 $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] );
1609 }
1610 }
1611 }
1612
1613 // Normalize and set the web server ID based on the detected name.
1614 if ( ! empty( $webserver['name'] ) ) {
1615 $normalized_name = strtolower( (string) $webserver['name'] );
1616 $webserver['id'] = trim( (string) preg_replace( '/[^a-z0-9]+/', '-', $normalized_name ), '-' );
1617 switch ( $normalized_name ) {
1618 case 'httpd':
1619 case 'apache':
1620 $webserver['id'] = 'apache';
1621 $webserver['name'] = 'Apache HTTPD';
1622 break;
1623 case 'nginx':
1624 $webserver['id'] = 'nginx';
1625 $webserver['name'] = 'nginx';
1626 break;
1627 case 'openresty':
1628 $webserver['id'] = 'nginx';
1629 $webserver['name'] = 'OpenResty';
1630 break;
1631 case 'tengine':
1632 $webserver['id'] = 'nginx';
1633 $webserver['name'] = 'Tengine';
1634 break;
1635 // Additional web servers can be added here.
1636 }
1637 }
1638
1639 // If the version is not detected, try to get it from the OS.
1640 if ( empty( $webserver['version'] ) && wpvulnerability_can_shell_exec() ) {
1641 if ( 'apache' === $webserver['id'] && wpvulnerability_analyze_filter( 'apache' ) ) {
1642 $apache_version = wpvulnerability_safe_shell_exec( 'apache', 'apache2 -v' );
1643 if ( empty( $apache_version ) ) {
1644 $apache_version = wpvulnerability_safe_shell_exec( 'apache', 'httpd -v' );
1645 }
1646 if ( ! empty( $apache_version ) && preg_match( '/Apache\/([\d.]+)/', $apache_version, $version_matches ) ) {
1647 $webserver['version'] = $version_matches[1];
1648 // Replace "-N" at the end with ".N" if present.
1649 if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) {
1650 $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] );
1651 }
1652 }
1653 } elseif ( 'nginx' === $webserver['id'] && wpvulnerability_analyze_filter( 'nginx' ) ) {
1654 $nginx_version = wpvulnerability_safe_shell_exec( 'nginx', 'nginx -v' );
1655 if ( ! empty( $nginx_version ) && preg_match( '/nginx\/([\d.]+)/', $nginx_version, $version_matches ) ) {
1656 $webserver['version'] = $version_matches[1];
1657 // Replace "-N" at the end with ".N" if present.
1658 if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) {
1659 $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] );
1660 }
1661 } else {
1662 $angie_version = wpvulnerability_safe_shell_exec( 'nginx', 'angie -v' );
1663 if ( ! empty( $angie_version ) && preg_match( '/angie\/([\d.]+)/', $angie_version, $version_matches ) ) {
1664 $webserver['version'] = $version_matches[1];
1665 // Replace "-N" at the end with ".N" if present.
1666 if ( preg_match( '/-(\d+)$/', $webserver['version'], $suffix_matches ) ) {
1667 $webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] );
1668 }
1669 }
1670 }
1671 }
1672 }
1673
1674 // Sanitize and validate the web server version format.
1675 if ( null !== $webserver['version'] && '' !== $webserver['version'] ) {
1676 // Sanitize the version number to ensure it's in a 'major.minor.patch' format.
1677 $webserver['version'] = wpvulnerability_sanitize_and_validate_version( $webserver['version'] );
1678
1679 if ( null !== $webserver['version'] && ! preg_match( '/^\d+(?:\.\d+)*$/', $webserver['version'] ) ) {
1680 $webserver['version'] = null;
1681 }
1682 }
1683
1684 // Return the detected web server information.
1685 return $webserver;
1686 }
1687
1688 /**
1689 * Fires plugin and legacy hooks when the database reports an error.
1690 *
1691 * @since 4.3.0
1692 *
1693 * @param string $last_error The database error message.
1694 *
1695 * @return void
1696 */
1697 function wpvulnerability_handle_wpdb_last_error( $last_error ) {
1698 $last_error = trim( (string) $last_error );
1699
1700 if ( '' === $last_error ) {
1701 return;
1702 }
1703
1704 /**
1705 * Fires when WPVulnerability detects a database error while debugging is enabled.
1706 *
1707 * @since 4.3.0
1708 *
1709 * @param string $last_error The database error message.
1710 */
1711 do_action( 'wpvulnerability_wpdb_last_error', $last_error );
1712
1713 // Preserve backward compatibility with the legacy hook name.
1714 do_action_deprecated( 'wpdb_last_error', array( $last_error ), '4.3.0', 'wpvulnerability_wpdb_last_error' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
1715 }
1716
1717 /**
1718 * Detects the SQL server software and version from the database server.
1719 *
1720 * This function identifies the SQL server software (e.g., MariaDB, MySQL) and its version
1721 * by querying the database using the 'SHOW VARIABLES' command. It parses the server name
1722 * and version using the results and sanitizes the detected version number to a standard format (major.minor.patch).
1723 *
1724 * @since 3.4.0
1725 *
1726 * @return array{id: string|null, name: string|null, version: string|null} SQL server information.
1727 */
1728 function wpvulnerability_detect_sqlserver() {
1729 // Initialize an array to hold the SQL server information.
1730 $sqlserver = array(
1731 'id' => null,
1732 'name' => null,
1733 'version' => null,
1734 );
1735
1736 global $wpdb;
1737
1738 $version_source = '';
1739 $server_info_string = '';
1740
1741 // Query to get the database server type (version_comment).
1742 $database_results = $wpdb->get_results( $wpdb->prepare( 'SHOW VARIABLES LIKE %s', 'version_comment' ) ); // phpcs:ignore
1743 if ( $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1744 wpvulnerability_handle_wpdb_last_error( $wpdb->last_error );
1745 }
1746
1747 // Process the results to determine the database type.
1748 if ( ! empty( $database_results ) && isset( $database_results[0]->Value ) ) {
1749 $possible_database = trim( (string) $database_results[0]->Value );
1750
1751 if ( false !== stripos( $possible_database, 'mariadb' ) ) {
1752 $sqlserver['id'] = 'mariadb';
1753 $sqlserver['name'] = 'MariaDB';
1754 } elseif ( false !== stripos( $possible_database, 'mysql' ) ) {
1755 $sqlserver['id'] = 'mysql';
1756 $sqlserver['name'] = 'MySQL';
1757 }
1758 }
1759
1760 // Query to get the database server version.
1761 $version_results = $wpdb->get_results( $wpdb->prepare( 'SHOW VARIABLES LIKE %s', 'version' ) ); // phpcs:ignore
1762 if ( $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1763 wpvulnerability_handle_wpdb_last_error( $wpdb->last_error );
1764 }
1765
1766 if ( ! empty( $version_results ) && isset( $version_results[0]->Value ) ) {
1767 $version_source = trim( (string) $version_results[0]->Value );
1768 }
1769
1770 if ( empty( $sqlserver['id'] ) ) {
1771 $server_info_string = trim( (string) $wpdb->db_server_info() );
1772
1773 if ( '' === $server_info_string ) {
1774 $server_info_string = $wpdb->get_var( 'SELECT VERSION()' ); // phpcs:ignore
1775 if ( $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1776 wpvulnerability_handle_wpdb_last_error( $wpdb->last_error );
1777 }
1778 $server_info_string = is_string( $server_info_string ) ? trim( $server_info_string ) : '';
1779 }
1780
1781 if ( '' !== $server_info_string ) {
1782 if ( false !== stripos( $server_info_string, 'mariadb' ) ) {
1783 $sqlserver['id'] = 'mariadb';
1784 $sqlserver['name'] = 'MariaDB';
1785 } elseif ( false !== stripos( $server_info_string, 'mysql' ) ) {
1786 $sqlserver['id'] = 'mysql';
1787 $sqlserver['name'] = 'MySQL';
1788 }
1789
1790 if ( '' === $version_source ) {
1791 $version_source = $server_info_string;
1792 }
1793 }
1794 }
1795
1796 if ( '' !== $version_source ) {
1797 if ( preg_match( '/(\d+\.\d+\.\d+(?:-\d+)?)/', $version_source, $match ) ||
1798 preg_match( '/(\d+\.\d+(?:-\d+)?)/', $version_source, $match ) ) {
1799 $sqlserver['version'] = $match[1];
1800 } elseif ( 'mysql' === $sqlserver['id'] ) {
1801 // Fallback to the entire version string if regex doesn't match.
1802 $sqlserver['version'] = $version_source;
1803 }
1804 }
1805
1806 // Replace "-N" at the end with ".N" if present.
1807 if ( ! empty( $sqlserver['version'] ) && preg_match( '/-(\d+)$/', $sqlserver['version'], $suffix_matches ) ) {
1808 $sqlserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $sqlserver['version'] );
1809 }
1810
1811 // Sanitize and validate the version format.
1812 if ( ! empty( $sqlserver['version'] ) ) {
1813 $sqlserver['version'] = wpvulnerability_sanitize_and_validate_version( $sqlserver['version'] );
1814 }
1815
1816 // Return the detected SQL server information.
1817 return $sqlserver;
1818 }
1819
1820 /**
1821 * Returns a human-readable HTML entity for the given comparison operator.
1822 *
1823 * This function takes a comparison operator in string format and returns
1824 * its corresponding HTML entity for better readability in web contexts.
1825 *
1826 * @since 2.0.0
1827 *
1828 * @param string $op The operator string to prettify.
1829 *
1830 * @return string The pretty operator HTML string.
1831 */
1832 function wpvulnerability_pretty_operator( $op ) {
1833 // Normalize the operator string to lowercase and trim whitespace.
1834 $op = trim( strtolower( (string) $op ) );
1835
1836 // Define an associative array mapping operators to their HTML entities.
1837 $operator_map = array(
1838 'lt' => '&lt;&nbsp;', // Less than.
1839 'le' => '&le;&nbsp;', // Less than or equal to.
1840 'gt' => '&gt;&nbsp;', // Greater than.
1841 'ge' => '&ge;&nbsp;', // Greater than or equal to.
1842 'eq' => '&equals;&nbsp;', // Equal to.
1843 'ne' => '&ne;&nbsp;', // Not equal to.
1844 );
1845
1846 // Return the corresponding HTML entity, or the original operator if not recognized.
1847 return isset( $operator_map[ $op ] ) ? $operator_map[ $op ] : $op;
1848 }
1849
1850 /**
1851 * Returns a human-readable severity level.
1852 *
1853 * This function takes a severity string and returns a human-readable
1854 * severity level, localized for translation.
1855 *
1856 * @since 2.0.0
1857 *
1858 * @param string $severity The severity string to prettify.
1859 *
1860 * @return string The human-readable severity string.
1861 */
1862 function wpvulnerability_severity( $severity ) {
1863 // Normalize the severity string to lowercase and trim whitespace.
1864 $severity = trim( strtolower( (string) $severity ) );
1865
1866 // Define an associative array mapping severity codes to their human-readable equivalents.
1867 // Handles both legacy single-char codes (cvss.severity) and full-word values (cvss3.severity).
1868 $severity_map = array(
1869 'n' => __( 'None', 'wpvulnerability' ),
1870 'none' => __( 'None', 'wpvulnerability' ),
1871 'l' => __( 'Low', 'wpvulnerability' ),
1872 'low' => __( 'Low', 'wpvulnerability' ),
1873 'm' => __( 'Medium', 'wpvulnerability' ),
1874 'medium' => __( 'Medium', 'wpvulnerability' ),
1875 'h' => __( 'High', 'wpvulnerability' ),
1876 'high' => __( 'High', 'wpvulnerability' ),
1877 'c' => __( 'Critical', 'wpvulnerability' ),
1878 'critical' => __( 'Critical', 'wpvulnerability' ),
1879 );
1880
1881 // Return the corresponding human-readable severity, or the original if not recognized.
1882 return isset( $severity_map[ $severity ] ) ? $severity_map[ $severity ] : $severity;
1883 }
1884
1885 /**
1886 * Retrieves vulnerabilities information from the API.
1887 *
1888 * This function fetches vulnerability information based on the provided type and slug.
1889 * It supports caching to minimize API requests and improve performance.
1890 *
1891 * @since 2.0.0
1892 *
1893 * @param string $type The type of vulnerability. Can be 'core', 'plugin', or 'theme'.
1894 * @param string $slug The slug of the plugin or theme. For core vulnerabilities, it is the version string.
1895 * @param int $cache Optional. Whether to use cache. Default is 1 (true).
1896 *
1897 * @return array<mixed>|false An array with the vulnerability information or false if there's an error.
1898 */
1899 function wpvulnerability_get( $type, $slug = '', $cache = 1 ) {
1900 // Validate vulnerability type and normalize.
1901 $type = strtolower( trim( (string) $type ) );
1902 $valid_types = array( 'core', 'plugin', 'theme' );
1903
1904 if ( ! in_array( $type, $valid_types, true ) ) {
1905 wp_die( 'Unknown vulnerability type sent.' );
1906 }
1907
1908 // Validate slug for plugin or theme.
1909 if ( ( 'plugin' === $type || 'theme' === $type ) && empty( sanitize_title( $slug ) ) ) {
1910 return false;
1911 }
1912
1913 // Validate slug for core.
1914 if ( 'core' === $type && ! wpvulnerability_sanitize_version( $slug ) ) {
1915 return false;
1916 }
1917
1918 // Cache key.
1919 $key = 'wpvulnerability_' . $type . '_' . $slug;
1920
1921 // Attempt to retrieve cached data.
1922 $vulnerability_data = $cache ? ( is_multisite() ? get_site_transient( $key ) : get_transient( $key ) ) : null;
1923
1924 // If not cached, fetch updated data.
1925 if ( empty( $vulnerability_data ) ) {
1926 $url = WPVULNERABILITY_API_HOST . $type . '/' . $slug . '/';
1927 $response = wp_remote_get( $url, array( 'timeout' => 2.5 ) );
1928 wpvulnerability_maybe_log_api_response( $url, $response );
1929
1930 if ( ! is_wp_error( $response ) ) {
1931 $body = wp_remote_retrieve_body( $response );
1932
1933 // Cache the response data.
1934 if ( is_multisite() ) {
1935 set_site_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() );
1936 } else {
1937 set_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() );
1938 }
1939
1940 $vulnerability_data = $body; // Use the fresh data.
1941 }
1942 }
1943
1944 if ( ! is_string( $vulnerability_data ) || '' === $vulnerability_data ) {
1945 return false;
1946 }
1947 $decoded = json_decode( $vulnerability_data, true );
1948 return is_array( $decoded ) ? $decoded : false;
1949 }
1950
1951 /**
1952 * Retrieve vulnerabilities for a specific version of WordPress Core.
1953 *
1954 * This function fetches vulnerability information for a given version of WordPress Core.
1955 * If no version is provided, it retrieves vulnerabilities for the currently installed version.
1956 * It supports caching to minimize API requests and improve performance.
1957 *
1958 * @since 2.0.0
1959 *
1960 * @param string|null $version The version number of WordPress Core. If null, retrieves for the installed version.
1961 * @param int $cache Optional. Whether to use cache. Default is 1 (true).
1962 *
1963 * @return list<array<string, mixed>>|false Array of vulnerabilities, or false on error.
1964 */
1965 function wpvulnerability_get_core( $version = null, $cache = 1 ) {
1966 // Sanitize the version number.
1967 if ( ! wpvulnerability_sanitize_version( $version ) ) {
1968 $version = null; // Reset version if sanitization fails.
1969 }
1970
1971 // If version number is null, retrieve for the installed version.
1972 if ( is_null( $version ) ) {
1973 $version = get_bloginfo( 'version' );
1974 }
1975
1976 // Get vulnerabilities from the API.
1977 $response = wpvulnerability_get( 'core', $version, $cache );
1978
1979 // Check for errors in the response.
1980 if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) {
1981 return false;
1982 }
1983
1984 $data_section = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
1985 $vuln_raw = isset( $data_section['vulnerability'] ) && is_array( $data_section['vulnerability'] ) ? $data_section['vulnerability'] : array();
1986
1987 if ( empty( $vuln_raw ) ) {
1988 return false;
1989 }
1990
1991 // Process vulnerabilities and return as an array.
1992 $vulnerabilities = array();
1993 foreach ( $vuln_raw as $v ) {
1994 if ( ! is_array( $v ) ) {
1995 continue;
1996 }
1997 $v_name = $v['name'] ?? null;
1998 $v_link = $v['link'] ?? null;
1999 $vulnerabilities[] = array(
2000 'name' => is_scalar( $v_name ) ? wp_kses( (string) $v_name, 'strip' ) : null,
2001 'link' => is_scalar( $v_link ) ? esc_url_raw( (string) $v_link ) : null,
2002 'source' => isset( $v['source'] ) ? $v['source'] : null,
2003 'impact' => isset( $v['impact'] ) ? $v['impact'] : null,
2004 'uuid' => is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '',
2005 );
2006 }
2007
2008 return $vulnerabilities;
2009 }
2010
2011 /**
2012 * Determines if a vulnerability applies to the specified version of the plugin.
2013 *
2014 * @since 3.5.0 Introduced.
2015 *
2016 * @param array<mixed> $v The vulnerability data.
2017 * @param string $version The version of the plugin.
2018 *
2019 * @return bool True if the vulnerability applies, false otherwise.
2020 */
2021 function wpvulnerability_is_vulnerability_applicable( $v, $version ) {
2022 $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array();
2023
2024 $min_op = isset( $op['min_operator'] ) && is_scalar( $op['min_operator'] ) ? (string) $op['min_operator'] : '';
2025 $max_op = isset( $op['max_operator'] ) && is_scalar( $op['max_operator'] ) ? (string) $op['max_operator'] : '';
2026 $min_ver = isset( $op['min_version'] ) && is_scalar( $op['min_version'] ) ? (string) $op['min_version'] : '';
2027 $max_ver = isset( $op['max_version'] ) && is_scalar( $op['max_version'] ) ? (string) $op['max_version'] : '';
2028
2029 // Check if the vulnerability has minimum and maximum versions.
2030 if ( '' !== $min_op && '' !== $max_op ) {
2031 return version_compare( $version, $min_ver, $min_op ) &&
2032 version_compare( $version, $max_ver, $max_op );
2033 }
2034
2035 // Check if the vulnerability has only a maximum version.
2036 if ( '' !== $max_op ) {
2037 return version_compare( $version, $max_ver, $max_op );
2038 }
2039
2040 // Check if the vulnerability has only a minimum version.
2041 if ( '' !== $min_op ) {
2042 return version_compare( $version, $min_ver, $min_op );
2043 }
2044
2045 return false;
2046 }
2047
2048 /**
2049 * Retrieves vulnerabilities for a specified plugin, optionally returning general plugin data.
2050 *
2051 * This function sanitizes the plugin slug and verifies the version number before querying the vulnerability API.
2052 * If `$data` is set to 1, it returns general information about the plugin instead of vulnerabilities.
2053 * The function returns an array of vulnerabilities or plugin data based on the `$data` parameter, or `false`
2054 * if no vulnerabilities are found or the version number is invalid and `$data` is not set.
2055 *
2056 * @since 2.0.0 Introduced.
2057 *
2058 * @param string $slug The slug of the plugin to check for vulnerabilities.
2059 * @param string $version The version of the plugin to check. The function may return `false` if this is invalid and `$data` is not set.
2060 * @param int $data Optional. Set to 1 to return general plugin data instead of vulnerabilities. Default 0 (return vulnerabilities).
2061 * @param int $cache Optional. Whether to use cache. Default is 1 (true).
2062 *
2063 * @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.
2064 */
2065 function wpvulnerability_get_plugin( $slug, $version, $data = 0, $cache = 1 ) {
2066 // Sanitize the plugin slug.
2067 $slug = sanitize_title( $slug );
2068
2069 // If the version number is invalid, return false unless $data is set.
2070 if ( ! wpvulnerability_sanitize_version( $version ) && ! $data ) {
2071 return false;
2072 }
2073
2074 // Get the response from the vulnerability API.
2075 $response = wpvulnerability_get( 'plugin', $slug, $cache );
2076
2077 // If $data is set to 1, return general plugin data.
2078 if ( 1 === $data && is_array( $response ) ) {
2079 $resp_data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
2080 $name_raw = $resp_data['name'] ?? '';
2081 $link_raw = $resp_data['link'] ?? '';
2082 $latest_raw = $resp_data['latest'] ?? 0;
2083 $closed_raw = $resp_data['closed'] ?? 0;
2084 return array(
2085 'name' => wp_kses( is_scalar( $name_raw ) ? (string) $name_raw : '', 'strip' ),
2086 'link' => esc_url( is_scalar( $link_raw ) ? (string) $link_raw : '' ),
2087 'latest' => number_format( is_scalar( $latest_raw ) ? (int) $latest_raw : 0, 0, '.', '' ),
2088 'closed' => number_format( is_scalar( $closed_raw ) ? (int) $closed_raw : 0, 0, '.', '' ),
2089 );
2090 }
2091
2092 // Check for errors in the response.
2093 if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) {
2094 return false;
2095 }
2096
2097 $resp_data2 = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
2098 $vuln_list = isset( $resp_data2['vulnerability'] ) && is_array( $resp_data2['vulnerability'] ) ? $resp_data2['vulnerability'] : array();
2099
2100 if ( empty( $vuln_list ) ) {
2101 return false;
2102 }
2103
2104 // Create an empty array to store vulnerabilities.
2105 $vulnerabilities = array();
2106
2107 // Loop through each vulnerability.
2108 foreach ( $vuln_list as $v ) {
2109 if ( ! is_array( $v ) ) {
2110 continue;
2111 }
2112 // Check version constraints and add vulnerabilities accordingly.
2113 if ( wpvulnerability_is_vulnerability_applicable( $v, $version ) ) {
2114 $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array();
2115 $min_op_raw = $op['min_operator'] ?? '';
2116 $max_op_raw = $op['max_operator'] ?? '';
2117 $min_ver_raw = $op['min_version'] ?? '';
2118 $max_ver_raw = $op['max_version'] ?? '';
2119 $unfixed_raw = $op['unfixed'] ?? 0;
2120 $closed_raw2 = $op['closed'] ?? 0;
2121 $min_op = is_scalar( $min_op_raw ) ? (string) $min_op_raw : '';
2122 $max_op = is_scalar( $max_op_raw ) ? (string) $max_op_raw : '';
2123 $min_ver = is_scalar( $min_ver_raw ) ? (string) $min_ver_raw : '';
2124 $max_ver = is_scalar( $max_ver_raw ) ? (string) $max_ver_raw : '';
2125 $v_name_raw = $v['name'] ?? '';
2126 $v_desc_raw = $v['description'] ?? '';
2127 $vulnerabilities[] = array(
2128 'name' => wp_kses( is_scalar( $v_name_raw ) ? (string) $v_name_raw : '', 'strip' ),
2129 'description' => wp_kses_post( is_scalar( $v_desc_raw ) ? (string) $v_desc_raw : '' ),
2130 'versions' => wp_kses(
2131 wpvulnerability_pretty_operator( $min_op ) . $min_ver . ' - ' .
2132 wpvulnerability_pretty_operator( $max_op ) . $max_ver,
2133 'strip'
2134 ),
2135 'version' => wp_kses( '' !== $min_ver ? $min_ver : $max_ver, 'strip' ),
2136 'unfixed' => is_scalar( $unfixed_raw ) ? (int) $unfixed_raw : 0,
2137 'closed' => is_scalar( $closed_raw2 ) ? (int) $closed_raw2 : 0,
2138 'source' => isset( $v['source'] ) ? $v['source'] : null,
2139 'impact' => isset( $v['impact'] ) ? $v['impact'] : null,
2140 'uuid' => is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '',
2141 );
2142 }
2143 }
2144
2145 return $vulnerabilities;
2146 }
2147
2148 /**
2149 * Get vulnerabilities for a specific theme.
2150 *
2151 * This function retrieves and sanitizes the theme slug and version before querying the vulnerability API.
2152 * It returns an array of vulnerabilities if any are found, or false if there are none.
2153 *
2154 * @since 3.5.0
2155 *
2156 * @param string $slug Slug of the theme.
2157 * @param string $version Version of the theme.
2158 * @param int $cache Optional. Whether to use cache. Default is 1 (true).
2159 *
2160 * @return list<array<string, mixed>>|false Returns an array of vulnerabilities, or false if there are none.
2161 */
2162 function wpvulnerability_get_theme( $slug, $version, $cache = 1 ) {
2163 // Sanitize the theme slug.
2164 $slug = sanitize_title( $slug );
2165
2166 // Validate the version number.
2167 if ( ! wpvulnerability_sanitize_version( $version ) ) {
2168 return false; // Return false if the version is invalid.
2169 }
2170
2171 // Get the response from the vulnerability API.
2172 $response = wpvulnerability_get( 'theme', $slug, $cache );
2173
2174 // Check for errors in the response.
2175 if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) {
2176 return false;
2177 }
2178
2179 $theme_data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
2180 $vuln_list = isset( $theme_data['vulnerability'] ) && is_array( $theme_data['vulnerability'] ) ? $theme_data['vulnerability'] : array();
2181
2182 if ( empty( $vuln_list ) ) {
2183 return false;
2184 }
2185
2186 // Process each vulnerability.
2187 $vulnerabilities = array();
2188 foreach ( $vuln_list as $v ) {
2189 if ( ! is_array( $v ) ) {
2190 continue;
2191 }
2192 // Check if the version falls within the min and max operator range.
2193 if ( wpvulnerability_is_vulnerability_applicable( $v, $version ) ) {
2194 $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array();
2195 $min_op_raw = $op['min_operator'] ?? '';
2196 $max_op_raw = $op['max_operator'] ?? '';
2197 $min_ver_raw = $op['min_version'] ?? '';
2198 $max_ver_raw = $op['max_version'] ?? '';
2199 $unfixed_raw = $op['unfixed'] ?? 0;
2200 $closed_raw = $op['closed'] ?? 0;
2201 $min_op = is_scalar( $min_op_raw ) ? (string) $min_op_raw : '';
2202 $max_op = is_scalar( $max_op_raw ) ? (string) $max_op_raw : '';
2203 $min_ver = is_scalar( $min_ver_raw ) ? (string) $min_ver_raw : '';
2204 $max_ver = is_scalar( $max_ver_raw ) ? (string) $max_ver_raw : '';
2205 $v_name_raw = $v['name'] ?? '';
2206 $v_desc_raw = $v['description'] ?? '';
2207 $vulnerabilities[] = array(
2208 'name' => wp_kses( is_scalar( $v_name_raw ) ? (string) $v_name_raw : '', 'strip' ),
2209 'description' => wp_kses_post( is_scalar( $v_desc_raw ) ? (string) $v_desc_raw : '' ),
2210 'versions' => wp_kses(
2211 wpvulnerability_pretty_operator( $min_op ) . $min_ver . ' - ' .
2212 wpvulnerability_pretty_operator( $max_op ) . $max_ver,
2213 'strip'
2214 ),
2215 'version' => wp_kses( '' !== $min_ver ? $min_ver : $max_ver, 'strip' ),
2216 'unfixed' => is_scalar( $unfixed_raw ) ? (int) $unfixed_raw : 0,
2217 'closed' => is_scalar( $closed_raw ) ? (int) $closed_raw : 0,
2218 'source' => isset( $v['source'] ) ? $v['source'] : null,
2219 'impact' => isset( $v['impact'] ) ? $v['impact'] : null,
2220 'uuid' => is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '',
2221 );
2222 }
2223 }
2224
2225 return $vulnerabilities;
2226 }
2227
2228 /**
2229 * Get statistics.
2230 *
2231 * Returns an array with statistical information about vulnerabilities and their respective products.
2232 *
2233 * @since 2.0.0
2234 *
2235 * @param int $cache Optional. Whether to use cache. Default is 1 (true).
2236 *
2237 * @return array<string, mixed>|false Returns an array with the statistical information if successful, false otherwise.
2238 */
2239 function wpvulnerability_get_statistics( $cache = 1 ) {
2240 $key = 'wpvulnerability_stats';
2241
2242 // Attempt to get cached statistics.
2243 $vulnerability = $cache ? ( is_multisite() ? get_site_transient( $key ) : get_transient( $key ) ) : null;
2244
2245 // If cached statistics are not available, retrieve them from the API.
2246 if ( empty( $vulnerability ) ) {
2247 $url = WPVULNERABILITY_API_HOST;
2248 $response = wp_remote_get( $url, array( 'timeout' => 2.5 ) );
2249 wpvulnerability_maybe_log_api_response( $url, $response );
2250
2251 if ( ! is_wp_error( $response ) ) {
2252 $body = wp_remote_retrieve_body( $response );
2253 // Cache the response data.
2254 if ( is_multisite() ) {
2255 set_site_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() );
2256 } else {
2257 set_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() );
2258 }
2259 $vulnerability = $body; // Use the fresh data.
2260 }
2261 }
2262
2263 // Decode the JSON response and check for statistics.
2264 $response = json_decode( is_string( $vulnerability ) ? $vulnerability : '', true );
2265 if ( ! is_array( $response ) || ! isset( $response['stats'] ) || ! is_array( $response['stats'] ) ) {
2266 return false;
2267 }
2268
2269 // Extract typed intermediate arrays to avoid mixed-type access errors.
2270 $stats = $response['stats'];
2271 $products = isset( $stats['products'] ) && is_array( $stats['products'] ) ? $stats['products'] : array();
2272 $sources_raw = isset( $stats['vulnerabilities'] ) && is_array( $stats['vulnerabilities'] ) ? $stats['vulnerabilities'] : array();
2273 $behind = isset( $response['behindtheproject'] ) && is_array( $response['behindtheproject'] ) ? $response['behindtheproject'] : array();
2274 $updated_raw = $response['updated'] ?? 0;
2275 $updated = is_scalar( $updated_raw ) ? (int) $updated_raw : 0;
2276
2277 $sponsors = array();
2278 if ( isset( $behind['sponsors'] ) && is_array( $behind['sponsors'] ) && count( $behind['sponsors'] ) ) {
2279 foreach ( $behind['sponsors'] as $s ) {
2280 $sponsors[] = $s;
2281 }
2282 }
2283
2284 $contributors = array();
2285 if ( isset( $behind['contributors'] ) && is_array( $behind['contributors'] ) && count( $behind['contributors'] ) ) {
2286 foreach ( $behind['contributors'] as $s ) {
2287 $contributors[] = $s;
2288 }
2289 }
2290
2291 /**
2292 * Cast a scalar value to int safely.
2293 *
2294 * @param mixed $x Value to cast.
2295 * @return int
2296 */
2297 $to_int = static function ( $x ): int {
2298 return is_scalar( $x ) ? (int) $x : 0;
2299 };
2300
2301 // Build per-source vulnerability breakdown (stats.vulnerabilities.{source}).
2302 $sources_data = array();
2303 $known_sources = array( 'cve', 'euvd', 'jvn', 'patchstack', 'wpscan', 'wordfence' );
2304 foreach ( $known_sources as $src ) {
2305 if ( isset( $sources_raw[ $src ] ) && is_array( $sources_raw[ $src ] ) ) {
2306 $src_data = $sources_raw[ $src ];
2307 $sources_data[ $src ] = array(
2308 'core' => $to_int( $src_data['core'] ?? 0 ),
2309 'plugins' => $to_int( $src_data['plugins'] ?? 0 ),
2310 'themes' => $to_int( $src_data['themes'] ?? 0 ),
2311 );
2312 }
2313 }
2314
2315 // Return an array with statistical information.
2316 return array(
2317 'core' => array(
2318 'versions' => $to_int( $products['core'] ?? 0 ),
2319 ),
2320 'plugins' => array(
2321 'products' => $to_int( $products['plugins'] ?? 0 ),
2322 'vulnerabilities' => $to_int( $stats['plugins'] ?? 0 ),
2323 ),
2324 'themes' => array(
2325 'products' => $to_int( $products['themes'] ?? 0 ),
2326 'vulnerabilities' => $to_int( $stats['themes'] ?? 0 ),
2327 ),
2328 'php' => array(
2329 'vulnerabilities' => $to_int( $stats['php'] ?? 0 ),
2330 ),
2331 'apache' => array(
2332 'vulnerabilities' => $to_int( $stats['apache'] ?? 0 ),
2333 ),
2334 'nginx' => array(
2335 'vulnerabilities' => $to_int( $stats['nginx'] ?? 0 ),
2336 ),
2337 'mariadb' => array(
2338 'vulnerabilities' => $to_int( $stats['mariadb'] ?? 0 ),
2339 ),
2340 'mysql' => array(
2341 'vulnerabilities' => $to_int( $stats['mysql'] ?? 0 ),
2342 ),
2343 'imagemagick' => array(
2344 'vulnerabilities' => $to_int( $stats['imagemagick'] ?? 0 ),
2345 ),
2346 'curl' => array(
2347 'vulnerabilities' => $to_int( $stats['curl'] ?? 0 ),
2348 ),
2349 'memcached' => array(
2350 'vulnerabilities' => $to_int( $stats['memcached'] ?? 0 ),
2351 ),
2352 'redis' => array(
2353 'vulnerabilities' => $to_int( $stats['redis'] ?? 0 ),
2354 ),
2355 'sqlite' => array(
2356 'vulnerabilities' => $to_int( $stats['sqlite'] ?? 0 ),
2357 ),
2358 'sources' => $sources_data,
2359 'sponsors' => $sponsors,
2360 'contributors' => $contributors,
2361 'updated' => array(
2362 'unixepoch' => $updated,
2363 'datetime' => gmdate( 'Y-m-d H:i:s', $updated ),
2364 'iso8601' => gmdate( 'c', $updated ),
2365 'rfc2822' => gmdate( 'r', $updated ),
2366 ),
2367 );
2368 }
2369
2370 /**
2371 * Retrieves the latest vulnerability statistics.
2372 *
2373 * This function calls the wpvulnerability API to get fresh statistics related to vulnerabilities
2374 * and returns the updated information.
2375 *
2376 * @since 3.4.0
2377 *
2378 * @return array<string, mixed>|false The updated vulnerability statistics, or false on error.
2379 */
2380 function wpvulnerability_get_fresh_statistics() {
2381 // Call the function to get the latest vulnerability statistics.
2382 $statistics_api_response = wpvulnerability_get_statistics();
2383
2384 // Return the response from the API.
2385 return $statistics_api_response;
2386 }
2387
2388 /**
2389 * Retrieves and caches the latest vulnerability statistics.
2390 *
2391 * This function retrieves the most recent vulnerability statistics, caches the data,
2392 * and returns the information as a JSON-encoded array. The cache expiration timestamp is also updated.
2393 *
2394 * @since 3.4.0
2395 *
2396 * @return string JSON-encoded array containing the vulnerability statistics, or empty string on encoding error.
2397 */
2398 function wpvulnerability_statistics_get() {
2399 // Retrieve fresh statistics.
2400 $statistics = wpvulnerability_get_fresh_statistics();
2401
2402 // Cache the statistics data and the timestamp for cache expiration.
2403 $encoded_statistics = wp_json_encode( $statistics );
2404 if ( false === $encoded_statistics ) {
2405 $encoded_statistics = '';
2406 }
2407 $cache_expiration = number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' );
2408
2409 if ( is_multisite() ) {
2410 update_site_option( 'wpvulnerability-statistics', $encoded_statistics );
2411 update_site_option( 'wpvulnerability-statistics-cache', $cache_expiration );
2412 } else {
2413 update_option( 'wpvulnerability-statistics', $encoded_statistics );
2414 update_option( 'wpvulnerability-statistics-cache', $cache_expiration );
2415 }
2416
2417 // Return the JSON-encoded array of statistics data.
2418 return $encoded_statistics;
2419 }
2420
2421 /**
2422 * Get vulnerabilities for a specific product version.
2423 *
2424 * This function retrieves vulnerability data for a specified product version.
2425 * It supports caching to minimize API requests and improve performance.
2426 *
2427 * @since 3.5.0
2428 *
2429 * @param string $type The type of product (e.g., 'php', 'apache', 'nginx', 'mariadb', 'mysql').
2430 * @param string $version The version of the product to check.
2431 * @param int $cache Optional. Whether to use cache. Default is 1 (true).
2432 *
2433 * @return list<array<string, mixed>>|false Returns an array of vulnerabilities, or false if there are none.
2434 */
2435 function wpvulnerability_get_vulnerabilities( $type, $version, $cache = 1 ) {
2436 $key = 'wpvulnerability_' . $type;
2437 $vulnerability_data = null;
2438 $vulnerability = array();
2439
2440 // Get cached statistics if available.
2441 if ( $cache ) {
2442 $vulnerability_data = is_multisite() ? get_site_transient( $key ) : get_transient( $key );
2443 }
2444
2445 // If cached statistics are not available, retrieve them from the API and store them in cache.
2446 if ( empty( $vulnerability_data ) ) {
2447 $url = WPVULNERABILITY_API_HOST . $type . '/' . $version . '/';
2448 $response = wp_remote_get( $url, array( 'timeout' => 2.5 ) );
2449 wpvulnerability_maybe_log_api_response( $url, $response );
2450
2451 if ( ! is_wp_error( $response ) ) {
2452 $body = wp_remote_retrieve_body( $response );
2453 if ( $cache ) {
2454 if ( is_multisite() ) {
2455 set_site_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() );
2456 } else {
2457 set_transient( $key, $body, HOUR_IN_SECONDS * wpvulnerability_cache_hours() );
2458 }
2459 }
2460 $vulnerability_data = $body; // Use the fresh data.
2461 }
2462 }
2463
2464 // If the response does not contain vulnerabilities, return false.
2465 $response = json_decode( is_string( $vulnerability_data ) ? $vulnerability_data : '', true );
2466
2467 if ( ! is_array( $response ) || ( isset( $response['error'] ) && $response['error'] ) ) {
2468 return false;
2469 }
2470
2471 $resp_data2 = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
2472 $vuln_list = isset( $resp_data2['vulnerability'] ) && is_array( $resp_data2['vulnerability'] ) ? $resp_data2['vulnerability'] : array();
2473
2474 if ( empty( $vuln_list ) ) {
2475 return false;
2476 }
2477
2478 // Process each vulnerability.
2479 foreach ( $vuln_list as $v ) {
2480 if ( ! is_array( $v ) ) {
2481 continue;
2482 }
2483 $v_name_raw = $v['name'] ?? '';
2484 $name = is_scalar( $v_name_raw ) ? wp_kses( (string) $v_name_raw, 'strip' ) : '';
2485 $source = isset( $v['source'] ) ? $v['source'] : null;
2486 $op = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array();
2487
2488 $min_op_raw = $op['min_operator'] ?? '';
2489 $max_op_raw = $op['max_operator'] ?? '';
2490 $min_ver_raw = $op['min_version'] ?? '';
2491 $max_ver_raw = $op['max_version'] ?? '';
2492 $unfixed_raw = $op['unfixed'] ?? 0;
2493 $min_op = is_scalar( $min_op_raw ) ? (string) $min_op_raw : '';
2494 $max_op = is_scalar( $max_op_raw ) ? (string) $max_op_raw : '';
2495 $min_ver = is_scalar( $min_ver_raw ) ? (string) $min_ver_raw : '';
2496 $max_ver = is_scalar( $max_ver_raw ) ? (string) $max_ver_raw : '';
2497 $unfixed = is_scalar( $unfixed_raw ) ? (int) $unfixed_raw : 0;
2498
2499 $impact = isset( $v['impact'] ) ? $v['impact'] : null;
2500 $uuid = is_scalar( $v['uuid'] ?? '' ) ? (string) ( $v['uuid'] ?? '' ) : '';
2501
2502 // Check if the version falls within the specified min and max operator range.
2503 if ( '' !== $min_op && '' !== $max_op ) {
2504 if ( version_compare( $version, $min_ver, $min_op ) &&
2505 version_compare( $version, $max_ver, $max_op ) ) {
2506 $vulnerability[] = array(
2507 'name' => $name,
2508 'versions' => wp_kses( wpvulnerability_pretty_operator( $min_op ) . $min_ver . ' - ' . wpvulnerability_pretty_operator( $max_op ) . $max_ver, 'strip' ),
2509 'version' => wp_kses( $min_ver, 'strip' ),
2510 'unfixed' => $unfixed,
2511 'source' => $source,
2512 'impact' => $impact,
2513 'uuid' => $uuid,
2514 );
2515 }
2516 } elseif ( '' !== $max_op ) {
2517 if ( version_compare( $version, $max_ver, $max_op ) ) {
2518 $vulnerability[] = array(
2519 'name' => $name,
2520 'versions' => wp_kses( wpvulnerability_pretty_operator( $max_op ) . $max_ver, 'strip' ),
2521 'version' => wp_kses( $max_ver, 'strip' ),
2522 'unfixed' => $unfixed,
2523 'source' => $source,
2524 'impact' => $impact,
2525 'uuid' => $uuid,
2526 );
2527 }
2528 } elseif ( '' !== $min_op ) {
2529 if ( version_compare( $version, $min_ver, $min_op ) ) {
2530 $vulnerability[] = array(
2531 'name' => $name,
2532 'versions' => wp_kses( wpvulnerability_pretty_operator( $min_op ) . $min_ver, 'strip' ),
2533 'version' => wp_kses( $min_ver, 'strip' ),
2534 'unfixed' => $unfixed,
2535 'source' => $source,
2536 'impact' => $impact,
2537 'uuid' => $uuid,
2538 );
2539 }
2540 }
2541 }
2542
2543 return $vulnerability;
2544 }
2545
2546 /**
2547 * Get the current security mode configuration.
2548 *
2549 * Returns the security mode that controls shell_exec usage.
2550 * Valid modes: 'standard', 'strict', 'disabled'.
2551 *
2552 * @since 4.3.0
2553 *
2554 * @return string Current security mode.
2555 */
2556 function wpvulnerability_get_security_mode() {
2557 if ( defined( 'WPVULNERABILITY_SECURITY_MODE' ) ) {
2558 $mode = strtolower( trim( (string) WPVULNERABILITY_SECURITY_MODE ) );
2559 $valid_modes = array( 'standard', 'strict', 'disabled' );
2560 if ( in_array( $mode, $valid_modes, true ) ) {
2561 return $mode;
2562 }
2563 }
2564
2565 $settings = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() );
2566 if ( ! is_array( $settings ) ) {
2567 $settings = array();
2568 }
2569 if ( isset( $settings['security_mode'] ) ) {
2570 $security_mode_raw = $settings['security_mode'];
2571 $mode = strtolower( trim( is_scalar( $security_mode_raw ) ? (string) $security_mode_raw : '' ) );
2572 $valid_modes = array( 'standard', 'strict', 'disabled' );
2573 if ( in_array( $mode, $valid_modes, true ) ) {
2574 return $mode;
2575 }
2576 }
2577
2578 return 'standard';
2579 }
2580
2581 /**
2582 * Validate a shell command before execution.
2583 *
2584 * Ensures commands match allowed patterns and don't contain dangerous characters.
2585 *
2586 * @since 4.3.0
2587 *
2588 * @param string $command Command to validate.
2589 *
2590 * @return bool True if command is safe, false otherwise.
2591 */
2592 function wpvulnerability_validate_shell_command( $command ) {
2593 $command = trim( (string) $command );
2594
2595 if ( '' === $command ) {
2596 return false;
2597 }
2598
2599 // Whitelist of allowed command bases.
2600 $allowed_commands = array(
2601 'convert',
2602 'magick',
2603 'identify',
2604 'redis-server',
2605 'memcached',
2606 'sqlite3',
2607 'which',
2608 'apache2',
2609 'httpd',
2610 'nginx',
2611 'angie',
2612 'caddy',
2613 'php',
2614 'curl',
2615 );
2616
2617 // Extract the base command (first word).
2618 $parts = explode( ' ', $command );
2619 $base_command = $parts[0];
2620
2621 // Check if the base command is in the allowlist (exact match, not substring).
2622 $command_allowed = in_array( strtolower( $base_command ), $allowed_commands, true );
2623
2624 if ( ! $command_allowed ) {
2625 wpvulnerability_maybe_log(
2626 'Shell command validation failed: command not in whitelist',
2627 array(
2628 'command' => $command,
2629 'base' => $base_command,
2630 )
2631 );
2632 return false;
2633 }
2634
2635 // Dangerous patterns that should never appear in commands.
2636 $dangerous_patterns = array(
2637 ';',
2638 '|',
2639 '&',
2640 '`',
2641 '$(',
2642 '<',
2643 '>',
2644 "\n",
2645 "\r",
2646 "\0",
2647 );
2648
2649 foreach ( $dangerous_patterns as $pattern ) {
2650 if ( false !== strpos( $command, $pattern ) ) {
2651 wpvulnerability_maybe_log(
2652 'Shell command validation failed: dangerous pattern detected',
2653 array(
2654 'command' => $command,
2655 'pattern' => $pattern,
2656 )
2657 );
2658 return false;
2659 }
2660 }
2661
2662 return true;
2663 }
2664
2665 /**
2666 * Register the custom post type used to store shell_exec logs.
2667 *
2668 * @since 4.3.0
2669 *
2670 * @return void
2671 */
2672 function wpvulnerability_register_shell_log_post_type() {
2673 register_post_type(
2674 'wpv_shell_log',
2675 array(
2676 'labels' => array(
2677 'name' => __( 'WPVulnerability Shell Logs', 'wpvulnerability' ),
2678 'singular_name' => __( 'WPVulnerability Shell Log', 'wpvulnerability' ),
2679 ),
2680 'public' => false,
2681 'exclude_from_search' => true,
2682 'publicly_queryable' => false,
2683 'show_ui' => false,
2684 'show_in_menu' => false,
2685 'supports' => array( 'title', 'editor' ),
2686 )
2687 );
2688 }
2689 add_action( 'init', 'wpvulnerability_register_shell_log_post_type' );
2690
2691 /**
2692 * Log a shell_exec execution attempt.
2693 *
2694 * Stores information about shell command execution for security auditing.
2695 *
2696 * @since 4.3.0
2697 *
2698 * @param string $component Component making the request.
2699 * @param string $command Command that was executed or attempted.
2700 * @param string|null $output Command output (null if not executed).
2701 * @param bool $success Whether execution was successful.
2702 * @param string $reason Reason for success/failure.
2703 *
2704 * @return void
2705 */
2706 function wpvulnerability_log_shell_exec( $component, $command, $output, $success, $reason ) {
2707 if ( 0 >= wpvulnerability_log_retention_days() ) {
2708 return;
2709 }
2710
2711 $log_data = array(
2712 'component' => sanitize_text_field( $component ),
2713 'command' => sanitize_text_field( $command ),
2714 'output' => $output ? substr( sanitize_textarea_field( $output ), 0, 1000 ) : null,
2715 'success' => (bool) $success,
2716 'reason' => sanitize_text_field( $reason ),
2717 'timestamp' => current_time( 'mysql' ),
2718 'user' => is_user_logged_in() ? wp_get_current_user()->user_login : 'system',
2719 'ip' => ( isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown', // phpcs:ignore
2720 );
2721
2722 $log_id = wp_insert_post(
2723 array(
2724 'post_type' => 'wpv_shell_log',
2725 'post_status' => 'publish',
2726 'post_title' => sprintf( '[%s] %s', $component, $reason ),
2727 'post_content' => wp_slash( (string) wp_json_encode( $log_data ) ),
2728 'post_author' => 0,
2729 ),
2730 true
2731 );
2732
2733 if ( is_wp_error( $log_id ) ) {
2734 wpvulnerability_maybe_log(
2735 'Failed to create shell exec log entry',
2736 array(
2737 'error' => $log_id->get_error_message(),
2738 )
2739 );
2740 }
2741 }
2742
2743 /**
2744 * Retrieve shell execution logs for display in admin.
2745 *
2746 * @since 4.3.0
2747 *
2748 * @param int $per_page Optional. Number of logs to return. Default 50.
2749 * @param int $paged Optional. Page number to retrieve. Default 1.
2750 *
2751 * @return WP_Post[] Array of log posts.
2752 */
2753 function wpvulnerability_get_shell_exec_logs( $per_page = 50, $paged = 1 ) {
2754 $per_page = max( 1, (int) $per_page );
2755 $paged = max( 1, (int) $paged );
2756 $offset = ( $paged - 1 ) * $per_page;
2757
2758 return get_posts(
2759 array(
2760 'post_type' => 'wpv_shell_log',
2761 'post_status' => 'publish',
2762 'posts_per_page' => $per_page,
2763 'orderby' => 'date',
2764 'order' => 'DESC',
2765 'offset' => $offset,
2766 'no_found_rows' => true,
2767 )
2768 );
2769 }
2770
2771 /**
2772 * Count the total amount of stored shell exec log entries.
2773 *
2774 * @since 4.3.0
2775 *
2776 * @return int Number of log posts.
2777 */
2778 function wpvulnerability_count_shell_exec_logs() {
2779 $counts = wp_count_posts( 'wpv_shell_log' );
2780
2781 if ( ! isset( $counts->publish ) ) {
2782 return 0;
2783 }
2784
2785 return (int) $counts->publish;
2786 }
2787
2788 /**
2789 * Delete shell exec logs that fall outside of the configured retention window.
2790 *
2791 * @since 4.3.0
2792 *
2793 * @return void
2794 */
2795 function wpvulnerability_delete_expired_shell_logs() {
2796 $retention = wpvulnerability_log_retention_days();
2797 if ( $retention <= 0 ) {
2798 return;
2799 }
2800
2801 $threshold_ts = strtotime( '-' . $retention . ' days', time() );
2802 $threshold = gmdate( 'Y-m-d H:i:s', false !== $threshold_ts ? $threshold_ts : time() );
2803
2804 do {
2805 $logs = get_posts(
2806 array(
2807 'post_type' => 'wpv_shell_log',
2808 'post_status' => 'publish',
2809 'fields' => 'ids',
2810 'posts_per_page' => 100,
2811 'orderby' => 'date',
2812 'order' => 'ASC',
2813 'no_found_rows' => true,
2814 'cache_results' => false,
2815 'update_post_term_cache' => false,
2816 'update_post_meta_cache' => false,
2817 'date_query' => array(
2818 array(
2819 'before' => $threshold,
2820 'inclusive' => false,
2821 ),
2822 ),
2823 )
2824 );
2825
2826 if ( empty( $logs ) ) {
2827 break;
2828 }
2829
2830 foreach ( $logs as $log_id ) {
2831 wp_delete_post( $log_id, true );
2832 }
2833 $logs_count = count( $logs );
2834 } while ( $logs_count >= 100 );
2835 }
2836
2837 add_action( 'wpvulnerability_cleanup_logs', 'wpvulnerability_delete_expired_shell_logs' );
2838
2839 /**
2840 * Safe wrapper for shell_exec with validation and logging.
2841 *
2842 * This function wraps shell_exec calls with security checks and audit logging.
2843 *
2844 * @since 4.3.0
2845 *
2846 * @param string $component Component requesting shell execution.
2847 * @param string $command Command to execute.
2848 *
2849 * @return string|null Command output on success, null on failure.
2850 */
2851 function wpvulnerability_safe_shell_exec( $component, $command ) {
2852 // Check if shell_exec is allowed for this component.
2853 if ( ! wpvulnerability_can_shell_exec( $component ) ) {
2854 wpvulnerability_log_shell_exec( $component, $command, null, false, 'disabled' );
2855 return null;
2856 }
2857
2858 // Validate command structure.
2859 if ( ! wpvulnerability_validate_shell_command( $command ) ) {
2860 wpvulnerability_log_shell_exec( $component, $command, null, false, 'validation_failed' );
2861 return null;
2862 }
2863
2864 // Execute command.
2865 $output_raw = @shell_exec( escapeshellcmd( $command ) . ' 2>&1' ); // phpcs:ignore
2866 $output = ( false === $output_raw ) ? null : $output_raw;
2867
2868 // Log execution.
2869 wpvulnerability_log_shell_exec(
2870 $component,
2871 $command,
2872 $output,
2873 ! empty( $output ),
2874 'executed'
2875 );
2876
2877 return $output;
2878 }
2879