PluginProbe
WPVulnerability / 5.1.6
WPVulnerability v5.1.6
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-plugins.php

wpvulnerability-plugins.php in WPVulnerability 5.1.6, at wpvulnerability-plugins.php

792 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 : '&mdash;';
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">&#9888; ' . esc_html__( 'Actively exploited', 'wpvulnerability' );
207 if ( $kev && null !== $kev_date ) {
208 $information .= ' &middot; ' . esc_html( $kev_date );
209 }
210 $information .= '</span>';
211 }
212 if ( $show_poc ) {
213 $information .= '<span class="wpvuln-poc-label">&#9889; ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . '</span>';
214 }
215 if ( $show_auto ) {
216 $information .= '<span class="wpvuln-auto-label">&#9881; ' . 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 static $plugins_data = null;
515
516 $now = time();
517 $year = strtotime( '-1 year', $now );
518
519 if ( 'last_updated' === $column_name && $plugin_file ) {
520
521 $plugin_slug = '';
522
523 // Extract the plugin slug from the file path.
524 $folder_name = explode( '/', $plugin_file );
525
526 // Use the first folder segment as the plugin slug.
527 $plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' );
528 unset( $folder_name );
529
530 // If the plugin slug is empty, extract it from the plugin data.
531 if ( '' === $plugin_slug && isset( $plugin_data['TextDomain'] ) ) {
532 $td_raw3 = $plugin_data['TextDomain'];
533 $plugin_slug = wp_kses( is_scalar( $td_raw3 ) ? (string) $td_raw3 : '', 'strip' );
534 }
535
536 if ( '' !== $plugin_slug ) {
537
538 // Retrieve the vulnerabilities for all plugins once per request; this
539 // callback runs for every row of the plugins list table.
540 if ( null === $plugins_data ) {
541 $raw_plugins_data = is_multisite() ? get_site_option( 'wpvulnerability-plugins-data', '' ) : get_option( 'wpvulnerability-plugins-data', '' );
542 $plugins_data = json_decode( is_string( $raw_plugins_data ) ? $raw_plugins_data : '', true );
543 if ( ! is_array( $plugins_data ) ) {
544 $plugins_data = array();
545 }
546 }
547
548 // Get the plugin data from the stored data.
549 if ( isset( $plugins_data[ $plugin_file ] ) && is_array( $plugins_data[ $plugin_file ] ) ) {
550 $pd = $plugins_data[ $plugin_file ];
551
552 $pd_latest_raw = $pd['latest'] ?? 0;
553 $pd_latest = is_scalar( $pd_latest_raw ) ? intval( $pd_latest_raw ) : 0;
554 if ( $pd_latest > 0 ) {
555
556 $timestamp = $pd_latest;
557 $df_raw = get_option( 'date_format', 'Y-m-d' );
558 $date_format = is_scalar( $df_raw ) ? (string) $df_raw : 'Y-m-d';
559 $plugin_data_updated = (string) wp_date( $date_format, $timestamp );
560 $plugin_data_ago = human_time_diff( $timestamp );
561
562 $warning_date = $pd_latest < $year;
563 $pd_closed_raw = $pd['closed'] ?? 0;
564 $warning_closed = isset( $pd['closed'] ) && ( is_scalar( $pd_closed_raw ) ? intval( $pd_closed_raw ) : 0 );
565
566 echo '<p>' . wp_kses( $plugin_data_updated, 'strip' ) . ' (' . wp_kses( (string) $plugin_data_ago, 'strip' ) . ')</p>';
567
568 if ( $warning_date ) {
569 echo '<p><strong>⚠️ ';
570 esc_html_e( 'It hasn\'t been updated in over a year.', 'wpvulnerability' );
571 echo '</strong></p>';
572 }
573
574 if ( $warning_closed ) {
575 echo '<p><strong>⚠️ ';
576 esc_html_e( 'It may no longer be available (closed?).', 'wpvulnerability' );
577 echo '</strong></p>';
578 }
579 } else {
580 echo '<p></p>';
581 }
582 }
583 }
584 }
585 }
586
587 /**
588 * Adds a 'Last Updated' column to the plugins table list in the WordPress admin area.
589 *
590 * This function iterates over the existing columns in the plugins table and inserts a new column titled 'Last Updated'
591 * just after the 'description' column if it exists. If the 'description' column is not found, the 'Last Updated'
592 * column is appended at the end. The function is typically hooked to the 'manage_plugins_columns' filter in WordPress
593 * to modify the columns of the plugins table.
594 *
595 * @since 3.1.0 Introduced.
596 *
597 * @param array<string, string> $columns An associative array of column names and titles for the plugins table.
598 *
599 * @return array<string, string> An associative array containing the modified list of columns, including the new 'Last Updated' column.
600 */
601 function wpvulnerability_plugin_add_lastupdated_column( $columns ) {
602
603 $toadd = true;
604 $new_columns = array();
605
606 // Loop through each existing column and add it to the new columns array.
607 foreach ( $columns as $key => $title ) {
608
609 // Add the existing column to the new columns array.
610 $new_columns[ $key ] = $title;
611
612 // Insert your custom column before the 'auto-updates' column.
613 if ( 'description' === $key && $toadd ) {
614 $new_columns['last_updated'] = __( 'Last updated on', 'wpvulnerability' );
615 $toadd = false;
616 }
617 }
618
619 // If 'auto-updates' column is not found, add 'last_updated' column at the end.
620 if ( $toadd ) {
621 $new_columns['last_updated'] = __( 'Last updated on', 'wpvulnerability' );
622 }
623
624 // Return the modified columns array.
625 return $new_columns;
626 }
627
628 /**
629 * Admin Head
630 * Adds vulnerability information after the plugin row and notices on the plugin page based on the installed plugins cache.
631 *
632 * @since 2.0.0
633 *
634 * @return void
635 */
636 function wpvulnerability_plugin_page() {
637
638 // Check if the current page is the plugins page.
639 global $pagenow;
640
641 if ( wpvulnerability_analyze_filter( 'plugins' ) && 'plugins.php' === $pagenow && wpvulnerability_capabilities() ) {
642
643 // Get the vulnerabilities for the installed plugins.
644 $plugins = wpvulnerability_plugin_get_vulnerabilities();
645
646 // Loop through the plugins and add vulnerability information after the plugin row for vulnerable plugins.
647 foreach ( $plugins as $file_path => $plugin_data ) {
648
649 if ( is_array( $plugin_data ) && isset( $plugin_data['vulnerable'] ) ) {
650 $vulnerable_raw = $plugin_data['vulnerable'];
651 if ( 1 === ( is_scalar( $vulnerable_raw ) ? intval( $vulnerable_raw ) : 0 ) ) {
652 add_action( 'after_plugin_row_' . $file_path, 'wpvulnerability_plugin_info_after', 10, 2 );
653 }
654 }
655 }
656
657 // Add 'Last Updated' column to the plugins table based on user capabilities.
658 if ( is_multisite() ) {
659
660 add_filter( 'manage_plugins-network_columns', 'wpvulnerability_plugin_add_lastupdated_column' );
661
662 } else {
663
664 add_filter( 'manage_plugins_columns', 'wpvulnerability_plugin_add_lastupdated_column' );
665
666 }
667
668 add_action( 'manage_plugins_custom_column', 'wpvulnerability_plugin_show_lastupdated', 10, 3 );
669
670 }
671 }
672 // Add notices for vulnerable plugins on the plugin page.
673 add_action( 'admin_head', 'wpvulnerability_plugin_page' );
674
675 /**
676 * Filters the plugins list to show only vulnerable plugins when the "Vulnerable" tab is selected.
677 *
678 * This function hooks into the WordPress plugins listing to filter the displayed plugins based on their
679 * vulnerability status. When the "Vulnerable" tab is selected (identified by the `plugin_status=vulnerable`
680 * query parameter), it filters the plugins list to include only those plugins with known vulnerabilities.
681 *
682 * The function retrieves the vulnerabilities for all plugins from the WordPress options table and compares
683 * them against the active list of plugins. Plugins without vulnerabilities are removed from the list, leaving
684 * only those that are considered vulnerable.
685 *
686 * @since 3.3.5
687 *
688 * @return void
689 */
690 function wpvulnerability_plugins_filter() {
691 if ( isset( $_GET['plugin_status'] ) && 'vulnerable' === $_GET['plugin_status'] ) { // phpcs:ignore
692
693 // Verify nonce for CSRF protection.
694 $nonce_raw = isset( $_GET['wpv_nonce'] ) && is_string( $_GET['wpv_nonce'] ) ? $_GET['wpv_nonce'] : ''; // phpcs:ignore
695 $nonce = sanitize_text_field( wp_unslash( $nonce_raw ) );
696
697 if ( ! wp_verify_nonce( $nonce, 'wpvulnerability_filter_plugins' ) ) {
698 // If nonce verification fails, silently return without filtering.
699 // This provides graceful degradation - users simply see all plugins instead of an error.
700 return;
701 }
702
703 global $wp_list_table;
704
705 // Retrieve the vulnerabilities for all plugins from the options table and decode the JSON.
706 $raw_pv = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' );
707 $plugin_vulnerabilities = json_decode( is_string( $raw_pv ) ? $raw_pv : '', true );
708 if ( ! is_array( $plugin_vulnerabilities ) ) {
709 $plugin_vulnerabilities = array();
710 }
711
712 foreach ( $wp_list_table->items as $plugin_file => $plugin_data ) {
713 $pf_data = isset( $plugin_vulnerabilities[ $plugin_file ] ) && is_array( $plugin_vulnerabilities[ $plugin_file ] ) ? $plugin_vulnerabilities[ $plugin_file ] : array();
714 $pf_vulns = isset( $pf_data['vulnerabilities'] ) && is_array( $pf_data['vulnerabilities'] ) ? $pf_data['vulnerabilities'] : array();
715 if ( empty( $pf_vulns ) ) {
716 unset( $wp_list_table->items[ $plugin_file ] );
717 }
718 }
719 }
720 }
721 add_action( 'pre_current_active_plugins', 'wpvulnerability_plugins_filter' );
722
723 /**
724 * Adds a "Vulnerable" tab to the WordPress plugins page that displays the count of vulnerable plugins.
725 *
726 * This function checks the cache for the number of vulnerable plugins and adds a new tab to the plugins
727 * management page in the WordPress admin area. The tab displays the count of vulnerable plugins and highlights it
728 * if it is currently active.
729 *
730 * @since 3.3.5
731 *
732 * @param array<string, string> $views An array of existing plugin views (tabs) in the WordPress admin plugins page.
733 *
734 * @return array<string, string> The modified array of views including the "Vulnerable" tab.
735 */
736 function wpvulnerability_plugins_view( $views ) {
737
738 if ( ! wpvulnerability_analyze_filter( 'plugins' ) ) {
739 return $views;
740 }
741
742 // Retrieve the number of plugins vulnerabilities from cache.
743 $raw_count = is_multisite()
744 ? get_site_option( 'wpvulnerability-plugins-vulnerable', '0' )
745 : get_option( 'wpvulnerability-plugins-vulnerable', '0' );
746
747 $decoded_count = json_decode( is_string( $raw_count ) ? $raw_count : '0', true );
748 $wpvulnerability_plugins_total = is_scalar( $decoded_count ) ? intval( $decoded_count ) : 0;
749
750 $current_class = ( isset( $_GET['plugin_status'] ) && 'vulnerable' === $_GET['plugin_status'] ) ? ' class="current"' : ''; // phpcs:ignore
751
752 $url = is_multisite()
753 ? network_admin_url( 'plugins.php?plugin_status=vulnerable' )
754 : admin_url( 'plugins.php?plugin_status=vulnerable' );
755
756 // Add nonce for CSRF protection.
757 $url = esc_url( wp_nonce_url( $url, 'wpvulnerability_filter_plugins', 'wpv_nonce' ) );
758
759 $views['vulnerable'] = sprintf(
760 '<a href="%s"%s>%s</a>',
761 $url,
762 $current_class,
763 // translators: the number of vulnerabilities.
764 sprintf( __( 'Vulnerabilities (%d)', 'wpvulnerability' ), $wpvulnerability_plugins_total )
765 );
766
767 return $views;
768 }
769
770 /**
771 * Adds a custom filter to the plugins page in the WordPress admin to display a tab for vulnerable plugins.
772 *
773 * This function hooks into the 'views_plugins' filter to add a custom tab or view for displaying vulnerable plugins
774 * on the plugins management page in the WordPress admin area. The tab is added in both single-site and multisite
775 * installations, but in a multisite setup, it is only added to the network admin area.
776 *
777 * @since 3.3.5
778 *
779 * @return void
780 */
781 function wpvulnerability_plugins_add_tab() {
782
783 if ( is_multisite() ) {
784 if ( is_network_admin() ) {
785 add_filter( 'views_plugins-network', 'wpvulnerability_plugins_view' );
786 }
787 } else {
788 add_filter( 'views_plugins', 'wpvulnerability_plugins_view' );
789 }
790 }
791 add_action( 'admin_head', 'wpvulnerability_plugins_add_tab' );
792