diff --git a/projects/plugins/protect/changelog/add-protect-scan-history b/projects/plugins/protect/changelog/add-protect-scan-history new file mode 100644 index 0000000000000..65ea1c00103c0 --- /dev/null +++ b/projects/plugins/protect/changelog/add-protect-scan-history @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add Scan_History class diff --git a/projects/plugins/protect/src/class-jetpack-protect.php b/projects/plugins/protect/src/class-jetpack-protect.php index 3c32031a478f4..26730756a952c 100644 --- a/projects/plugins/protect/src/class-jetpack-protect.php +++ b/projects/plugins/protect/src/class-jetpack-protect.php @@ -21,6 +21,7 @@ use Automattic\Jetpack\Plugins_Installer; use Automattic\Jetpack\Protect\Onboarding; use Automattic\Jetpack\Protect\REST_Controller; +use Automattic\Jetpack\Protect\Scan_History; use Automattic\Jetpack\Protect\Site_Health; use Automattic\Jetpack\Protect_Status\Plan; use Automattic\Jetpack\Protect_Status\Status; @@ -211,6 +212,7 @@ public function initial_state() { 'apiNonce' => wp_create_nonce( 'wp_rest' ), 'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ), 'status' => Status::get_status( $refresh_status_from_wpcom ), + 'scanHistory' => Scan_History::get_scan_history( $refresh_status_from_wpcom ), 'installedPlugins' => Plugins_Installer::get_plugins(), 'installedThemes' => Sync_Functions::get_themes(), 'wpVersion' => $wp_version, diff --git a/projects/plugins/protect/src/class-scan-history.php b/projects/plugins/protect/src/class-scan-history.php new file mode 100644 index 0000000000000..9636dfb6404d1 --- /dev/null +++ b/projects/plugins/protect/src/class-scan-history.php @@ -0,0 +1,345 @@ + (int) $option_timestamp; + } + + /** + * Checks if we should consider the stored cache or bypass it + * + * @return boolean + */ + public static function should_use_cache() { + return ! ( ( defined( 'JETPACK_PROTECT_DEV__BYPASS_CACHE' ) && JETPACK_PROTECT_DEV__BYPASS_CACHE ) ); + } + + /** + * Gets the current cached history + * + * @return bool|array False if value is not found. Array with values if cache is found. + */ + public static function get_from_options() { + return maybe_unserialize( get_option( static::OPTION_NAME ) ); + } + + /** + * Updated the cached history and its timestamp + * + * @param array $history The new history to be cached. + * @return void + */ + public static function update_history_option( $history ) { + // TODO: Sanitize $history. + update_option( static::OPTION_NAME, maybe_serialize( $history ) ); + update_option( static::OPTION_TIMESTAMP_NAME, time() + static::OPTION_EXPIRES_AFTER ); + } + + /** + * Gets the current history of the Jetpack Protect checks + * + * @param bool $refresh_from_wpcom Refresh the local plan and history cache from wpcom. + * @param array $filter The filter to apply to the data. + * @return History_Model|bool + */ + public static function get_scan_history( $refresh_from_wpcom = false, $filter = null ) { + $has_required_plan = Plan::has_required_plan(); + if ( ! $has_required_plan ) { + return false; + } + + if ( self::$history !== null ) { + return self::$history; + } + + if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) { + $history = self::fetch_from_api(); + } else { + $history = self::get_from_options(); + } + + if ( is_wp_error( $history ) ) { + $history = new History_Model( + array( + 'error' => true, + 'error_code' => $history->get_error_code(), + 'error_message' => $history->get_error_message(), + ) + ); + } else { + $history = self::normalize_api_data( $history, $filter ); + } + + self::$history = $history; + return $history; + } + + /** + * Gets the Scan API endpoint + * + * @return WP_Error|string + */ + public static function get_api_url() { + $blog_id = Jetpack_Options::get_option( 'id' ); + $is_connected = ( new Connection_Manager() )->is_connected(); + + if ( ! $blog_id || ! $is_connected ) { + return new WP_Error( 'site_not_connected' ); + } + + $api_url = sprintf( self::SCAN_HISTORY_API_BASE, $blog_id ); + + return $api_url; + } + + /** + * Fetches the history data from the Scan API + * + * @return WP_Error|array + */ + public static function fetch_from_api() { + $api_url = self::get_api_url(); + if ( is_wp_error( $api_url ) ) { + return $api_url; + } + + $response = Client::wpcom_json_api_request_as_blog( + $api_url, + '2', + array( 'method' => 'GET' ), + null, + 'wpcom' + ); + + $response_code = wp_remote_retrieve_response_code( $response ); + + if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) { + return new WP_Error( 'failed_fetching_status', 'Failed to fetch Scan history from the server', array( 'status' => $response_code ) ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ) ); + $body->last_checked = ( new \DateTime() )->format( 'Y-m-d H:i:s' ); + self::update_history_option( $body ); + + return $body; + } + + /** + * Normalize API Data + * Formats the payload from the Scan API into an instance of History_Model. + * + * @param object $scan_data The data returned by the scan API. + * @param array $filter The filter to apply to the data. + * @return History_Model + */ + private static function normalize_api_data( $scan_data, $filter ) { + $history = new History_Model(); + $history->num_threats = 0; + $history->num_core_threats = 0; + $history->num_plugins_threats = 0; + $history->num_themes_threats = 0; + + if ( $filter ) { + $history->filter = $filter; + } + + $history->last_checked = $scan_data->last_checked; + + if ( empty( $scan_data->threats ) || ! is_array( $scan_data->threats ) ) { + return $history; + } + + foreach ( $scan_data->threats as $threat ) { + if ( ! in_array( $threat->status, $history->filter, true ) ) { + continue; + } + + if ( isset( $threat->extension->type ) ) { + if ( 'plugin' === $threat->extension->type ) { + self::handle_extension_threats( $threat, $history, 'plugin' ); + continue; + } + + if ( 'theme' === $threat->extension->type ) { + self::handle_extension_threats( $threat, $history, 'theme' ); + continue; + } + } + + if ( 'Vulnerable.WP.Core' === $threat->signature ) { + self::handle_core_threats( $threat, $history ); + continue; + } + + self::handle_additional_threats( $threat, $history ); + } + + return $history; + } + + /** + * Handles threats for extensions such as plugins or themes. + * + * @param object $threat The threat object. + * @param object $history The history object. + * @param string $type The type of extension ('plugin' or 'theme'). + * @return void + */ + private static function handle_extension_threats( $threat, $history, $type ) { + $extension_list = $type === 'plugin' ? 'plugins' : 'themes'; + $extensions = &$history->{ $extension_list}; + $found_index = null; + + // Check if the extension does not exist in the array + foreach ( $extensions as $index => $extension ) { + if ( $extension->slug === $threat->extension->slug ) { + $found_index = $index; + break; + } + } + + // Add the extension if it does not yet exist in the history + if ( $found_index === null ) { + $new_extension = new Extension_Model( + array( + 'name' => $threat->extension->name ?? null, + 'slug' => $threat->extension->slug ?? null, + 'version' => $threat->extension->version ?? null, + 'type' => $type, + 'checked' => true, + 'threats' => array(), + ) + ); + $extensions[] = $new_extension; + $found_index = array_key_last( $extensions ); + } + + // Add the threat to the found extension + $extensions[ $found_index ]->threats[] = new Threat_Model( $threat ); + + // Increment the threat counts + ++$history->num_threats; + if ( $type === 'plugin' ) { + ++$history->num_plugins_threats; + } elseif ( $type === 'theme' ) { + ++$history->num_themes_threats; + } + } + + /** + * Handles core threats + * + * @param object $threat The threat object. + * @param object $history The history object. + * @return void + */ + private static function handle_core_threats( $threat, $history ) { + // Check if the core version does not exist in the array + $found_index = null; + foreach ( $history->core as $index => $core ) { + if ( $core->version === $threat->version ) { + $found_index = $index; + break; + } + } + + // Add the extension if it does not yet exist in the history + if ( null === $found_index ) { + $new_core = new Extension_Model( + array( + 'name' => 'WordPress', + 'version' => $threat->version, + 'type' => 'core', + 'checked' => true, + 'threats' => array(), + ) + ); + $history->core[] = $new_core; + $found_index = array_key_last( $history->core ); + } + + // Add the threat to the found core + $history->core[ $found_index ]->threats[] = new Threat_Model( $threat ); + + ++$history->num_threats; + ++$history->num_core_threats; + } + + /** + * Handles additional threats that are not core, plugin or theme + * + * @param object $threat The threat object. + * @param object $history The history object. + * @return void + */ + private static function handle_additional_threats( $threat, $history ) { + if ( ! empty( $threat->filename ) ) { + $history->files[] = new Threat_Model( $threat ); + ++$history->num_threats; + } elseif ( ! empty( $threat->table ) ) { + $history->database[] = new Threat_Model( $threat ); + ++$history->num_threats; + } + } +} diff --git a/projects/plugins/protect/src/models/class-history-model.php b/projects/plugins/protect/src/models/class-history-model.php new file mode 100644 index 0000000000000..040d652f696f7 --- /dev/null +++ b/projects/plugins/protect/src/models/class-history-model.php @@ -0,0 +1,124 @@ + + */ + public $core = array(); + + /** + * Status themes. + * + * @var array + */ + public $themes = array(); + + /** + * Status plugins. + * + * @var array + */ + public $plugins = array(); + + /** + * File threats. + * + * @var array + */ + public $files = array(); + + /** + * Database threats. + * + * @var array + */ + public $database = array(); + + /** + * Whether there was an error loading the history. + * + * @var bool + */ + public $error = false; + + /** + * The error code thrown when loading the history. + * + * @var string + */ + public $error_code; + + /** + * The error message thrown when loading the history. + * + * @var string + */ + public $error_message; + + /** + * Status constructor. + * + * @param array $history The history data to load into the class instance. + */ + public function __construct( $history = array() ) { + foreach ( $history as $property => $value ) { + if ( property_exists( $this, $property ) ) { + $this->$property = $value; + } + } + } +}