Skip to content

Commit

Permalink
Protect: Add Scan_History class (#37903)
Browse files Browse the repository at this point in the history
  • Loading branch information
dkmyta authored and nateweller committed Jul 15, 2024
1 parent 7d4923c commit 5d62914
Show file tree
Hide file tree
Showing 4 changed files with 475 additions and 0 deletions.
4 changes: 4 additions & 0 deletions projects/plugins/protect/changelog/add-protect-scan-history
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add Scan_History class
2 changes: 2 additions & 0 deletions projects/plugins/protect/src/class-jetpack-protect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
345 changes: 345 additions & 0 deletions projects/plugins/protect/src/class-scan-history.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
<?php
/**
* Class to handle the Scan Status of Jetpack Protect
*
* @package automattic/jetpack-protect-plugin
*/

namespace Automattic\Jetpack\Protect;

use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Jetpack_Options;
use WP_Error;

/**
* Class that handles fetching of threats from the Scan API
*/
class Scan_History {
/**
* Scan endpoint
*
* @var string
*/
const SCAN_HISTORY_API_BASE = '/sites/%d/scan/history';

/**
* Name of the option where history is stored
*
* @var string
*/
const OPTION_NAME = 'jetpack_scan_history';

/**
* Name of the option where the timestamp of the history is stored
*
* @var string
*/
const OPTION_TIMESTAMP_NAME = 'jetpack_scan_history_timestamp';

/**
* Time in seconds that the cache should last
*
* @var int
*/
const OPTION_EXPIRES_AFTER = 300; // 5 minutes.

/**
* Memoization for the current history
*
* @var null|History_Model
*/
public static $history = null;

/**
* Checks if the current cached history is expired and should be renewed
*
* @return boolean
*/
public static function is_cache_expired() {
$option_timestamp = get_option( static::OPTION_TIMESTAMP_NAME );

if ( ! $option_timestamp ) {
return true;
}

return time() > (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;
}
}
}
Loading

0 comments on commit 5d62914

Please sign in to comment.