2026-02-05 17:08:59 +03:00

634 lines
21 KiB
PHP

<?php
class OCVMScan {
private $settings;
private $scan_data = null;
private $last_scan_data = array();
private $items_without_fixed_in = array();
private $versions = array(
'wp' => ONECOM_WP_CORE_VERSION,
'plugins' => array(),
'themes' => array(),
);
private $http_error = false;
private $http_response;
private $http_args = array(
'timeout' => 10,
'httpversion' => '1.0',
'compress' => false,
'decompress' => true,
'sslverify' => true,
'stream' => false,
);
public $isNewVulnerabilityExist = false;
public $isFixVersionMissing = false;
public function __construct() {
$this->settings = new OCVMSettings();
$this->setCron();
}
public function collectVersions(): void {
// get all active plugins
$activePlugins = get_site_option( 'active_plugins' );
// active plugins' slug and version
foreach ( $activePlugins as $activePlugin ) {
$this->versions['plugins'][] = array(
'slug' => explode( DIRECTORY_SEPARATOR, $activePlugin )[0],
'installed_version' => get_plugin_data( WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $activePlugin, false, false )['Version'],
);
}
// in case current theme is a child theme then fetch parent theme's details
if ( get_template_directory() !== get_stylesheet_directory() && ! empty( wp_get_theme()->parent() ) ) {
$theme = wp_get_theme()->parent();
} else {
// get active theme
$theme = wp_get_theme();
}
// active theme's slug and version
$this->versions['themes'][] = array(
'slug' => $theme->template,
'installed_version' => $theme->version,
);
}
/**
* Send versions of all plugins/themes/core
*/
public function sendVersions() {
// stats
$this->sendVersionStats( $this->versions );
// http call
$this->http_args['user-agent'] = 'WordPress/' . ONECOM_WP_CORE_VERSION . '; ' . home_url();
$this->http_args['body'] = json_encode( $this->versions );
$this->http_response = wp_remote_post( $this->settings::ocvm_endpoint, $this->http_args );
}
/**
* Process http_response
*/
public function processResponse() {
if ( is_wp_error( $this->http_response ) ) {
if ( isset( $this->http_response->errors['http_request_failed'] ) ) {
$this->http_error = __( 'Connection timed out', OC_VALIDATOR_DOMAIN );
} else {
$this->http_error = $this->http_response->get_error_message();
}
} else {
if ( wp_remote_retrieve_response_code( $this->http_response ) !== 200 ) {
$this->http_error = '(' . wp_remote_retrieve_response_code( $this->http_response ) . ') ' . wp_remote_retrieve_response_message( $this->http_response );
} else {
$body = wp_remote_retrieve_body( $this->http_response );
$body_arr = json_decode( $body, 1 );
if ( null !== $body_arr['error'] ) {
error_log( 'Error reported by API endpoint --> ' . $body );
} else {
// To handle the case of plugins that are having same slug for free and pro version but different names
$plugins_array = $body_arr['data']['plugins'] ?? array();
if ( is_array( $plugins_array ) ) {
foreach ( $plugins_array as $plugin => $vulnerabilities ) {
$name = $this->get_name_for_slug( $plugin, 'plugins' );
foreach ( $vulnerabilities['vulnerabilities'] as $key => $value ) {
if ( isset( $value['product_name_premium'] ) && '' !== $value['product_name_premium'] && $name !== $value['product_name_premium'] ) {
error_log( 'vulnerability for the premium version of plugin found removing it..' );
unset( $plugins_array[ $plugin ]['vulnerabilities'][ $key ] );
// Check if vulnerabilities array is empty after unset
if ( empty( $plugins_array[ $plugin ]['vulnerabilities'] ) ) {
unset( $plugins_array[ $plugin ] );
break;
}
}
}
}
}
$this->scan_data = array_merge(
array(
'plugins' => (array) $plugins_array,
'themes' => (array) $body_arr['data']['themes'],
'wp' => (array) $body_arr['data']['wp'],
)
);
}
}
}
}
/**
* Check if Vulnerability reported by Endpoint
* If no vulnerabilities, clear existing vulnerabilities in db
*/
public function vulnerabilityExists(): bool {
if (
empty( $this->scan_data['wp'] ) &&
empty( $this->scan_data['plugins'] ) &&
empty( $this->scan_data['themes'] )
) {
// Get existing VM data
$existing_vm_data = $this->settings->get();
// if no vulnerability exits now, push existing vulnerability (if any) to VM log as they are fixed (via update, delete, deactivation)
$fixed_vuls = $existing_vm_data['vulnerabilities'];
if ( count( $fixed_vuls ) > 0 ) {
$history_log_obj = new OCVMHistoryLog();
$history_log_obj->iterateVulnerabilitiesForLog( $fixed_vuls );
}
// As no vulnerability found in this scan, clear existing vulnerabilities (however retain settings)
$existing_vm_data['vulnerabilities'] = array();
$this->settings->update( $existing_vm_data );
return false;
}
return true;
}
/**
* Get FQN of plugin by slug
* @param $slug string Theme's stylesheet or Plugin's PHP dir name
* @return string Name of the plugin/theme
*/
protected function get_name_for_slug( $slug, $type ): string {
if ( 'themes' === $type ) {
// theme headers
$themeData = wp_get_theme( $slug );
return $themeData->get( 'Name' );
}
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$plugin_files = get_plugins( '/' . $slug );
if ( ! $plugin_files ) {
return '';
}
$plugin_files = array_keys( $plugin_files );
$plugin_dir = $slug . '/' . reset( $plugin_files );
$pluginData = get_plugin_data( trailingslashit( WP_PLUGIN_DIR ) . $plugin_dir );
return $pluginData['Name'];
}
/**
* Iterator
*/
public function arrayIterator( $items, $type = 'plugins' ) {
$arr_items = array();
foreach ( $items as $slug => $vuls ) {
// find the highest fix version across all the vulnerabilities found for this item
$arr_items[ $slug ]['fixed_in'] = max( array_column( $vuls['vulnerabilities'], 'fixed_in' ) );
$arr_items[ $slug ]['name'] = $this->get_name_for_slug( $slug, $type );
$arr_items[ $slug ] = array_merge( $arr_items[ $slug ], $vuls );
// cleanup duplicate vulnerability codes
$vuls_arr = array();
foreach ( $arr_items[ $slug ]['vulnerabilities'] as $k => $v ) {
//check is fixed version is missing
if ( $arr_items[ $slug ]['vulnerabilities'][ $k ]['fixed_in'] === '' ) {
// Store items with missing fixed-in, to prevent repeat mail after update attempt
if ( ! array_key_exists( $type, $this->items_without_fixed_in ) || ! in_array( $slug, $this->items_without_fixed_in[ $type ] ) ) {
$this->items_without_fixed_in[ $type ][] = $slug;
}
error_log( 'Fixed in missing for ' . $type );
$this->isFixVersionMissing = true;
}
//round cvss score at two decimal points
$arr_items[ $slug ]['vulnerabilities'][ $k ]['cvss_score'] = sprintf( '%.2f', $arr_items[ $slug ]['vulnerabilities'][ $k ]['cvss_score'] );
$v['cvss_score'] = sprintf( '%.2f', $v['cvss_score'] );
$vuln_type = $v['vuln_type'];
$fixedIn = $v['fixed_in'];
if ( isset( $vuls_arr[ $vuln_type ] ) ) {
// Compare fixed_in values to keep the one with the higher fixed_in value
$existingFixedIn = $vuls_arr[ $vuln_type ]['fixed_in'];
if ( version_compare( $fixedIn, $existingFixedIn, '>' ) ) {
// Replace the existing vulnerability with the new one
$vuls_arr[ $vuln_type ] = $v;
} elseif ( version_compare( $fixedIn, $existingFixedIn, '<=' ) ) {
unset( $arr_items[ $slug ]['vulnerabilities'][ $k ] );
}
} else {
$vuls_arr[ $vuln_type ] = $v;
}
}
// Convert the associative array back to a sequential array
$arr_items[ $slug ]['vulnerabilities'] = array_values( $vuls_arr );
}
return $arr_items;
}
/**
* Save vulnerabilities in DB
*/
public function saveVulnerabilities() {
$existing = $this->settings->get();
$db_data = array();
$history_log_obj = new OCVMHistoryLog();
//TODO: delete the entries of plugins/themes/core from $existing data if any of them dont exist now.
// This is required to clean up stale data and to avoid sending/showing irrelevant mails/notifications.
if ( ! empty( $this->scan_data['plugins'] ) ) {
$db_data['plugins'] = $this->arrayIterator( $this->scan_data['plugins'] );
}
if ( ! empty( $this->scan_data['themes'] ) ) {
$db_data['themes'] = $this->arrayIterator( $this->scan_data['themes'], 'themes' );
}
if ( ! empty( $this->scan_data['wp'] ) && ! empty( $this->scan_data['wp']['vulnerabilities'] ) ) {
$temp = array_unique( array_column( $this->scan_data['wp']['vulnerabilities'], 'vuln_type' ) );
$unique_vul = array_intersect_key( $this->scan_data['wp']['vulnerabilities'], $temp );
shuffle( $unique_vul );
foreach ( $unique_vul as $k => $v ) {
//check is fixed version is missing
if ( $v['fixed_in'] === '' ) {
error_log( 'Fixed in missing for WP' );
$this->items_without_fixed_in['wp'] = '';
$this->isFixVersionMissing = true;
}
//round cvss score at two decimal points
$unique_vul[ $k ]['cvss_score'] = sprintf( '%.2f', $unique_vul[ $k ]['cvss_score'] );
}
$wp_vuls = $unique_vul;
$db_data['wp']['installed_version'] = $this->scan_data['wp']['installed_version'];
$db_data['wp']['fixed_in'] = max( array_column( $wp_vuls, 'fixed_in' ) );
$db_data['wp']['vulnerabilities'] = $wp_vuls;
$db_data['wp']['name'] = 'WordPress Core';
}
//check is new vulnerability exist
$this->isNewVulnerabilityFound( $db_data, $existing );
if ( empty( $existing['vulnerabilities'] ) ) {
$existing['vulnerabilities'] = $db_data;
} else {
$this->last_scan_data = $this->extractVulnerableItems( $existing['vulnerabilities'] );
// if existing vulnerabilities are not in latest scan (updated, deleted, or deactivated), push to VM log
$fixed_vuls = $history_log_obj->extractFixedVulnerabilities( $existing['vulnerabilities'], $db_data );
if ( count( $fixed_vuls ) > 0 ) {
error_log( 'Fixed vulnerabilities found to push in log' );
$history_log_obj->iterateVulnerabilitiesForLog( $fixed_vuls );
} else {
error_log( 'No fixed vulnerabilities found so far' );
}
$existing['vulnerabilities'] = $db_data;
}
// save all vulnerabilities
$existing['vulnerabilities'] = $db_data;
$this->settings->update( $existing );
}
/**
* Check is found any new vulnerability
* @param $new_vul
* @param $existing_vul
* @return void
*/
public function isNewVulnerabilityFound( $new_vul, $existing_vul ) {
global $wp_version;
if ( ! is_array( $new_vul ) && ! is_array( $existing_vul ) ) {
return;
}
error_log( '~~~ Checking for new vulnerability ~~~' );
//Prepare default structure for vulnerability check
$new_vul = $new_vul + array(
'plugins' => array(),
'themes' => array(),
'wp' => array( 'vulnerabilities' => array() ),
);
$existing_vul['vulnerabilities'] = $existing_vul['vulnerabilities'] + array(
'plugins' => array(),
'themes' => array(),
'wp' => array( 'vulnerabilities' => array() ),
);
$pluginCheck = ( isset( $new_vul['plugins'] ) ) ? array_diff_key( $new_vul['plugins'], $existing_vul['vulnerabilities']['plugins'] ) : array();
//check new vulnerability for themes
$themeCheck = ( isset( $new_vul['themes'] ) ) ? array_diff_key( $new_vul['themes'], $existing_vul['vulnerabilities']['themes'] ) : array();
//check new vulnerability for WP
$existingWP = isset( $existing_vul['vulnerabilities']['wp']['vulnerabilities'] ) ? count( $existing_vul['vulnerabilities']['wp']['vulnerabilities'] ) : 0;
$newWP = ( isset( $new_vul['wp']['vulnerabilities'] ) ) ? count( $new_vul['wp']['vulnerabilities'] ) : 0;
//get new vulnerability regarding WP core
$wpCheck = $newWP - $existingWP;
//get the max fixed_in version
$ver = ( $newWP > 0 ) ? max( array_column( $new_vul['wp']['vulnerabilities'], 'fixed_in' ) ) : $wp_version;
$user_settings = $this->settings->get();
//check is installed WP and coming WP vulnerability fixed_in are same or empty
//then, override the WP vulnerability if found the same version during auto-update on
if ( version_compare( $wp_version, $ver ) >= 0 && 1 == $user_settings['settings']['auto_update'] ) {
$wpCheck = 0;
}
if ( count( $pluginCheck ) > 0 || count( $themeCheck ) > 0 || $wpCheck > 0 ) {
error_log( '~~~ New vulnerability found ~~~' );
$this->isNewVulnerabilityExist = true;
return;
}
error_log( '~~~ No new vulnerability found ~~~' );
}
/**
* Check and Display Vulnerabilities if any
*/
public function manageVulnerabilities(): void {
// return if wp core update is in progress/scheduled shortly
if (!$this->wp_core_updates_not_scheduled()) {
error_log('wp core update in-progress/scheduled shortly terminating VM scan.');
return;
}
error_log( 'Scan started!' );
self::collectVersions();
self::sendVersions();
self::processResponse();
if ( false !== $this->http_error ) {
error_log( 'Error found -- ' . $this->http_error );
return;
// stop execution and log error if required
}
if ( null === $this->scan_data ) {
error_log( 'No data found from API response -- ' . json_encode( $this->http_response ) );
return;
// stop execution and log error if required
}
if ( true !== self::vulnerabilityExists() ) {
error_log( 'No vulnerabilities found! All good! Exiting...' );
return;
}
error_log( 'Vulnerabilities found!' );
self::saveVulnerabilities();
// get user's preferences
$user_settings = $this->settings->get();
$notify_admins = new OCVMSendEmails();
//get daily email send status
$daily_email_sent = get_site_transient( 'ocvm_daily_email_sent' );
//Allow auto-update only for mWP
if ( 1 == $user_settings['settings']['auto_update'] && $this->settings->isPremium() ) {
// Auto-update items
$auto_update = new OCVMAutoUpdates();
$auto_update->updateItems();
// check user's preferences for email type, send vuls-fixed and vuls-found email
if (
1 === $user_settings['settings']['notify_all'] &&
1 === $user_settings['settings']['email_type']['email_fixed'] &&
current_action() === 'ocvm_scan'
) {
error_log( 'Sending vuls-fixed / vuls-found on ocvm_scan run as per requirement' );
$this->fixedAndNoAutoFixedEmail( $notify_admins, $auto_update );
}
} else {
// check user's preferences for email type
if (
1 === $user_settings['settings']['notify_all'] &&
1 === $user_settings['settings']['email_type']['email_detect']
) {
// send vuls-found email
// on new vulnerability found we will send always email
// ocvm_scan should run once a day if email is not sent for a day
// if email is already sent, then will skip sending email via ocvm_scan
// otherwise, we will skip sending email
if ( $this->isNewVulnerabilityExist ) {
error_log( 'Sending email on new vulnerability found.' );
$notify_admins->sendEmail();
( ! $daily_email_sent ) ? set_site_transient( 'ocvm_daily_email_sent', 'yes', 7 * DAY_IN_SECONDS ) : '';
} elseif ( current_action() === 'ocvm_scan' && ! $daily_email_sent ) {
error_log( 'Sending email on ocvm_scan run' );
$notify_admins->sendEmail();
//set site transient for 24hr
set_site_transient( 'ocvm_daily_email_sent', 'yes', 7 * DAY_IN_SECONDS );
} else {
error_log( 'Skip sending email because no new vulnerability found.' );
}
}
}
}
public function fixedAndNoAutoFixedEmail( $notify_admins, $auto_update ): void {
error_log( 'Sending email for vulnerability on the basis of condition' );
//send auto-update email for vulnerability fixed if no case of missing fixed version
if ( ! $this->isFixVersionMissing ) {
error_log( 'Sending email only for fixed vulnerability' );
$notify_admins->sendEmail( 'vulsFixed', $auto_update->updateAttempt );
return;
}
// Send vulsNotAutoFixed only if any fixed version is missing and weekly mail is not sent or new vul found
$daily_email_sent = get_site_transient( 'ocvm_daily_email_sent' );
if ( current_action() === 'ocvm_scan'
&& ( ! $daily_email_sent || ! $this->vulExistsInLastScan( $this->items_without_fixed_in, $this->last_scan_data ) )
) {
$notify_admins->sendEmail( 'vulsNotAutoFixed', $auto_update->updateAttempt );
set_site_transient( 'ocvm_daily_email_sent', 'yes', 7 * DAY_IN_SECONDS );
}
error_log( 'Sending email for vulnerability fix always' );
$notify_admins->sendEmail( 'vulsFixed', $auto_update->updateAttempt );
}
/**
* Set cron if not exists
*/
public function setCron() {
if ( ! wp_next_scheduled( $this->settings::wpcron_hook ) ) {
wp_schedule_event(
time(),
'daily',
// TODO: enable 24 hours duration before real deployment
// $this->settings->get()['scan_duration'],
$this->settings::wpcron_hook
);
}
}
/**
* Prepare vulerable (last scan) items names, rmove `vulnerablities` and extra data
*
* @return array
*/
public function extractVulnerableItems( $vuls ): array {
$vul_items = array();
foreach ( $vuls as $item => $value ) {
// Get vulnerable plugins and themes slugs
if ( $item === 'plugins' || $item === 'themes' ) {
$vul_items[ $item ] = array_keys( $value );
} elseif ( $item === 'wp' ) {
$vul_items[ $item ] = '';
}
}
return $vul_items;
}
/**
* Check if all current i.e new vulnerable (missing fixed-in) items were already exists in last scan
* If exists (and weekly mail sent), we will prevent sending 'vulsNotAutoFixed' mail after auto update
*
* @return bool
*/
public function vulExistsInLastScan( $new_vuls, $old_vuls ): bool {
foreach ( $new_vuls as $type => $items ) {
// Return false if the 'plugins', 'themes' or 'wp' key found in new_vuls does not exists in old_vuls
if ( ! isset( $old_vuls[ $type ] ) ) {
return false;
}
// Return false any plugin/theme from new_vuls does not exists in old_vuls, else return true as all vuls found in last scan
foreach ( $items as $item ) {
if ( ! in_array( $item, $old_vuls[ $type ] ) ) {
return false;
}
}
}
return true;
}
/**
* Delete cron upon deactivation
*/
public function deleteCron() {
$timestamp = wp_next_scheduled( 'bl_cron_hook' );
wp_unschedule_event( $timestamp, 'bl_cron_hook' );
}
/**
* Schedule WPCron scan scheduling after 30sec for multiple events (activation, deactivation, updation, deletion)
* @param $plugin
* @param $network_wide
* @return void
*/
public function scheduleOCVMScan() {
wp_schedule_single_event( time() + 30, 'ocvm_scan' );
}
/**
* Schedule event after 30sec of auto update enabled
*
* @return void
*/
public function scheduleCronAfterAutoupdate() {
wp_schedule_single_event( time() + 30, 'ocvm_scan' );
}
/**
* Send version stats
*/
public function sendVersionStats( $ver = array() ): void {
if ( empty( $ver ) ) {
return;
}
// Add one.com features related data
$additional_info = $this->ocFeaturesInfo( $ver );
class_exists( 'OCPushStats' ) ?
OCPushStats::push_vul_monitor_stats( 'scan', 'setting', 'vulnerability_monitor', array( 'active_versions' => $additional_info ) ) :
'';
}
/**
* Add one.com features related essential info in VM Scan
*/
public function ocFeaturesInfo($ver) {
// Add ALP status
$alp_status = (string) get_site_option( 'onecom_login_masking', '' );
$ver['alp_status'] = $alp_status;
// add error page status
$error_class_path = WP_CONTENT_DIR . DIRECTORY_SEPARATOR . 'fatal-error-handler.php';
$error_page = new Onecom_Error_Page();
$error_page_status = ( file_exists( $error_class_path ) && $error_page->is_onecom_plugin() ) ? '1' : '0';
$ver['error_page_status'] = $error_page_status;
// add cookie banner status
$settings = get_site_option( 'oc_cb_configuration' );
$cb_status = ( empty( $settings ) || empty( $settings['config'] ) ) ? '0' : $settings['config']['show'];
$ver['cookie_banner_status'] = $cb_status;
// add old cu consent
$old_consent = get_site_option( 'oc_cu_consent' );
$old_consent_status = false !== $old_consent ? $old_consent : '';
$ver['old_consent_status'] = $old_consent_status;
// add new cu consent
$new_consent = get_site_option( 'onecom_data_consent_status' );
$new_consent_status = false !== $new_consent ? $new_consent : '';
$ver['data_consent_status'] = $new_consent_status;
return $ver;
}
/**
* @return bool
* check if wp core update is running
*/
public function wp_core_updates_not_scheduled()
{
if (file_exists(ABSPATH . '.maintenance')) {
return false; // Update is in progress
}
// Check if update lock is active (via core_updater.lock)
$update_locked = get_option('core_updater.lock');
if ($update_locked) {
return false;
}
// Check if an automatic update is scheduled in the near future
$scheduled_event = wp_get_scheduled_event('wp_maybe_auto_update');
if ($scheduled_event) {
$time_until_next_update = $scheduled_event->timestamp - time();
if ($time_until_next_update > 0 && $time_until_next_update < 1800) { // Next 30 minutes
return false; // Update is scheduled soon
}
}
// No update in progress or scheduled soon
return true;
}
}