wpvulnerability/wpvulnerability-notifications.php
2026-08-23 07:08:42 +00:00

945 lines
36 KiB
PHP

<?php
/**
* Notifications functions
*
* @package WPVulnerability
*
* @version 2.0.0
*/
defined( 'ABSPATH' ) || die( 'No script kiddies please!' );
/**
* Disables email notifications when requested via URL.
*
* When the `wpvulnerability_disable_email` query parameter is present and the accompanying
* nonce validates, this handler disables future email notifications for the current site.
*
* @since 4.1.3
*
* @return void
*/
function wpvulnerability_disable_notifications_via_url() {
if ( empty( $_GET['wpvulnerability_disable_email'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( is_string( $_GET['nonce'] ) ? $_GET['nonce'] : '' ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! wp_verify_nonce( $nonce, 'wpvulnerability-disable-email' ) ) {
wp_die( esc_html__( 'Invalid request.', 'wpvulnerability' ) );
}
$settings = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' );
if ( ! is_array( $settings ) ) {
$settings = array();
}
$notify_settings = isset( $settings['notify'] ) ? $settings['notify'] : array();
$settings['notify'] = wpvulnerability_normalize_notify_settings( $notify_settings );
$settings['notify']['email'] = 'n';
if ( is_multisite() ) {
update_site_option( 'wpvulnerability-config', $settings );
} else {
update_option( 'wpvulnerability-config', $settings );
}
wp_die(
esc_html__( 'You have unsubscribed from WPVulnerability notifications.', 'wpvulnerability' ),
esc_html__( 'WPVulnerability', 'wpvulnerability' ),
array( 'response' => 200 )
);
}
add_action( 'init', 'wpvulnerability_disable_notifications_via_url' );
/**
* Determine if the webhook host is in the allowed list.
*
* @since 4.3.0
*
* @param string $host Host portion of the webhook URL.
* @param array<string> $allowed_hosts Allowed host suffixes.
*
* @return bool True when the host matches the allow list.
*/
function wpvulnerability_is_allowed_webhook_host( $host, $allowed_hosts ) {
foreach ( $allowed_hosts as $allowed_host ) {
$allowed_host = strtolower( trim( (string) $allowed_host ) );
if ( '' === $allowed_host ) {
continue;
}
if ( $host === $allowed_host ) {
return true;
}
$suffix = '.' . $allowed_host;
if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
return true;
}
}
return false;
}
/**
* Validates a webhook URL against an allow list and HTTPS enforcement.
*
* @since 4.3.0
*
* @param string $webhook_url Webhook URL to validate.
* @param array<string> $allowed_hosts Allowed host suffixes.
*
* @return string Sanitized URL or empty string when invalid.
*/
function wpvulnerability_validate_webhook_url( $webhook_url, $allowed_hosts ) {
$webhook_url = esc_url_raw( trim( (string) $webhook_url ) );
if ( empty( $webhook_url ) ) {
return '';
}
$parts = wp_parse_url( $webhook_url );
if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
return '';
}
if ( 'https' !== strtolower( (string) $parts['scheme'] ) ) {
return '';
}
$host = strtolower( (string) $parts['host'] );
if ( ! wpvulnerability_is_allowed_webhook_host( $host, $allowed_hosts ) ) {
return '';
}
return $webhook_url;
}
/**
* Masks a secret value for display in the admin UI.
*
* Per the project security checklist, secrets (webhook URLs, bot tokens) are
* never rendered in full: only their last 4 characters are shown.
*
* @since 5.1.4
*
* @param string $value Secret value to mask.
*
* @return string Masked value, or an empty string when the input is empty.
*/
function wpvulnerability_mask_secret( $value ) {
$value = (string) $value;
if ( '' === $value ) {
return '';
}
return '••••••••' . mb_substr( $value, -4 );
}
/**
* Retrieves the unsubscribe URL for WPVulnerability email notifications.
*
* Generates a signed URL that disables future email notifications when
* visited. The URL works in both single and multisite environments and is
* validated through the `wpvulnerability_disable_notifications_via_url`
* handler. When running in multisite, it targets the network home only if
* the plugin is network-activated; otherwise it uses the current site's
* home URL.
*
* @since 4.1.7
*
* @return string The unsubscribe URL including the security nonce.
*/
function wpvulnerability_get_disable_notifications_url() {
$nonce = wp_create_nonce( 'wpvulnerability-disable-email' );
$base_url = home_url( '/' );
if ( is_multisite() ) {
if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_basename = defined( 'WPVULNERABILITY_PLUGIN_BASE' ) ? WPVULNERABILITY_PLUGIN_BASE : plugin_basename( WPVULNERABILITY_PLUGIN_FILE );
if ( function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_basename ) ) {
$base_url = network_home_url( '/' );
}
}
return add_query_arg(
array(
'wpvulnerability_disable_email' => 1,
'nonce' => $nonce,
),
$base_url
);
}
/**
* Prepares the HTML email message.
*
* This function generates an HTML email message with the given title and content.
* It includes basic styling and structure to ensure compatibility with most email clients.
*
* @since 2.0.0
*
* @param string $title The title of the email message.
* @param string $content The content of the email message.
*
* @return string The prepared HTML email message.
*/
function wpvulnerability_email_prepare( $title, $content ) {
$message = '';
$message .= '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">' . "\n";
$message .= '<html xmlns="http://www.w3.org/1999/xhtml" style="box-sizing: border-box; margin: 0;">' . "\n";
$message .= '<head>' . "\n";
$message .= ' <meta name="viewport" content="width=device-width">' . "\n";
$message .= ' <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' . "\n";
$message .= ' <title>WPVulnerability</title>' . "\n";
$message .= ' <style type="text/css">' . "\n";
$message .= ' img { max-width: 100%; }' . "\n";
$message .= ' body { -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.2em; }' . "\n";
$message .= ' body { background-color: #f6f6f6; }' . "\n";
$message .= ' @media only screen and (max-width: 640px) {' . "\n";
$message .= ' body { padding: 0 !important; }' . "\n";
$message .= ' h1, h2, h3, h4 { margin: 20px 0 5px 0 !important; }' . "\n";
$message .= ' .container { padding: 0 !important; width: 100% !important; }' . "\n";
$message .= ' .content { padding: 0 !important; }' . "\n";
$message .= ' .content-wrap { padding: 10px !important; }' . "\n";
$message .= ' .invoice { width: 100% !important; }' . "\n";
$message .= ' }' . "\n";
$message .= ' </style>' . "\n";
$message .= '</head>' . "\n";
$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";
$message .= ' <table class="body-wrap" style="box-sizing: border-box; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">' . "\n";
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$message .= ' <td style="box-sizing: border-box; vertical-align: top; margin: 0;" valign="top"></td>' . "\n";
$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";
$message .= ' <div class="content" style="box-sizing: border-box; max-width: 600px; display: block; margin: 0 auto; padding: 20px;">' . "\n";
$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";
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= ' <table width="100%" cellpadding="0" cellspacing="0" style="box-sizing: border-box; margin: 0;">' . "\n";
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= ' <img class="aligncenter" src="' . WPVULNERABILITY_PLUGIN_URL . 'assets/icon-128x128.png" width="64" height="64" alt="WPVulnerability">' . "\n";
$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";
// Add the site URL based on the multisite configuration.
if ( is_multisite() ) {
$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";
} else {
$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";
}
$message .= ' </td>' . "\n";
$message .= ' </tr>' . "\n";
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= $content; // Add the main content of the email.
$message .= ' </td>' . "\n";
$message .= ' </tr>' . "\n";
$message .= ' </table>' . "\n";
$message .= ' <div class="footer" style="box-sizing: border-box; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;">' . "\n";
$message .= ' <table width="100%" style="box-sizing: border-box; margin: 0;">' . "\n";
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= sprintf(
// translators: %1$s the website of Database, %2$s database site name.
__( 'Learn more about the WordPress Vulnerability Database API at <a href="%1$s">%2$s</a>', 'wpvulnerability' ),
'https://www.wpvulnerability.com/',
'WPVulnerability'
);
$message .= ' </td>' . "\n";
$message .= ' </tr>' . "\n";
$unsubscribe_url = wpvulnerability_get_disable_notifications_url();
$unsubscribe = wp_kses(
sprintf(
// translators: %1$s unsubscribe URL.
__( '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' ),
esc_url( $unsubscribe_url )
),
array(
'a' => array(
'href' => array(),
),
'strong' => array(),
)
);
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= ' <p style="box-sizing: border-box; color: #999; text-align: center; margin: 0;">' . $unsubscribe . '</p>' . "\n";
$message .= ' </td>' . "\n";
$message .= ' </tr>' . "\n";
// Add the site URL in the footer based on the multisite configuration.
if ( is_multisite() ) {
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= ' </tr>' . "\n";
} else {
$message .= ' <tr style="box-sizing: border-box; margin: 0;">' . "\n";
$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";
$message .= ' </tr>' . "\n";
}
$message .= ' </table>' . "\n";
$message .= ' </div>' . "\n";
$message .= ' </td>' . "\n";
$message .= ' <td style="box-sizing: border-box; vertical-align: top; margin: 0;" valign="top"></td>' . "\n";
$message .= ' </tr>' . "\n";
$message .= ' </table>' . "\n";
$message .= ' </div>' . "\n";
$message .= ' </td>' . "\n";
$message .= ' </tr>' . "\n";
$message .= ' </table>' . "\n";
$message .= '</body>' . "\n";
$message .= '</html>';
// Return the prepared HTML email message.
return $message;
}
/**
* Send a vulnerability notification to Slack.
*
* @since 4.1.3
*
* @param string $webhook_url Slack webhook URL.
* @param string $message Message body to deliver.
*
* @return bool True on success, false on failure.
*/
function wpvulnerability_send_slack_notification( $webhook_url, $message ) {
$webhook_url = wpvulnerability_validate_webhook_url(
$webhook_url,
array(
'hooks.slack.com',
)
);
if ( empty( $webhook_url ) || empty( $message ) ) {
return false;
}
$encoded_slack = wp_json_encode( array( 'text' => $message ) );
$args = array(
'body' => false !== $encoded_slack ? $encoded_slack : '',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'timeout' => 10,
'data_format' => 'body',
);
$response = wp_remote_post( $webhook_url, $args );
if ( is_wp_error( $response ) ) {
return false;
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
return $status_code >= 200 && $status_code < 300;
}
/**
* Send a vulnerability notification to Microsoft Teams.
*
* @since 4.1.3
*
* @param string $webhook_url Teams webhook URL.
* @param string $message Message body to deliver.
*
* @return bool True on success, false on failure.
*/
function wpvulnerability_send_teams_notification( $webhook_url, $message ) {
$webhook_url = wpvulnerability_validate_webhook_url(
$webhook_url,
array(
'office.com',
'office365.com',
'api.hooks.microsoft.com',
)
);
if ( empty( $webhook_url ) || empty( $message ) ) {
return false;
}
$encoded_teams = wp_json_encode( array( 'text' => $message ) );
$args = array(
'body' => false !== $encoded_teams ? $encoded_teams : '',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'timeout' => 10,
'data_format' => 'body',
);
$response = wp_remote_post( $webhook_url, $args );
if ( is_wp_error( $response ) ) {
return false;
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
return $status_code >= 200 && $status_code < 300;
}
/**
* Send a vulnerability notification to Discord.
*
* @since 4.3.0
*
* @param string $webhook_url Discord webhook URL.
* @param string $message Message body to deliver.
*
* @return bool True on success, false on failure.
*/
function wpvulnerability_send_discord_notification( $webhook_url, $message ) {
$webhook_url = wpvulnerability_validate_webhook_url(
$webhook_url,
array(
'discord.com',
'discordapp.com',
)
);
if ( empty( $webhook_url ) || empty( $message ) ) {
return false;
}
// Discord has a 2000 character limit per message.
if ( strlen( $message ) > 2000 ) {
$message = substr( $message, 0, 1997 ) . '...';
}
$encoded_discord = wp_json_encode( array( 'content' => $message ) );
$args = array(
'body' => false !== $encoded_discord ? $encoded_discord : '',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'timeout' => 10,
'data_format' => 'body',
);
$response = wp_remote_post( $webhook_url, $args );
if ( is_wp_error( $response ) ) {
return false;
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
return $status_code >= 200 && $status_code < 300;
}
/**
* Send a vulnerability notification to Telegram.
*
* @since 4.3.0
*
* @param string $bot_token Telegram bot token.
* @param string $chat_id Telegram chat ID.
* @param string $message Message body to deliver.
*
* @return bool True on success, false on failure.
*/
function wpvulnerability_send_telegram_notification( $bot_token, $chat_id, $message ) {
// Sanitize inputs.
$bot_token = sanitize_text_field( trim( (string) $bot_token ) );
$chat_id = sanitize_text_field( trim( (string) $chat_id ) );
if ( empty( $bot_token ) || empty( $chat_id ) || empty( $message ) ) {
return false;
}
// Validate bot token format (should be like: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
if ( ! preg_match( '/^\d+:[A-Za-z0-9_-]+$/', $bot_token ) ) {
return false;
}
// Telegram has a 4096 character limit per message.
if ( strlen( $message ) > 4096 ) {
$message = substr( $message, 0, 4093 ) . '...';
}
$api_url = 'https://api.telegram.org/bot' . $bot_token . '/sendMessage';
$encoded_telegram = wp_json_encode(
array(
'chat_id' => $chat_id,
'text' => $message,
)
);
$args = array(
'body' => false !== $encoded_telegram ? $encoded_telegram : '',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'timeout' => 10,
'data_format' => 'body',
);
$response = wp_remote_post( $api_url, $args );
if ( is_wp_error( $response ) ) {
return false;
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
return $status_code >= 200 && $status_code < 300;
}
/**
* Executes the vulnerability notification process for a WordPress site.
*
* This function checks for vulnerabilities in the WordPress core, plugins, themes, PHP environment, and web server components.
* It generates an HTML email report detailing any vulnerabilities found. If the function is called with
* the $forced parameter set to true, it will send an email even if no vulnerabilities are found, which is useful for testing purposes.
*
* @since 2.0.0
*
* @param bool $forced Optional. If set to true, forces the sending of a notification email regardless of whether vulnerabilities are found. Default false.
* @return bool True when the email was successfully sent, false otherwise.
*/
function wpvulnerability_execute_notification( $forced = false ) {
$email_content = '';
$wpvulnerability_settings = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' );
if ( ! is_array( $wpvulnerability_settings ) ) {
$wpvulnerability_settings = array();
}
$notify_settings = isset( $wpvulnerability_settings['notify'] ) ? $wpvulnerability_settings['notify'] : array();
$wpvulnerability_settings['notify'] = wpvulnerability_normalize_notify_settings( $notify_settings );
$email_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['email'] ) && ! empty( $wpvulnerability_settings['emails'] );
$slack_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['slack'] ) && ! empty( $wpvulnerability_settings['slack_webhook'] );
$teams_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['teams'] ) && ! empty( $wpvulnerability_settings['teams_webhook'] );
$discord_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['discord'] ) && ! empty( $wpvulnerability_settings['discord_webhook'] );
$telegram_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['telegram'] ) && ! empty( $wpvulnerability_settings['telegram_bot_token'] ) && ! empty( $wpvulnerability_settings['telegram_chat_id'] );
if ( ! $forced && ( empty( $wpvulnerability_settings['period'] ) || ( ! $email_enabled && ! $slack_enabled && ! $teams_enabled && ! $discord_enabled && ! $telegram_enabled ) ) ) {
return false;
}
// Generate HTML for core, plugins, and themes vulnerabilities.
$html_core = wpvulnerability_analyze_filter( 'core' ) && wpvulnerability_get_component_count( 'core' ) ? wpvulnerability_html_core() : null;
$html_plugins = wpvulnerability_analyze_filter( 'plugins' ) && wpvulnerability_get_component_count( 'plugins' ) ? wpvulnerability_html_plugins() : null;
$html_themes = wpvulnerability_analyze_filter( 'themes' ) && wpvulnerability_get_component_count( 'themes' ) ? wpvulnerability_html_themes() : null;
// Generate HTML for PHP, Apache, Nginx, MariaDB, MySQL... vulnerabilities.
$html_php = wpvulnerability_analyze_filter( 'php' ) && wpvulnerability_get_component_count( 'php' ) ? wpvulnerability_html_software( 'php' ) : null;
$html_apache = wpvulnerability_analyze_filter( 'apache' ) && wpvulnerability_get_component_count( 'apache' ) ? wpvulnerability_html_software( 'apache' ) : null;
$html_nginx = wpvulnerability_analyze_filter( 'nginx' ) && wpvulnerability_get_component_count( 'nginx' ) ? wpvulnerability_html_software( 'nginx' ) : null;
$html_mariadb = wpvulnerability_analyze_filter( 'mariadb' ) && wpvulnerability_get_component_count( 'mariadb' ) ? wpvulnerability_html_software( 'mariadb' ) : null;
$html_mysql = wpvulnerability_analyze_filter( 'mysql' ) && wpvulnerability_get_component_count( 'mysql' ) ? wpvulnerability_html_software( 'mysql' ) : null;
$html_imagemagick = wpvulnerability_analyze_filter( 'imagemagick' ) && wpvulnerability_get_component_count( 'imagemagick' ) ? wpvulnerability_html_software( 'imagemagick' ) : null;
$html_curl = wpvulnerability_analyze_filter( 'curl' ) && wpvulnerability_get_component_count( 'curl' ) ? wpvulnerability_html_software( 'curl' ) : null;
$html_memcached = wpvulnerability_analyze_filter( 'memcached' ) && wpvulnerability_get_component_count( 'memcached' ) ? wpvulnerability_html_software( 'memcached' ) : null;
$html_redis = wpvulnerability_analyze_filter( 'redis' ) && wpvulnerability_get_component_count( 'redis' ) ? wpvulnerability_html_software( 'redis' ) : null;
$html_sqlite = wpvulnerability_analyze_filter( 'sqlite' ) && wpvulnerability_get_component_count( 'sqlite' ) ? wpvulnerability_html_software( 'sqlite' ) : null;
$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 ) );
// If forced email sending is not enabled and no vulnerabilities were found, exit the function.
if ( ! $forced && $all_empty ) {
return false;
} elseif ( $forced && $all_empty ) {
$email_content .= '<h2>' . esc_html__( 'No vulnerabilities found', 'wpvulnerability' ) . '</h2>';
$email_content .= '<p>' . esc_html__( 'This is likely a test. The site does not have vulnerabilities.', 'wpvulnerability' ) . '</p>';
}
// Append core vulnerabilities HTML to the email content.
if ( ! empty( $html_core ) ) {
$email_content .= '<h2>' . esc_html__( 'Core vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_core;
}
// Append plugins vulnerabilities HTML to the email content.
if ( ! empty( $html_plugins ) ) {
$email_content .= '<h2>' . esc_html__( 'Plugins vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_plugins;
}
// Append themes vulnerabilities HTML to the email content.
if ( ! empty( $html_themes ) ) {
$email_content .= '<h2>' . esc_html__( 'Themes vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_themes;
}
// Append PHP vulnerabilities HTML to the email content.
if ( ! empty( $html_php ) ) {
$email_content .= '<h2>' . esc_html__( 'PHP vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_php;
}
// Append Apache vulnerabilities HTML to the email content.
if ( ! empty( $html_apache ) ) {
$email_content .= '<h2>' . esc_html__( 'Apache HTTPD vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_apache;
}
// Append Nginx vulnerabilities HTML to the email content.
if ( ! empty( $html_nginx ) ) {
$email_content .= '<h2>' . esc_html__( 'Nginx vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_nginx;
}
// Append MariaDB vulnerabilities HTML to the email content.
if ( ! empty( $html_mariadb ) ) {
$email_content .= '<h2>' . esc_html__( 'MariaDB vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_mariadb;
}
// Append MySQL vulnerabilities HTML to the email content.
if ( ! empty( $html_mysql ) ) {
$email_content .= '<h2>' . esc_html__( 'MySQL vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_mysql;
}
// Append ImageMagick vulnerabilities HTML to the email content.
if ( ! empty( $html_imagemagick ) ) {
$email_content .= '<h2>' . esc_html__( 'ImageMagick vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_imagemagick;
}
// Append curl vulnerabilities HTML to the email content.
if ( ! empty( $html_curl ) ) {
$email_content .= '<h2>' . esc_html__( 'curl vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_curl;
}
// Append memcached vulnerabilities HTML to the email content.
if ( ! empty( $html_memcached ) ) {
$email_content .= '<h2>' . esc_html__( 'memcached vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_memcached;
}
// Append Redis vulnerabilities HTML to the email content.
if ( ! empty( $html_redis ) ) {
$email_content .= '<h2>' . esc_html__( 'Redis vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_redis;
}
// Append SQLite vulnerabilities HTML to the email content.
if ( ! empty( $html_sqlite ) ) {
$email_content .= '<h2>' . esc_html__( 'SQLite vulnerabilities', 'wpvulnerability' ) . '</h2>';
$email_content .= $html_sqlite;
}
// Get the site name.
$admin_site = is_multisite() ? get_site_option( 'site_name' ) : get_bloginfo( 'name' );
// Get the admin email.
$admin_email = is_multisite() ? get_site_option( 'admin_email' ) : get_bloginfo( 'admin_email' );
$from_email = is_scalar( $admin_email ) ? sanitize_email( (string) $admin_email ) : '';
// Check if WPVULNERABILITY_MAIL is defined and valid, and use it if available.
if ( defined( 'WPVULNERABILITY_MAIL' ) ) {
$wpvulnerability_sender_email = sanitize_email( trim( (string) WPVULNERABILITY_MAIL ) );
if ( is_email( $wpvulnerability_sender_email ) ) {
$from_email = $wpvulnerability_sender_email;
}
unset( $wpvulnerability_sender_email );
}
// Prepare email subject and content.
$email_subject = sprintf(
// translators: Site name.
__( 'Vulnerability found: %s', 'wpvulnerability' ),
( is_scalar( $admin_site ) ? (string) $admin_site : '' )
);
$email_prepared = wpvulnerability_email_prepare( esc_html__( 'Vulnerability found', 'wpvulnerability' ), $email_content );
// Prepare email headers.
$email_headers = array();
$email_headers[] = 'From: WPVulnerability <' . $from_email . '>';
$email_headers[] = 'Content-Type: text/html; charset=UTF-8';
if ( $forced && ( empty( $wpvulnerability_settings['emails'] ) ) ) {
// Determine the recipient email.
$wpvulnerability_settings['emails'] = array( $admin_email );
}
$wpmail = false;
if ( $email_enabled || $forced ) {
$mail_to = is_array( $wpvulnerability_settings['emails'] ) ? array_map(
static function ( $e ) {
return is_scalar( $e ) ? (string) $e : '';
},
$wpvulnerability_settings['emails']
) : ( is_scalar( $wpvulnerability_settings['emails'] ) ? (string) $wpvulnerability_settings['emails'] : '' );
$wpmail = wp_mail( $mail_to, $email_subject, $email_prepared, $email_headers );
}
$text_message_body = wpvulnerability_html_to_plain_text( $email_content );
$text_message = trim( (string) ( $email_subject . "\n\n" . $text_message_body ) );
// Track whether any webhook channel delivered successfully, so a webhook-only
// notification (email disabled) is not reported as a failure.
$webhook_sent = false;
if ( $slack_enabled && wpvulnerability_send_slack_notification( ( is_scalar( $wpvulnerability_settings['slack_webhook'] ) ? (string) $wpvulnerability_settings['slack_webhook'] : '' ), $text_message ) ) {
$webhook_sent = true;
}
if ( $teams_enabled && wpvulnerability_send_teams_notification( ( is_scalar( $wpvulnerability_settings['teams_webhook'] ) ? (string) $wpvulnerability_settings['teams_webhook'] : '' ), $text_message ) ) {
$webhook_sent = true;
}
if ( $discord_enabled && wpvulnerability_send_discord_notification( ( is_scalar( $wpvulnerability_settings['discord_webhook'] ) ? (string) $wpvulnerability_settings['discord_webhook'] : '' ), $text_message ) ) {
$webhook_sent = true;
}
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 ) ) {
$webhook_sent = true;
}
return $wpmail || $webhook_sent;
}
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM properties follow upstream naming.
/**
* Convert HTML notification content into a plain text representation.
*
* Ensures notifications sent to chat platforms retain meaningful structure by
* translating headings, paragraphs, lists, tables, and links into readable
* plain text. List indentation and table rows are preserved with appropriate
* line breaks so the resulting message can be consumed without HTML support.
*
* @since 4.1.4
*
* @param string $html HTML fragment to convert.
*
* @return string Normalized plain text message.
*/
function wpvulnerability_html_to_plain_text( $html ) {
$html = (string) $html;
if ( '' === trim( (string) $html ) ) {
return '';
}
if ( ! class_exists( 'DOMDocument', false ) ) {
return wp_strip_all_tags( $html );
}
$libxml_previous_state = libxml_use_internal_errors( true );
$dom = new DOMDocument();
$wrapped_html = '<div>' . $html . '</div>';
$load_flags = 0;
if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) {
$load_flags |= LIBXML_HTML_NOIMPLIED;
}
if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) {
$load_flags |= LIBXML_HTML_NODEFDTD;
}
$dom->loadHTML( '<?xml encoding="utf-8" ?>' . $wrapped_html, $load_flags );
libxml_clear_errors();
libxml_use_internal_errors( $libxml_previous_state );
$list_stack = array();
$output = '';
$doc_element = $dom->documentElement;
if ( null !== $doc_element ) {
foreach ( $doc_element->childNodes as $child_node ) {
$output .= wpvulnerability_dom_node_to_plain_text( $child_node, $list_stack );
}
}
$output = html_entity_decode( $output, ENT_QUOTES, 'UTF-8' );
$output = preg_replace( '#/\\*.*?\\*/#s', '', $output ) ?? $output;
$output = preg_replace( '/[ \t]+\n/', "\n", $output ) ?? $output;
$output = preg_replace( "/\n{3,}/", "\n\n", $output ) ?? $output;
return trim( (string) $output );
}
/**
* Recursively convert DOM nodes to plain text.
*
* @since 4.1.4
*
* @param DOMNode $node Node being transformed.
* @param array<int, array<string, mixed>> $list_stack Stack describing parent list context.
*
* @return string Plain text representation of the node.
*/
function wpvulnerability_dom_node_to_plain_text( DOMNode $node, array &$list_stack ): string {
if ( XML_TEXT_NODE === $node->nodeType ) {
$raw_value = $node->nodeValue ?? '';
return preg_replace( '/\\s+/u', ' ', $raw_value ) ?? $raw_value;
}
if ( XML_ELEMENT_NODE !== $node->nodeType ) {
return '';
}
$tag_name = strtolower( (string) $node->nodeName );
switch ( $tag_name ) {
case 'br':
return "\n";
case 'p':
case 'div':
case 'section':
case 'article':
case 'header':
case 'footer':
$content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
return '' === $content ? '' : $content . "\n\n";
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
$content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
return '' === $content ? '' : $content . "\n\n";
case 'strong':
case 'em':
case 'span':
case 'code':
case 'b':
case 'i':
case 'u':
case 'small':
return wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
case 'a':
$content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
$href = '';
if ( null !== $node->attributes && null !== $node->attributes->getNamedItem( 'href' ) ) {
$href_attribute = $node->attributes->getNamedItem( 'href' );
$href = trim( (string) $href_attribute->nodeValue );
}
if ( '' !== $href && '' !== $content && false === strpos( $content, $href ) ) {
return $content . ' (' . $href . ')';
}
return $content;
case 'ul':
case 'ol':
$list_stack[] = array(
'type' => $tag_name,
'index' => 0,
);
$content = wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
array_pop( $list_stack );
return $content . ( '' === $content ? '' : "\n" );
case 'li':
$depth = count( $list_stack );
$indent = $depth > 0 ? str_repeat( ' ', $depth - 1 ) : '';
$marker = '- ';
if ( $depth > 0 ) {
$current_index = $depth - 1;
$list_stack[ $current_index ]['index'] = ( is_int( $list_stack[ $current_index ]['index'] ) ? $list_stack[ $current_index ]['index'] : 0 ) + 1;
if ( isset( $list_stack[ $current_index ]['type'] ) && 'ol' === $list_stack[ $current_index ]['type'] ) {
$marker = $list_stack[ $current_index ]['index'] . '. ';
}
}
$content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
if ( '' === $content ) {
return '';
}
$content = preg_replace( '/\n/', "\n" . $indent . ' ', $content ) ?? $content;
return $indent . $marker . $content . "\n";
case 'table':
$rows = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
return '' === $rows ? '' : $rows . "\n";
case 'thead':
case 'tbody':
case 'tfoot':
case 'tr':
return wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
case 'th':
case 'td':
$content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) );
return '' === $content ? '' : $content . ' | ';
default:
return wpvulnerability_dom_children_to_plain_text( $node, $list_stack );
}
}
/**
* Generate plain text for the children of a DOM node.
*
* @since 4.1.4
*
* @param DOMNode $node Parent DOM node.
* @param array<int, array<string, mixed>> $list_stack Stack describing parent list context.
*
* @return string Concatenated plain text for child nodes.
*/
function wpvulnerability_dom_children_to_plain_text( DOMNode $node, array &$list_stack ): string {
$text = '';
foreach ( $node->childNodes as $child ) {
$child_text = wpvulnerability_dom_node_to_plain_text( $child, $list_stack );
if ( '' === $child_text ) {
continue;
}
$text .= $child_text;
}
if ( 'td' === strtolower( (string) $node->nodeName ) || 'th' === strtolower( (string) $node->nodeName ) ) {
$text = rtrim( (string) $text, ' |' );
}
if ( 'tr' === strtolower( (string) $node->nodeName ) ) {
$text = rtrim( (string) $text, ' |' );
$text = '' === $text ? '' : $text . "\n";
}
return $text;
}
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase