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

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

728 lines 30.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Themes functions
4 *
5 * @package WPVulnerability
6 *
7 * @version 2.0.0
8 */
9
10 defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
11
12 /**
13 * Generate a deterministic signature for the installed themes list.
14 *
15 * @since 4.1.2
16 *
17 * @param array<string,WP_Theme|array<string,mixed>> $themes List of themes returned by wp_get_themes().
18 * @return string Hash representing the installed themes and their versions.
19 */
20 function wpvulnerability_themes_generate_signature( $themes ) {
21 $normalized = array();
22
23 foreach ( $themes as $slug => $theme_data ) {
24 $theme_slug = sanitize_text_field( (string) $slug );
25 $version = '';
26
27 if ( $theme_data instanceof WP_Theme ) {
28 $version = sanitize_text_field( (string) $theme_data->get( 'Version' ) );
29 } elseif ( isset( $theme_data['Version'] ) ) {
30 $version = sanitize_text_field( is_scalar( $theme_data['Version'] ) ? (string) $theme_data['Version'] : '' );
31 }
32
33 $normalized[ $theme_slug ] = $version;
34 }
35
36 ksort( $normalized );
37
38 $encoded = wp_json_encode( $normalized );
39 return md5( false !== $encoded ? $encoded : '' );
40 }
41
42 /**
43 * Retrieve the signature of the currently installed themes.
44 *
45 * @since 4.1.2
46 *
47 * @return string Hash representing the installed themes and their versions.
48 */
49 function wpvulnerability_themes_get_current_signature() {
50 return wpvulnerability_themes_generate_signature( wp_get_themes() );
51 }
52
53 /**
54 * Adds a vulnerability notice under vulnerable themes.
55 *
56 * @since 2.0.0
57 *
58 * @param string $theme_file Main themes folder/file name.
59 * @param WP_Theme $theme_data Theme data object.
60 *
61 * @return void
62 */
63 function wpvulnerability_theme_info_after( $theme_file, $theme_data ) {
64
65 // Retrieve the vulnerabilities for all themes from the options table and decode the JSON.
66 $raw_themes = is_multisite() ? get_site_option( 'wpvulnerability-themes', '' ) : get_option( 'wpvulnerability-themes', '' );
67 $theme_vulnerabilities = json_decode( is_string( $raw_themes ) ? $raw_themes : '', true );
68 if ( ! is_array( $theme_vulnerabilities ) ) {
69 $theme_vulnerabilities = array();
70 }
71
72 // Determine whether the theme is active and add an appropriate CSS class to the table row.
73 $current_theme = wp_get_theme();
74 $tr_class = '';
75 if ( $theme_file === $current_theme->get_stylesheet() ) {
76 $tr_class .= 'active';
77 }
78
79 // Generate the vulnerability notice message with the theme name.
80 $message = sprintf(
81 /* translators: 1: Plugin or theme name. */
82 __( '%1$s has a known vulnerability that may be affecting your installed version.', 'wpvulnerability' ),
83 wp_kses( (string) $theme_data->get( 'Name' ), 'strip' )
84 );
85
86 // Begin generating the table row HTML markup with appropriate CSS classes and the vulnerability notice message.
87 $information = '<tr class="wpvulnerability ' . esc_attr( $tr_class ) . '">';
88 $information .= '<td colspan="4">';
89 $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></p>';
90 $information .= '<table>';
91
92 // Loop through all vulnerabilities for the current theme and add their details to the table row HTML markup.
93 $tf_entry = isset( $theme_vulnerabilities[ $theme_file ] ) && is_array( $theme_vulnerabilities[ $theme_file ] ) ? $theme_vulnerabilities[ $theme_file ] : array();
94 $tf_wpv = isset( $tf_entry['wpvulnerability'] ) && is_array( $tf_entry['wpvulnerability'] ) ? $tf_entry['wpvulnerability'] : array();
95 $vulnerabilities = isset( $tf_wpv['vulnerabilities'] ) && is_array( $tf_wpv['vulnerabilities'] ) ? $tf_wpv['vulnerabilities'] : array();
96
97 foreach ( $vulnerabilities as $vulnerability ) {
98 if ( ! is_array( $vulnerability ) ) {
99 continue;
100 }
101
102 $vuln_versions_raw = $vulnerability['versions'] ?? '';
103 $vuln_versions = is_scalar( $vuln_versions_raw ) ? (string) $vuln_versions_raw : '';
104 $vuln_closed_raw = $vulnerability['closed'] ?? 0;
105 $vuln_closed = is_scalar( $vuln_closed_raw ) ? intval( $vuln_closed_raw ) : 0;
106 $vuln_unfixed_raw = $vulnerability['unfixed'] ?? 0;
107 $vuln_unfixed = is_scalar( $vuln_unfixed_raw ) ? intval( $vuln_unfixed_raw ) : 0;
108 $vuln_impact = isset( $vulnerability['impact'] ) && is_array( $vulnerability['impact'] ) ? $vulnerability['impact'] : array();
109 $vuln_cvss = isset( $vuln_impact['cvss'] ) && is_array( $vuln_impact['cvss'] ) ? $vuln_impact['cvss'] : array();
110 $vuln_cvss2 = isset( $vuln_impact['cvss2'] ) && is_array( $vuln_impact['cvss2'] ) ? $vuln_impact['cvss2'] : array();
111 $vuln_cvss3 = isset( $vuln_impact['cvss3'] ) && is_array( $vuln_impact['cvss3'] ) ? $vuln_impact['cvss3'] : array();
112 $vuln_cvss4 = isset( $vuln_impact['cvss4'] ) && is_array( $vuln_impact['cvss4'] ) ? $vuln_impact['cvss4'] : array();
113 $vuln_ssvc = isset( $vuln_impact['ssvc'] ) && is_array( $vuln_impact['ssvc'] ) ? $vuln_impact['ssvc'] : array();
114 $vuln_cwe = isset( $vuln_impact['cwe'] ) && is_array( $vuln_impact['cwe'] ) ? $vuln_impact['cwe'] : array();
115 $vuln_sources = isset( $vulnerability['source'] ) && is_array( $vulnerability['source'] ) ? $vulnerability['source'] : array();
116
117 $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] );
118 $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : '';
119 $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : '';
120 $kev_date_raw = $vuln_ssvc['kev_date'] ?? null;
121 $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null;
122 $epss_raw = $vuln_impact['epss'] ?? null;
123 $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null;
124 $description = wpvulnerability_get_source_description( $vuln_sources );
125
126 // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss.
127 $score_raw = null;
128 $sev_raw = null;
129 foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) {
130 if ( empty( $cvss_c ) ) {
131 continue;
132 }
133 $s_raw = $cvss_c['score'] ?? null;
134 $v_raw = $cvss_c['severity'] ?? null;
135 $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null;
136 $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null;
137 if ( null !== $s || null !== $v ) {
138 $score_raw = $s;
139 $sev_raw = $v;
140 break;
141 }
142 }
143
144 $what = array();
145 foreach ( $vuln_cwe as $vulnerability_cwe ) {
146 if ( ! is_array( $vulnerability_cwe ) ) {
147 continue;
148 }
149 $cwe_name = $vulnerability_cwe['name'] ?? '';
150 $cwe_desc = $vulnerability_cwe['description'] ?? '';
151 $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>';
152 }
153
154 $version_display = wpvulnerability_clean_version_range( $vuln_versions );
155 $source_pills = wpvulnerability_render_source_pills( $vuln_sources );
156 $score_badge = wpvulnerability_render_score_badge( $score_raw, $sev_raw, $epss );
157
158 $information .= '<tr>';
159 // Version range column.
160 $information .= '<td style="max-width: 256px; min-width: 96px; vertical-align: top; padding-top: 6px;">';
161 $information .= '' !== $version_display
162 ? '<span class="wpvuln-versions">' . $version_display . '</span>'
163 : '&mdash;';
164 $information .= '</td>';
165 // Details column.
166 $information .= '<td>';
167 $show_active = $kev || 'active' === $exploitation;
168 $show_poc = 'poc' === $exploitation;
169 $show_auto = 'yes' === $automatable;
170 if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) {
171 $information .= '<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:5px;">';
172 if ( $show_active ) {
173 $information .= '<span class="wpvuln-kev-label">&#9888; ' . esc_html__( 'Actively exploited', 'wpvulnerability' );
174 if ( $kev && null !== $kev_date ) {
175 $information .= ' &middot; ' . esc_html( $kev_date );
176 }
177 $information .= '</span>';
178 }
179 if ( $show_poc ) {
180 $information .= '<span class="wpvuln-poc-label">&#9889; ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . '</span>';
181 }
182 if ( $show_auto ) {
183 $information .= '<span class="wpvuln-auto-label">&#9881; ' . esc_html__( 'Automatable', 'wpvulnerability' ) . '</span>';
184 }
185 if ( '' !== $score_badge ) {
186 $information .= $score_badge;
187 }
188 $information .= '</div>';
189 }
190 if ( null !== $description ) {
191 $information .= '<div style="padding-bottom: 5px;">' . esc_html( $description ) . '</div>';
192 }
193 if ( $vuln_closed || $vuln_unfixed ) {
194 $information .= '<div style="padding-bottom: 5px;">';
195 if ( $vuln_closed ) {
196 $information .= '<div class="text-red">' . esc_html__( 'This theme is closed. Please replace it with another.', 'wpvulnerability' ) . '</div>';
197 }
198 if ( $vuln_unfixed ) {
199 $information .= '<div class="text-red">' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming theme updates.', 'wpvulnerability' ) . '</div>';
200 }
201 $information .= '</div>';
202 }
203 if ( ! empty( $what ) ) {
204 $information .= '<div style="padding-bottom: 5px;">';
205 foreach ( $what as $w ) {
206 $information .= $w;
207 }
208 $information .= '</div>';
209 }
210 if ( '' !== $source_pills ) {
211 $information .= '<div class="wpvuln-refs-row"><span class="wpvuln-refs-label">' . esc_html__( 'References:', 'wpvulnerability' ) . '</span>';
212 $information .= $source_pills;
213 $information .= '</div>';
214 }
215 $information .= '</td>';
216 $information .= '</tr>';
217 }
218
219 $information .= '</table>';
220 $information .= '</td>';
221 $information .= '</tr>';
222
223 echo $information; // phpcs:ignore
224 }
225
226 /**
227 * Retrieves vulnerabilities for a given theme and updates its data.
228 *
229 * @since 2.0.0
230 *
231 * @param array<string, mixed> $theme_data The theme data array (must contain a 'data' key with a WP_Theme object).
232 * @param string $theme_slug The slug to the theme.
233 *
234 * @return array<string, mixed> The updated theme data array.
235 */
236 function wpvulnerability_get_fresh_theme_vulnerabilities( $theme_data, $theme_slug ) {
237
238 // Get the theme version and slug from the theme data.
239 $theme_obj = isset( $theme_data['data'] ) && ( $theme_data['data'] instanceof WP_Theme ) ? $theme_data['data'] : null;
240 $theme_version = null !== $theme_obj ? wp_kses( (string) $theme_obj->get( 'Version' ), 'strip' ) : '';
241
242 $theme_data_v = array();
243 $theme_data_v['slug'] = $theme_slug;
244 $theme_data_v['name'] = null !== $theme_obj ? (string) $theme_obj->get( 'Name' ) : '';
245
246 // Initialize vulnerability related fields.
247 $theme_data_v['vulnerabilities'] = null;
248 $theme_data_v['vulnerable'] = 0;
249
250 // Retrieve vulnerabilities for the theme using its slug and version.
251 if ( ! empty( $theme_slug ) ) {
252
253 $theme_api_response = wpvulnerability_get_theme( $theme_slug, $theme_version, 0 );
254
255 // If vulnerabilities are found, update the theme data accordingly.
256 if ( ! empty( $theme_api_response ) ) {
257
258 $theme_data_v['vulnerabilities'] = $theme_api_response;
259 $theme_data_v['vulnerable'] = 1;
260
261 }
262 }
263
264 return $theme_data_v;
265 }
266
267 /**
268 * Get Installed Themes
269 * Retrieves the list of installed themes, checks for vulnerabilities in each of them, caches the data, and sends an email notification if vulnerabilities are detected.
270 *
271 * @since 2.0.0
272 * @since 4.1.2 Stores a signature of the installed themes to detect inventory changes.
273 *
274 * @return string JSON-encoded array of theme data with vulnerabilities and vulnerable status, or '[]' on encoding error.
275 */
276 function wpvulnerability_theme_get_installed() {
277
278 $wpvulnerability_themes_vulnerable = 0;
279 $themes_v = array();
280 $themes = wp_get_themes();
281 $signature = wpvulnerability_themes_generate_signature( $themes );
282
283 foreach ( $themes as $slug => $theme_data ) {
284
285 // Store the theme data.
286 $themes_v[ $slug ]['data'] = $theme_data;
287
288 // Get fresh vulnerabilities for the theme.
289 $themes_v[ $slug ]['wpvulnerability'] = wpvulnerability_get_fresh_theme_vulnerabilities( $themes_v[ $slug ], $slug );
290
291 // If the theme is vulnerable, increment the vulnerable themes counter.
292 if ( isset( $themes_v[ $slug ]['wpvulnerability']['vulnerable'] ) && 1 === ( is_scalar( $themes_v[ $slug ]['wpvulnerability']['vulnerable'] ) ? (int) $themes_v[ $slug ]['wpvulnerability']['vulnerable'] : 0 ) ) {
293 ++$wpvulnerability_themes_vulnerable;
294 }
295 }
296
297 // Update options for multisite installations.
298 if ( is_multisite() ) {
299 update_site_option( 'wpvulnerability-themes', wp_json_encode( $themes_v ) );
300 update_site_option( 'wpvulnerability-themes-vulnerable', wp_json_encode( number_format( $wpvulnerability_themes_vulnerable, 0, '.', '' ) ) );
301 update_site_option( 'wpvulnerability-themes-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) );
302 update_site_option( 'wpvulnerability-themes-signature', wp_json_encode( $signature ) );
303 } else {
304 update_option( 'wpvulnerability-themes', wp_json_encode( $themes_v ), false );
305 update_option( 'wpvulnerability-themes-vulnerable', wp_json_encode( number_format( $wpvulnerability_themes_vulnerable, 0, '.', '' ) ), false );
306 update_option( 'wpvulnerability-themes-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false );
307 update_option( 'wpvulnerability-themes-signature', wp_json_encode( $signature ), false );
308 }
309
310 $encoded = wp_json_encode( $themes_v );
311 return false !== $encoded ? $encoded : '[]';
312 }
313
314 /**
315 * Get cached themes vulnerabilities without contacting the API. Data updates via scheduled or manual refreshes.
316 *
317 * @since 2.0.0
318 * @since 4.1.2 Refreshes when the installed themes signature changes.
319 *
320 * @return array<string, mixed> Array of installed themes with their vulnerabilities.
321 */
322 function wpvulnerability_theme_get_vulnerabilities() {
323
324 $raw = is_multisite() ? get_site_option( 'wpvulnerability-themes', '' ) : get_option( 'wpvulnerability-themes', '' );
325 $theme_data = json_decode( is_string( $raw ) ? $raw : '', true );
326
327 return is_array( $theme_data ) ? $theme_data : array();
328 }
329
330 /**
331 * Update the installed themes cache and remove any old cache data.
332 *
333 * @since 2.0.0
334 *
335 * @return void
336 */
337 function wpvulnerability_theme_get_vulnerabilities_clean() {
338 wpvulnerability_clear_cache( 'themes' );
339 wpvulnerability_theme_get_installed();
340 }
341
342 /**
343 * Admin Head
344 * Adds vulnerability information after the theme row and notices on the theme page based on the installed theme cache.
345 *
346 * @since 2.0.0
347 *
348 * @return void
349 */
350 function wpvulnerability_theme_page() {
351
352 // Check if the current page is the themes page.
353 global $pagenow;
354 if ( wpvulnerability_analyze_filter( 'themes' ) && 'themes.php' === $pagenow && wpvulnerability_capabilities() ) {
355
356 // Get the vulnerabilities for the installed themes.
357 $themes = wpvulnerability_theme_get_vulnerabilities();
358
359 // Loop through the themes and add vulnerability information after the theme row for vulnerable themes.
360 foreach ( $themes as $theme_file => $theme_data ) {
361 if ( ! is_array( $theme_data ) ) {
362 continue;
363 }
364 $td_wpv = isset( $theme_data['wpvulnerability'] ) && is_array( $theme_data['wpvulnerability'] ) ? $theme_data['wpvulnerability'] : array();
365 if ( isset( $td_wpv['vulnerable'] ) && 1 === ( is_scalar( $td_wpv['vulnerable'] ) ? (int) $td_wpv['vulnerable'] : 0 ) ) {
366 add_action( 'after_theme_row_' . esc_attr( $theme_file ), 'wpvulnerability_theme_info_after', 10, 2 );
367 }
368 }
369 }
370 }
371 // Add notices for vulnerable themes on the theme page.
372 add_action( 'admin_head', 'wpvulnerability_theme_page' );
373
374 /**
375 * Build the vulnerability HTML block for the single-site theme details modal.
376 *
377 * Generates a standalone table (no wrapping <tr>) with one row per vulnerability,
378 * using the same column layout and badges as the theme-row renderer.
379 *
380 * @since 5.0.0
381 *
382 * @param array<mixed> $vulnerabilities Array of vulnerability objects from the cache.
383 * @return string HTML string ready to be injected into the modal, or empty string if none.
384 */
385 function wpvulnerability_theme_modal_html( $vulnerabilities ) {
386 if ( empty( $vulnerabilities ) ) {
387 return '';
388 }
389
390 $icon = '<img src="' . esc_url( WPVULNERABILITY_PLUGIN_URL ) . 'assets/icon.svg" style="height:14px;vertical-align:text-top;width:14px;" alt="" title="WPVulnerability">';
391 $html = '<p class="text-red">' . $icon . ' <strong>' . esc_html__( 'This theme has known vulnerabilities that may be affecting your installed version.', 'wpvulnerability' ) . '</strong></p>';
392 $html .= '<table class="widefat wpvulnerability">';
393
394 foreach ( $vulnerabilities as $vulnerability ) {
395 if ( ! is_array( $vulnerability ) ) {
396 continue;
397 }
398
399 $vuln_versions_raw = $vulnerability['versions'] ?? '';
400 $vuln_versions = is_scalar( $vuln_versions_raw ) ? (string) $vuln_versions_raw : '';
401 $vuln_closed_raw = $vulnerability['closed'] ?? 0;
402 $vuln_closed = is_scalar( $vuln_closed_raw ) ? intval( $vuln_closed_raw ) : 0;
403 $vuln_unfixed_raw = $vulnerability['unfixed'] ?? 0;
404 $vuln_unfixed = is_scalar( $vuln_unfixed_raw ) ? intval( $vuln_unfixed_raw ) : 0;
405 $vuln_impact = isset( $vulnerability['impact'] ) && is_array( $vulnerability['impact'] ) ? $vulnerability['impact'] : array();
406 $vuln_cvss = isset( $vuln_impact['cvss'] ) && is_array( $vuln_impact['cvss'] ) ? $vuln_impact['cvss'] : array();
407 $vuln_cvss2 = isset( $vuln_impact['cvss2'] ) && is_array( $vuln_impact['cvss2'] ) ? $vuln_impact['cvss2'] : array();
408 $vuln_cvss3 = isset( $vuln_impact['cvss3'] ) && is_array( $vuln_impact['cvss3'] ) ? $vuln_impact['cvss3'] : array();
409 $vuln_cvss4 = isset( $vuln_impact['cvss4'] ) && is_array( $vuln_impact['cvss4'] ) ? $vuln_impact['cvss4'] : array();
410 $vuln_ssvc = isset( $vuln_impact['ssvc'] ) && is_array( $vuln_impact['ssvc'] ) ? $vuln_impact['ssvc'] : array();
411 $vuln_cwe = isset( $vuln_impact['cwe'] ) && is_array( $vuln_impact['cwe'] ) ? $vuln_impact['cwe'] : array();
412 $vuln_sources = isset( $vulnerability['source'] ) && is_array( $vulnerability['source'] ) ? $vulnerability['source'] : array();
413
414 $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] );
415 $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : '';
416 $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : '';
417 $kev_date_raw = $vuln_ssvc['kev_date'] ?? null;
418 $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null;
419 $epss_raw = $vuln_impact['epss'] ?? null;
420 $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null;
421 $description = wpvulnerability_get_source_description( $vuln_sources );
422
423 // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss.
424 $score_raw = null;
425 $sev_raw = null;
426 foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) {
427 if ( empty( $cvss_c ) ) {
428 continue;
429 }
430 $s_raw = $cvss_c['score'] ?? null;
431 $v_raw = $cvss_c['severity'] ?? null;
432 $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null;
433 $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null;
434 if ( null !== $s || null !== $v ) {
435 $score_raw = $s;
436 $sev_raw = $v;
437 break;
438 }
439 }
440
441 $what = array();
442 foreach ( $vuln_cwe as $vulnerability_cwe ) {
443 if ( ! is_array( $vulnerability_cwe ) ) {
444 continue;
445 }
446 $cwe_name = $vulnerability_cwe['name'] ?? '';
447 $cwe_desc = $vulnerability_cwe['description'] ?? '';
448 $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>';
449 }
450
451 $version_display = wpvulnerability_clean_version_range( $vuln_versions );
452 $source_pills = wpvulnerability_render_source_pills( $vuln_sources );
453 $score_badge = wpvulnerability_render_score_badge( $score_raw, $sev_raw, $epss );
454 $show_active = $kev || 'active' === $exploitation;
455 $show_poc = 'poc' === $exploitation;
456 $show_auto = 'yes' === $automatable;
457
458 $html .= '<tr>';
459 $html .= '<td style="max-width:256px; min-width:96px; vertical-align:top; padding-top:6px;">';
460 $html .= '' !== $version_display
461 ? '<span class="wpvuln-versions">' . $version_display . '</span>'
462 : '&mdash;';
463 $html .= '</td>';
464 $html .= '<td>';
465
466 if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) {
467 $html .= '<div style="display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:5px;">';
468 if ( $show_active ) {
469 $html .= '<span class="wpvuln-kev-label">&#9888; ' . esc_html__( 'Actively exploited', 'wpvulnerability' );
470 if ( $kev && null !== $kev_date ) {
471 $html .= ' &middot; ' . esc_html( $kev_date );
472 }
473 $html .= '</span>';
474 }
475 if ( $show_poc ) {
476 $html .= '<span class="wpvuln-poc-label">&#9889; ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . '</span>';
477 }
478 if ( $show_auto ) {
479 $html .= '<span class="wpvuln-auto-label">&#9881; ' . esc_html__( 'Automatable', 'wpvulnerability' ) . '</span>';
480 }
481 if ( '' !== $score_badge ) {
482 $html .= $score_badge;
483 }
484 $html .= '</div>';
485 }
486 if ( null !== $description ) {
487 $html .= '<div style="padding-bottom:5px;">' . esc_html( $description ) . '</div>';
488 }
489 if ( $vuln_closed || $vuln_unfixed ) {
490 $html .= '<div style="padding-bottom:5px;">';
491 if ( $vuln_closed ) {
492 $html .= '<div class="text-red">' . esc_html__( 'This theme is closed. Please replace it with another.', 'wpvulnerability' ) . '</div>';
493 }
494 if ( $vuln_unfixed ) {
495 $html .= '<div class="text-red">' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming theme updates.', 'wpvulnerability' ) . '</div>';
496 }
497 $html .= '</div>';
498 }
499 if ( ! empty( $what ) ) {
500 $html .= '<div style="padding-bottom:5px;">' . implode( '', $what ) . '</div>';
501 }
502 if ( '' !== $source_pills ) {
503 $html .= '<div class="wpvuln-refs-row"><span class="wpvuln-refs-label">' . esc_html__( 'References:', 'wpvulnerability' ) . '</span>';
504 $html .= $source_pills;
505 $html .= '</div>';
506 }
507
508 $html .= '</td>';
509 $html .= '</tr>';
510 }
511
512 $html .= '</table>';
513 return $html;
514 }
515
516 /**
517 * Inject pre-rendered vulnerability HTML into the theme data passed to the JS modal.
518 *
519 * Hooks into wp_prepare_themes_for_js to add a wpvulnerability_html key for each
520 * vulnerable theme. The JS template patch in wpvulnerability_theme_modal_template_patch()
521 * then surfaces this data inside the modal.
522 *
523 * @since 5.0.0
524 *
525 * @param array<string, mixed> $prepared_themes Themes data prepared for JS.
526 * @return array<string, mixed> Modified themes data.
527 */
528 function wpvulnerability_filter_prepare_themes_for_js( $prepared_themes ) {
529 if ( ! wpvulnerability_analyze_filter( 'themes' ) || ! wpvulnerability_capabilities() ) {
530 return $prepared_themes;
531 }
532
533 $theme_vulns = wpvulnerability_theme_get_vulnerabilities();
534
535 foreach ( $prepared_themes as $slug => $theme ) {
536 if ( ! isset( $theme_vulns[ $slug ] ) || ! is_array( $theme_vulns[ $slug ] ) ) {
537 continue;
538 }
539 $entry = $theme_vulns[ $slug ];
540 $td_wpv = isset( $entry['wpvulnerability'] ) && is_array( $entry['wpvulnerability'] ) ? $entry['wpvulnerability'] : array();
541 if ( ! isset( $td_wpv['vulnerable'] ) || 1 !== ( is_scalar( $td_wpv['vulnerable'] ) ? (int) $td_wpv['vulnerable'] : 0 ) ) {
542 continue;
543 }
544 $vulnerabilities = isset( $td_wpv['vulnerabilities'] ) && is_array( $td_wpv['vulnerabilities'] ) ? $td_wpv['vulnerabilities'] : array();
545 if ( empty( $vulnerabilities ) ) {
546 continue;
547 }
548 if ( is_array( $prepared_themes[ $slug ] ) ) {
549 $prepared_themes[ $slug ]['wpvulnerability_html'] = wpvulnerability_theme_modal_html( $vulnerabilities );
550 }
551 }
552
553 return $prepared_themes;
554 }
555 add_filter( 'wp_prepare_themes_for_js', 'wpvulnerability_filter_prepare_themes_for_js' );
556
557 /**
558 * Patch the #tmpl-theme-single Underscore template to display vulnerability data.
559 *
560 * Injects a conditional block after the tags section so that when a theme's JS data
561 * contains wpvulnerability_html, that HTML is rendered inside the details modal.
562 * Only runs on the single-site themes.php page.
563 *
564 * @since 5.0.0
565 *
566 * @return void
567 */
568 function wpvulnerability_theme_modal_template_patch() {
569 global $pagenow;
570 if ( 'themes.php' !== $pagenow || is_multisite() || ! wpvulnerability_capabilities() || ! wpvulnerability_analyze_filter( 'themes' ) ) {
571 return;
572 }
573 ?>
574 <script>
575 (function () {
576 var tmpl = document.getElementById( 'tmpl-theme-single' );
577 if ( ! tmpl ) { return; }
578
579 // Find the tags block and insert our section right after its closing <# } #>.
580 var marker = 'class="theme-tags"';
581 var closeTag = '<# } #>';
582 var pos = tmpl.innerHTML.indexOf( marker );
583 if ( pos === -1 ) { return; }
584 var closePos = tmpl.innerHTML.indexOf( closeTag, pos );
585 if ( closePos === -1 ) { return; }
586
587 var insertAt = closePos + closeTag.length;
588 var inject =
589 '\n<# if ( data.wpvulnerability_html ) { #>' +
590 '<div class="wpvulnerability-theme-modal" style="margin-top:12px;">' +
591 '{{{ data.wpvulnerability_html }}}' +
592 '</div>' +
593 '<# } #>';
594
595 tmpl.innerHTML =
596 tmpl.innerHTML.slice( 0, insertAt ) +
597 inject +
598 tmpl.innerHTML.slice( insertAt );
599 }());
600 </script>
601 <?php
602 }
603 add_action( 'admin_footer', 'wpvulnerability_theme_modal_template_patch' );
604
605 /**
606 * Filters the themes list to show only vulnerable themes when the "Vulnerable" tab is selected.
607 *
608 * This function hooks into the WordPress themes listing in the network admin to filter the displayed themes
609 * based on their vulnerability status. When the "Vulnerable" tab is selected (identified by the `theme_status=vulnerable`
610 * query parameter), it filters the themes list to include only those themes with known vulnerabilities.
611 *
612 * The function retrieves the vulnerabilities for all themes from the WordPress options table and compares
613 * them against the active list of themes. Themes without vulnerabilities are removed from the list, leaving
614 * only those that are considered vulnerable.
615 *
616 * @since 3.3.5
617 *
618 * @global object $wp_list_table The WordPress list table object for managing themes.
619 *
620 * @return void
621 */
622 function wpvulnerability_themes_filter() {
623 if ( isset( $_GET['theme_status'] ) && 'vulnerable' === $_GET['theme_status'] ) { // phpcs:ignore
624
625 // Verify nonce for CSRF protection.
626 $nonce = isset( $_GET['wpv_nonce'] ) ? sanitize_text_field( wp_unslash( is_string( $_GET['wpv_nonce'] ) ? $_GET['wpv_nonce'] : '' ) ) : '';
627
628 if ( ! wp_verify_nonce( $nonce, 'wpvulnerability_filter_themes' ) ) {
629 // If nonce verification fails, silently return without filtering.
630 // This provides graceful degradation - users simply see all themes instead of an error.
631 return;
632 }
633
634 global $wp_list_table;
635
636 // Retrieve the vulnerabilities for all themes from the options table and decode the JSON.
637 $raw_tv = is_multisite() ? get_site_option( 'wpvulnerability-themes', '' ) : get_option( 'wpvulnerability-themes', '' );
638 $theme_vulnerabilities = json_decode( is_string( $raw_tv ) ? $raw_tv : '', true );
639 if ( ! is_array( $theme_vulnerabilities ) ) {
640 $theme_vulnerabilities = array();
641 }
642
643 // Loop through the items in the themes list table.
644 foreach ( $wp_list_table->items as $theme_file => $theme_data ) {
645 $tf_entry = isset( $theme_vulnerabilities[ $theme_file ] ) && is_array( $theme_vulnerabilities[ $theme_file ] ) ? $theme_vulnerabilities[ $theme_file ] : array();
646 $tf_wpv = isset( $tf_entry['wpvulnerability'] ) && is_array( $tf_entry['wpvulnerability'] ) ? $tf_entry['wpvulnerability'] : array();
647 if ( empty( $tf_wpv['vulnerable'] ) || 0 === ( is_scalar( $tf_wpv['vulnerable'] ) ? (int) $tf_wpv['vulnerable'] : 0 ) ) {
648 unset( $wp_list_table->items[ $theme_file ] );
649 }
650 }
651 }
652 }
653
654 /**
655 * Initializes the vulnerability filtering for the themes list in the network admin area of a multisite installation.
656 *
657 * This function checks if the current environment is a multisite network and whether the user is in the network
658 * admin area. If both conditions are met, it hooks into the 'admin_head-themes.php' action to apply a filter that
659 * shows only vulnerable themes in the themes list.
660 *
661 * @since 3.3.5
662 *
663 * @return void
664 */
665 function wpvulnerability_themes_filter_init() {
666 if ( is_multisite() && is_network_admin() ) {
667 add_action( 'admin_head-themes.php', 'wpvulnerability_themes_filter' );
668 }
669 }
670 add_action( 'network_admin_menu', 'wpvulnerability_themes_filter_init' );
671
672 /**
673 * Adds a "Vulnerable" tab to the WordPress themes page that displays the count of vulnerable themes.
674 *
675 * This function checks the cache for the number of vulnerable themes and adds a new tab to the themes
676 * management page in the WordPress admin area. The tab displays the count of vulnerable themes and highlights it
677 * if it is currently active. The tab is added only in the network admin area of a multisite installation.
678 *
679 * @since 3.3.5
680 *
681 * @param array<string, string> $views An array of existing theme views (tabs) in the WordPress admin themes page.
682 *
683 * @return array<string, string> The modified array of views including the "Vulnerable" tab.
684 */
685 function wpvulnerability_themes_view( $views ) {
686 if ( ! wpvulnerability_analyze_filter( 'themes' ) ) {
687 return $views;
688 }
689
690 $raw_count = is_multisite() ? get_site_option( 'wpvulnerability-themes-vulnerable', '0' ) : get_option( 'wpvulnerability-themes-vulnerable', '0' );
691 $wpvulnerability_themes_total = ( is_scalar( json_decode( is_string( $raw_count ) ? $raw_count : '0', true ) ) ? (int) json_decode( is_string( $raw_count ) ? $raw_count : '0', true ) : 0 );
692
693 if ( is_multisite() && is_network_admin() ) {
694 $url = network_admin_url( 'themes.php?theme_status=vulnerable' );
695
696 // Add nonce for CSRF protection.
697 $url = esc_url( wp_nonce_url( $url, 'wpvulnerability_filter_themes', 'wpv_nonce' ) );
698
699 $views['vulnerable'] = sprintf(
700 '<a href="%s"%s>%s</a>',
701 $url,
702 ( isset( $_GET['theme_status'] ) && 'vulnerable' === $_GET['theme_status'] ? ' class="current"' : '' ), // phpcs:ignore
703 // translators: the number of vulnerabilities.
704 sprintf( __( 'Vulnerabilities (%d)', 'wpvulnerability' ), $wpvulnerability_themes_total )
705 );
706 }
707
708 return $views;
709 }
710
711 /**
712 * Adds a custom filter to the themes page in the WordPress admin to display a tab for vulnerable themes.
713 *
714 * This function hooks into the 'views_themes-network' filter to add a custom tab or view for displaying vulnerable themes
715 * on the themes management page in the WordPress network admin area. The tab is added only in a multisite setup
716 * and specifically in the network admin context.
717 *
718 * @since 3.3.5
719 *
720 * @return void
721 */
722 function wpvulnerability_themes_add_tab() {
723 if ( is_multisite() && is_network_admin() ) {
724 add_filter( 'views_themes-network', 'wpvulnerability_themes_view' );
725 }
726 }
727 add_action( 'admin_head', 'wpvulnerability_themes_add_tab' );
728