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

701 lines
24 KiB
PHP

<?php
class OCVMAutoUpdates {
use OCVMVulnerabilities;
public $updateAttempt = array(
'time' => '',
'failed' => array(),
'successful' => array(),
);
private $settings;
private $items;
const PLUGINS_PACKAGE_URL = 'https://downloads.wordpress.org/plugin/%s.%s.zip';
const THEMES_PACKAGE_URL = 'https://downloads.wordpress.org/theme/%s.%s.zip';
const WP_PACKAGE_URL = 'https://downloads.wordpress.org/release/%s/wordpress-%s.zip';
const WP_PACKAGE_NO_CONTENT_URL = 'https://downloads.wordpress.org/release/wordpress-%s-no-content.zip';
const ERROR_DESCRIPTION = 'Error description --> ';
const WP_UPGRADER_FILE_PATH = 'wp-admin/includes/class-wp-upgrader.php';
const WP_UPDATE_FILE_PATH = 'wp-admin/includes/update.php';
const WP_FILE_SYSTEM_PATH = 'wp-admin/includes/file.php';
const WP_MISC_PATH = 'wp-admin/includes/misc.php';
const LOG_DIVIDER = '#################################';
public function __construct() {
$this->settings = new OCVMSettings();
// ???
}
/**
* Get FQN of plugin by slug
* @param $slug string Plugin's PHP file name (without extension)
* @return string directory/plugin_file.php
*/
protected function find_plugin_for_slug( $slug ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$plugin_files = get_plugins( '/' . $slug );
if ( ! $plugin_files ) {
return '';
}
$plugin_files = array_keys( $plugin_files );
return $slug . '/' . reset( $plugin_files );
}
/**
* Prepare update attempt records
*/
public function prepareAttempt( $type, $slug, $new_ver, $itemFQN, $item_data ) {
$attempt = array(
'type' => $type,
'slug' => $slug,
'dir' => $itemFQN,
'new_version' => $new_ver,
'time' => time(),
);
if ( ! empty( $item_data ) ) {
$attempt['name'] = $item_data['Name'];
$attempt['old_version'] = $item_data['Version'];
}
// get latest state from DB
$settings = $this->settings->get();
if ( 'wp' === $type ) {
$vuls = $settings['vulnerabilities'][ $type ]['vulnerabilities'];
} else {
$vuls = $settings['vulnerabilities'][ $type ][ $slug ]['vulnerabilities'];
}
foreach ( $vuls as $v ) {
// modified to manipulate the vuln_type string and url recieved from new API
$desc = 'wp_vul_' . strtolower( str_replace( array( ' ', '(', ')' ), array( '_', '', '' ), $v['vuln_type'] ) );
if ( $this->vulTranslation( $desc ) !== '' ) {
$attempt['vuls'][ $v['id'] ]['id'] = $this->vulTranslation( $desc );
} elseif ( isset( $attempt['vuls'][ $v['id'] ]['id'] ) || isset( $v['description'] ) ) {
$attempt['vuls'][ $v['id'] ]['id'] = $v['description'];
} else {
$attempt['vuls'][ $v['id'] ]['id'] = __( 'wp_vul_unknown', 'onecom-wp' );
}
$attempt['vuls'][ $v['id'] ]['url'] = $v['url'];
}
return $attempt;
}
/**
* Update item records after attempting their update
* @param string $type
* @param string $slug
* @param array $attempt
* @return void
*/
public function updateItemRecords( $type, $slug, $attempt = array(), $ver = '' ): void {
// seat belt
if ( empty( $type ) || empty( $slug ) ) {
error_log( "Failed to update records because either 'itemType' or 'itemSlug' or both are empty." );
return;
}
// get latest state from DB
$settings = $this->settings->get();
// Prepare consistent structure to make use of existing iterate/push functions
$log_item_data = array();
// remove the item from vulnerabilities list. in case of WP, clear the array
if ( 'wp' === $type ) {
// Before clearing vul, Add fixed item to VM log at one less level as compared to themes/plugins
$log_item_data[ $type ] = $settings['vulnerabilities'][ $type ];
$log_item_data[ $type ]['log_item_result'] = 'Auto-updated';
$log_item_data[ $type ]['log_latest_version'] = $ver;
// clear wp vulnerability
$settings['vulnerabilities'][ $type ] = array();
} else {
$failed_vm = array();
if ( isset( $attempt['failed'] ) && is_array( $attempt['failed'] ) && ! empty( $attempt['failed'] ) ) {
foreach ( $attempt['failed'] as $failed_array ) {
$slug = strtolower( $failed_array['slug'] );
if ( isset( $settings['vulnerabilities'][ $type ][ $slug ] ) ) {
$failed_vm[ $slug ] = $settings['vulnerabilities'][ $type ][ $slug ];
unset( $settings['vulnerabilities'][ $type ][ $slug ] );
}
}
}
/**
* Add relevant information (type) inside vul item array itself
* Then, Fixed vul item array is ready to prepare in $log_item_data
*/
$settings['vulnerabilities'][ $type ][ $slug ]['item_type'] = $type;
$log_item_data[ $type ][ $slug ] = $settings['vulnerabilities'][ $type ][ $slug ];
$log_item_data[ $type ][ $slug ]['log_item_result'] = 'Auto-updated';
$log_item_data[ $type ][ $slug ]['log_latest_version'] = $ver;
// remove the item for which we have attempted an update
unset( $settings['vulnerabilities'][ $type ][ $slug ] );
$settings['vulnerabilities'][ $type ] = array_merge( $settings['vulnerabilities'][ $type ], $failed_vm );
}
// Fixed vulnerability item is Ready to Iterate (to extract all vuls from item) for VM log push
error_log( '======= Fixed vulnerability item is ready to iterate for log push =======' );
$history_log_obj = new OCVMHistoryLog();
$history_log_obj->iterateVulnerabilitiesForLog( $log_item_data );
// save the current attempt of item updates in database
if ( ! empty( $attempt ) ) {
if ( ! empty( $this->updateAttempt ) ) {
foreach ( $this->updateAttempt as $types => &$attempts ) {
if ( $types === 'time' ) {
continue;
}
// update attempt counts in transient
$this->add_update_attempts_count( $types, $attempts );
foreach ( $attempts as &$attempt ) {
// stored with attempts to prevent dupliate counts in the same attempt
$attempt['attempt_stored'] = true;
}
}
}
// Remove the failed attempts
if ( isset( $this->updateAttempt['failed'] ) ) {
unset( $this->updateAttempt['failed'] );
}
$settings['update_attempts'] = $this->updateAttempt;
}
$this->settings->update( $settings );
}
/** Update single plugin
* @param $type string Type of item (plugins)
* @param $slug string Filename of the item
* @param $ver string Desired version for update
* @return void
*/
public function updateTypePlugins( $type, $slug, $ver, $plugin_package ) {
// get all details of the plugin using slug
$pluginFQN = $this->find_plugin_for_slug( $slug );
if ( empty( $pluginFQN ) ) {
error_log( "Skipping update by rule...[Plugin '{$slug}' no longer exists]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug );
return;
}
if ( false === is_plugin_active( $pluginFQN ) ) {
error_log( "Skipping update by rule...[Plugin '{$slug}' is no longer active]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug );
return;
}
// load plugin functions to get plugin headers
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
// plugin headers by plugin dir
// e.g., /../../../../../test-plugin/test-plugin.php
$pluginData = get_plugin_data( trailingslashit( WP_PLUGIN_DIR ) . $pluginFQN );
// exit if fixed_version and current versions are same.
if ( (string) $ver === (string) $pluginData['Version'] ) {
error_log( "Skipping update by rule...[Plugin '{$slug}' is already at the 'fixed' version]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug, array(), $ver );
return;
}
// check if the plugin update with same versions was updated in last 24 hours to prevent repetitive attempts
if ( $this->validate_against_last_update( $slug, $type, $ver, $pluginData['Version'] ) ) {
return;
}
// record update attempt details
$attempt = $this->prepareAttempt( $type, $slug, $ver, $pluginFQN, $pluginData );
error_log( self::LOG_DIVIDER );
error_log( "##### starting update of {$slug}" );
error_log( self::LOG_DIVIDER );
$pluginObj = new stdClass();
$pluginObj->slug = $slug;
$pluginObj->plugin = $pluginFQN;
$pluginObj->new_version = $ver;
$pluginObj->package = ( $plugin_package !== '' ) ? $plugin_package : sprintf( self::PLUGINS_PACKAGE_URL, $slug, $ver );
$current = get_site_transient( 'update_plugins' );
if ( ! is_object( $current ) ) {
$current = new stdClass();
}
$current->response[ $pluginFQN ] = $pluginObj;
set_site_transient( 'update_plugins', $current );
require_once ABSPATH . self::WP_FILE_SYSTEM_PATH;
require_once ABSPATH . self::WP_UPGRADER_FILE_PATH;
require_once ABSPATH . self::WP_UPDATE_FILE_PATH;
require_once ABSPATH . self::WP_MISC_PATH;
wp_cache_flush();
$upgrader = new Plugin_Upgrader();
$result = $upgrader->upgrade( $pluginFQN );
// modified to handle the null result in case of failed plugin downloads may be due to the non-working url of plugin zip
if ( is_wp_error( $result ) || ! $result ) {
//TODO: handle failed updates - perhaps send an email and remove records from DB
// keep status of update
$this->updateAttempt['failed'][] = $attempt;
error_log( 'Error occurred during update of plugin --> ' . $pluginFQN );
// added for the case when update fails may be due to the non-working url of plugin zip
$err_description = ! is_null( $result ) ? json_encode( $result->get_error_message() ) : 'Plugin download failed for some reason.';
error_log( self::ERROR_DESCRIPTION . $err_description );
} else {
$pluginData = get_plugin_data( trailingslashit( WP_PLUGIN_DIR ) . $pluginFQN );
if ( (string) $ver <= (string) $pluginData['Version'] ) {
error_log( '######### Plugin version verified update was successful #######' );
// keep status of update
$this->updateAttempt['successful'][] = $attempt;
error_log( "Updated {$slug} successfully --> " . $pluginFQN );
} else {
$this->updateAttempt['failed'][] = $attempt;
error_log( '######### Plugin version verification check failed adding to failed attempts #########' );
}
}
// update records for this item if successfully updated
$this->updateItemRecords( $type, $slug, $this->updateAttempt, $ver );
}
/**
* Update single theme
* @param $type string Type of item (themes)
* @param $slug string Filename of the item
* @param $ver string Desired version for update
* @return void
*/
public function updateTypeThemes( $type, $slug, $ver, $theme_package ) {
// theme headers
$themeData = wp_get_theme( $slug );
// check if theme exists
if ( false === $themeData->exists() ) {
error_log( "Skipping update by rule...[Theme '{$slug}' no longer exists]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug );
return;
}
// check if theme is active
if ( get_site_option( 'template' ) !== $themeData->template ) {
error_log( "Skipping update by rule...[Theme '{$slug}' is no longer active]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug );
return;
}
// exit if fixed_version is equal to current versions are same.
if ( (string) $ver === (string) $themeData->get( 'Version' ) ) {
error_log( "Skipping update by rule...[Theme '{$slug}' is already at the 'fixed' version]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug, array(), $ver );
return;
}
// check if the theme update with same versions was updated in last 24 hours to prevent repetitive attempts
if ( $this->validate_against_last_update( $slug, $type, $ver, $themeData->get( 'Version' ) ) ) {
return;
}
// record update attempt details
$attempt = $this->prepareAttempt( $type, $slug, $ver, $themeData->stylesheet, $themeData );
error_log( self::LOG_DIVIDER );
error_log( "##### starting update of {$slug}" );
error_log( self::LOG_DIVIDER );
$themeObj['theme'] = $themeData->template;
$themeObj['new_version'] = $ver;
$themeObj['package'] = ( $theme_package !== '' ) ? $theme_package : sprintf( self::THEMES_PACKAGE_URL, $slug, $ver );
$current = get_site_transient( 'update_themes' );
if ( $current !== false ) {
$current->response[ $themeData->template ] = $themeObj;
}
set_site_transient( 'update_themes', $current );
require_once ABSPATH . self::WP_FILE_SYSTEM_PATH;
require_once ABSPATH . self::WP_UPGRADER_FILE_PATH;
require_once ABSPATH . self::WP_UPDATE_FILE_PATH;
require_once ABSPATH . self::WP_MISC_PATH;
wp_cache_flush();
$upgrader = new Theme_Upgrader();
$result = $upgrader->upgrade( $themeData->template );
if ( is_wp_error( $result ) || ! $result ) {
//TODO: handle failed updates - perhaps send an email and remove records from DB
// keep status of update
$this->updateAttempt['failed'][] = $attempt;
error_log( 'Error occurred during update of theme --> ' . $themeData->template );
$err_description = ! is_null( $result ) ? json_encode( $result->get_error_message() ) : 'Theme download failed for some reason.';
error_log( self::ERROR_DESCRIPTION . $err_description );
} else {
// theme headers
$themeData = wp_get_theme( $slug );
if ( (string) $ver === (string) $themeData->get( 'Version' ) ) {
error_log( '######### Theme version verified update was successful #######' );
// keep status of update
$this->updateAttempt['successful'][] = $attempt;
error_log( "Updated {$slug} successfully --> " . $themeData->template );
} else {
$this->updateAttempt['failed'][] = $attempt;
error_log( '######### Theme version verification check failed adding to failed attempts #########' );
}
}
$this->updateItemRecords( $type, $slug, $this->updateAttempt, $ver );
}
/**
* Update single theme
* @param $type string Type of item (themes)
* @param $slug string Filename of the item
* @param $ver string Desired version for update
* @return void
*/
public function updateTypeWp( $type, $slug, $ver ) {
global $wpdb, $wp_version;
if ( version_compare( $wp_version, $ver ) >= 0 ) {
error_log( "Skipping update by rule...[WP core is already at a greater or equal version than provided version i.e., {$wp_version} >= {$ver}]" );
// (remove this item from database) update records for this item
$this->updateItemRecords( $type, $slug, array(), $wp_version );
return;
}
$item_data = array(
'Name' => 'WordPress core',
'Version' => $wp_version,
);
// record update attempt details
$attempt = $this->prepareAttempt( $type, $slug, $ver, '', $item_data );
error_log( 'attempt for WP ==> ' . json_encode( $attempt ) );
error_log( self::LOG_DIVIDER );
error_log( "##### starting update to WP core v{$ver}" );
error_log( self::LOG_DIVIDER );
$locale = apply_filters( 'core_version_check_locale', get_locale() );
$wp_obj = new stdClass();
$wp_obj->response = 'upgrade';
$wp_obj->download = sprintf( self::WP_PACKAGE_URL, $locale, $ver );
$wp_obj->locale = $locale;
$wp_obj->current = $wp_version;
$wp_obj->version = $wp_version;
$wp_obj->php_version = phpversion();
$wp_obj->mysql_version = preg_replace( '/[^0-9.].*/', '', $wpdb->db_version() );
$wp_obj->new_bundled = $wp_version;
$package = new stdClass();
$package->full = $wp_obj->download;
$package->no_content = sprintf( self::WP_PACKAGE_NO_CONTENT_URL, $ver );
$package->new_bundled = '';
$package->partial = '';
$package->rollback = '';
$wp_obj->packages = $package;
$current = get_site_transient( 'update_core' );
//create a generic empty class if $current is not an object
if ( ! is_object( $current ) ) {
$current = new stdClass();
}
$current->updates = array( $wp_obj );
set_site_transient( 'update_core', $current );
require_once ABSPATH . self::WP_FILE_SYSTEM_PATH;
require_once ABSPATH . self::WP_UPGRADER_FILE_PATH;
require_once ABSPATH . self::WP_UPDATE_FILE_PATH;
require_once ABSPATH . self::WP_MISC_PATH;
wp_cache_flush();
$upgrader = new Core_Upgrader();
$result = $upgrader->upgrade( $wp_obj, array( 'attempt_rollback' => true ) );
if ( is_wp_error( $result ) || ! $result ) {
//TODO: handle failed updates - perhaps send an email and remove records from DB
// keep status of update
$this->updateAttempt['failed'][] = $attempt;
error_log( "Error occurred during update of WP core --> {$ver}" );
error_log( self::ERROR_DESCRIPTION . json_encode( $result->get_error_message() ) );
} else {
// keep status of update
$this->updateAttempt['successful'][] = $attempt;
error_log( "Updated WP core v{$ver} successfully" );
}
// update records for this item if successfully updated
$this->updateItemRecords( $type, $slug, $this->updateAttempt, $ver );
}
/**
* Update single item
* @param $type string Type of item (plugins/themes/core)
* @param $slug string Filename of the item
* @param $ver string Desired version for update
* @return void
*/
public function updateSingle( $type, $slug, $ver, $package_url = '' ): void {
// call update method based on item type
if ( 'plugins' === $type || 'themes' === $type ) {
call_user_func(
array( $this, 'updateType' . ucfirst( $type ) ),
$type,
$slug,
$ver,
$package_url
);
} else {
call_user_func(
array( $this, 'updateType' . ucfirst( $type ) ),
$type,
$slug,
$ver
);
}
// alternate dynamic function call
//[$this, "updateType".ucfirst($type)]($type,$slug,$ver);
// select suitable WP's update method based on received params
// remove entry for this item from Settings in DB. So that its admin notice will go away.
// add slug in updateState array.
}
/**
* Auto update items
*/
public function updateItems() {
$items = $this->settings->get();
$this->items = $items['vulnerabilities'];
$this->updateAttempt['time'] = time();
foreach ( $this->items as $type => $vuls ) {
if ( 'wp' === $type ) {
$this->updateSingle( $type, $type, $vuls['fixed_in'] );
continue;
}
foreach ( $vuls as $slug => $item ) {
// skip if fix version not present
if ( empty( $item['fixed_in'] ) ) {
continue;
}
$get_latest_ver = $this->get_wp_latest_plugin_version( $slug, $type );
$patched_in_range = false;
foreach ( $item['vulnerabilities'] as $vuln ) {
if ( is_array( $vuln['patched_in_ranges'] ) && ! empty( $vuln['patched_in_ranges'] ) ) {
$patched_in_range = true;
break;
}
}
if ( $type === 'plugins' && 'woocommerce' === $slug ) {
$this->updateSingle( $type, $slug, $item['fixed_in'] );
} elseif ( false !== $get_latest_ver && version_compare( $item['fixed_in'], $get_latest_ver['version'], '>' ) ) {
$update_transient = get_site_transient( 'update_plugins' );
$pluginFQN = $this->find_plugin_for_slug( $slug );
if ( $update_transient && ! empty( $pluginFQN ) && isset( $update_transient->response[ $pluginFQN ]->new_version ) ) {
$item['fixed_in'] = $update_transient->response[ $pluginFQN ]->new_version;
}
$this->updateSingle( $type, $slug, $item['fixed_in'] );
} elseif ( $patched_in_range ) {
$this->updateSingle( $type, $slug, $item['fixed_in'] );
} elseif ( $get_latest_ver !== false ) {
$this->updateSingle( $type, $slug, $get_latest_ver['version'], $get_latest_ver['download_link'] );
} else {
$this->updateSingle( $type, $slug, $item['fixed_in'] );
}
}
}
// send email with param updateState array
}
/**
* Get the latest version of a WordPress plugin or theme.
*
* This function retrieves the latest version of a WordPress plugin or theme
* from the WordPress.org API based on the provided slug and type.
*
* @param string $slug The slug of the plugin or theme.
* @param string $type The type of the resource ('plugin' or 'theme').
*
* @return string|false The latest version of the resource as a string,
*
*/
public function get_wp_latest_plugin_version( $slug, $type ) {
// Ensure the slug is provided.
if ( empty( $slug ) ) {
return false;
}
// Define the API endpoint based on the type.
$api_endpoint = ( $type === 'themes' )
? "https://api.wordpress.org/themes/info/1.1/?action=theme_information&request[slug]={$slug}"
: "https://api.wordpress.org/plugins/info/1.0/{$slug}.json";
// Make an HTTP request to the API.
$response = wp_safe_remote_get( $api_endpoint );
// Get the HTTP status code from the response.
$status_code = wp_remote_retrieve_response_code( $response );
// Check if the status code is not 200 (OK).
if ( $status_code !== 200 ) {
return false; // Request was not successful.
}
// Check if the request was successful but the response is empty.
if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
return false; // Empty or failed response.
}
// Parse the JSON response.
$data = json_decode( $response['body'], true );
// Check if JSON decoding was successful and if the data contains version information.
if ( $data && isset( $data['version'] ) && isset( $data['download_link'] ) ) {
$plugin_info = array(
'version' => $data['version'],
'download_link' => $data['download_link'],
);
return $plugin_info; // Return version and download link in an array.
} else {
return false; // Version information not found in the response.
}
}
/**
* @param $settings
* @param $type
* function to add/increment the update attempts count to the transients, which can be further used to limit repetitive attempts
* @return void
*/
public function add_update_attempts_count( $type, $settings ) {
// Implemented to add the attempt counts with the update attempts
$last_update_attempts = array();
if ( isset( $settings ) && is_array( $settings ) && ! empty( $settings ) ) {
unset( $settings['time'] );
$last_update = get_site_transient( 'ocvm_last_update_attempt' );
if ( ! $last_update ) {
foreach ( $settings as $success_attempt ) {
unset( $success_attempt['vuls'] );
$success_attempt['attempt_count'] = 1;
$last_update_attempts[ $type ][] = $success_attempt;
}
} else {
$last_update_attempts = json_decode( $last_update, true );
if ( $last_update_attempts !== null ) {
foreach ( $settings as $success_attempt ) {
if ( isset( $success_attempt['attempt_stored'] ) && $success_attempt['attempt_stored'] === true ) {
continue;
}
$found = false;
if ( isset( $last_update_attempts[ $type ] ) ) {
foreach ( $last_update_attempts[ $type ] as &$attempt ) {
if ( $success_attempt['slug'] === $attempt['slug'] &&
$success_attempt['new_version'] === $attempt['new_version'] &&
$success_attempt['old_version'] === $attempt['old_version'] ) {
// Increment attempt count
$attempt['attempt_count'] = ( $attempt['attempt_count'] ?? 0 ) + 1;
$found = true;
}
}
}
// If matching attempt not found, add a new attempt
if ( ! $found ) {
$new_attempt = $success_attempt;
unset( $new_attempt['vuls'] );
$new_attempt['attempt_count'] = 1;
$last_update_attempts[ $type ][] = $new_attempt;
}
}
}
}
// Store updated last_update_attempts array in transient
set_site_transient( 'ocvm_last_update_attempt', json_encode( $last_update_attempts ), 24 * HOUR_IN_SECONDS );
}
}
/**
* @param $slug
* @param $type
* @param $new_version
* @param $old_version
* function to verify the current attempt with the last successful attempt to prevent repetitive updates & notifications in case of fake success
* @return bool
*/
public function validate_against_last_update( $slug, $type, $new_version, $old_version ) {
$last_update = get_site_transient( 'ocvm_last_update_attempt' );
if ( $last_update ) {
$last_update_attempts = json_decode( $last_update, true );
if ( $last_update_attempts !== null ) {
foreach ( $last_update_attempts as $attempt_type ) {
foreach ( $attempt_type as $attempt ) {
if ( $slug === $attempt['slug'] &&
$new_version === $attempt['new_version'] &&
$old_version === $attempt['old_version'] &&
( $attempt['attempt_count'] >= 3 ) ) {
$type = ucfirst( $type );
error_log( "Skipping update by rule...[$type '{$slug}' update for the same version was attempted more than 3 times in last 24 hours ]" );
return true;
}
}
}
}
}
return false;
}
}