Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.61% covered (success)
91.61%
131 / 143
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Protect_Status
92.20% covered (success)
92.20%
130 / 141
42.86% covered (danger)
42.86%
3 / 7
39.72
0.00% covered (danger)
0.00%
0 / 1
 get_status
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 get_api_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fetch_from_server
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 normalize_protect_report_data
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 normalize_extension_data
91.67% covered (success)
91.67%
44 / 48
0.00% covered (danger)
0.00%
0 / 1
10.06
 normalize_core_data
87.10% covered (warning)
87.10%
27 / 31
0.00% covered (danger)
0.00%
0 / 1
8.14
 sort_threats
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Class to handle the Protect Status of Jetpack Protect
4 *
5 * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
6 *
7 * @package automattic/jetpack-protect-status
8 */
9
10namespace Automattic\Jetpack\Protect_Status;
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14use Automattic\Jetpack\Plugins_Installer;
15use Automattic\Jetpack\Protect_Models\Extension_Model;
16use Automattic\Jetpack\Protect_Models\Status_Model;
17use Automattic\Jetpack\Protect_Models\Threat_Model;
18use Automattic\Jetpack\Protect_Models\Vulnerability_Model;
19use Automattic\Jetpack\Sync\Functions as Sync_Functions;
20use Jetpack_Options;
21use WP_Error;
22
23if ( ! defined( 'ABSPATH' ) ) {
24    exit( 0 );
25}
26
27/**
28 * Class that handles fetching and caching the Status of vulnerabilities check from the WPCOM servers
29 */
30class Protect_Status extends Status {
31
32    /**
33     * WPCOM endpoint
34     *
35     * @var string
36     */
37    const REST_API_BASE = '/sites/%d/jetpack-protect-status';
38
39    /**
40     * Name of the option where status is stored
41     *
42     * @var string
43     */
44    const OPTION_NAME = 'jetpack_protect_status';
45
46    /**
47     * Name of the option where the timestamp of the status is stored
48     *
49     * @var string
50     */
51    const OPTION_TIMESTAMP_NAME = 'jetpack_protect_status_time';
52
53    /**
54     * Gets the current status of the Jetpack Protect checks
55     *
56     * @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
57     * @return Status_Model
58     */
59    public static function get_status( $refresh_from_wpcom = false ) {
60        if ( self::$status !== null ) {
61            return self::$status;
62        }
63
64        if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
65            $status = self::fetch_from_server();
66        } else {
67            $status = self::get_from_options();
68        }
69
70        if ( is_wp_error( $status ) ) {
71            $status = new Status_Model(
72                array(
73                    'error'         => true,
74                    'error_code'    => $status->get_error_code(),
75                    'error_message' => $status->get_error_message(),
76                )
77            );
78        } else {
79            $status = self::normalize_protect_report_data( $status );
80        }
81
82        self::$status = $status;
83        return $status;
84    }
85
86    /**
87     * Gets the WPCOM API endpoint
88     *
89     * @return WP_Error|string
90     */
91    public static function get_api_url() {
92        $blog_id      = Jetpack_Options::get_option( 'id' );
93        $is_connected = ( new Connection_Manager() )->is_connected();
94
95        if ( ! $blog_id || ! $is_connected ) {
96            return new WP_Error( 'site_not_connected' );
97        }
98
99        $api_url = sprintf( self::REST_API_BASE, $blog_id );
100
101        return $api_url;
102    }
103
104    /**
105     * Fetches the status from WPCOM servers
106     *
107     * @return WP_Error|array
108     */
109    public static function fetch_from_server() {
110        $api_url = self::get_api_url();
111        if ( is_wp_error( $api_url ) ) {
112            return $api_url;
113        }
114
115        $response = Client::wpcom_json_api_request_as_blog(
116            self::get_api_url(),
117            '2',
118            array(
119                'method'  => 'GET',
120                'timeout' => 30,
121            ),
122            null,
123            'wpcom'
124        );
125
126        $response_code = wp_remote_retrieve_response_code( $response );
127
128        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
129            return new WP_Error( 'failed_fetching_status', 'Failed to fetch Protect Status data from server', array( 'status' => $response_code ) );
130        }
131
132        $body = json_decode( wp_remote_retrieve_body( $response ) );
133        self::update_status_option( $body );
134        return $body;
135    }
136
137    /**
138     * Normalize data from the Protect Report data source.
139     *
140     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
141     *
142     * @param object $report_data Data from the Protect Report.
143     * @return Status_Model
144     */
145    protected static function normalize_protect_report_data( $report_data ) {
146        $status              = new Status_Model();
147        $status->data_source = 'protect_report';
148
149        // map report data properties directly into the Status_Model
150        $status->status              = isset( $report_data->status ) ? $report_data->status : null;
151        $status->last_checked        = isset( $report_data->last_checked ) ? $report_data->last_checked : null;
152        $status->num_threats         = isset( $report_data->num_vulnerabilities ) ? $report_data->num_vulnerabilities : null;
153        $status->num_themes_threats  = isset( $report_data->num_themes_vulnerabilities ) ? $report_data->num_themes_vulnerabilities : null;
154        $status->num_plugins_threats = isset( $report_data->num_plugins_vulnerabilities ) ? $report_data->num_plugins_vulnerabilities : null;
155        $status->has_unchecked_items = false;
156
157        // normalize extension information
158        self::normalize_extension_data( $status, $report_data, 'themes' );
159        self::normalize_extension_data( $status, $report_data, 'plugins' );
160        self::normalize_core_data( $status, $report_data );
161
162        // sort extensions by number of threats
163        $status->themes  = self::sort_threats( $status->themes );
164        $status->plugins = self::sort_threats( $status->plugins );
165
166        return $status;
167    }
168
169    /**
170     * Normalize theme and plugin information from the Protect Report data source.
171     *
172     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
173     *
174     * @param object $status The status object to normalize.
175     * @param object $report_data Data from the Protect Report.
176     * @param string $extension_type The type of extension to normalize. Either 'themes' or 'plugins'.
177     *
178     * @return void
179     */
180    protected static function normalize_extension_data( &$status, $report_data, $extension_type ) {
181        if ( ! in_array( $extension_type, array( 'plugins', 'themes' ), true ) ) {
182            return;
183        }
184
185        $installed_extensions = 'plugins' === $extension_type ? Plugins_Installer::get_plugins() : Sync_Functions::get_themes();
186        $checked_extensions   = isset( $report_data->{ $extension_type } ) ? $report_data->{ $extension_type } : new \stdClass();
187
188        /**
189         * Extension slug <=> threats data map.
190         *
191         * @var Extension_Model[] $extension_threats Array of Extension_Model objects indexed by slug.
192         */
193        $extension_threats = array();
194
195        // Initialize the extension threats map with all extensions currently installed on the site
196        foreach ( $installed_extensions as $slug => $installed_extension ) {
197            $extension_threats[ $slug ] = new Extension_Model(
198                array(
199                    'slug'    => $slug,
200                    'name'    => $installed_extension['Name'],
201                    'version' => $installed_extension['Version'],
202                    'type'    => $extension_type,
203                    'checked' => isset( $checked_extensions->{ $slug } ),
204                )
205            );
206        }
207
208        foreach ( $checked_extensions as $slug => $checked_extension ) {
209            $installed_extension = $installed_extensions[ $slug ] ?? null;
210
211            // extension is no longer installed on the site
212            if ( ! $installed_extension ) {
213                continue;
214            }
215
216            $extension = new Extension_Model(
217                array(
218                    'name'    => $installed_extension['Name'],
219                    'version' => $installed_extension['Version'],
220                    'slug'    => $slug,
221                    'checked' => false,
222                    'type'    => $extension_type,
223                )
224            );
225
226            // extension version has changed since the report
227            if ( $installed_extension['Version'] !== $checked_extension->version ) {
228                // maintain $status->{ themes|plugins } for backwards compatibility.
229                $extension_threats[ $slug ] = $extension;
230                continue;
231            }
232
233            $extension->checked         = true;
234            $extension_threats[ $slug ] = $extension;
235
236            if ( is_array( $checked_extension->vulnerabilities ) && ! empty( $checked_extension->vulnerabilities ) ) {
237                // normalize the vulnerabilities data
238                $vulnerabilities = array_map(
239                    function ( $vulnerability ) {
240                        return new Vulnerability_Model( $vulnerability );
241                    },
242                    $checked_extension->vulnerabilities
243                );
244
245                // convert the detected vulnerabilities into a vulnerable extension threat
246                $threat = Threat_Model::generate_from_extension_vulnerabilities( $extension, $vulnerabilities );
247
248                $threat_extension = clone $extension;
249                $extension_threat = clone $threat;
250
251                $extension_threat->extension           = null;
252                $extension_threats[ $slug ]->threats[] = $extension_threat;
253
254                $threat->extension = $threat_extension;
255                $status->threats[] = $threat;
256            }
257        }
258
259        $status->{ $extension_type } = array_values( $extension_threats );
260    }
261
262    /**
263     * Normalize the core information from the Protect Report data source.
264     *
265     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
266     *
267     * @param object $status The status object to normalize.
268     * @param object $report_data Data from the Protect Report.
269     *
270     * @return void
271     */
272    protected static function normalize_core_data( &$status, $report_data ) {
273        global $wp_version;
274
275        // Ensure the report data has the core property.
276        if ( ! isset( $report_data->core ) || ! $report_data->core
277            || ! isset( $report_data->core->version ) || ! $report_data->core->version ) {
278            $report_data->core          = new \stdClass();
279            $report_data->core->version = new \stdClass();
280        }
281
282        $core = new Extension_Model(
283            array(
284                'type'    => 'core',
285                'name'    => 'WordPress',
286                'slug'    => 'wordpress',
287                'version' => $wp_version,
288                'checked' => false,
289            )
290        );
291
292        // Core version has changed since the report.
293        if ( $report_data->core->version !== $wp_version ) {
294            // Maintain $status->core for backwards compatibility.
295            $status->core = $core;
296            return;
297        }
298
299        // If we've made it this far, the core version has been checked.
300        $core->checked = true;
301
302        // Generate a threat from core vulnerabilities.
303        if ( is_array( $report_data->core->vulnerabilities ) && ! empty( $report_data->core->vulnerabilities ) ) {
304            // normalize the vulnerabilities data
305            $vulnerabilities = array_map(
306                function ( $vulnerability ) {
307                    return new Vulnerability_Model( $vulnerability );
308                },
309                $report_data->core->vulnerabilities
310            );
311
312            // convert the detected vulnerabilities into a vulnerable extension threat
313            $threat = Threat_Model::generate_from_extension_vulnerabilities( $core, $vulnerabilities );
314
315            $threat_extension = clone $core;
316            $extension_threat = clone $threat;
317
318            $core->threats[]   = $extension_threat;
319            $threat->extension = $threat_extension;
320
321            $status->threats[] = $threat;
322        }
323
324        $status->core = $core;
325    }
326
327    /**
328     * Sort By Threats
329     *
330     * @param array<Extension_Model> $threats Array of threats to sort.
331     *
332     * @return array<Extension_Model> The sorted $threats array.
333     */
334    protected static function sort_threats( $threats ) {
335        usort(
336            $threats,
337            function ( $a, $b ) {
338                return count( $a->threats ) - count( $b->threats );
339            }
340        );
341
342        return $threats;
343    }
344}