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.
273 lines
11 KiB
PHP
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() ),
|
|
);
|
|
}
|
|
}
|