This commit is contained in:
Javier Casares 2026-07-09 06:20:06 +00:00
commit 31f7e97d39
26 changed files with 719 additions and 655 deletions

View file

@ -1,5 +1,26 @@
== Changelog ==
= [5.1.0] - 2026-07-08 =
**Security**
* `wpvulnerability_validate_shell_command()` now uses exact `in_array()` match instead of `stripos()` substring matching for the shell-command allowlist (defense-in-depth).
* `wpvulnerability_detect_php()`, `wpvulnerability_detect_curl()`, and `wpvulnerability_detect_webserver()` now route through `wpvulnerability_safe_shell_exec()`, so every software-detection shell call is validated and recorded in the Shell Execution Audit Log. Previously these called `shell_exec()` directly, bypassing the wrapper and the audit log. As a side effect this also fixes nginx/angie version detection: `escapeshellcmd()` was escaping the `2>&1` redirect, so stderr (where nginx prints its version) was never captured.
**Fixed**
* Multisite uninstall fatal error: `Uncaught Error: Undefined constant "WPVULNERABILITY_PLUGIN_BASE"` when "Delete all plugin data on uninstall" was enabled. `uninstall.php` now defines the constant before loading `wpvulnerability-run.php`.
* `WPVULNERABILITY_HIDE_*` constants now stop `shell_exec` detection for hidden components during scheduled scans and in the admin "Software Detection Methods" panel. Previously they only hid the results from the UI, so the audit log kept filling with "command not found" entries for components the administrator had explicitly deactivated.
* `wpvulnerability_detect_webserver()` no longer shell-probes a hidden web server via the sibling path: `WPVULNERABILITY_HIDE_NGINX` and `WPVULNERABILITY_HIDE_APACHE` now fully isolate the hidden server.
* Multisite cron requests on non-main subsites no longer load all plugin module files. No WPVulnerability cron events are scheduled on subsites, so the extra loading was wasted work.
* LiteSpeed / OpenLiteSpeed / Caddy shell detection in the WP_DEBUG diagnostic panel now works: the commands no longer use `2>/dev/null` (which the shell-command validator rejected), `caddy` was added to the allowlist, and `which` output is validated as a real path so "command not found" messages are not mistaken for a detection.
**Changed**
* `Network: true` added to the plugin header to declare network-aware multisite behaviour.
* Removed the unused `$plugin_status` parameter from `wpvulnerability_plugin_info_after()`; the PHPCS suite now passes with zero warnings.
* Multisite network dashboard: the "Site Health" footer link pointed to `wp-admin/network/site-health.php`, which does not exist (Site Health is a per-site screen). It now links to the main site's `wp-admin/site-health.php`.
= [5.0.1] - 2026-06-02 =
**Fixed**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -3,9 +3,9 @@ Contributors: javiercasares, davidperez, lbonomo, alexclassroom
Tags: security, vulnerability, site-health
Requires at least: 5.6
Tested up to: 7.1
Stable tag: 5.0.1
Stable tag: 5.1.0
Requires PHP: 7.0
Version: 5.0.1
Version: 5.1.0
License: GPL-3.0-or-later
License URI: https://spdx.org/licenses/GPL-3.0-or-later.html
@ -201,6 +201,27 @@ First of all, peace of mind. Investigate what the vulnerability is and, above al
== Changelog ==
= [5.1.0] - 2026-07-08 =
**Security**
* `wpvulnerability_validate_shell_command()` now uses exact `in_array()` match instead of `stripos()` substring matching for the shell-command allowlist (defense-in-depth).
* `wpvulnerability_detect_php()`, `wpvulnerability_detect_curl()`, and `wpvulnerability_detect_webserver()` now route through `wpvulnerability_safe_shell_exec()`, so every software-detection shell call is validated and recorded in the Shell Execution Audit Log. Previously these called `shell_exec()` directly, bypassing the wrapper and the audit log. As a side effect this also fixes nginx/angie version detection: `escapeshellcmd()` was escaping the `2>&1` redirect, so stderr (where nginx prints its version) was never captured.
**Fixed**
* Multisite uninstall fatal error: `Uncaught Error: Undefined constant "WPVULNERABILITY_PLUGIN_BASE"` when "Delete all plugin data on uninstall" was enabled. `uninstall.php` now defines the constant before loading `wpvulnerability-run.php`.
* `WPVULNERABILITY_HIDE_*` constants now stop `shell_exec` detection for hidden components during scheduled scans and in the admin "Software Detection Methods" panel. Previously they only hid the results from the UI, so the audit log kept filling with "command not found" entries for components the administrator had explicitly deactivated.
* `wpvulnerability_detect_webserver()` no longer shell-probes a hidden web server via the sibling path: `WPVULNERABILITY_HIDE_NGINX` and `WPVULNERABILITY_HIDE_APACHE` now fully isolate the hidden server.
* Multisite cron requests on non-main subsites no longer load all plugin module files. No WPVulnerability cron events are scheduled on subsites, so the extra loading was wasted work.
* LiteSpeed / OpenLiteSpeed / Caddy shell detection in the WP_DEBUG diagnostic panel now works: the commands no longer use `2>/dev/null` (which the shell-command validator rejected), `caddy` was added to the allowlist, and `which` output is validated as a real path so "command not found" messages are not mistaken for a detection.
**Changed**
* `Network: true` added to the plugin header to declare network-aware multisite behaviour.
* Removed the unused `$plugin_status` parameter from `wpvulnerability_plugin_info_after()`; the PHPCS suite now passes with zero warnings.
* Multisite network dashboard: the "Site Health" footer link pointed to `wp-admin/network/site-health.php`, which does not exist (Site Health is a per-site screen). It now links to the main site's `wp-admin/site-health.php`.
= [5.0.1] - 2026-06-02 =
**Fixed**

