This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,228 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.13] - 2026-06-08
### Changed
- Internal updates.
## [0.7.12] - 2026-06-05
### Changed
- Internal updates.
## [0.7.11] - 2026-05-21
### Fixed
- Phan: Address PhanPluginDuplicateConditionalNullCoalescing violations. [#48887]
## [0.7.10] - 2026-05-19
### Changed
- Internal updates.
## [0.7.9] - 2026-05-04
### Changed
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
## [0.7.8] - 2026-02-23
### Changed
- Update dependencies. [#44736]
## [0.7.7] - 2026-02-02
### Changed
- Update dependencies. [#44736]
## [0.7.6] - 2025-12-08
### Fixed
- Ensure proper flags are used with `json_encode()`. [#46092]
## [0.7.5] - 2025-12-01
### Changed
- Update dependencies. [#44736]
## [0.7.4] - 2025-10-20
### Changed
- Internal updates.
## [0.7.3] - 2025-09-15
### Changed
- Internal updates.
## [0.7.2] - 2025-08-11
### Changed
- Update dependencies. [#44673]
## [0.7.1] - 2025-08-04
### Changed
- Internal updates.
## [0.7.0] - 2025-07-30
### Added
- Ensure user is fully aware that deleting an extension via delete-fixer may break their site. [#44521]
## [0.6.1] - 2025-07-21
### Changed
- Update dependencies.
## [0.6.0] - 2025-06-05
### Added
- Add functionality to correctly display database threats in the Protect UI. [#43663]
## [0.5.11] - 2025-05-05
### Changed
- Update dependencies.
## [0.5.10] - 2025-04-28
### Changed
- Internal updates.
## [0.5.9] - 2025-03-31
### Changed
- Update dependencies.
## [0.5.8] - 2025-03-21
### Changed
- Internal updates.
## [0.5.7] - 2025-03-18
### Changed
- Update dependencies.
## [0.5.6] - 2025-03-17
### Changed
- Internal updates.
## [0.5.5] - 2025-03-12
### Changed
- Internal updates.
## [0.5.4] - 2025-03-05
### Changed
- Internal updates.
## [0.5.3] - 2025-03-03
### Changed
- Internal updates.
## [0.5.2] - 2025-02-24
### Changed
- Update dependencies.
## [0.5.1] - 2025-02-11
### Fixed
- Protect Status: Ensure vulnerabilities property is always an array. [#41694]
## [0.5.0] - 2025-02-10
### Changed
- Combine multiple vulnerability results for the same extension into a single vulnerable extension threat result. [#40863]
## [0.4.3] - 2025-02-03
### Fixed
- Code: Remove extra params on function calls. [#41263]
- Fix a bug when core version data is not interpreted correctly from the report data response. [#41503]
## [0.4.2] - 2025-01-20
### Fixed
- Fix Current_Plan::supports() call from breaking cache on every call. [#41010]
## [0.4.1] - 2024-12-23
### Fixed
- Fix PHP warnings caused by uninstalled extensions. [#40622]
## [0.4.0] - 2024-12-04
### Added
- Add extension data to threats. [#40400]
## [0.3.1] - 2024-11-25
### Changed
- Updated dependencies. [#40286]
## [0.3.0] - 2024-11-14
### Added
- Added threats property to protect status. [#40097]
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [0.2.2] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [0.2.1] - 2024-10-29
### Changed
- Internal updates.
## [0.2.0] - 2024-09-23
### Changed
- Adds a fixable_threats status property [#39125]
## [0.1.5] - 2024-09-05
### Changed
- Update dependencies.
## [0.1.4] - 2024-09-05
### Changed
- Update dependencies.
## [0.1.3] - 2024-08-26
### Changed
- Updated package dependencies. [#39004]
## [0.1.2] - 2024-08-19
### Changed
- Internal updates.
## [0.1.1] - 2024-08-09
### Changed
- Update dependencies.
## 0.1.0 - 2024-07-15
### Added
- Initial version. [#37894]
### Changed
- Updated package dependencies. [#37894]
[0.7.13]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.12...v0.7.13
[0.7.12]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.11...v0.7.12
[0.7.11]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.10...v0.7.11
[0.7.10]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.9...v0.7.10
[0.7.9]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.8...v0.7.9
[0.7.8]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.7...v0.7.8
[0.7.7]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.6...v0.7.7
[0.7.6]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.5...v0.7.6
[0.7.5]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.4...v0.7.5
[0.7.4]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.3...v0.7.4
[0.7.3]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.2...v0.7.3
[0.7.2]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.1...v0.7.2
[0.7.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/Automattic/jetpack-protect-status/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.11...v0.6.0
[0.5.11]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.10...v0.5.11
[0.5.10]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.9...v0.5.10
[0.5.9]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.8...v0.5.9
[0.5.8]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.7...v0.5.8
[0.5.7]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/Automattic/jetpack-protect-status/compare/v0.4.3...v0.5.0
[0.4.3]: https://github.com/Automattic/jetpack-protect-status/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/Automattic/jetpack-protect-status/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/Automattic/jetpack-protect-status/compare/v0.3.1...v0.4.0
[0.3.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Automattic/jetpack-protect-status/compare/v0.2.2...v0.3.0
[0.2.2]: https://github.com/Automattic/jetpack-protect-status/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Automattic/jetpack-protect-status/compare/v0.1.5...v0.2.0
[0.1.5]: https://github.com/Automattic/jetpack-protect-status/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/Automattic/jetpack-protect-status/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/Automattic/jetpack-protect-status/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/Automattic/jetpack-protect-status/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/Automattic/jetpack-protect-status/compare/v0.1.0...v0.1.1
@@ -0,0 +1,111 @@
<?php
/**
* Class to handle the Protect plan
*
* @package automattic/jetpack-protect-status
*/
namespace Automattic\Jetpack\Protect_Status;
use Automattic\Jetpack\Current_Plan;
/**
* The Plan class.
*/
class Plan {
/**
* The meta name used to store the cache date
*
* @var string
*/
const CACHE_DATE_META_NAME = 'protect-cache-date';
/**
* Valid pediord for the cache: One week.
*/
const CACHE_VALIDITY_PERIOD = 7 * DAY_IN_SECONDS;
/**
* The meta name used to store the cache
*
* @var string
*/
const CACHE_META_NAME = 'protect-cache';
/**
* Checks if the cache is old, meaning we need to fetch new data from WPCOM
*/
private static function is_cache_old() {
if ( empty( self::get_product_from_cache() ) ) {
return true;
}
$cache_date = get_user_meta( get_current_user_id(), self::CACHE_DATE_META_NAME, true );
return time() - (int) $cache_date > ( self::CACHE_VALIDITY_PERIOD );
}
/**
* Gets the product list from the user cache
*/
private static function get_product_from_cache() {
return get_user_meta( get_current_user_id(), self::CACHE_META_NAME, true );
}
/**
* Gets the product data
*
* @param string $wpcom_product The product slug.
* @return array
*/
public static function get_product( $wpcom_product = 'jetpack_scan' ) {
if ( ! self::is_cache_old() ) {
return self::get_product_from_cache();
}
$request_url = 'https://public-api.wordpress.com/rest/v1.1/products?locale=' . get_user_locale() . '&type=jetpack';
$wpcom_request = wp_remote_get( esc_url_raw( $request_url ) );
$response_code = wp_remote_retrieve_response_code( $wpcom_request );
if ( 200 === $response_code ) {
$products = json_decode( wp_remote_retrieve_body( $wpcom_request ) );
// Pick the desired product...
$product = $products->{$wpcom_product};
// ... and store it into the cache.
update_user_meta( get_current_user_id(), self::CACHE_DATE_META_NAME, time() );
update_user_meta( get_current_user_id(), self::CACHE_META_NAME, $product );
return $product;
}
return new \WP_Error(
'failed_to_fetch_data',
esc_html__( 'Unable to fetch the requested data.', 'jetpack-protect-status' ),
array(
'status' => $response_code,
'request' => $wpcom_request,
)
);
}
/**
* Has Required Plan
*
* @param bool $force_refresh Refresh the local plan cache from wpcom.
* @return bool True when the site has a plan or product that supports the paid Protect tier.
*/
public static function has_required_plan( $force_refresh = false ) {
static $has_scan = null;
if ( null === $has_scan || $force_refresh ) {
$products = array_column( Current_Plan::get_products(), 'product_slug' );
// Check for a plan or product that enables scan.
$plan_supports_scan = Current_Plan::supports( 'scan', $force_refresh );
$has_scan_product = count( array_intersect( array( 'jetpack_scan', 'jetpack_scan_monthly' ), $products ) ) > 0;
$has_scan = $plan_supports_scan || $has_scan_product;
}
return $has_scan;
}
}
@@ -0,0 +1,344 @@
<?php
/**
* Class to handle the Protect Status of Jetpack Protect
*
* @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
*
* @package automattic/jetpack-protect-status
*/
namespace Automattic\Jetpack\Protect_Status;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Plugins_Installer;
use Automattic\Jetpack\Protect_Models\Extension_Model;
use Automattic\Jetpack\Protect_Models\Status_Model;
use Automattic\Jetpack\Protect_Models\Threat_Model;
use Automattic\Jetpack\Protect_Models\Vulnerability_Model;
use Automattic\Jetpack\Sync\Functions as Sync_Functions;
use Jetpack_Options;
use WP_Error;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class that handles fetching and caching the Status of vulnerabilities check from the WPCOM servers
*/
class Protect_Status extends Status {
/**
* WPCOM endpoint
*
* @var string
*/
const REST_API_BASE = '/sites/%d/jetpack-protect-status';
/**
* Name of the option where status is stored
*
* @var string
*/
const OPTION_NAME = 'jetpack_protect_status';
/**
* Name of the option where the timestamp of the status is stored
*
* @var string
*/
const OPTION_TIMESTAMP_NAME = 'jetpack_protect_status_time';
/**
* Gets the current status of the Jetpack Protect checks
*
* @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
* @return Status_Model
*/
public static function get_status( $refresh_from_wpcom = false ) {
if ( self::$status !== null ) {
return self::$status;
}
if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
$status = self::fetch_from_server();
} else {
$status = self::get_from_options();
}
if ( is_wp_error( $status ) ) {
$status = new Status_Model(
array(
'error' => true,
'error_code' => $status->get_error_code(),
'error_message' => $status->get_error_message(),
)
);
} else {
$status = self::normalize_protect_report_data( $status );
}
self::$status = $status;
return $status;
}
/**
* Gets the WPCOM 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::REST_API_BASE, $blog_id );
return $api_url;
}
/**
* Fetches the status from WPCOM servers
*
* @return WP_Error|array
*/
public static function fetch_from_server() {
$api_url = self::get_api_url();
if ( is_wp_error( $api_url ) ) {
return $api_url;
}
$response = Client::wpcom_json_api_request_as_blog(
self::get_api_url(),
'2',
array(
'method' => 'GET',
'timeout' => 30,
),
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 Protect Status data from server', array( 'status' => $response_code ) );
}
$body = json_decode( wp_remote_retrieve_body( $response ) );
self::update_status_option( $body );
return $body;
}
/**
* Normalize data from the Protect Report data source.
*
* @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
*
* @param object $report_data Data from the Protect Report.
* @return Status_Model
*/
protected static function normalize_protect_report_data( $report_data ) {
$status = new Status_Model();
$status->data_source = 'protect_report';
// map report data properties directly into the Status_Model
$status->status = $report_data->status ?? null;
$status->last_checked = $report_data->last_checked ?? null;
$status->num_threats = $report_data->num_vulnerabilities ?? null;
$status->num_themes_threats = $report_data->num_themes_vulnerabilities ?? null;
$status->num_plugins_threats = $report_data->num_plugins_vulnerabilities ?? null;
$status->has_unchecked_items = false;
// normalize extension information
self::normalize_extension_data( $status, $report_data, 'themes' );
self::normalize_extension_data( $status, $report_data, 'plugins' );
self::normalize_core_data( $status, $report_data );
// sort extensions by number of threats
$status->themes = self::sort_threats( $status->themes );
$status->plugins = self::sort_threats( $status->plugins );
return $status;
}
/**
* Normalize theme and plugin information from the Protect Report data source.
*
* @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
*
* @param object $status The status object to normalize.
* @param object $report_data Data from the Protect Report.
* @param string $extension_type The type of extension to normalize. Either 'themes' or 'plugins'.
*
* @return void
*/
protected static function normalize_extension_data( &$status, $report_data, $extension_type ) {
if ( ! in_array( $extension_type, array( 'plugins', 'themes' ), true ) ) {
return;
}
$installed_extensions = 'plugins' === $extension_type ? Plugins_Installer::get_plugins() : Sync_Functions::get_themes();
$checked_extensions = $report_data->{ $extension_type } ?? new \stdClass();
/**
* Extension slug <=> threats data map.
*
* @var Extension_Model[] $extension_threats Array of Extension_Model objects indexed by slug.
*/
$extension_threats = array();
// Initialize the extension threats map with all extensions currently installed on the site
foreach ( $installed_extensions as $slug => $installed_extension ) {
$extension_threats[ $slug ] = new Extension_Model(
array(
'slug' => $slug,
'name' => $installed_extension['Name'],
'version' => $installed_extension['Version'],
'type' => $extension_type,
'checked' => isset( $checked_extensions->{ $slug } ),
)
);
}
foreach ( $checked_extensions as $slug => $checked_extension ) {
$installed_extension = $installed_extensions[ $slug ] ?? null;
// extension is no longer installed on the site
if ( ! $installed_extension ) {
continue;
}
$extension = new Extension_Model(
array(
'name' => $installed_extension['Name'],
'version' => $installed_extension['Version'],
'slug' => $slug,
'checked' => false,
'type' => $extension_type,
)
);
// extension version has changed since the report
if ( $installed_extension['Version'] !== $checked_extension->version ) {
// maintain $status->{ themes|plugins } for backwards compatibility.
$extension_threats[ $slug ] = $extension;
continue;
}
$extension->checked = true;
$extension_threats[ $slug ] = $extension;
if ( is_array( $checked_extension->vulnerabilities ) && ! empty( $checked_extension->vulnerabilities ) ) {
// normalize the vulnerabilities data
$vulnerabilities = array_map(
function ( $vulnerability ) {
return new Vulnerability_Model( $vulnerability );
},
$checked_extension->vulnerabilities
);
// convert the detected vulnerabilities into a vulnerable extension threat
$threat = Threat_Model::generate_from_extension_vulnerabilities( $extension, $vulnerabilities );
$threat_extension = clone $extension;
$extension_threat = clone $threat;
$extension_threat->extension = null;
$extension_threats[ $slug ]->threats[] = $extension_threat;
$threat->extension = $threat_extension;
$status->threats[] = $threat;
}
}
$status->{ $extension_type } = array_values( $extension_threats );
}
/**
* Normalize the core information from the Protect Report data source.
*
* @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
*
* @param object $status The status object to normalize.
* @param object $report_data Data from the Protect Report.
*
* @return void
*/
protected static function normalize_core_data( &$status, $report_data ) {
global $wp_version;
// Ensure the report data has the core property.
if ( ! isset( $report_data->core ) || ! $report_data->core
|| ! isset( $report_data->core->version ) || ! $report_data->core->version ) {
$report_data->core = new \stdClass();
$report_data->core->version = new \stdClass();
}
$core = new Extension_Model(
array(
'type' => 'core',
'name' => 'WordPress',
'slug' => 'wordpress',
'version' => $wp_version,
'checked' => false,
)
);
// Core version has changed since the report.
if ( $report_data->core->version !== $wp_version ) {
// Maintain $status->core for backwards compatibility.
$status->core = $core;
return;
}
// If we've made it this far, the core version has been checked.
$core->checked = true;
// Generate a threat from core vulnerabilities.
if ( is_array( $report_data->core->vulnerabilities ) && ! empty( $report_data->core->vulnerabilities ) ) {
// normalize the vulnerabilities data
$vulnerabilities = array_map(
function ( $vulnerability ) {
return new Vulnerability_Model( $vulnerability );
},
$report_data->core->vulnerabilities
);
// convert the detected vulnerabilities into a vulnerable extension threat
$threat = Threat_Model::generate_from_extension_vulnerabilities( $core, $vulnerabilities );
$threat_extension = clone $core;
$extension_threat = clone $threat;
$core->threats[] = $extension_threat;
$threat->extension = $threat_extension;
$status->threats[] = $threat;
}
$status->core = $core;
}
/**
* Sort By Threats
*
* @param array<Extension_Model> $threats Array of threats to sort.
*
* @return array<Extension_Model> The sorted $threats array.
*/
protected static function sort_threats( $threats ) {
usort(
$threats,
function ( $a, $b ) {
return count( $a->threats ) - count( $b->threats );
}
);
return $threats;
}
}
@@ -0,0 +1,112 @@
<?php
/**
* Class file for managing REST API endpoints for package protect-status.
*
* @package automattic/jetpack-protect-status
*/
namespace Automattic\Jetpack\Protect_Status;
use Automattic\Jetpack\Connection\Rest_Authentication;
use WP_REST_Request;
use WP_REST_Response;
/**
* Class REST_Controller
*/
class REST_Controller {
/**
* Initialize the plugin's REST API.
*
* @return void
*/
public static function init() {
// Set up the REST authentication hooks.
Rest_Authentication::init();
// Add custom WP REST API endoints.
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
}
/**
* Register the REST API routes.
*
* @return void
*/
public static function register_rest_endpoints() {
register_rest_route(
'jetpack-protect/v1',
'check-plan',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::api_check_plan',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
)
);
register_rest_route(
'jetpack-protect/v1',
'status',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::api_get_status',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
)
);
register_rest_route(
'jetpack-protect/v1',
'clear-scan-cache',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::api_clear_scan_cache',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
)
);
}
/**
* Return site plan data for the API endpoint
*
* @return WP_REST_Response
*/
public static function api_check_plan() {
$has_required_plan = Plan::has_required_plan();
return rest_ensure_response( $has_required_plan );
}
/**
* Return Protect Status for the API endpoint
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response
*/
public static function api_get_status( $request ) {
$status = Status::get_status( $request['hard_refresh'] );
return rest_ensure_response( $status );
}
/**
* Clear the Scan_Status cache for the API endpoint
*
* @return WP_REST_Response
*/
public static function api_clear_scan_cache() {
$cache_cleared = Scan_Status::delete_option();
if ( ! $cache_cleared ) {
return new WP_REST_Response( 'An error occurred while attempting to clear the Jetpack Scan cache.', 500 );
}
return new WP_REST_Response( 'Jetpack Scan cache cleared.' );
}
}
@@ -0,0 +1,365 @@
<?php
/**
* Class to handle the Scan Status of Jetpack Protect
*
* @phan-suppress PhanDeprecatedFunction -- Maintaining backwards compatibility.
*
* @package automattic/jetpack-protect-status
*/
namespace Automattic\Jetpack\Protect_Status;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Plugins_Installer;
use Automattic\Jetpack\Protect_Models\Extension_Model;
use Automattic\Jetpack\Protect_Models\Status_Model;
use Automattic\Jetpack\Protect_Models\Threat_Model;
use Automattic\Jetpack\Sync\Functions as Sync_Functions;
use Jetpack_Options;
use WP_Error;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Class that handles fetching of threats from the Scan API
*/
class Scan_Status extends Status {
/**
* Scan endpoint
*
* @var string
*/
const SCAN_API_BASE = '/sites/%d/scan';
/**
* Name of the option where status is stored
*
* @var string
*/
const OPTION_NAME = 'jetpack_scan_status';
/**
* Name of the option where the timestamp of the status is stored
*
* @var string
*/
const OPTION_TIMESTAMP_NAME = 'jetpack_scan_status_timestamp';
/**
* Time in seconds that the cache should last
*
* @var int
*/
const OPTION_EXPIRES_AFTER = 300; // 5 minutes.
/**
* Gets the current status of the Jetpack Protect checks
*
* @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
* @return Status_Model
*/
public static function get_status( $refresh_from_wpcom = false ) {
if ( self::$status !== null ) {
return self::$status;
}
if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
$status = self::fetch_from_api();
} else {
$status = self::get_from_options();
}
if ( is_wp_error( $status ) ) {
$status = new Status_Model(
array(
'error' => true,
'error_code' => $status->get_error_code(),
'error_message' => $status->get_error_message(),
)
);
} else {
$status = self::normalize_api_data( $status );
}
self::$status = $status;
return $status;
}
/**
* 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_API_BASE, $blog_id );
return $api_url;
}
/**
* Fetches the status 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(
self::get_api_url(),
'2',
array(
'method' => 'GET',
'timeout' => 30,
),
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 data from the server', array( 'status' => $response_code ) );
}
$body = json_decode( wp_remote_retrieve_body( $response ) );
self::update_status_option( $body );
return $body;
}
/**
* Normalize API Data
*
* Formats the payload from the Scan API into an instance of Status_Model.
*
* @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
*
* @param object $scan_data The data returned by the scan API.
*
* @return Status_Model
*/
private static function normalize_api_data( $scan_data ) {
global $wp_version;
$installed_plugins = Plugins_Installer::get_plugins();
$installed_themes = Sync_Functions::get_themes();
$plugins = array();
$themes = array();
$core = new Extension_Model(
array(
'name' => 'WordPress',
'slug' => 'wordpress',
'version' => $wp_version,
'type' => 'core',
'checked' => true, // to do: default to false once Scan API has manifest
)
);
$files = array();
$database = array();
$status = new Status_Model(
array(
'data_source' => 'scan_api',
'status' => $scan_data->state ?? null,
'num_threats' => 0,
'num_themes_threats' => 0,
'num_plugins_threats' => 0,
'has_unchecked_items' => false,
'current_progress' => $scan_data->current->progress ?? null,
)
);
// Format the "last checked" timestamp.
if ( ! empty( $scan_data->most_recent->timestamp ) ) {
$date = new \DateTime( $scan_data->most_recent->timestamp );
$status->last_checked = $date->format( 'Y-m-d H:i:s' );
}
// Ensure all installed plugins and themes are represented in the status.
foreach ( $installed_plugins as $path => $installed_plugin ) {
$slug = str_replace( '.php', '', explode( '/', $path )[0] );
$plugin = new Extension_Model(
array(
'name' => $installed_plugin['Name'],
'version' => $installed_plugin['Version'],
'slug' => $slug,
'type' => 'plugins',
'checked' => true, // to do: default to false once Scan API has manifest
)
);
$plugins[ $slug ] = $plugin;
}
foreach ( $installed_themes as $path => $installed_theme ) {
$slug = str_replace( '.php', '', explode( '/', $path )[0] );
$theme = new Extension_Model(
array(
'name' => $installed_theme['Name'],
'version' => $installed_theme['Version'],
'slug' => $slug,
'type' => 'themes',
'checked' => true, // to do: default to false once Scan API has manifest
)
);
$themes[ $slug ] = $theme;
}
// Merge the threats into the status model.
if ( isset( $scan_data->threats ) && is_array( $scan_data->threats ) ) {
foreach ( $scan_data->threats as $scan_threat ) {
if ( isset( $scan_threat->fixable ) && $scan_threat->fixable ) {
$status->fixable_threat_ids[] = $scan_threat->id;
}
$db_details = null;
if ( ! empty( $scan_threat->table ) ) {
$db_details = (object) array_merge(
array(
'table' => $scan_threat->table,
'pk_column' => $scan_threat->pk_column ?? null,
'pk_value' => $scan_threat->value ?? null,
),
! empty( $scan_threat->details ) ? array( 'details' => $scan_threat->details ) : array()
);
}
$threat = new Threat_Model(
array(
'id' => $scan_threat->id ?? null,
'signature' => $scan_threat->signature ?? null,
'title' => $scan_threat->title ?? null,
'description' => $scan_threat->description ?? null,
'vulnerability_description' => $scan_threat->vulnerability_description ?? null,
'extension' => $scan_threat->extension ?? null,
'fix_description' => $scan_threat->fix_description ?? null,
'payload_subtitle' => $scan_threat->payload_subtitle ?? null,
'payload_description' => $scan_threat->payload_description ?? null,
'first_detected' => $scan_threat->first_detected ?? null,
'fixed_in' => isset( $scan_threat->fixer->fixer ) && 'update' === $scan_threat->fixer->fixer ? $scan_threat->fixer->target : null,
'severity' => $scan_threat->severity ?? null,
'fixable' => $scan_threat->fixer ?? null,
'status' => $scan_threat->status ?? null,
'filename' => $scan_threat->filename ?? null,
'context' => $scan_threat->context ?? null,
'source' => $scan_threat->source ?? null,
'table' => $scan_threat->table ?? null,
'details' => $db_details,
)
);
// Theme and Plugin Threats
if ( ! empty( $scan_threat->extension ) && in_array( $scan_threat->extension->type, array( 'plugin', 'theme' ), true ) ) {
$installed_extension = 'plugin' === $scan_threat->extension->type ? ( $plugins[ $scan_threat->extension->slug ] ?? null ) : ( $themes[ $scan_threat->extension->slug ] ?? null );
// If the extension is no longer installed, skip this threat.
// todo: use version_compare()
if ( ! $installed_extension ) {
continue;
}
// Push the threat to the appropriate extension.
switch ( $scan_threat->extension->type ) {
case 'plugin':
$plugins[ $scan_threat->extension->slug ]->threats[] = clone $threat;
++$status->num_plugins_threats;
break;
case 'theme':
$themes[ $scan_threat->extension->slug ]->threats[] = clone $threat;
++$status->num_themes_threats;
break;
default:
break;
}
$threat->extension = new Extension_Model(
array(
'name' => $scan_threat->extension->name ?? null,
'slug' => $scan_threat->extension->slug ?? null,
'version' => $scan_threat->extension->version ?? null,
'type' => $scan_threat->extension->type . 's',
'checked' => $installed_extension->version === $scan_threat->extension->version,
)
);
} elseif ( isset( $threat->signature ) && 'Vulnerable.WP.Core' === $threat->signature ) {
// Vulnerable WordPress Core Version Threats
// If the core version has changed, skip this threat.
// todo: use version_compare()
if ( $scan_threat->version !== $wp_version ) {
continue;
}
$core->threats[] = $threat;
} elseif ( ! empty( $threat->filename ) ) {
// File Threats
$files[] = $threat;
} elseif ( ! empty( $scan_threat->table ) ) {
// Database Threats
$database[] = $threat;
}
$status->threats[] = $threat;
++$status->num_threats;
}
}
$status->threats = static::sort_threats( $status->threats );
// maintain deprecated properties for backwards compatibility
$status->plugins = array_values( $plugins );
$status->themes = array_values( $themes );
$status->core = $core;
$status->files = $files;
$status->database = $database;
return $status;
}
/**
* Sort By Threats
*
* @param array<Threat_Model> $threats Array of threats to sort.
*
* @return array<Threat_Model> The sorted $threats array.
*/
protected static function sort_threats( $threats ) {
usort(
$threats,
function ( $a, $b ) {
// Order by active status first...
if ( $a->status !== $b->status ) {
return 'active' === $a->status ? -1 : 1;
}
// ...then by severity...
if ( $a->severity !== $b->severity ) {
return $a->severity > $b->severity ? -1 : 1;
}
// ...then date added.
if ( $a->first_detected !== $b->first_detected ) {
return strtotime( $a->first_detected ) < strtotime( $b->first_detected ) ? -1 : 1;
}
return 0;
}
);
return $threats;
}
}
@@ -0,0 +1,300 @@
<?php
/**
* Class to handle the Status of Jetpack Protect
*
* @package automattic/jetpack-protect-status
*/
namespace Automattic\Jetpack\Protect_Status;
use Automattic\Jetpack\Protect_Models\Status_Model;
/**
* Class that handles fetching and caching the Status of vulnerabilities check from the WPCOM servers
*/
class Status {
const PACKAGE_VERSION = '0.7.13';
/**
* Name of the option where status is stored
*
* @var string
*/
const OPTION_NAME = '';
/**
* Name of the option where the timestamp of the status is stored
*
* @var string
*/
const OPTION_TIMESTAMP_NAME = '';
/**
* Time in seconds that the cache should last
*
* @var int
*/
const OPTION_EXPIRES_AFTER = 3600; // 1 hour.
/**
* Time in seconds that the cache for the initial empty response should last
*
* @var int
*/
const INITIAL_OPTION_EXPIRES_AFTER = 1 * MINUTE_IN_SECONDS;
/**
* Memoization for the current status
*
* @var null|Status_Model
*/
public static $status = null;
/**
* Gets the current status of the Jetpack Protect checks
*
* @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
* @return Status_Model
*/
public static function get_status( $refresh_from_wpcom = false ) {
$use_scan_status = Plan::has_required_plan( $refresh_from_wpcom );
if ( defined( 'JETPACK_PROTECT_DEV__DATA_SOURCE' ) ) {
if ( 'scan_api' === JETPACK_PROTECT_DEV__DATA_SOURCE ) {
$use_scan_status = true;
}
if ( 'protect_report' === JETPACK_PROTECT_DEV__DATA_SOURCE ) {
$use_scan_status = false;
}
}
self::$status = $use_scan_status ? Scan_Status::get_status( $refresh_from_wpcom ) : Protect_Status::get_status( $refresh_from_wpcom );
return self::$status;
}
/**
* Checks if the current cached status 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 status
*
* @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 status and its timestamp
*
* @param array $status The new status to be cached.
* @return void
*/
public static function update_status_option( $status ) {
// TODO: Sanitize $status.
update_option( static::OPTION_NAME, maybe_serialize( $status ) );
$end_date = self::get_cache_end_date_by_status( $status );
update_option( static::OPTION_TIMESTAMP_NAME, $end_date );
}
/**
* Returns the timestamp the cache should expire depending on the current status
*
* Initial empty status, which are returned before the first check was performed, should be cache for less time
*
* @param object $status The response from the server being cached.
* @return int The timestamp when the cache should expire.
*/
public static function get_cache_end_date_by_status( $status ) {
if ( ! is_object( $status ) || empty( $status->last_checked ) ) {
return time() + static::INITIAL_OPTION_EXPIRES_AFTER;
}
return time() + static::OPTION_EXPIRES_AFTER;
}
/**
* Delete the cached status and its timestamp
*
* @return bool Whether all related status options were successfully deleted.
*/
public static function delete_option() {
$option_deleted = delete_option( static::OPTION_NAME );
$option_timestamp_deleted = delete_option( static::OPTION_TIMESTAMP_NAME );
return $option_deleted && $option_timestamp_deleted;
}
/**
* Checks the current status to see if there are any threats found
*
* @return boolean
*/
public static function has_threats() {
return 0 < self::get_total_threats();
}
/**
* Gets the total number of threats found
*
* @return integer
*/
public static function get_total_threats() {
$status = static::get_status();
return count( $status->threats );
}
/**
* Get all threats combined
*
* @return array
*/
public static function get_all_threats() {
$status = static::get_status();
return $status->threats;
}
/**
* Get threats found for WordPress core
*
* @deprecated 0.3.0
*
* @return array
*/
public static function get_wordpress_threats() {
return self::get_threats( 'core' );
}
/**
* Get threats found for themes
*
* @deprecated 0.3.0
*
* @return array
*/
public static function get_themes_threats() {
return self::get_threats( 'themes' );
}
/**
* Get threats found for plugins
*
* @deprecated 0.3.0
*
* @return array
*/
public static function get_plugins_threats() {
return self::get_threats( 'plugins' );
}
/**
* Get threats found for files
*
* @deprecated 0.3.0
*
* @return array
*/
public static function get_files_threats() {
return self::get_threats( 'files' );
}
/**
* Get threats found for plugins
*
* @deprecated 0.3.0
*
* @return array
*/
public static function get_database_threats() {
return self::get_threats( 'database' );
}
/**
* Get the threats for one type of extension or core
*
* @param string $type What threats you want to get. Possible values are 'core', 'themes' and 'plugins'.
*
* @return array
*/
public static function get_threats( $type ) {
$status = static::get_status();
if ( in_array( $type, array( 'plugin', 'theme', 'core' ), true ) ) {
return array_filter(
$status->threats,
function ( $threat ) use ( $type ) {
return isset( $threat->extension ) && $type === $threat->extension->type;
}
);
}
if ( 'files' === $type ) {
return array_filter(
$status->threats,
function ( $threat ) {
return ! empty( $threat->filename );
}
);
}
if ( 'database' === $type ) {
return array_filter(
$status->threats,
function ( $threat ) {
return ! empty( $threat->table );
}
);
}
return $status->threats;
}
/**
* Sort By Threats
*
* @deprecated 0.3.0
*
* @param array<object> $threats Array of threats to sort.
*
* @return array<object> The sorted $threats array.
*/
protected static function sort_threats( $threats ) {
usort(
$threats,
function ( $a, $b ) {
// sort primarily based on the presence of threats
$ret = empty( $a->threats ) <=> empty( $b->threats );
// sort secondarily on whether the item has been checked
if ( ! $ret ) {
$ret = $a->checked <=> $b->checked;
}
return $ret;
}
);
return $threats;
}
}