| 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 |
: '—'; |
| 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">⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); |
| 174 |
if ( $kev && null !== $kev_date ) { |
| 175 |
$information .= ' · ' . esc_html( $kev_date ); |
| 176 |
} |
| 177 |
$information .= '</span>'; |
| 178 |
} |
| 179 |
if ( $show_poc ) { |
| 180 |
$information .= '<span class="wpvuln-poc-label">⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . '</span>'; |
| 181 |
} |
| 182 |
if ( $show_auto ) { |
| 183 |
$information .= '<span class="wpvuln-auto-label">⚙ ' . 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 |
: '—'; |
| 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">⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); |
| 470 |
if ( $kev && null !== $kev_date ) { |
| 471 |
$html .= ' · ' . esc_html( $kev_date ); |
| 472 |
} |
| 473 |
$html .= '</span>'; |
| 474 |
} |
| 475 |
if ( $show_poc ) { |
| 476 |
$html .= '<span class="wpvuln-poc-label">⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . '</span>'; |
| 477 |
} |
| 478 |
if ( $show_auto ) { |
| 479 |
$html .= '<span class="wpvuln-auto-label">⚙ ' . 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 |
|