View file

@ -28,9 +28,15 @@ if ( ! $wpvulnerability_delete_on_uninstall ) {
}
// User opted in: load the uninstall routine from the main plugin file.
// The main plugin file is not loaded during uninstall, so the constants that
// wpvulnerability-run.php references at the top level must be defined here first
// to avoid a fatal "Undefined constant" error (see wpvulnerability-run.php top-level add_filter).
if ( ! defined( 'WPVULNERABILITY_PLUGIN_PATH' ) ) {
define( 'WPVULNERABILITY_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
}
if ( ! defined( 'WPVULNERABILITY_PLUGIN_BASE' ) ) {
define( 'WPVULNERABILITY_PLUGIN_BASE', plugin_basename( WPVULNERABILITY_PLUGIN_PATH . 'wpvulnerability.php' ) );
}
require_once WPVULNERABILITY_PLUGIN_PATH . 'wpvulnerability-run.php';

View file

@ -3036,7 +3036,7 @@ function wpvulnerability_display_security_status() {
<?php if ( $log_retention > 0 ) : ?>
<span class="wpvulnerability-status-badge success">
<?php
/* translators: %d: number of days */
/* translators: %d: Number of days. */
echo esc_html( sprintf( __( 'Enabled (%d days)', 'wpvulnerability' ), $log_retention ) );
?>
</span>
@ -3110,6 +3110,12 @@ function wpvulnerability_display_detection_methods() {
foreach ( array_keys( $components ) as $component ) {
$detection = null;
// Do not run detection for components hidden via the analysis settings or
// a WPVULNERABILITY_HIDE_* constant: they must not trigger shell_exec.
if ( ! wpvulnerability_analyze_filter( $component ) ) {
continue;
}
switch ( $component ) {
case 'imagemagick':
$detection = wpvulnerability_detect_imagemagick();
@ -3638,7 +3644,7 @@ function wpvulnerability_render_debug_section_config() {
<td>
<?php
$retention_days = isset( $config['log_retention_days'] ) && is_scalar( $config['log_retention_days'] ) ? absint( (int) $config['log_retention_days'] ) : 14;
/* translators: %d: number of days */
/* translators: %d: Number of days. */
echo esc_html( sprintf( _n( '%d day', '%d days', $retention_days, 'wpvulnerability' ), $retention_days ) );
?>
</td>

View file

@ -2736,7 +2736,7 @@ function wpvulnerability_admin_dashboard_content() {
// Footer links.
echo '<div class="wpvuln-footer">';
if ( version_compare( get_bloginfo( 'version' ), '5.2', '>=' ) ) {
echo '<a href="' . esc_url( network_admin_url( 'site-health.php' ) ) . '">' . esc_html( __( 'Site Health', 'wpvulnerability' ) ) . '</a> | ';
echo '<a href="' . esc_url( get_admin_url( get_main_site_id(), 'site-health.php' ) ) . '">' . esc_html( __( 'Site Health', 'wpvulnerability' ) ) . '</a> | ';
}
echo '<a href="' . esc_url( network_admin_url( 'settings.php?page=wpvulnerability-options' ) ) . '">' . esc_html( __( 'Settings', 'wpvulnerability' ) ) . '</a>';
echo '</div>';
@ -3215,7 +3215,7 @@ function wpvulnerability_display_security_status() {
<?php if ( $log_retention > 0 ) : ?>
<span class="wpvulnerability-status-badge success">
<?php
/* translators: %d: number of days */
/* translators: %d: Number of days. */
echo esc_html( sprintf( __( 'Enabled (%d days)', 'wpvulnerability' ), $log_retention ) );
?>
</span>
@ -3289,6 +3289,12 @@ function wpvulnerability_display_detection_methods() {
foreach ( array_keys( $components ) as $component ) {
$detection = null;
// Do not run detection for components hidden via the analysis settings or
// a WPVULNERABILITY_HIDE_* constant: they must not trigger shell_exec.
if ( ! wpvulnerability_analyze_filter( $component ) ) {
continue;
}
switch ( $component ) {
case 'imagemagick':
$detection = wpvulnerability_detect_imagemagick();
@ -3834,7 +3840,7 @@ function wpvulnerability_render_debug_section_config() {
<td>
<?php
$retention_days = isset( $config['log_retention_days'] ) && is_scalar( $config['log_retention_days'] ) ? absint( (int) $config['log_retention_days'] ) : 14;
/* translators: %d: number of days */
/* translators: %d: Number of days. */
echo esc_html( sprintf( _n( '%d day', '%d days', $retention_days, 'wpvulnerability' ), $retention_days ) );
?>
</td>

View file

@ -173,20 +173,23 @@ function wpvulnerability_debug_detect_webserver() {
// Try shell commands for additional detection if shell_exec is allowed.
if ( 'Unknown' === $webserver['name'] && function_exists( 'wpvulnerability_safe_shell_exec' ) ) {
// Try LiteSpeed.
$litespeed_test = wpvulnerability_safe_shell_exec( 'apache', 'which litespeed 2>/dev/null' );
if ( ! empty( $litespeed_test ) ) {
// Try LiteSpeed. `which` prints the binary path on success; on failure it
// prints a "which: no litespeed ..." message to stderr. The wrapper merges
// stderr into stdout (2>&1), so validate that the output is actually a path.
$litespeed_test = wpvulnerability_safe_shell_exec( 'apache', 'which litespeed' );
if ( ! empty( $litespeed_test ) && 0 === strpos( trim( (string) $litespeed_test ), '/' ) ) {
$webserver['name'] = 'LiteSpeed';
}
// Try OpenLiteSpeed.
$openlitespeed_test = wpvulnerability_safe_shell_exec( 'apache', 'which openlitespeed 2>/dev/null' );
if ( ! empty( $openlitespeed_test ) ) {
$openlitespeed_test = wpvulnerability_safe_shell_exec( 'apache', 'which openlitespeed' );
if ( ! empty( $openlitespeed_test ) && 0 === strpos( trim( (string) $openlitespeed_test ), '/' ) ) {
$webserver['name'] = 'OpenLiteSpeed';
}
// Try Caddy.
$caddy_version = wpvulnerability_safe_shell_exec( 'apache', 'caddy version 2>/dev/null' );
// Try Caddy. The version regex guards against false positives: a "command
// not found" message does not match a version pattern.
$caddy_version = wpvulnerability_safe_shell_exec( 'apache', 'caddy version' );
if ( ! empty( $caddy_version ) && preg_match( '/v?(\d+\.\d+\.\d+)/', $caddy_version, $version_match ) ) {
$webserver['name'] = 'Caddy';
$webserver['version'] = $version_match[1];

View file

@ -1342,8 +1342,8 @@ function wpvulnerability_detect_php() {
// Second method: use system commands if the first fails and shell_exec is available.
if ( empty( $version ) && wpvulnerability_can_shell_exec() ) {
// Command to check PHP version.
$version_output = shell_exec( escapeshellcmd( 'php -v' ) ); // phpcs:ignore
// Command to check PHP version (routed through the safe wrapper for validation + audit logging).
$version_output = wpvulnerability_safe_shell_exec( 'php', 'php -v' );
if ( ! empty( $version_output ) && preg_match( '/PHP\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) {
$version = $matches[1];
@ -1377,8 +1377,8 @@ function wpvulnerability_detect_curl() {
// Second method: use system commands if the first fails and shell_exec is available.
if ( empty( $version ) && wpvulnerability_can_shell_exec() ) {
// Command to check cURL version.
$version_output = shell_exec( escapeshellcmd( 'curl --version' ) ); // phpcs:ignore
// Command to check cURL version (routed through the safe wrapper for validation + audit logging).
$version_output = wpvulnerability_safe_shell_exec( 'curl', 'curl --version' );
if ( ! empty( $version_output ) && preg_match( '/curl\s+(\d+\.\d+(?:\.\d+)?(?:-\d+)?)/i', $version_output, $matches ) ) {
$version = $matches[1];
@ -1642,10 +1642,10 @@ function wpvulnerability_detect_webserver() {
// If the version is not detected, try to get it from the OS.
if ( empty( $webserver['version'] ) && wpvulnerability_can_shell_exec() ) {
if ( 'apache' === $webserver['id'] ) {
$apache_version = shell_exec( escapeshellcmd( 'apache2 -v 2>&1' ) ); // phpcs:ignore
if ( 'apache' === $webserver['id'] && wpvulnerability_analyze_filter( 'apache' ) ) {
$apache_version = wpvulnerability_safe_shell_exec( 'apache', 'apache2 -v' );
if ( empty( $apache_version ) ) {
$apache_version = shell_exec( escapeshellcmd( 'httpd -v 2>&1' ) ); // phpcs:ignore
$apache_version = wpvulnerability_safe_shell_exec( 'apache', 'httpd -v' );
}
if ( ! empty( $apache_version ) && preg_match( '/Apache\/([\d.]+)/', $apache_version, $version_matches ) ) {
$webserver['version'] = $version_matches[1];
@ -1654,8 +1654,8 @@ function wpvulnerability_detect_webserver() {
$webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] );
}
}
} elseif ( 'nginx' === $webserver['id'] ) {
$nginx_version = shell_exec( escapeshellcmd( 'nginx -v 2>&1' ) ); // phpcs:ignore
} elseif ( 'nginx' === $webserver['id'] && wpvulnerability_analyze_filter( 'nginx' ) ) {
$nginx_version = wpvulnerability_safe_shell_exec( 'nginx', 'nginx -v' );
if ( ! empty( $nginx_version ) && preg_match( '/nginx\/([\d.]+)/', $nginx_version, $version_matches ) ) {
$webserver['version'] = $version_matches[1];
// Replace "-N" at the end with ".N" if present.
@ -1663,7 +1663,7 @@ function wpvulnerability_detect_webserver() {
$webserver['version'] = preg_replace( '/-(\d+)$/', '.' . $suffix_matches[1], $webserver['version'] );
}
} else {
$angie_version = shell_exec( escapeshellcmd( 'angie -v 2>&1' ) ); // phpcs:ignore
$angie_version = wpvulnerability_safe_shell_exec( 'nginx', 'angie -v' );
if ( ! empty( $angie_version ) && preg_match( '/angie\/([\d.]+)/', $angie_version, $version_matches ) ) {
$webserver['version'] = $version_matches[1];
// Replace "-N" at the end with ".N" if present.
@ -2613,6 +2613,7 @@ function wpvulnerability_validate_shell_command( $command ) {
'httpd',
'nginx',
'angie',
'caddy',
'php',
'curl',
);
@ -2621,14 +2622,8 @@ function wpvulnerability_validate_shell_command( $command ) {
$parts = explode( ' ', $command );
$base_command = $parts[0];
// Check if base command is in whitelist.
$command_allowed = false;
foreach ( $allowed_commands as $allowed ) {
if ( false !== stripos( $base_command, $allowed ) ) {
$command_allowed = true;
break;
}
}
// Check if the base command is in the allowlist (exact match, not substring).
$command_allowed = in_array( strtolower( $base_command ), $allowed_commands, true );
if ( ! $command_allowed ) {
wpvulnerability_maybe_log(

View file

@ -92,11 +92,10 @@ function wpvulnerability_plugins_get_current_signature() {
*
* @param string $plugin_file Main plugin folder/file name.
* @param array<string, mixed> $plugin_data Plugin data array containing information about the plugin.
* @param string $plugin_status Plugin status (active, inactive, etc.).
*
* @return void
*/
function wpvulnerability_plugin_info_after( $plugin_file, $plugin_data, $plugin_status = '' ) {
function wpvulnerability_plugin_info_after( $plugin_file, $plugin_data ) {
// Retrieve the vulnerabilities for all plugins from the options table and decode the JSON.
$raw_plugins = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' );
@ -112,7 +111,7 @@ function wpvulnerability_plugin_info_after( $plugin_file, $plugin_data, $plugin_
// Generate the vulnerability notice message with the plugin name.
$message = sprintf(
/* translators: 1: Plugin name */
/* translators: 1: Plugin or theme name. */
__( '%1$s has a known vulnerability that may be affecting your installed version.', 'wpvulnerability' ),
wp_kses( is_scalar( $plugin_data['Name'] ) ? (string) $plugin_data['Name'] : '', 'strip' )
);
@ -649,7 +648,7 @@ function wpvulnerability_plugin_page() {
if ( is_array( $plugin_data ) && isset( $plugin_data['vulnerable'] ) ) {
$vulnerable_raw = $plugin_data['vulnerable'];
if ( 1 === ( is_scalar( $vulnerable_raw ) ? intval( $vulnerable_raw ) : 0 ) ) {
add_action( 'after_plugin_row_' . $file_path, 'wpvulnerability_plugin_info_after', 10, 3 );
add_action( 'after_plugin_row_' . $file_path, 'wpvulnerability_plugin_info_after', 10, 2 );
}
}
}

View file

@ -264,6 +264,14 @@ function wpvulnerability_software_get_vulnerabilities( $software ) {
*/
function wpvulnerability_get_vulnerabilities_clean( $software ) {
// Skip detection for components the administrator has hidden (either via the
// analysis settings or a WPVULNERABILITY_HIDE_* wp-config constant). This
// prevents shell_exec from running for components that are meant to be
// deactivated, matching the behaviour of the core/plugins/themes clean functions.
if ( ! wpvulnerability_analyze_filter( $software ) ) {
return;
}
// Update the installed software cache.
wpvulnerability_get_installed( $software );
}

View file

@ -78,7 +78,7 @@ function wpvulnerability_theme_info_after( $theme_file, $theme_data ) {
// Generate the vulnerability notice message with the theme name.
$message = sprintf(
/* translators: 1: theme name */
/* translators: 1: Plugin or theme name. */
__( '%1$s has a known vulnerability that may be affecting your installed version.', 'wpvulnerability' ),
wp_kses( (string) $theme_data->get( 'Name' ), 'strip' )
);

View file

@ -5,17 +5,18 @@
* Description: Receive information about possible vulnerabilities in your WordPress from WordPress Vulnerability Database API.
* Requires at least: 5.6
* Requires PHP: 7.0
* Version: 5.0.1
* Version: 5.1.0
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* License: GPL-3.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-3.0.txt
* Text Domain: wpvulnerability
* Domain Path: /languages
* Network: true
*
* @package WPVulnerability
*
* @version 5.0.1
* @version 5.1.0
*/
defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
@ -23,7 +24,7 @@ defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
/**
* Set some constants that I can change in future versions.
*/
define( 'WPVULNERABILITY_PLUGIN_VERSION', '5.0.1' );
define( 'WPVULNERABILITY_PLUGIN_VERSION', '5.1.0' );
define( 'WPVULNERABILITY_API_HOST', 'https://www.wpvulnerability.net/' );
/**
@ -119,7 +120,7 @@ if (
( ! is_multisite() && is_admin() ) ||
( is_multisite() && ( is_network_admin() || is_main_site() ) ) ||
( defined( 'WP_CLI' ) && WP_CLI ) || // @phpstan-ignore booleanAnd.rightAlwaysFalse (WP_CLI is false in bootstrap but true at runtime)
wp_doing_cron()
( wp_doing_cron() && ( ! is_multisite() || is_main_site() ) ) // Cron events only run on the main site (see wpvulnerability-schedule.php); skip full module loading on subsite cron.
) {
require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-run.php';