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-notifications.php

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

924 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Notifications functions
4 *
5 * @package WPVulnerability
6 *
7 * @version 2.0.0
8 */
9
10 defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
11
12 /**
13 * Disables email notifications when requested via URL.
14 *
15 * When the `wpvulnerability_disable_email` query parameter is present and the accompanying
16 * nonce validates, this handler disables future email notifications for the current site.
17 *
18 * @since 4.1.3
19 *
20 * @return void
21 */
22 function wpvulnerability_disable_notifications_via_url() {
23 if ( empty( $_GET['wpvulnerability_disable_email'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
24 return;
25 }
26
27 $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( is_string( $_GET['nonce'] ) ? $_GET['nonce'] : '' ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
28
29 if ( ! wp_verify_nonce( $nonce, 'wpvulnerability-disable-email' ) ) {
30 wp_die( esc_html__( 'Invalid request.', 'wpvulnerability' ) );
31 }
32
33 $settings = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' );
34
35 if ( ! is_array( $settings ) ) {
36 $settings = array();
37 }
38
39 $notify_settings = isset( $settings['notify'] ) ? $settings['notify'] : array();
40 $settings['notify'] = wpvulnerability_normalize_notify_settings( $notify_settings );
41 $settings['notify']['email'] = 'n';
42
43 if ( is_multisite() ) {
44 update_site_option( 'wpvulnerability-config', $settings );
45 } else {
46 update_option( 'wpvulnerability-config', $settings );
47 }
48
49 wp_die(
50 esc_html__( 'You have unsubscribed from WPVulnerability notifications.', 'wpvulnerability' ),
51 esc_html__( 'WPVulnerability', 'wpvulnerability' ),
52 array( 'response' => 200 )
53 );
54 }
55 add_action( 'init', 'wpvulnerability_disable_notifications_via_url' );
56
57 /**
58 * Determine if the webhook host is in the allowed list.
59 *
60 * @since 4.3.0
61 *
62 * @param string $host Host portion of the webhook URL.
63 * @param array<string> $allowed_hosts Allowed host suffixes.
64 *
65 * @return bool True when the host matches the allow list.
66 */
67 function wpvulnerability_is_allowed_webhook_host( $host, $allowed_hosts ) {
68 foreach ( $allowed_hosts as $allowed_host ) {
69 $allowed_host = strtolower( trim( (string) $allowed_host ) );
70 if ( '' === $allowed_host ) {
71 continue;
72 }
73
74 if ( $host === $allowed_host ) {
75 return true;
76 }
77
78 $suffix = '.' . $allowed_host;
79 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
80 return true;
81 }
82 }
83
84 return false;
85 }
86
87 /**
88 * Validates a webhook URL against an allow list and HTTPS enforcement.
89 *
90 * @since 4.3.0
91 *
92 * @param string $webhook_url Webhook URL to validate.
93 * @param array<string> $allowed_hosts Allowed host suffixes.
94 *
95 * @return string Sanitized URL or empty string when invalid.
96 */
97 function wpvulnerability_validate_webhook_url( $webhook_url, $allowed_hosts ) {
98 $webhook_url = esc_url_raw( trim( (string) $webhook_url ) );
99
100 if ( empty( $webhook_url ) ) {
101 return '';
102 }
103
104 $parts = wp_parse_url( $webhook_url );
105
106 if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
107 return '';
108 }
109
110 if ( 'https' !== strtolower( (string) $parts['scheme'] ) ) {
111 return '';
112 }
113
114 $host = strtolower( (string) $parts['host'] );
115
116 if ( ! wpvulnerability_is_allowed_webhook_host( $host, $allowed_hosts ) ) {
117 return '';
118 }
119
120 return $webhook_url;
121 }
122
123 /**
124 * Retrieves the unsubscribe URL for WPVulnerability email notifications.
125 *
126 * Generates a signed URL that disables future email notifications when
127 * visited. The URL works in both single and multisite environments and is
128 * validated through the `wpvulnerability_disable_notifications_via_url`
129 * handler. When running in multisite, it targets the network home only if
130 * the plugin is network-activated; otherwise it uses the current site's
131 * home URL.
132 *
133 * @since 4.1.7
134 *
135 * @return string The unsubscribe URL including the security nonce.
136 */
137 function wpvulnerability_get_disable_notifications_url() {
138 $nonce = wp_create_nonce( 'wpvulnerability-disable-email' );
139
140 $base_url = home_url( '/' );
141
142 if ( is_multisite() ) {
143 if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
144 require_once ABSPATH . 'wp-admin/includes/plugin.php';
145 }
146
147 $plugin_basename = defined( 'WPVULNERABILITY_PLUGIN_BASE' ) ? WPVULNERABILITY_PLUGIN_BASE : plugin_basename( WPVULNERABILITY_PLUGIN_FILE );
148
149 if ( function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_basename ) ) {
150 $base_url = network_home_url( '/' );
151 }
152 }
153
154 return add_query_arg(
155 array(
156 'wpvulnerability_disable_email' => 1,
157 'nonce' => $nonce,
158 ),
159 $base_url
160 );
161 }
162
163 /**
164 * Prepares the HTML email message.
165 *
166 * This function generates an HTML email message with the given title and content.
167 * It includes basic styling and structure to ensure compatibility with most email clients.
168 *
169 * @since 2.0.0
170 *
171 * @param string $title The title of the email message.
172 * @param string $content The content of the email message.
173 *
174 * @return string The prepared HTML email message.
175 */
176 function wpvulnerability_email_prepare( $title, $content ) {
177
178 $message = '';
179 $message .= '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">' . "\n";
180 $message .= '<html xmlns="http://www.w3.org/1999/xhtml" style="box-sizing: border-box; margin: 0;">' . "\n";
181 $message .= '<head>' . "\n";
182 $message .= ' <meta name="viewport" content="width=device-width">' . "\n";
183 $message .= ' <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' . "\n";
184 $message .= ' <title>WPVulnerability</title>' . "\n";
185 $message .= ' <style type="text/css">' . "\n";
186 $message .= ' img { max-width: 100%; }' . "\n";
187 $message .= ' body { -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.2em; }' . "\n";
188 $message .= ' body { background-color: #f6f6f6; }' . "\n";
189 $message .= ' @media only screen and (max-width: 640px) {' . "\n";
190 $message .= ' body { padding: 0 !important; }' . "\n";
191 $message .= ' h1, h2, h3, h4 { margin: 20px 0 5px 0 !important; }' . "\n";
192 $message .= ' .container { padding: 0 !important; width: 100% !important; }' . "\n";
193 $message .= ' .content { padding: 0 !important; }' . "\n";
194 $message .= ' .content-wrap { padding: 10px !important; }' . "\n";
195 $message .= ' .invoice { width: 100% !important; }' . "\n";
196 $message .= ' }' . "\n";
197 $message .= ' </style>' . "\n";
198 $message .= '</head>' . "\n";
199 $message .= '<body itemscope itemtype="http://schema.org/EmailMessage" style="box-sizing: border-box; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">' . "\n";
200 $message .= ' <table class="body-wrap" style="box-sizing: border-box; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">' . "\n";
201 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
202 $message .= ' <td style="box-sizing: border-box; vertical-align: top; margin: 0;" valign="top"></td>' . "\n";
203 $message .= ' <td class="container" width="600" style="box-sizing: border-box; vertical-align: top; display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">' . "\n";
204 $message .= ' <div class="content" style="box-sizing: border-box; max-width: 600px; display: block; margin: 0 auto; padding: 20px;">' . "\n";
205 $message .= ' <table class="main" width="100%" cellpadding="0" cellspacing="0" style="box-sizing: border-box; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff">' . "\n";
206 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
207 $message .= ' <td class="content-wrap aligncenter" style="box-sizing: border-box; vertical-align: top; text-align: center; margin: 0; padding: 20px;" align="center" valign="top">' . "\n";
208 $message .= ' <table width="100%" cellpadding="0" cellspacing="0" style="box-sizing: border-box; margin: 0;">' . "\n";
209 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
210 $message .= ' <td class="content-block" style="box-sizing: border-box; vertical-align: top; margin: 0; padding: 0 0 20px; text-align: center;" valign="top">' . "\n";
211 $message .= ' <img class="aligncenter" src="' . WPVULNERABILITY_PLUGIN_URL . 'assets/icon-128x128.png" width="64" height="64" alt="WPVulnerability">' . "\n";
212 $message .= ' <h1 class="aligncenter" style="box-sizing: border-box; color: #000; line-height: 1.2em; text-align: center; margin: 40px 0 0;" align="center">' . esc_html( $title ) . '</h1>' . "\n";
213
214 // Add the site URL based on the multisite configuration.
215 if ( is_multisite() ) {
216 $message .= ' <p class="aligncenter" style="box-sizing: border-box; color: #000; line-height: 1.2em; text-align: center; margin: 5px 0 0;" align="center"><a href="' . esc_url( network_site_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( network_site_url() ) . '</a></p>' . "\n";
217 } else {
218 $message .= ' <p class="aligncenter" style="box-sizing: border-box; color: #000; line-height: 1.2em; text-align: center; margin: 5px 0 0;" align="center"><a href="' . esc_url( site_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( site_url() ) . '</a></p>' . "\n";
219 }
220
221 $message .= ' </td>' . "\n";
222 $message .= ' </tr>' . "\n";
223 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
224 $message .= ' <td class="content-block alignleft" style="box-sizing: border-box; vertical-align: top; text-align: left; margin: 0; padding: 0 0 20px;" valign="top">' . "\n";
225 $message .= $content; // Add the main content of the email.
226 $message .= ' </td>' . "\n";
227 $message .= ' </tr>' . "\n";
228 $message .= ' </table>' . "\n";
229 $message .= ' <div class="footer" style="box-sizing: border-box; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;">' . "\n";
230 $message .= ' <table width="100%" style="box-sizing: border-box; margin: 0;">' . "\n";
231 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
232 $message .= ' <td class="aligncenter content-block" style="box-sizing: border-box; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">' . "\n";
233 $message .= sprintf(
234 // translators: %1$s the website of Database, %2$s database site name.
235 __( 'Learn more about the WordPress Vulnerability Database API at <a href="%1$s">%2$s</a>', 'wpvulnerability' ),
236 'https://www.wpvulnerability.com/',
237 'WPVulnerability'
238 );
239 $message .= ' </td>' . "\n";
240 $message .= ' </tr>' . "\n";
241
242 $unsubscribe_url = wpvulnerability_get_disable_notifications_url();
243 $unsubscribe = wp_kses(
244 sprintf(
245 // translators: %1$s unsubscribe URL.
246 __( 'Want to stop receiving these alerts? <a href="%1$s">Unsubscribe</a> or change the email frequency to <strong>Never</strong> in the WPVulnerability settings.', 'wpvulnerability' ),
247 esc_url( $unsubscribe_url )
248 ),
249 array(
250 'a' => array(
251 'href' => array(),
252 ),
253 'strong' => array(),
254 )
255 );
256
257 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
258 $message .= ' <td class="aligncenter content-block" style="box-sizing: border-box; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">' . "\n";
259 $message .= ' <p style="box-sizing: border-box; color: #999; text-align: center; margin: 0;">' . $unsubscribe . '</p>' . "\n";
260 $message .= ' </td>' . "\n";
261 $message .= ' </tr>' . "\n";
262
263 // Add the site URL in the footer based on the multisite configuration.
264 if ( is_multisite() ) {
265 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
266 $message .= ' <td class="aligncenter content-block" style="box-sizing: border-box; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top"><a href="' . esc_url( network_site_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( network_site_url() ) . '</a></td>' . "\n";
267 $message .= ' </tr>' . "\n";
268 } else {
269 $message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
270 $message .= ' <td class="aligncenter content-block" style="box-sizing: border-box; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top"><a href="' . esc_url( site_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( site_url() ) . '</a></td>' . "\n";
271 $message .= ' </tr>' . "\n";
272 }
273
274 $message .= ' </table>' . "\n";
275 $message .= ' </div>' . "\n";
276 $message .= ' </td>' . "\n";
277 $message .= ' <td style="box-sizing: border-box; vertical-align: top; margin: 0;" valign="top"></td>' . "\n";
278 $message .= ' </tr>' . "\n";
279 $message .= ' </table>' . "\n";
280 $message .= ' </div>' . "\n";
281 $message .= ' </td>' . "\n";
282 $message .= ' </tr>' . "\n";
283 $message .= ' </table>' . "\n";
284 $message .= '</body>' . "\n";
285 $message .= '</html>';
286
287 // Return the prepared HTML email message.
288 return $message;
289 }
290
291 /**
292 * Send a vulnerability notification to Slack.
293 *
294 * @since 4.1.3
295 *
296 * @param string $webhook_url Slack webhook URL.
297 * @param string $message Message body to deliver.
298 *
299 * @return bool True on success, false on failure.
300 */
301 function wpvulnerability_send_slack_notification( $webhook_url, $message ) {
302 $webhook_url = wpvulnerability_validate_webhook_url(
303 $webhook_url,
304 array(
305 'hooks.slack.com',
306 )
307 );
308
309 if ( empty( $webhook_url ) || empty( $message ) ) {
310 return false;
311 }
312
313 $encoded_slack = wp_json_encode( array( 'text' => $message ) );
314 $args = array(
315 'body' => false !== $encoded_slack ? $encoded_slack : '',
316 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
317 'timeout' => 10,
318 'data_format' => 'body',
319 );
320
321 $response = wp_remote_post( $webhook_url, $args );
322
323 if ( is_wp_error( $response ) ) {
324 return false;
325 }
326
327 $status_code = (int) wp_remote_retrieve_response_code( $response );
328
329 return $status_code >= 200 && $status_code < 300;
330 }
331
332 /**
333 * Send a vulnerability notification to Microsoft Teams.
334 *
335 * @since 4.1.3
336 *
337 * @param string $webhook_url Teams webhook URL.
338 * @param string $message Message body to deliver.
339 *
340 * @return bool True on success, false on failure.
341 */
342 function wpvulnerability_send_teams_notification( $webhook_url, $message ) {
343 $webhook_url = wpvulnerability_validate_webhook_url(
344 $webhook_url,
345 array(
346 'office.com',
347 'office365.com',
348 'api.hooks.microsoft.com',
349 )
350 );
351
352 if ( empty( $webhook_url ) || empty( $message ) ) {
353 return false;
354 }
355
356 $encoded_teams = wp_json_encode( array( 'text' => $message ) );
357 $args = array(
358 'body' => false !== $encoded_teams ? $encoded_teams : '',
359 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
360 'timeout' => 10,
361 'data_format' => 'body',
362 );
363
364 $response = wp_remote_post( $webhook_url, $args );
365
366 if ( is_wp_error( $response ) ) {
367 return false;
368 }
369
370 $status_code = (int) wp_remote_retrieve_response_code( $response );
371
372 return $status_code >= 200 && $status_code < 300;
373 }
374
375 /**
376 * Send a vulnerability notification to Discord.
377 *
378 * @since 4.3.0
379 *
380 * @param string $webhook_url Discord webhook URL.
381 * @param string $message Message body to deliver.
382 *
383 * @return bool True on success, false on failure.
384 */
385 function wpvulnerability_send_discord_notification( $webhook_url, $message ) {
386 $webhook_url = wpvulnerability_validate_webhook_url(
387 $webhook_url,
388 array(
389 'discord.com',
390 'discordapp.com',
391 )
392 );
393
394 if ( empty( $webhook_url ) || empty( $message ) ) {
395 return false;
396 }
397
398 // Discord has a 2000 character limit per message.
399 if ( strlen( $message ) > 2000 ) {
400 $message = substr( $message, 0, 1997 ) . '...';
401 }
402
403 $encoded_discord = wp_json_encode( array( 'content' => $message ) );
404 $args = array(
405 'body' => false !== $encoded_discord ? $encoded_discord : '',
406 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
407 'timeout' => 10,
408 'data_format' => 'body',
409 );
410
411 $response = wp_remote_post( $webhook_url, $args );
412
413 if ( is_wp_error( $response ) ) {
414 return false;
415 }
416
417 $status_code = (int) wp_remote_retrieve_response_code( $response );
418
419 return $status_code >= 200 && $status_code < 300;
420 }
421
422 /**
423 * Send a vulnerability notification to Telegram.
424 *
425 * @since 4.3.0
426 *
427 * @param string $bot_token Telegram bot token.
428 * @param string $chat_id Telegram chat ID.
429 * @param string $message Message body to deliver.
430 *
431 * @return bool True on success, false on failure.
432 */
433 function wpvulnerability_send_telegram_notification( $bot_token, $chat_id, $message ) {
434 // Sanitize inputs.
435 $bot_token = sanitize_text_field( trim( (string) $bot_token ) );
436 $chat_id = sanitize_text_field( trim( (string) $chat_id ) );
437
438 if ( empty( $bot_token ) || empty( $chat_id ) || empty( $message ) ) {
439 return false;
440 }
441
442 // Validate bot token format (should be like: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
443 if ( ! preg_match( '/^\d+:[A-Za-z0-9_-]+$/', $bot_token ) ) {
444 return false;
445 }
446
447 // Telegram has a 4096 character limit per message.
448 if ( strlen( $message ) > 4096 ) {
449 $message = substr( $message, 0, 4093 ) . '...';
450 }
451
452 $api_url = 'https://api.telegram.org/bot' . $bot_token . '/sendMessage';
453
454 $encoded_telegram = wp_json_encode(
455 array(
456 'chat_id' => $chat_id,
457 'text' => $message,
458 )
459 );
460 $args = array(
461 'body' => false !== $encoded_telegram ? $encoded_telegram : '',
462 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
463 'timeout' => 10,
464 'data_format' => 'body',
465 );
466
467 $response = wp_remote_post( $api_url, $args );
468
469 if ( is_wp_error( $response ) ) {
470 return false;
471 }
472
473 $status_code = (int) wp_remote_retrieve_response_code( $response );
474
475 return $status_code >= 200 && $status_code < 300;
476 }
477
478 /**
479 * Executes the vulnerability notification process for a WordPress site.
480 *
481 * This function checks for vulnerabilities in the WordPress core, plugins, themes, PHP environment, and web server components.
482 * It generates an HTML email report detailing any vulnerabilities found. If the function is called with
483 * the $forced parameter set to true, it will send an email even if no vulnerabilities are found, which is useful for testing purposes.
484 *
485 * @since 2.0.0
486 *
487 * @param bool $forced Optional. If set to true, forces the sending of a notification email regardless of whether vulnerabilities are found. Default false.
488 * @return bool True when the email was successfully sent, false otherwise.
489 */
490 function wpvulnerability_execute_notification( $forced = false ) {
491 $email_content = '';
492 $wpvulnerability_settings = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' );
493
494 if ( ! is_array( $wpvulnerability_settings ) ) {
495 $wpvulnerability_settings = array();
496 }
497
498 $notify_settings = isset( $wpvulnerability_settings['notify'] ) ? $wpvulnerability_settings['notify'] : array();
499 $wpvulnerability_settings['notify'] = wpvulnerability_normalize_notify_settings( $notify_settings );
500
501 $email_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['email'] ) && ! empty( $wpvulnerability_settings['emails'] );
502 $slack_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['slack'] ) && ! empty( $wpvulnerability_settings['slack_webhook'] );
503 $teams_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['teams'] ) && ! empty( $wpvulnerability_settings['teams_webhook'] );
504 $discord_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['discord'] ) && ! empty( $wpvulnerability_settings['discord_webhook'] );
505 $telegram_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['telegram'] ) && ! empty( $wpvulnerability_settings['telegram_bot_token'] ) && ! empty( $wpvulnerability_settings['telegram_chat_id'] );
506
507 if ( ! $forced && ( empty( $wpvulnerability_settings['period'] ) || ( ! $email_enabled && ! $slack_enabled && ! $teams_enabled && ! $discord_enabled && ! $telegram_enabled ) ) ) {
508 return false;
509 }
510
511 // Generate HTML for core, plugins, and themes vulnerabilities.
512 $html_core = wpvulnerability_analyze_filter( 'core' ) && wpvulnerability_get_component_count( 'core' ) ? wpvulnerability_html_core() : null;
513
514 $html_plugins = wpvulnerability_analyze_filter( 'plugins' ) && wpvulnerability_get_component_count( 'plugins' ) ? wpvulnerability_html_plugins() : null;
515
516 $html_themes = wpvulnerability_analyze_filter( 'themes' ) && wpvulnerability_get_component_count( 'themes' ) ? wpvulnerability_html_themes() : null;
517
518 // Generate HTML for PHP, Apache, Nginx, MariaDB, MySQL... vulnerabilities.
519 $html_php = wpvulnerability_analyze_filter( 'php' ) && wpvulnerability_get_component_count( 'php' ) ? wpvulnerability_html_software( 'php' ) : null;
520
521 $html_apache = wpvulnerability_analyze_filter( 'apache' ) && wpvulnerability_get_component_count( 'apache' ) ? wpvulnerability_html_software( 'apache' ) : null;
522
523 $html_nginx = wpvulnerability_analyze_filter( 'nginx' ) && wpvulnerability_get_component_count( 'nginx' ) ? wpvulnerability_html_software( 'nginx' ) : null;
524
525 $html_mariadb = wpvulnerability_analyze_filter( 'mariadb' ) && wpvulnerability_get_component_count( 'mariadb' ) ? wpvulnerability_html_software( 'mariadb' ) : null;
526
527 $html_mysql = wpvulnerability_analyze_filter( 'mysql' ) && wpvulnerability_get_component_count( 'mysql' ) ? wpvulnerability_html_software( 'mysql' ) : null;
528
529 $html_imagemagick = wpvulnerability_analyze_filter( 'imagemagick' ) && wpvulnerability_get_component_count( 'imagemagick' ) ? wpvulnerability_html_software( 'imagemagick' ) : null;
530
531 $html_curl = wpvulnerability_analyze_filter( 'curl' ) && wpvulnerability_get_component_count( 'curl' ) ? wpvulnerability_html_software( 'curl' ) : null;
532
533 $html_memcached = wpvulnerability_analyze_filter( 'memcached' ) && wpvulnerability_get_component_count( 'memcached' ) ? wpvulnerability_html_software( 'memcached' ) : null;
534
535 $html_redis = wpvulnerability_analyze_filter( 'redis' ) && wpvulnerability_get_component_count( 'redis' ) ? wpvulnerability_html_software( 'redis' ) : null;
536
537 $html_sqlite = wpvulnerability_analyze_filter( 'sqlite' ) && wpvulnerability_get_component_count( 'sqlite' ) ? wpvulnerability_html_software( 'sqlite' ) : null;
538
539 $all_empty = ( empty( $html_core ) && empty( $html_plugins ) && empty( $html_themes ) && empty( $html_php ) && empty( $html_apache ) && empty( $html_nginx ) && empty( $html_mariadb ) && empty( $html_mysql ) && empty( $html_imagemagick ) && empty( $html_curl ) && empty( $html_memcached ) && empty( $html_redis ) && empty( $html_sqlite ) );
540
541 // If forced email sending is not enabled and no vulnerabilities were found, exit the function.
542 if ( ! $forced && $all_empty ) {
543 return false;
544 } elseif ( $forced && $all_empty ) {
545 $email_content .= '<h2>' . esc_html__( 'No vulnerabilities found', 'wpvulnerability' ) . '</h2>';
546 $email_content .= '<p>' . esc_html__( 'This is likely a test. The site does not have vulnerabilities.', 'wpvulnerability' ) . '</p>';
547 }
548
549 // Append core vulnerabilities HTML to the email content.
550 if ( ! empty( $html_core ) ) {
551 $email_content .= '<h2>' . esc_html__( 'Core vulnerabilities', 'wpvulnerability' ) . '</h2>';
552 $email_content .= $html_core;
553 }
554
555 // Append plugins vulnerabilities HTML to the email content.
556 if ( ! empty( $html_plugins ) ) {
557 $email_content .= '<h2>' . esc_html__( 'Plugins vulnerabilities', 'wpvulnerability' ) . '</h2>';
558 $email_content .= $html_plugins;
559 }
560
561 // Append themes vulnerabilities HTML to the email content.
562 if ( ! empty( $html_themes ) ) {
563 $email_content .= '<h2>' . esc_html__( 'Themes vulnerabilities', 'wpvulnerability' ) . '</h2>';
564 $email_content .= $html_themes;
565 }
566
567 // Append PHP vulnerabilities HTML to the email content.
568 if ( ! empty( $html_php ) ) {
569 $email_content .= '<h2>' . esc_html__( 'PHP vulnerabilities', 'wpvulnerability' ) . '</h2>';
570 $email_content .= $html_php;
571 }
572
573 // Append Apache vulnerabilities HTML to the email content.
574 if ( ! empty( $html_apache ) ) {
575 $email_content .= '<h2>' . esc_html__( 'Apache HTTPD vulnerabilities', 'wpvulnerability' ) . '</h2>';
576 $email_content .= $html_apache;
577 }
578
579 // Append Nginx vulnerabilities HTML to the email content.
580 if ( ! empty( $html_nginx ) ) {
581 $email_content .= '<h2>' . esc_html__( 'Nginx vulnerabilities', 'wpvulnerability' ) . '</h2>';
582 $email_content .= $html_nginx;
583 }
584
585 // Append MariaDB vulnerabilities HTML to the email content.
586 if ( ! empty( $html_mariadb ) ) {
587 $email_content .= '<h2>' . esc_html__( 'MariaDB vulnerabilities', 'wpvulnerability' ) . '</h2>';
588 $email_content .= $html_mariadb;
589 }
590
591 // Append MySQL vulnerabilities HTML to the email content.
592 if ( ! empty( $html_mysql ) ) {
593 $email_content .= '<h2>' . esc_html__( 'MySQL vulnerabilities', 'wpvulnerability' ) . '</h2>';
594 $email_content .= $html_mysql;
595 }
596
597 // Append ImageMagick vulnerabilities HTML to the email content.
598 if ( ! empty( $html_imagemagick ) ) {
599 $email_content .= '<h2>' . esc_html__( 'ImageMagick vulnerabilities', 'wpvulnerability' ) . '</h2>';
600 $email_content .= $html_imagemagick;
601 }
602
603 // Append curl vulnerabilities HTML to the email content.
604 if ( ! empty( $html_curl ) ) {
605 $email_content .= '<h2>' . esc_html__( 'curl vulnerabilities', 'wpvulnerability' ) . '</h2>';
606 $email_content .= $html_curl;
607 }
608
609 // Append memcached vulnerabilities HTML to the email content.
610 if ( ! empty( $html_memcached ) ) {
611 $email_content .= '<h2>' . esc_html__( 'memcached vulnerabilities', 'wpvulnerability' ) . '</h2>';
612 $email_content .= $html_memcached;
613 }
614
615 // Append Redis vulnerabilities HTML to the email content.
616 if ( ! empty( $html_redis ) ) {
617 $email_content .= '<h2>' . esc_html__( 'Redis vulnerabilities', 'wpvulnerability' ) . '</h2>';
618 $email_content .= $html_redis;
619 }
620
621 // Append SQLite vulnerabilities HTML to the email content.
622 if ( ! empty( $html_sqlite ) ) {
623 $email_content .= '<h2>' . esc_html__( 'SQLite vulnerabilities', 'wpvulnerability' ) . '</h2>';
624 $email_content .= $html_sqlite;
625 }
626
627 // Get the site name.
628 $admin_site = is_multisite() ? get_site_option( 'site_name' ) : get_bloginfo( 'name' );
629
630 // Get the admin email.
631 $admin_email = is_multisite() ? get_site_option( 'admin_email' ) : get_bloginfo( 'admin_email' );
632 $from_email = $admin_email;
633
634 // Check if WPVULNERABILITY_MAIL is defined and valid, and use it if available.
635 if ( defined( 'WPVULNERABILITY_MAIL' ) ) {
636 $wpvulnerability_sender_email = sanitize_email( trim( (string) WPVULNERABILITY_MAIL ) );
637 if ( is_email( $wpvulnerability_sender_email ) ) {
638 $from_email = $wpvulnerability_sender_email;
639 }
640 unset( $wpvulnerability_sender_email );
641 }
642
643 // Prepare email subject and content.
644 $email_subject = sprintf(
645 // translators: Site name.
646 __( 'Vulnerability found: %s', 'wpvulnerability' ),
647 ( is_scalar( $admin_site ) ? (string) $admin_site : '' )
648 );
649
650 $email_prepared = wpvulnerability_email_prepare( esc_html__( 'Vulnerability found', 'wpvulnerability' ), $email_content );
651
652 // Prepare email headers.
653 $email_headers = array();
654 $email_headers[] = 'From: WPVulnerability <' . ( is_scalar( $from_email ) ? (string) $from_email : '' ) . '>';
655 $email_headers[] = 'Content-Type: text/html; charset=UTF-8';
656
657 if ( $forced && ( empty( $wpvulnerability_settings['emails'] ) ) ) {
658 // Determine the recipient email.
659 $wpvulnerability_settings['emails'] = array( $admin_email );
660 }
661
662 $wpmail = false;
663
664 if ( $email_enabled || $forced ) {
665 $mail_to = is_array( $wpvulnerability_settings['emails'] ) ? array_map(
666 static function ( $e ) {
667 return is_scalar( $e ) ? (string) $e : '';
668 },
669 $wpvulnerability_settings['emails']
670 ) : ( is_scalar( $wpvulnerability_settings['emails'] ) ? (string) $wpvulnerability_settings['emails'] : '' );
671 $wpmail = wp_mail( $mail_to, $email_subject, $email_prepared, $email_headers );
672 }
673
674 $text_message_body = wpvulnerability_html_to_plain_text( $email_content );
675 $text_message = trim( (string) ( $email_subject . "\n\n" . $text_message_body ) );
676
677 // Track whether any webhook channel delivered successfully, so a webhook-only
678 // notification (email disabled) is not reported as a failure.
679 $webhook_sent = false;
680
681 if ( $slack_enabled && wpvulnerability_send_slack_notification( ( is_scalar( $wpvulnerability_settings['slack_webhook'] ) ? (string) $wpvulnerability_settings['slack_webhook'] : '' ), $text_message ) ) {
682 $webhook_sent = true;
683 }
684
685 if ( $teams_enabled && wpvulnerability_send_teams_notification( ( is_scalar( $wpvulnerability_settings['teams_webhook'] ) ? (string) $wpvulnerability_settings['teams_webhook'] : '' ), $text_message ) ) {
686 $webhook_sent = true;
687 }
688
689 if ( $discord_enabled && wpvulnerability_send_discord_notification( ( is_scalar( $wpvulnerability_settings['discord_webhook'] ) ? (string) $wpvulnerability_settings['discord_webhook'] : '' ), $text_message ) ) {
690 $webhook_sent = true;
691 }
692
693 if ( $telegram_enabled && wpvulnerability_send_telegram_notification( ( is_scalar( $wpvulnerability_settings['telegram_bot_token'] ) ? (string) $wpvulnerability_settings['telegram_bot_token'] : '' ), ( is_scalar( $wpvulnerability_settings['telegram_chat_id'] ) ? (string) $wpvulnerability_settings['telegram_chat_id'] : '' ), $text_message ) ) {
694 $webhook_sent = true;
695 }
696
697 return $wpmail || $webhook_sent;
698 }
699
700 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM properties follow upstream naming.
701 /**
702 * Convert HTML notification content into a plain text representation.
703 *
704 * Ensures notifications sent to chat platforms retain meaningful structure by
705 * translating headings, paragraphs, lists, tables, and links into readable
706 * plain text. List indentation and table rows are preserved with appropriate
707 * line breaks so the resulting message can be consumed without HTML support.
708 *
709 * @since 4.1.4
710 *
711 * @param string $html HTML fragment to convert.
712 *
713 * @return string Normalized plain text message.
714 */
715 function wpvulnerability_html_to_plain_text( $html ) {
716 $html = (string) $html;
717
718 if ( '' === trim( (string) $html ) ) {
719 return '';
720 }
721
722 if ( ! class_exists( 'DOMDocument', false ) ) {
723 return wp_strip_all_tags( $html );
724 }
725
726 $libxml_previous_state = libxml_use_internal_errors( true );
727 $dom = new DOMDocument();
728 $wrapped_html = '<div>' . $html . '</div>';
729 $load_flags = 0;
730
731 if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) {
732 $load_flags |= LIBXML_HTML_NOIMPLIED;
733 }
734
735 if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) {
736 $load_flags |= LIBXML_HTML_NODEFDTD;
737 }
738
739 $dom->loadHTML( '<?xml encoding="utf-8" ?>' . $wrapped_html, $load_flags );
740 libxml_clear_errors();
741 libxml_use_internal_errors( $libxml_previous_state );
742
743 $list_stack = array();
744 $output = '';
745
746 $doc_element = $dom->documentElement;
747 if ( null !== $doc_element ) {
748 foreach ( $doc_element->childNodes as $child_node ) {
749 $output .= wpvulnerability_dom_node_to_plain_text( $child_node, $list_stack );
750 }
751 }
752
753 $output = html_entity_decode( $output, ENT_QUOTES, 'UTF-8' );
754 $output = preg_replace( '#/\\*.*?\\*/#s', '', $output ) ?? $output;
755 $output = preg_replace( '/[ \t]+\n/', "\n", $output ) ?? $output;
756 $output = preg_replace( "/\n{3,}/", "\n\n", $output ) ?? $output;
757
758 return trim( (string) $output );
759 }
760
761 /**
762 * Recursively convert DOM nodes to plain text.
763 *
764 * @since 4.1.4
765 *
766 * @param DOMNode $node Node being transformed.
767 * @param array<int, array<string, mixed>> $list_stack Stack describing parent list context.
768 *
769 * @return string Plain text representation of the node.
770 */
771 function wpvulnerability_dom_node_to_plain_text( DOMNode $node, array &$list_stack ): string {
772 if ( XML_TEXT_NODE === $node->nodeType ) {
773 $raw_value = $node->nodeValue ?? '';
774 return preg_replace( '/\\s+/u', ' ', $raw_value ) ?? $raw_value;
775 }
776
777 if ( XML_ELEMENT_NODE !== $node->nodeType ) {
778 return '';
779 }
780
781 $tag_name = strtolower( (string) $node->nodeName );
782
783 switch ( $tag_name ) {
784 case 'br':
785 return "\n";
786
787 case 'p':
788 case 'div':
789 case 'section':
790 case 'article':
791 case 'header':
792 case 'footer':
793 $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
794
795 return '' === $content ? '' : $content . "\n\n";
796
797 case 'h1':
798 case 'h2':
799 case 'h3':
800 case 'h4':
801 case 'h5':
802 case 'h6':
803 $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
804
805 return '' === $content ? '' : $content . "\n\n";
806
807 case 'strong':
808 case 'em':
809 case 'span':
810 case 'code':
811 case 'b':
812 case 'i':
813 case 'u':
814 case 'small':
815 return wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
816
817 case 'a':
818 $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
819 $href = '';
820
821 if ( null !== $node->attributes && null !== $node->attributes->getNamedItem( 'href' ) ) {
822 $href_attribute = $node->attributes->getNamedItem( 'href' );
823 $href = trim( (string) $href_attribute->nodeValue );
824 }
825
826 if ( '' !== $href && '' !== $content && false === strpos( $content, $href ) ) {
827 return $content . ' (' . $href . ')';
828 }
829
830 return $content;
831
832 case 'ul':
833 case 'ol':
834 $list_stack[] = array(
835 'type' => $tag_name,
836 'index' => 0,
837 );
838 $content = wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
839 array_pop( $list_stack );
840
841 return $content . ( '' === $content ? '' : "\n" );
842
843 case 'li':
844 $depth = count( $list_stack );
845 $indent = $depth > 0 ? str_repeat( ' ', $depth - 1 ) : '';
846 $marker = '- ';
847
848 if ( $depth > 0 ) {
849 $current_index = $depth - 1;
850 $list_stack[ $current_index ]['index'] = ( is_int( $list_stack[ $current_index ]['index'] ) ? $list_stack[ $current_index ]['index'] : 0 ) + 1;
851
852 if ( isset( $list_stack[ $current_index ]['type'] ) && 'ol' === $list_stack[ $current_index ]['type'] ) {
853 $marker = $list_stack[ $current_index ]['index'] . '. ';
854 }
855 }
856
857 $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
858
859 if ( '' === $content ) {
860 return '';
861 }
862
863 $content = preg_replace( '/\n/', "\n" . $indent . ' ', $content ) ?? $content;
864
865 return $indent . $marker . $content . "\n";
866
867 case 'table':
868 $rows = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
869
870 return '' === $rows ? '' : $rows . "\n";
871
872 case 'thead':
873 case 'tbody':
874 case 'tfoot':
875 case 'tr':
876 return wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
877
878 case 'th':
879 case 'td':
880 $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
881
882 return '' === $content ? '' : $content . ' | ';
883
884 default:
885 return wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
886 }
887 }
888
889 /**
890 * Generate plain text for the children of a DOM node.
891 *
892 * @since 4.1.4
893 *
894 * @param DOMNode $node Parent DOM node.
895 * @param array<int, array<string, mixed>> $list_stack Stack describing parent list context.
896 *
897 * @return string Concatenated plain text for child nodes.
898 */
899 function wpvulnerability_dom_children_to_plain_text( DOMNode $node, array &$list_stack ): string {
900 $text = '';
901
902 foreach ( $node->childNodes as $child ) {
903 $child_text = wpvulnerability_dom_node_to_plain_text( $child, $list_stack );
904
905 if ( '' === $child_text ) {
906 continue;
907 }
908
909 $text .= $child_text;
910 }
911
912 if ( 'td' === strtolower( (string) $node->nodeName ) || 'th' === strtolower( (string) $node->nodeName ) ) {
913 $text = rtrim( (string) $text, ' |' );
914 }
915
916 if ( 'tr' === strtolower( (string) $node->nodeName ) ) {
917 $text = rtrim( (string) $text, ' |' );
918 $text = '' === $text ? '' : $text . "\n";
919 }
920
921 return $text;
922 }
923 // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
924