| 1 |
<?php |
| 2 |
/** |
| 3 |
* Plugin functions |
| 4 |
* |
| 5 |
* @package WPVulnerability |
| 6 |
* |
| 7 |
* @version 2.0.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
defined( 'ABSPATH' ) || die( 'No script kiddies please!' ); |
| 11 |
|
| 12 |
/** |
| 13 |
* Enqueues the admin JavaScript for plugin update interactions. |
| 14 |
* |
| 15 |
* @since 4.1.0 |
| 16 |
* |
| 17 |
* @param string $hook Current admin page hook. |
| 18 |
* |
| 19 |
* @return void |
| 20 |
*/ |
| 21 |
function wpvulnerability_plugins_admin_enqueue_scripts( $hook ) { |
| 22 |
if ( 'plugins.php' !== $hook && 'plugins-network' !== $hook ) { |
| 23 |
return; |
| 24 |
} |
| 25 |
|
| 26 |
wp_enqueue_script( |
| 27 |
'wpvulnerability-admin-js', |
| 28 |
WPVULNERABILITY_PLUGIN_URL . 'assets/admin.js', |
| 29 |
array( 'jquery' ), |
| 30 |
WPVULNERABILITY_PLUGIN_VERSION, |
| 31 |
true |
| 32 |
); |
| 33 |
} |
| 34 |
add_action( 'admin_enqueue_scripts', 'wpvulnerability_plugins_admin_enqueue_scripts' ); |
| 35 |
|
| 36 |
/** |
| 37 |
* Generate a deterministic signature for the installed plugins list. |
| 38 |
* |
| 39 |
* @since 4.1.2 |
| 40 |
* |
| 41 |
* @param array<string,array<string,mixed>> $plugins List of plugins returned by get_plugins(). |
| 42 |
* @return string Hash representing the installed plugins and their versions. |
| 43 |
*/ |
| 44 |
function wpvulnerability_plugins_generate_signature( $plugins ) { |
| 45 |
$normalized = array(); |
| 46 |
|
| 47 |
foreach ( $plugins as $file_path => $plugin_data ) { |
| 48 |
$plugin_file = sanitize_text_field( (string) $file_path ); |
| 49 |
$version = ''; |
| 50 |
|
| 51 |
if ( isset( $plugin_data['Version'] ) ) { |
| 52 |
$v_raw = $plugin_data['Version']; |
| 53 |
$version = sanitize_text_field( is_scalar( $v_raw ) ? (string) $v_raw : '' ); |
| 54 |
} |
| 55 |
|
| 56 |
$normalized[ $plugin_file ] = $version; |
| 57 |
} |
| 58 |
|
| 59 |
ksort( $normalized ); |
| 60 |
|
| 61 |
$encoded = wp_json_encode( $normalized ); |
| 62 |
return md5( false !== $encoded ? $encoded : '' ); |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* Retrieve the signature of the currently installed plugins. |
| 67 |
* |
| 68 |
* @since 4.1.2 |
| 69 |
* |
| 70 |
* @return string Hash representing the installed plugins and their versions. |
| 71 |
*/ |
| 72 |
function wpvulnerability_plugins_get_current_signature() { |
| 73 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 74 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 75 |
} |
| 76 |
|
| 77 |
return wpvulnerability_plugins_generate_signature( get_plugins() ); |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Adds a vulnerability notice under vulnerable plugins. |
| 82 |
* |
| 83 |
* This function retrieves the vulnerability data for the specified plugin from the WordPress options table |
| 84 |
* and displays a detailed notice below the plugin's row on the plugins management page in the WordPress admin area. |
| 85 |
* The notice includes information about the plugin's vulnerabilities, such as affected versions, severity, CVSS scores, |
| 86 |
* and links to sources. |
| 87 |
* |
| 88 |
* The function is applicable both in single-site and multisite installations. In a multisite setup, the notice |
| 89 |
* is displayed only in the network admin area or in the site admin area of individual sites. |
| 90 |
* |
| 91 |
* @since 2.0.0 |
| 92 |
* |
| 93 |
* @param string $plugin_file Main plugin folder/file name. |
| 94 |
* @param array<string, mixed> $plugin_data Plugin data array containing information about the plugin. |
| 95 |
* |
| 96 |
* @return void |
| 97 |
*/ |
| 98 |
function wpvulnerability_plugin_info_after( $plugin_file, $plugin_data ) { |
| 99 |
|
| 100 |
// Retrieve the vulnerabilities for all plugins from the options table and decode the JSON. |
| 101 |
$raw_plugins = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' ); |
| 102 |
$plugin_vulnerabilities = json_decode( is_string( $raw_plugins ) ? $raw_plugins : '', true ); |
| 103 |
if ( ! is_array( $plugin_vulnerabilities ) ) { |
| 104 |
$plugin_vulnerabilities = array(); |
| 105 |
} |
| 106 |
|
| 107 |
if ( ( is_multisite() && is_network_admin() ) || ! is_multisite() ) { |
| 108 |
|
| 109 |
// Determine whether the plugin is active and add an appropriate CSS class to the table row. |
| 110 |
$tr_class = is_plugin_active( $plugin_file ) ? 'active' : ''; |
| 111 |
|
| 112 |
// Generate the vulnerability notice message with the plugin name. |
| 113 |
$message = sprintf( |
| 114 |
/* translators: 1: Plugin or theme name. */ |
| 115 |
__( '%1$s has a known vulnerability that may be affecting your installed version.', 'wpvulnerability' ), |
| 116 |
wp_kses( is_scalar( $plugin_data['Name'] ) ? (string) $plugin_data['Name'] : '', 'strip' ) |
| 117 |
); |
| 118 |
|
| 119 |
// Begin generating the table row HTML markup with appropriate CSS classes and the vulnerability notice message. |
| 120 |
$information = '<tr class="wpvulnerability ' . esc_attr( $tr_class ) . '">'; |
| 121 |
$information .= '<td colspan="4">'; |
| 122 |
$information .= '<p class="text-red"><img src="' . esc_url( WPVULNERABILITY_PLUGIN_URL ) . 'assets/icon.svg" style="height: 16px; vertical-align: text-top; width: 16px;" alt="" title="WPVulnerability"> <strong>' . esc_html( $message ) . '</strong>'; |
| 123 |
$information .= '</p>'; |
| 124 |
$information .= '<table>'; |
| 125 |
|
| 126 |
// Loop through all vulnerabilities for the current plugin and add their details to the table row HTML markup. |
| 127 |
$pf_entry = isset( $plugin_vulnerabilities[ $plugin_file ] ) && is_array( $plugin_vulnerabilities[ $plugin_file ] ) ? $plugin_vulnerabilities[ $plugin_file ] : array(); |
| 128 |
$vulnerabilities = isset( $pf_entry['vulnerabilities'] ) && is_array( $pf_entry['vulnerabilities'] ) ? $pf_entry['vulnerabilities'] : array(); |
| 129 |
|
| 130 |
foreach ( $vulnerabilities as $vulnerability ) { |
| 131 |
if ( ! is_array( $vulnerability ) ) { |
| 132 |
continue; |
| 133 |
} |
| 134 |
|
| 135 |
$vuln_versions_raw = $vulnerability['versions'] ?? ''; |
| 136 |
$vuln_versions = is_scalar( $vuln_versions_raw ) ? (string) $vuln_versions_raw : ''; |
| 137 |
$vuln_closed_raw = $vulnerability['closed'] ?? 0; |
| 138 |
$vuln_closed = is_scalar( $vuln_closed_raw ) ? intval( $vuln_closed_raw ) : 0; |
| 139 |
$vuln_unfixed_raw = $vulnerability['unfixed'] ?? 0; |
| 140 |
$vuln_unfixed = is_scalar( $vuln_unfixed_raw ) ? intval( $vuln_unfixed_raw ) : 0; |
| 141 |
$vuln_impact = isset( $vulnerability['impact'] ) && is_array( $vulnerability['impact'] ) ? $vulnerability['impact'] : array(); |
| 142 |
$vuln_cvss = isset( $vuln_impact['cvss'] ) && is_array( $vuln_impact['cvss'] ) ? $vuln_impact['cvss'] : array(); |
| 143 |
$vuln_cvss2 = isset( $vuln_impact['cvss2'] ) && is_array( $vuln_impact['cvss2'] ) ? $vuln_impact['cvss2'] : array(); |
| 144 |
$vuln_cvss3 = isset( $vuln_impact['cvss3'] ) && is_array( $vuln_impact['cvss3'] ) ? $vuln_impact['cvss3'] : array(); |
| 145 |
$vuln_cvss4 = isset( $vuln_impact['cvss4'] ) && is_array( $vuln_impact['cvss4'] ) ? $vuln_impact['cvss4'] : array(); |
| 146 |
$vuln_ssvc = isset( $vuln_impact['ssvc'] ) && is_array( $vuln_impact['ssvc'] ) ? $vuln_impact['ssvc'] : array(); |
| 147 |
$vuln_cwe = isset( $vuln_impact['cwe'] ) && is_array( $vuln_impact['cwe'] ) ? $vuln_impact['cwe'] : array(); |
| 148 |
$vuln_sources = isset( $vulnerability['source'] ) && is_array( $vulnerability['source'] ) ? $vulnerability['source'] : array(); |
| 149 |
|
| 150 |
$kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] ); |
| 151 |
$exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : ''; |
| 152 |
$automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : ''; |
| 153 |
$kev_date_raw = $vuln_ssvc['kev_date'] ?? null; |
| 154 |
$kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null; |
| 155 |
$epss_raw = $vuln_impact['epss'] ?? null; |
| 156 |
$epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null; |
| 157 |
$description = wpvulnerability_get_source_description( $vuln_sources ); |
| 158 |
|
| 159 |
// Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss. |
| 160 |
$score_raw = null; |
| 161 |
$sev_raw = null; |
| 162 |
foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) { |
| 163 |
if ( empty( $cvss_c ) ) { |
| 164 |
continue; |
| 165 |
} |
| 166 |
$s_raw = $cvss_c['score'] ?? null; |
| 167 |
$v_raw = $cvss_c['severity'] ?? null; |
| 168 |
$s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; |
| 169 |
$v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; |
| 170 |
if ( null !== $s || null !== $v ) { |
| 171 |
$score_raw = $s; |
| 172 |
$sev_raw = $v; |
| 173 |
break; |
| 174 |
} |
| 175 |
} |
| 176 |
|
| 177 |
$what = array(); |
| 178 |
foreach ( $vuln_cwe as $vulnerability_cwe ) { |
| 179 |
if ( ! is_array( $vulnerability_cwe ) ) { |
| 180 |
continue; |
| 181 |
} |
| 182 |
$cwe_name = $vulnerability_cwe['name'] ?? ''; |
| 183 |
$cwe_desc = $vulnerability_cwe['description'] ?? ''; |
| 184 |
$what[] = '<div><b>' . wp_kses( is_scalar( $cwe_name ) ? (string) $cwe_name : '', 'strip' ) . '</b></div><div><i>' . esc_html( is_scalar( $cwe_desc ) ? (string) $cwe_desc : '' ) . '</i></div>'; |
| 185 |
} |
| 186 |
|
| 187 |
$version_display = wpvulnerability_clean_version_range( $vuln_versions ); |
| 188 |
$source_pills = wpvulnerability_render_source_pills( $vuln_sources ); |
| 189 |
$score_badge = wpvulnerability_render_score_badge( $score_raw, $sev_raw, $epss ); |
| 190 |
|
| 191 |
$information .= '<tr>'; |
| 192 |
// Version range column. |
| 193 |
$information .= '<td style="max-width: 256px; min-width: 96px; vertical-align: top; padding-top: 6px;">'; |
| 194 |
$information .= '' !== $version_display |
| 195 |
? '<span class="wpvuln-versions">' . $version_display . '</span>' |
| 196 |
: '—'; |
| 197 |
$information .= '</td>'; |
| 198 |
// Details column. |
| 199 |
$information .= '<td>'; |
| 200 |
$show_active = $kev || 'active' === $exploitation; |
| 201 |
$show_poc = 'poc' === $exploitation; |
| 202 |
$show_auto = 'yes' === $automatable; |
| 203 |
if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) { |
| 204 |
$information .= '<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:5px;">'; |
| 205 |
if ( $show_active ) { |
| 206 |
$information .= '<span class="wpvuln-kev-label">⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); |
| 207 |
if ( $kev && null !== $kev_date ) { |
| 208 |
$information .= ' · ' . esc_html( $kev_date ); |
| 209 |
} |
| 210 |
$information .= '</span>'; |
| 211 |
} |
| 212 |
if ( $show_poc ) { |
| 213 |
$information .= '<span class="wpvuln-poc-label">⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . '</span>'; |
| 214 |
} |
| 215 |
if ( $show_auto ) { |
| 216 |
$information .= '<span class="wpvuln-auto-label">⚙ ' . esc_html__( 'Automatable', 'wpvulnerability' ) . '</span>'; |
| 217 |
} |
| 218 |
if ( '' !== $score_badge ) { |
| 219 |
$information .= $score_badge; |
| 220 |
} |
| 221 |
$information .= '</div>'; |
| 222 |
} |
| 223 |
if ( null !== $description ) { |
| 224 |
$information .= '<div style="padding-bottom: 5px;">' . esc_html( $description ) . '</div>'; |
| 225 |
} |
| 226 |
if ( $vuln_closed || $vuln_unfixed ) { |
| 227 |
$information .= '<div style="padding-bottom: 5px;">'; |
| 228 |
if ( $vuln_closed ) { |
| 229 |
$information .= '<div class="text-red">' . esc_html__( 'This plugin is closed. Please replace it with another.', 'wpvulnerability' ) . '</div>'; |
| 230 |
} |
| 231 |
if ( $vuln_unfixed ) { |
| 232 |
$information .= '<div class="text-red">' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming plugin updates.', 'wpvulnerability' ) . '</div>'; |
| 233 |
} |
| 234 |
$information .= '</div>'; |
| 235 |
} |
| 236 |
if ( ! empty( $what ) ) { |
| 237 |
$information .= '<div style="padding-bottom: 5px;">'; |
| 238 |
foreach ( $what as $w ) { |
| 239 |
$information .= $w; |
| 240 |
} |
| 241 |
$information .= '</div>'; |
| 242 |
} |
| 243 |
if ( '' !== $source_pills ) { |
| 244 |
$information .= '<div class="wpvuln-refs-row"><span class="wpvuln-refs-label">' . esc_html__( 'References:', 'wpvulnerability' ) . '</span>'; |
| 245 |
$information .= $source_pills; |
| 246 |
$information .= '</div>'; |
| 247 |
} |
| 248 |
$information .= '</td>'; |
| 249 |
$information .= '</tr>'; |
| 250 |
} |
| 251 |
|
| 252 |
$information .= '</table>'; |
| 253 |
$information .= '</td>'; |
| 254 |
$information .= '</tr>'; |
| 255 |
|
| 256 |
echo $information; // phpcs:ignore |
| 257 |
} |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* Retrieves vulnerabilities for a given plugin and updates its data. |
| 262 |
* |
| 263 |
* @since 2.0.0 |
| 264 |
* |
| 265 |
* @param array<string, mixed> $plugin_data The plugin data array. |
| 266 |
* @param string $file_path The path to the plugin file. |
| 267 |
* |
| 268 |
* @return array<string, mixed> The updated plugin data array. |
| 269 |
*/ |
| 270 |
function wpvulnerability_get_fresh_plugin_vulnerabilities( $plugin_data, $file_path ) { |
| 271 |
|
| 272 |
$plugin_slug = null; |
| 273 |
|
| 274 |
// Extract the folder name from the file path. |
| 275 |
$folder_name = explode( '/', $file_path ); |
| 276 |
|
| 277 |
// Use the first folder segment as the plugin slug. |
| 278 |
$plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' ); |
| 279 |
unset( $folder_name ); |
| 280 |
|
| 281 |
// If the plugin slug is empty, fall back to the TextDomain key. |
| 282 |
if ( empty( $plugin_slug ) && isset( $plugin_data['TextDomain'] ) ) { |
| 283 |
$td_raw = $plugin_data['TextDomain']; |
| 284 |
$plugin_slug = wp_kses( is_scalar( $td_raw ) ? (string) $td_raw : '', 'strip' ); |
| 285 |
} |
| 286 |
|
| 287 |
// Get the plugin version from the plugin data. |
| 288 |
$plugin_version_raw = $plugin_data['Version'] ?? ''; |
| 289 |
$plugin_version = wp_kses( is_scalar( $plugin_version_raw ) ? (string) $plugin_version_raw : '', 'strip' ); |
| 290 |
|
| 291 |
// Initialize vulnerability-related fields. |
| 292 |
$plugin_data['vulnerabilities'] = null; |
| 293 |
$plugin_data['vulnerable'] = 0; |
| 294 |
|
| 295 |
// Retrieve vulnerabilities for the plugin using its slug and version. |
| 296 |
if ( ! empty( $plugin_slug ) ) { |
| 297 |
|
| 298 |
$plugin_api_response = wpvulnerability_get_plugin( $plugin_slug, $plugin_version, 0, 0 ); |
| 299 |
|
| 300 |
// If vulnerabilities are found, update the plugin data accordingly. |
| 301 |
if ( ! empty( $plugin_api_response ) ) { |
| 302 |
|
| 303 |
$plugin_data['slug'] = $plugin_slug; |
| 304 |
$plugin_data['vulnerabilities'] = $plugin_api_response; |
| 305 |
$plugin_data['vulnerable'] = 1; |
| 306 |
|
| 307 |
} |
| 308 |
} |
| 309 |
|
| 310 |
return $plugin_data; |
| 311 |
} |
| 312 |
|
| 313 |
/** |
| 314 |
* Retrieves updated data for a specified plugin, potentially including vulnerability information. |
| 315 |
* |
| 316 |
* @since 3.1.0 |
| 317 |
* |
| 318 |
* @param array<string, mixed> $plugin_data The original plugin data array, expected to contain keys like 'TextDomain' and 'Version'. |
| 319 |
* @param string $file_path The file path of the plugin, used to determine the plugin's slug if 'TextDomain' is not specified in `$plugin_data`. |
| 320 |
* |
| 321 |
* @return array<mixed>|null Updated plugin data array with fresh information or null if the plugin slug cannot be determined or no updated information is available. |
| 322 |
*/ |
| 323 |
function wpvulnerability_get_fresh_plugin_data( $plugin_data, $file_path ) { |
| 324 |
|
| 325 |
$plugin_slug = ''; |
| 326 |
|
| 327 |
// Extract the folder name from the file path. |
| 328 |
$folder_name = explode( '/', $file_path ); |
| 329 |
|
| 330 |
// Use the first folder segment as the plugin slug. |
| 331 |
$plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' ); |
| 332 |
unset( $folder_name ); |
| 333 |
|
| 334 |
// If the plugin slug is still empty, use the TextDomain key from the plugin data if it exists. |
| 335 |
if ( '' === $plugin_slug && isset( $plugin_data['TextDomain'] ) ) { |
| 336 |
$td_raw2 = $plugin_data['TextDomain']; |
| 337 |
$plugin_slug = wp_kses( is_scalar( $td_raw2 ) ? (string) $td_raw2 : '', 'strip' ); |
| 338 |
} |
| 339 |
|
| 340 |
// Get the plugin version from the plugin data if it exists. |
| 341 |
$plugin_version_raw2 = $plugin_data['Version'] ?? ''; |
| 342 |
$plugin_version = wp_kses( is_scalar( $plugin_version_raw2 ) ? (string) $plugin_version_raw2 : '', 'strip' ); |
| 343 |
|
| 344 |
// Retrieve vulnerabilities for the plugin using its slug and version. |
| 345 |
if ( ! empty( $plugin_slug ) ) { |
| 346 |
|
| 347 |
$plugin_api_response = wpvulnerability_get_plugin( $plugin_slug, $plugin_version, 1, 1 ); |
| 348 |
|
| 349 |
// If vulnerabilities are found, return the updated plugin data. |
| 350 |
if ( ! empty( $plugin_api_response ) ) { |
| 351 |
return $plugin_api_response; |
| 352 |
} |
| 353 |
} |
| 354 |
|
| 355 |
return null; // Return null if no valid data is found. |
| 356 |
} |
| 357 |
|
| 358 |
/** |
| 359 |
* Get Installed Plugins |
| 360 |
* Retrieves the list of installed plugins, checks for vulnerabilities in each of them, caches the data, and sends an email notification if vulnerabilities are detected. |
| 361 |
* |
| 362 |
* @since 2.0.0 |
| 363 |
* @since 4.1.2 Stores a signature of the installed plugins to detect inventory changes. |
| 364 |
* |
| 365 |
* @return string JSON-encoded array of plugin data with vulnerabilities and vulnerable status, or '[]' on encoding error. |
| 366 |
*/ |
| 367 |
function wpvulnerability_plugin_get_installed() { |
| 368 |
|
| 369 |
$wpvulnerability_plugins_vulnerable = 0; |
| 370 |
|
| 371 |
// Ensure the get_plugins() function is available. |
| 372 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 373 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 374 |
} |
| 375 |
|
| 376 |
// Retrieve the list of installed plugins. |
| 377 |
$plugins = get_plugins(); |
| 378 |
$signature = wpvulnerability_plugins_generate_signature( $plugins ); |
| 379 |
|
| 380 |
// Iterate through each plugin and check for vulnerabilities. |
| 381 |
foreach ( $plugins as $file_path => $plugin_data ) { |
| 382 |
|
| 383 |
$plugins[ $file_path ] = wpvulnerability_get_fresh_plugin_vulnerabilities( $plugin_data, $file_path ); |
| 384 |
|
| 385 |
// Increment the vulnerable plugin counter if vulnerabilities are found. |
| 386 |
$vuln_flag = $plugins[ $file_path ]['vulnerable'] ?? null; |
| 387 |
if ( is_scalar( $vuln_flag ) && (int) $vuln_flag ) { |
| 388 |
++$wpvulnerability_plugins_vulnerable; |
| 389 |
} |
| 390 |
} |
| 391 |
|
| 392 |
// Update site options for multisite installations. |
| 393 |
if ( is_multisite() ) { |
| 394 |
update_site_option( 'wpvulnerability-plugins', wp_json_encode( $plugins ) ); |
| 395 |
update_site_option( 'wpvulnerability-plugins-vulnerable', wp_json_encode( number_format( $wpvulnerability_plugins_vulnerable, 0, '.', '' ) ) ); |
| 396 |
update_site_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); |
| 397 |
update_site_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ) ); |
| 398 |
} else { |
| 399 |
// Update options for single site installations. |
| 400 |
update_option( 'wpvulnerability-plugins', wp_json_encode( $plugins ), false ); |
| 401 |
update_option( 'wpvulnerability-plugins-vulnerable', wp_json_encode( number_format( $wpvulnerability_plugins_vulnerable, 0, '.', '' ) ), false ); |
| 402 |
update_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); |
| 403 |
update_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ), false ); |
| 404 |
} |
| 405 |
|
| 406 |
// Return the JSON-encoded array of plugin data. |
| 407 |
$encoded = wp_json_encode( $plugins ); |
| 408 |
return false !== $encoded ? $encoded : '[]'; |
| 409 |
} |
| 410 |
|
| 411 |
/** |
| 412 |
* Retrieves cached data for installed plugins, optionally refreshing when forced. |
| 413 |
* |
| 414 |
* @since 3.1.0 |
| 415 |
* @since 4.1.2 Refreshes automatically when the installed plugins signature changes. |
| 416 |
* |
| 417 |
* @param bool $clean Optional. Whether to force a refresh of the plugin data cache. Default false. |
| 418 |
* |
| 419 |
* @return string JSON-encoded array of plugin data, or '[]' on encoding error. |
| 420 |
*/ |
| 421 |
function wpvulnerability_plugin_get_data( $clean = false ) { |
| 422 |
if ( true === $clean ) { |
| 423 |
// Ensure the get_plugins() function is available. |
| 424 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 425 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 426 |
} |
| 427 |
|
| 428 |
// Retrieve the list of installed plugins. |
| 429 |
$plugins = get_plugins(); |
| 430 |
$pluginsdata = array(); |
| 431 |
$signature = wpvulnerability_plugins_generate_signature( $plugins ); |
| 432 |
|
| 433 |
// Iterate through each plugin and get fresh data. |
| 434 |
foreach ( $plugins as $file_path => $plugin_data ) { |
| 435 |
$pluginsdata[ $file_path ] = wpvulnerability_get_fresh_plugin_data( $plugin_data, $file_path ); |
| 436 |
} |
| 437 |
|
| 438 |
// Update site options for multisite installations. |
| 439 |
if ( is_multisite() ) { |
| 440 |
update_site_option( 'wpvulnerability-plugins-data', wp_json_encode( $pluginsdata ) ); |
| 441 |
update_site_option( 'wpvulnerability-plugins-cache-data', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); |
| 442 |
update_site_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); |
| 443 |
update_site_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ) ); |
| 444 |
} else { |
| 445 |
// Update options for single site installations. |
| 446 |
update_option( 'wpvulnerability-plugins-data', wp_json_encode( $pluginsdata ), false ); |
| 447 |
update_option( 'wpvulnerability-plugins-cache-data', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); |
| 448 |
update_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); |
| 449 |
update_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ), false ); |
| 450 |
} |
| 451 |
|
| 452 |
$encoded_data = wp_json_encode( $pluginsdata ); |
| 453 |
return false !== $encoded_data ? $encoded_data : '[]'; |
| 454 |
} |
| 455 |
|
| 456 |
$raw_pd = is_multisite() ? get_site_option( 'wpvulnerability-plugins-data', '' ) : get_option( 'wpvulnerability-plugins-data', '' ); |
| 457 |
$plugin_data = json_decode( is_string( $raw_pd ) ? $raw_pd : '', true ); |
| 458 |
|
| 459 |
if ( ! is_array( $plugin_data ) ) { |
| 460 |
$plugin_data = array(); |
| 461 |
} |
| 462 |
|
| 463 |
$encoded = wp_json_encode( $plugin_data ); |
| 464 |
return false !== $encoded ? $encoded : '[]'; |
| 465 |
} |
| 466 |
|
| 467 |
/** |
| 468 |
* Get cached plugin vulnerabilities without contacting the API. Data is refreshed by scheduled or manual updates. |
| 469 |
* |
| 470 |
* @since 2.0.0 |
| 471 |
* @since 4.1.2 Refreshes when the installed plugins signature changes. |
| 472 |
* |
| 473 |
* @return array<string, mixed> Array of installed plugins with their vulnerabilities. |
| 474 |
*/ |
| 475 |
function wpvulnerability_plugin_get_vulnerabilities() { |
| 476 |
|
| 477 |
$raw_data = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' ); |
| 478 |
$plugin_data = json_decode( is_string( $raw_data ) ? $raw_data : '', true ); |
| 479 |
|
| 480 |
return is_array( $plugin_data ) ? $plugin_data : array(); |
| 481 |
} |
| 482 |
|
| 483 |
/** |
| 484 |
* Update the installed plugins cache and remove any old cache data. |
| 485 |
* |
| 486 |
* @since 2.0.0 |
| 487 |
* |
| 488 |
* @return void |
| 489 |
*/ |
| 490 |
function wpvulnerability_plugin_get_vulnerabilities_clean() { |
| 491 |
wpvulnerability_clear_cache( 'plugins' ); |
| 492 |
wpvulnerability_plugin_get_installed(); |
| 493 |
wpvulnerability_plugin_get_data( true ); |
| 494 |
} |
| 495 |
|
| 496 |
/** |
| 497 |
* Displays information in the 'Last Updated' column for each plugin in the plugins list table. |
| 498 |
* |
| 499 |
* This function is triggered for each row in the plugins list table when the 'Last Updated' column is rendered. |
| 500 |
* It retrieves the last update date from stored plugin data, compares it against the current date to highlight |
| 501 |
* plugins not updated in over a year or those marked as closed, and displays this information. |
| 502 |
* |
| 503 |
* @since 3.1.0 Introduced. |
| 504 |
* |
| 505 |
* @param string $column_name The name of the current column being rendered. |
| 506 |
* @param string $plugin_file Path to the plugin file, relative to the plugins directory. |
| 507 |
* @param array<string, mixed> $plugin_data Array of plugin data, such as the plugin's name, version, and description. |
| 508 |
* |
| 509 |
* @return void Outputs the last updated information directly to the browser, including any warnings for plugins |
| 510 |
* not updated in over a year or marked as closed. |
| 511 |
*/ |
| 512 |
function wpvulnerability_plugin_show_lastupdated( $column_name, $plugin_file, $plugin_data ) { |
| 513 |
|
| 514 |
$now = time(); |
| 515 |
$year = strtotime( '-1 year', $now ); |
| 516 |
|
| 517 |
if ( 'last_updated' === $column_name && $plugin_file ) { |
| 518 |
|
| 519 |
$plugin_slug = ''; |
| 520 |
|
| 521 |
// Extract the plugin slug from the file path. |
| 522 |
$folder_name = explode( '/', $plugin_file ); |
| 523 |
|
| 524 |
// Use the first folder segment as the plugin slug. |
| 525 |
$plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' ); |
| 526 |
unset( $folder_name ); |
| 527 |
|
| 528 |
// If the plugin slug is empty, extract it from the plugin data. |
| 529 |
if ( '' === $plugin_slug && isset( $plugin_data['TextDomain'] ) ) { |
| 530 |
$td_raw3 = $plugin_data['TextDomain']; |
| 531 |
$plugin_slug = wp_kses( is_scalar( $td_raw3 ) ? (string) $td_raw3 : '', 'strip' ); |
| 532 |
} |
| 533 |
|
| 534 |
if ( '' !== $plugin_slug ) { |
| 535 |
|
| 536 |
// Retrieve the vulnerabilities for all plugins from the options table and decode the JSON. |
| 537 |
$raw_plugins_data = is_multisite() ? get_site_option( 'wpvulnerability-plugins-data', '' ) : get_option( 'wpvulnerability-plugins-data', '' ); |
| 538 |
$plugins_data = json_decode( is_string( $raw_plugins_data ) ? $raw_plugins_data : '', true ); |
| 539 |
if ( ! is_array( $plugins_data ) ) { |
| 540 |
$plugins_data = array(); |
| 541 |
} |
| 542 |
|
| 543 |
// Get the plugin data from the stored data. |
| 544 |
if ( isset( $plugins_data[ $plugin_file ] ) && is_array( $plugins_data[ $plugin_file ] ) ) { |
| 545 |
$pd = $plugins_data[ $plugin_file ]; |
| 546 |
|
| 547 |
$pd_latest_raw = $pd['latest'] ?? 0; |
| 548 |
$pd_latest = is_scalar( $pd_latest_raw ) ? intval( $pd_latest_raw ) : 0; |
| 549 |
if ( $pd_latest > 0 ) { |
| 550 |
|
| 551 |
$timestamp = $pd_latest; |
| 552 |
$df_raw = get_option( 'date_format', 'Y-m-d' ); |
| 553 |
$date_format = is_scalar( $df_raw ) ? (string) $df_raw : 'Y-m-d'; |
| 554 |
if ( function_exists( 'wp_date' ) ) { |
| 555 |
$plugin_data_updated = (string) wp_date( $date_format, $timestamp ); |
| 556 |
} else { |
| 557 |
$plugin_data_updated = gmdate( $date_format, $timestamp ); |
| 558 |
} |
| 559 |
$plugin_data_ago = human_time_diff( $timestamp ); |
| 560 |
|
| 561 |
$warning_date = $pd_latest < $year; |
| 562 |
$pd_closed_raw = $pd['closed'] ?? 0; |
| 563 |
$warning_closed = isset( $pd['closed'] ) && ( is_scalar( $pd_closed_raw ) ? intval( $pd_closed_raw ) : 0 ); |
| 564 |
|
| 565 |
echo '<p>' . wp_kses( $plugin_data_updated, 'strip' ) . ' (' . wp_kses( (string) $plugin_data_ago, 'strip' ) . ')</p>'; |
| 566 |
|
| 567 |
if ( $warning_date ) { |
| 568 |
echo '<p><strong>⚠️ '; |
| 569 |
esc_html_e( 'It hasn\'t been updated in over a year.', 'wpvulnerability' ); |
| 570 |
echo '</strong></p>'; |
| 571 |
} |
| 572 |
|
| 573 |
if ( $warning_closed ) { |
| 574 |
echo '<p><strong>⚠️ '; |
| 575 |
esc_html_e( 'It may no longer be available (closed?).', 'wpvulnerability' ); |
| 576 |
echo '</strong></p>'; |
| 577 |
} |
| 578 |
} else { |
| 579 |
echo '<p></p>'; |
| 580 |
} |
| 581 |
} |
| 582 |
} |
| 583 |
} |
| 584 |
} |
| 585 |
|
| 586 |
/** |
| 587 |
* Adds a 'Last Updated' column to the plugins table list in the WordPress admin area. |
| 588 |
* |
| 589 |
* This function iterates over the existing columns in the plugins table and inserts a new column titled 'Last Updated' |
| 590 |
* just before the 'auto-updates' column if it exists. If the 'auto-updates' column is not found, the 'Last Updated' |
| 591 |
* column is appended at the end. The function is typically hooked to the 'manage_plugins_columns' filter in WordPress |
| 592 |
* to modify the columns of the plugins table. |
| 593 |
* |
| 594 |
* @since 3.1.0 Introduced. |
| 595 |
* |
| 596 |
* @param array<string, string> $columns An associative array of column names and titles for the plugins table. |
| 597 |
* |
| 598 |
* @return array<string, string> An associative array containing the modified list of columns, including the new 'Last Updated' column. |
| 599 |
*/ |
| 600 |
function wpvulnerability_plugin_add_lastupdated_column( $columns ) { |
| 601 |
|
| 602 |
$toadd = true; |
| 603 |
$new_columns = array(); |
| 604 |
|
| 605 |
// Loop through each existing column and add it to the new columns array. |
| 606 |
foreach ( $columns as $key => $title ) { |
| 607 |
|
| 608 |
// Add the existing column to the new columns array. |
| 609 |
$new_columns[ $key ] = $title; |
| 610 |
|
| 611 |
// Insert your custom column before the 'auto-updates' column. |
| 612 |
if ( 'description' === $key && $toadd ) { |
| 613 |
$new_columns['last_updated'] = __( 'Last updated on', 'wpvulnerability' ); |
| 614 |
$toadd = false; |
| 615 |
} |
| 616 |
} |
| 617 |
|
| 618 |
// If 'auto-updates' column is not found, add 'last_updated' column at the end. |
| 619 |
if ( $toadd ) { |
| 620 |
$new_columns['last_updated'] = __( 'Last updated on', 'wpvulnerability' ); |
| 621 |
} |
| 622 |
|
| 623 |
// Return the modified columns array. |
| 624 |
return $new_columns; |
| 625 |
} |
| 626 |
|
| 627 |
/** |
| 628 |
* Admin Head |
| 629 |
* Adds vulnerability information after the plugin row and notices on the plugin page based on the installed plugins cache. |
| 630 |
* |
| 631 |
* @since 2.0.0 |
| 632 |
* |
| 633 |
* @return void |
| 634 |
*/ |
| 635 |
function wpvulnerability_plugin_page() { |
| 636 |
|
| 637 |
// Check if the current page is the plugins page. |
| 638 |
global $pagenow; |
| 639 |
|
| 640 |
if ( wpvulnerability_analyze_filter( 'plugins' ) && 'plugins.php' === $pagenow && wpvulnerability_capabilities() ) { |
| 641 |
|
| 642 |
// Get the vulnerabilities for the installed plugins. |
| 643 |
$plugins = wpvulnerability_plugin_get_vulnerabilities(); |
| 644 |
|
| 645 |
// Loop through the plugins and add vulnerability information after the plugin row for vulnerable plugins. |
| 646 |
foreach ( $plugins as $file_path => $plugin_data ) { |
| 647 |
|
| 648 |
if ( is_array( $plugin_data ) && isset( $plugin_data['vulnerable'] ) ) { |
| 649 |
$vulnerable_raw = $plugin_data['vulnerable']; |
| 650 |
if ( 1 === ( is_scalar( $vulnerable_raw ) ? intval( $vulnerable_raw ) : 0 ) ) { |
| 651 |
add_action( 'after_plugin_row_' . $file_path, 'wpvulnerability_plugin_info_after', 10, 2 ); |
| 652 |
} |
| 653 |
} |
| 654 |
} |
| 655 |
|
| 656 |
// Add 'Last Updated' column to the plugins table based on user capabilities. |
| 657 |
if ( is_multisite() ) { |
| 658 |
|
| 659 |
add_filter( 'manage_plugins-network_columns', 'wpvulnerability_plugin_add_lastupdated_column' ); |
| 660 |
|
| 661 |
} else { |
| 662 |
|
| 663 |
add_filter( 'manage_plugins_columns', 'wpvulnerability_plugin_add_lastupdated_column' ); |
| 664 |
|
| 665 |
} |
| 666 |
|
| 667 |
add_action( 'manage_plugins_custom_column', 'wpvulnerability_plugin_show_lastupdated', 10, 3 ); |
| 668 |
|
| 669 |
} |
| 670 |
} |
| 671 |
// Add notices for vulnerable plugins on the plugin page. |
| 672 |
add_action( 'admin_head', 'wpvulnerability_plugin_page' ); |
| 673 |
|
| 674 |
/** |
| 675 |
* Filters the plugins list to show only vulnerable plugins when the "Vulnerable" tab is selected. |
| 676 |
* |
| 677 |
* This function hooks into the WordPress plugins listing to filter the displayed plugins based on their |
| 678 |
* vulnerability status. When the "Vulnerable" tab is selected (identified by the `plugin_status=vulnerable` |
| 679 |
* query parameter), it filters the plugins list to include only those plugins with known vulnerabilities. |
| 680 |
* |
| 681 |
* The function retrieves the vulnerabilities for all plugins from the WordPress options table and compares |
| 682 |
* them against the active list of plugins. Plugins without vulnerabilities are removed from the list, leaving |
| 683 |
* only those that are considered vulnerable. |
| 684 |
* |
| 685 |
* @since 3.3.5 |
| 686 |
* |
| 687 |
* @return void |
| 688 |
*/ |
| 689 |
function wpvulnerability_plugins_filter() { |
| 690 |
if ( isset( $_GET['plugin_status'] ) && 'vulnerable' === $_GET['plugin_status'] ) { // phpcs:ignore |
| 691 |
|
| 692 |
// Verify nonce for CSRF protection. |
| 693 |
$nonce_raw = isset( $_GET['wpv_nonce'] ) && is_string( $_GET['wpv_nonce'] ) ? $_GET['wpv_nonce'] : ''; // phpcs:ignore |
| 694 |
$nonce = sanitize_text_field( wp_unslash( $nonce_raw ) ); |
| 695 |
|
| 696 |
if ( ! wp_verify_nonce( $nonce, 'wpvulnerability_filter_plugins' ) ) { |
| 697 |
// If nonce verification fails, silently return without filtering. |
| 698 |
// This provides graceful degradation - users simply see all plugins instead of an error. |
| 699 |
return; |
| 700 |
} |
| 701 |
|
| 702 |
global $wp_list_table; |
| 703 |
|
| 704 |
// Retrieve the vulnerabilities for all plugins from the options table and decode the JSON. |
| 705 |
$raw_pv = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' ); |
| 706 |
$plugin_vulnerabilities = json_decode( is_string( $raw_pv ) ? $raw_pv : '', true ); |
| 707 |
if ( ! is_array( $plugin_vulnerabilities ) ) { |
| 708 |
$plugin_vulnerabilities = array(); |
| 709 |
} |
| 710 |
|
| 711 |
foreach ( $wp_list_table->items as $plugin_file => $plugin_data ) { |
| 712 |
$pf_data = isset( $plugin_vulnerabilities[ $plugin_file ] ) && is_array( $plugin_vulnerabilities[ $plugin_file ] ) ? $plugin_vulnerabilities[ $plugin_file ] : array(); |
| 713 |
$pf_vulns = isset( $pf_data['vulnerabilities'] ) && is_array( $pf_data['vulnerabilities'] ) ? $pf_data['vulnerabilities'] : array(); |
| 714 |
if ( empty( $pf_vulns ) ) { |
| 715 |
unset( $wp_list_table->items[ $plugin_file ] ); |
| 716 |
} |
| 717 |
} |
| 718 |
} |
| 719 |
} |
| 720 |
add_action( 'pre_current_active_plugins', 'wpvulnerability_plugins_filter' ); |
| 721 |
|
| 722 |
/** |
| 723 |
* Adds a "Vulnerable" tab to the WordPress plugins page that displays the count of vulnerable plugins. |
| 724 |
* |
| 725 |
* This function checks the cache for the number of vulnerable plugins and adds a new tab to the plugins |
| 726 |
* management page in the WordPress admin area. The tab displays the count of vulnerable plugins and highlights it |
| 727 |
* if it is currently active. |
| 728 |
* |
| 729 |
* @since 3.3.5 |
| 730 |
* |
| 731 |
* @param array<string, string> $views An array of existing plugin views (tabs) in the WordPress admin plugins page. |
| 732 |
* |
| 733 |
* @return array<string, string> The modified array of views including the "Vulnerable" tab. |
| 734 |
*/ |
| 735 |
function wpvulnerability_plugins_view( $views ) { |
| 736 |
|
| 737 |
if ( ! wpvulnerability_analyze_filter( 'plugins' ) ) { |
| 738 |
return $views; |
| 739 |
} |
| 740 |
|
| 741 |
// Retrieve the number of plugins vulnerabilities from cache. |
| 742 |
$raw_count = is_multisite() |
| 743 |
? get_site_option( 'wpvulnerability-plugins-vulnerable', '0' ) |
| 744 |
: get_option( 'wpvulnerability-plugins-vulnerable', '0' ); |
| 745 |
|
| 746 |
$decoded_count = json_decode( is_string( $raw_count ) ? $raw_count : '0', true ); |
| 747 |
$wpvulnerability_plugins_total = is_scalar( $decoded_count ) ? intval( $decoded_count ) : 0; |
| 748 |
|
| 749 |
$current_class = ( isset( $_GET['plugin_status'] ) && 'vulnerable' === $_GET['plugin_status'] ) ? ' class="current"' : ''; // phpcs:ignore |
| 750 |
|
| 751 |
$url = is_multisite() |
| 752 |
? network_admin_url( 'plugins.php?plugin_status=vulnerable' ) |
| 753 |
: admin_url( 'plugins.php?plugin_status=vulnerable' ); |
| 754 |
|
| 755 |
// Add nonce for CSRF protection. |
| 756 |
$url = esc_url( wp_nonce_url( $url, 'wpvulnerability_filter_plugins', 'wpv_nonce' ) ); |
| 757 |
|
| 758 |
$views['vulnerable'] = sprintf( |
| 759 |
'<a href="%s"%s>%s</a>', |
| 760 |
$url, |
| 761 |
$current_class, |
| 762 |
// translators: the number of vulnerabilities. |
| 763 |
sprintf( __( 'Vulnerabilities (%d)', 'wpvulnerability' ), $wpvulnerability_plugins_total ) |
| 764 |
); |
| 765 |
|
| 766 |
return $views; |
| 767 |
} |
| 768 |
|
| 769 |
/** |
| 770 |
* Adds a custom filter to the plugins page in the WordPress admin to display a tab for vulnerable plugins. |
| 771 |
* |
| 772 |
* This function hooks into the 'views_plugins' filter to add a custom tab or view for displaying vulnerable plugins |
| 773 |
* on the plugins management page in the WordPress admin area. The tab is added in both single-site and multisite |
| 774 |
* installations, but in a multisite setup, it is only added to the network admin area. |
| 775 |
* |
| 776 |
* @since 3.3.5 |
| 777 |
* |
| 778 |
* @return void |
| 779 |
*/ |
| 780 |
function wpvulnerability_plugins_add_tab() { |
| 781 |
|
| 782 |
if ( is_multisite() ) { |
| 783 |
if ( is_network_admin() ) { |
| 784 |
add_filter( 'views_plugins-network', 'wpvulnerability_plugins_view' ); |
| 785 |
} |
| 786 |
} else { |
| 787 |
add_filter( 'views_plugins', 'wpvulnerability_plugins_view' ); |
| 788 |
} |
| 789 |
} |
| 790 |
add_action( 'admin_head', 'wpvulnerability_plugins_add_tab' ); |
| 791 |
|