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() ), ); } }