Files
argus-wp-defence/includes/class-argus-update-client.php
T
root df0f2fccb8 ARGUS WordPress Defence 1.0.0 — first production release
Automatic WordPress security: local firewall, malware and file-integrity
scanning, vulnerability protection, quarantine, scheduled backups, an
optional page cache, and automatic global threat intelligence.

See README.md for installation, update, and uninstall instructions.
2026-08-09 13:40:16 +00:00

273 lines
11 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Argus_Update_Client {
const CHECK_INTERVAL_SECS = 6 * HOUR_IN_SECONDS;
const LOCK_TRANSIENT = 'argus_wpd_update_check_lock';
const LAST_CHECK_OPTION = 'argus_wpd_update_last_check';
const MANIFEST_ETAG_OPTION = 'argus_wpd_update_manifest_etag';
const PENDING_MANIFEST_OPTION = 'argus_wpd_update_pending_manifest';
const LAST_SUCCESS_OPTION = 'argus_wpd_update_last_success';
const LAST_FAILURE_OPTION = 'argus_wpd_update_last_failure';
public static function manifest_url() {
return defined( 'ARGUS_WPD_UPDATE_MANIFEST_URL' ) ? ARGUS_WPD_UPDATE_MANIFEST_URL : '';
}
public static function public_key() {
return defined( 'ARGUS_WPD_UPDATE_PUBLIC_KEY' ) ? ARGUS_WPD_UPDATE_PUBLIC_KEY : '';
}
public static function is_configured() {
return '' !== self::manifest_url() && '' !== self::public_key();
}
public static function maybe_check() {
if ( ! self::is_configured() ) {
return;
}
$last_check = (int) get_option( self::LAST_CHECK_OPTION, 0 );
if ( ( $last_check + self::CHECK_INTERVAL_SECS ) > time() ) {
return;
}
if ( false !== get_transient( self::LOCK_TRANSIENT ) ) {
return;
}
set_transient( self::LOCK_TRANSIENT, 1, 4 * MINUTE_IN_SECONDS );
self::check_now();
delete_transient( self::LOCK_TRANSIENT );
}
public static function check_now() {
if ( ! self::is_configured() ) {
return array( 'success' => false, 'message' => __( 'No update source is configured.', 'argus-wordpress-defence' ), 'update_available' => false );
}
update_option( self::LAST_CHECK_OPTION, time(), false );
$args = array( 'timeout' => 15 );
$etag = get_option( self::MANIFEST_ETAG_OPTION, '' );
if ( $etag ) {
$args['headers'] = array( 'If-None-Match' => $etag );
}
$response = wp_remote_get( self::manifest_url(), $args );
if ( is_wp_error( $response ) ) {
return self::record_failure( __( 'Could not reach the update server.', 'argus-wordpress-defence' ) );
}
$code = wp_remote_retrieve_response_code( $response );
if ( 304 === $code ) {
return array( 'success' => true, 'message' => '', 'update_available' => false );
}
if ( 200 !== $code ) {
return self::record_failure( sprintf( 'Update server returned HTTP %d.', $code ) );
}
$new_etag = wp_remote_retrieve_header( $response, 'etag' );
if ( $new_etag ) {
update_option( self::MANIFEST_ETAG_OPTION, $new_etag, false );
}
$body = wp_remote_retrieve_body( $response );
$manifest = json_decode( $body, true );
if ( ! is_array( $manifest ) ) {
return self::record_failure( __( 'Update manifest was not valid JSON.', 'argus-wordpress-defence' ) );
}
return self::process_manifest( $manifest );
}
public static function process_manifest( array $manifest ) {
if ( ! self::verify_signature( $manifest ) ) {
return self::record_failure( __( 'Update manifest failed signature verification.', 'argus-wordpress-defence' ) );
}
$version = $manifest['version'] ?? '';
if ( '' === $version ) {
return self::record_failure( __( 'Update manifest is missing a version.', 'argus-wordpress-defence' ) );
}
if ( version_compare( $version, ARGUS_WPD_VERSION, '<=' ) ) {
return array( 'success' => true, 'message' => '', 'update_available' => false );
}
$last_installed = get_option( self::LAST_SUCCESS_OPTION, array() );
if ( ! empty( $last_installed['version'] ) && version_compare( $version, $last_installed['version'], '<=' ) ) {
return array( 'success' => true, 'message' => '', 'update_available' => false );
}
global $wp_version;
if ( ! empty( $manifest['min_php'] ) && version_compare( PHP_VERSION, $manifest['min_php'], '<' ) ) {
return self::record_failure( sprintf( 'Update %s requires PHP %s or newer.', $version, $manifest['min_php'] ) );
}
if ( ! empty( $manifest['min_wp'] ) && version_compare( $wp_version, $manifest['min_wp'], '<' ) ) {
return self::record_failure( sprintf( 'Update %s requires WordPress %s or newer.', $version, $manifest['min_wp'] ) );
}
update_option( self::PENDING_MANIFEST_OPTION, $manifest, false );
if ( ! empty( $manifest['critical'] ) ) {
return self::install_update( $manifest );
}
return array( 'success' => true, 'message' => '', 'update_available' => true );
}
public static function verify_signature( array $manifest ) {
if ( empty( $manifest['signature'] ) ) {
return false;
}
$signature = base64_decode( $manifest['signature'], true );
if ( false === $signature || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $signature ) ) {
return false;
}
$public_key_b64 = self::public_key();
if ( '' === $public_key_b64 ) {
return false;
}
$public_key = base64_decode( $public_key_b64, true );
if ( false === $public_key ) {
return false;
}
$canonical = self::canonical_payload( $manifest );
return sodium_crypto_sign_verify_detached( $signature, $canonical, $public_key );
}
protected static function canonical_payload( array $manifest ) {
$ordered = array();
foreach ( array( 'version', 'released_at', 'package_url', 'sha256', 'min_php', 'min_wp', 'critical' ) as $key ) {
$ordered[ $key ] = $manifest[ $key ] ?? null;
}
return wp_json_encode( $ordered, JSON_UNESCAPED_SLASHES );
}
public static function install_update( array $manifest, $target_dir = null ) {
$target_dir = $target_dir ?: ( WP_PLUGIN_DIR . '/argus-wordpress-defence' );
$version = $manifest['version'];
$upgrade_dir = trailingslashit( wp_get_upload_dir()['basedir'] ) . 'argus-wpd-data/update-staging';
if ( ! is_dir( $upgrade_dir ) && ! wp_mkdir_p( $upgrade_dir ) ) {
return self::record_failure( __( 'Could not prepare the update staging directory.', 'argus-wordpress-defence' ) );
}
$package_path = trailingslashit( $upgrade_dir ) . 'package-' . $version . '-' . bin2hex( random_bytes( 4 ) ) . '.zip';
$response = wp_remote_get( $manifest['package_url'], array( 'timeout' => 120, 'stream' => true, 'filename' => $package_path ) );
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
@unlink( $package_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions
return self::record_failure( __( 'Could not download the update package.', 'argus-wordpress-defence' ) );
}
if ( ! hash_equals( $manifest['sha256'], hash_file( 'sha256', $package_path ) ) ) {
wp_delete_file( $package_path );
return self::record_failure( __( 'Downloaded package hash did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) );
}
$extract_root = trailingslashit( $upgrade_dir ) . 'extract-' . $version . '-' . bin2hex( random_bytes( 4 ) );
if ( ! class_exists( 'ZipArchive' ) ) {
wp_delete_file( $package_path );
return self::record_failure( __( 'The PHP zip extension is not available -- cannot install the update.', 'argus-wordpress-defence' ) );
}
$zip = new ZipArchive();
if ( true !== $zip->open( $package_path ) || true !== $zip->extractTo( $extract_root ) ) {
wp_delete_file( $package_path );
return self::record_failure( __( 'Could not extract the update package.', 'argus-wordpress-defence' ) );
}
$zip->close();
wp_delete_file( $package_path );
$extracted_plugin_dir = trailingslashit( $extract_root ) . 'argus-wordpress-defence';
$new_main_file = trailingslashit( $extracted_plugin_dir ) . 'argus-wordpress-defence.php';
if ( ! file_exists( $new_main_file ) ) {
self::rrmdir( $extract_root );
return self::record_failure( __( 'Extracted package did not contain the expected plugin file.', 'argus-wordpress-defence' ) );
}
$header_contents = file_get_contents( $new_main_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( ! preg_match( '/Version:\s*([^\r\n]+)/', $header_contents, $m ) || trim( $m[1] ) !== $version ) {
self::rrmdir( $extract_root );
return self::record_failure( __( 'Extracted package version did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) );
}
$backup_dir = $target_dir . '-previous-' . time();
$had_previous = is_dir( $target_dir );
if ( $had_previous && ! rename( $target_dir, $backup_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
self::rrmdir( $extract_root );
return self::record_failure( __( 'Could not move the current plugin version aside for the update.', 'argus-wordpress-defence' ) );
}
if ( ! rename( $extracted_plugin_dir, $target_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
if ( $had_previous ) {
rename( $backup_dir, $target_dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
self::rrmdir( $extract_root );
return self::record_failure( __( 'Could not activate the downloaded update -- rolled back to the previous version.', 'argus-wordpress-defence' ) );
}
self::rrmdir( $extract_root );
if ( $had_previous ) {
self::rrmdir( $backup_dir );
}
delete_option( self::PENDING_MANIFEST_OPTION );
update_option(
self::LAST_SUCCESS_OPTION,
array( 'version' => $version, 'installed_at' => current_time( 'mysql', true ) ),
false
);
return array(
'success' => true,
'message' => ! empty( $manifest['critical'] )
? sprintf( __( 'A critical ARGUS security update was installed (%s).', 'argus-wordpress-defence' ), $version )
: sprintf( __( 'ARGUS Defence was updated to version %s.', 'argus-wordpress-defence' ), $version ),
'update_available' => false,
);
}
protected static function record_failure( $message ) {
update_option(
self::LAST_FAILURE_OPTION,
array( 'message' => $message, 'at' => current_time( 'mysql', true ) ),
false
);
Argus_Events::record( 'update_check_failed', 'medium', $message, array() );
return array( 'success' => false, 'message' => $message, 'update_available' => false );
}
protected static function rrmdir( $dir ) {
if ( ! is_dir( $dir ) ) {
return;
}
$items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST );
foreach ( $items as $item ) {
$item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
}
public static function status() {
return array(
'current_version' => ARGUS_WPD_VERSION,
'configured' => self::is_configured(),
'last_check' => get_option( self::LAST_CHECK_OPTION, 0 ),
'last_success' => get_option( self::LAST_SUCCESS_OPTION, array() ),
'last_failure' => get_option( self::LAST_FAILURE_OPTION, array() ),
'pending' => get_option( self::PENDING_MANIFEST_OPTION, array() ),
);
}
}