Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.72% covered (success)
92.72%
140 / 151
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Protect_Status
93.29% covered (success)
93.29%
139 / 149
55.56% covered (warning)
55.56%
5 / 9
39.46
0.00% covered (danger)
0.00%
0 / 1
 get_status
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 get_cached_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 are_checks_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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
1
 normalize_extension_data
91.67% covered (success)
91.67%
44 / 48
0.00% covered (danger)
0.00%
0 / 1
9.05
 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     * Name of the option where the site owner turns the daily vulnerability check on or off.
55     *
56     * @since $$next-version$$
57     *
58     * @var string
59     */
60    const CHECKS_OPTION_NAME = 'jetpack_protect_vulnerability_checks';
61
62    /**
63     * The report status while the site owner has the daily vulnerability check off.
64     *
65     * @since $$next-version$$
66     *
67     * @var string
68     */
69    const STATUS_DISABLED = 'disabled';
70
71    /**
72     * Gets the current status of the Jetpack Protect checks
73     *
74     * @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
75     * @return Status_Model
76     */
77    public static function get_status( $refresh_from_wpcom = false ) {
78        if ( ! self::are_checks_enabled() ) {
79            self::$status = new Status_Model( array( 'status' => self::STATUS_DISABLED ) );
80            return self::$status;
81        }
82
83        if ( self::$status !== null ) {
84            return self::$status;
85        }
86
87        if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
88            $status = self::fetch_from_server();
89        } else {
90            $status = self::get_from_options();
91        }
92
93        if ( is_wp_error( $status ) ) {
94            $status = new Status_Model(
95                array(
96                    'error'         => true,
97                    'error_code'    => $status->get_error_code(),
98                    'error_message' => $status->get_error_message(),
99                )
100            );
101        } else {
102            $status = self::normalize_protect_report_data( $status );
103        }
104
105        self::$status = $status;
106        return $status;
107    }
108
109    /**
110     * Gets the last Protect report stored on the site, even if it has expired, without asking WordPress.com.
111     *
112     * @since 0.9.0
113     *
114     * @return Status_Model|null Null when no report has been stored yet.
115     */
116    public static function get_cached_status() {
117        if ( ! self::are_checks_enabled() ) {
118            return self::get_status();
119        }
120        $report_data = self::get_from_options();
121        return is_object( $report_data ) ? self::normalize_protect_report_data( $report_data ) : null;
122    }
123
124    /**
125     * Whether the site owner lets Jetpack check the site every day for known vulnerabilities; always on with a Scan plan.
126     *
127     * @since $$next-version$$
128     *
129     * @return bool
130     */
131    public static function are_checks_enabled() {
132        return (bool) get_option( self::CHECKS_OPTION_NAME, true ) || Plan::has_required_plan();
133    }
134
135    /**
136     * Gets the WPCOM API endpoint
137     *
138     * @return WP_Error|string
139     */
140    public static function get_api_url() {
141        $blog_id      = Jetpack_Options::get_option( 'id' );
142        $is_connected = ( new Connection_Manager() )->is_connected();
143
144        if ( ! $blog_id || ! $is_connected ) {
145            return new WP_Error( 'site_not_connected' );
146        }
147
148        $api_url = sprintf( self::REST_API_BASE, $blog_id );
149
150        return $api_url;
151    }
152
153    /**
154     * Fetches the status from WPCOM servers
155     *
156     * @return WP_Error|array
157     */
158    public static function fetch_from_server() {
159        $api_url = self::get_api_url();
160        if ( is_wp_error( $api_url ) ) {
161            return $api_url;
162        }
163
164        $response = Client::wpcom_json_api_request_as_blog(
165            self::get_api_url(),
166            '2',
167            array(
168                'method'  => 'GET',
169                'timeout' => 30,
170            ),
171            null,
172            'wpcom'
173        );
174
175        $response_code = wp_remote_retrieve_response_code( $response );
176
177        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
178            return new WP_Error( 'failed_fetching_status', 'Failed to fetch Protect Status data from server', array( 'status' => $response_code ) );
179        }
180
181        $body = json_decode( wp_remote_retrieve_body( $response ) );
182        self::update_status_option( $body );
183        return $body;
184    }
185
186    /**
187     * Normalize data from the Protect Report data source.
188     *
189     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
190     *
191     * @param object $report_data Data from the Protect Report.
192     * @return Status_Model
193     */
194    protected static function normalize_protect_report_data( $report_data ) {
195        $status              = new Status_Model();
196        $status->data_source = 'protect_report';
197
198        // map report data properties directly into the Status_Model
199        $status->status              = $report_data->status ?? null;
200        $status->last_checked        = $report_data->last_checked ?? null;
201        $status->num_threats         = $report_data->num_vulnerabilities ?? null;
202        $status->num_themes_threats  = $report_data->num_themes_vulnerabilities ?? null;
203        $status->num_plugins_threats = $report_data->num_plugins_vulnerabilities ?? null;
204        $status->has_unchecked_items = false;
205
206        // normalize extension information
207        self::normalize_extension_data( $status, $report_data, 'themes' );
208        self::normalize_extension_data( $status, $report_data, 'plugins' );
209        self::normalize_core_data( $status, $report_data );
210
211        // sort extensions by number of threats
212        $status->themes  = self::sort_threats( $status->themes );
213        $status->plugins = self::sort_threats( $status->plugins );
214
215        return $status;
216    }
217
218    /**
219     * Normalize theme and plugin information from the Protect Report data source.
220     *
221     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
222     *
223     * @param object $status The status object to normalize.
224     * @param object $report_data Data from the Protect Report.
225     * @param string $extension_type The type of extension to normalize. Either 'themes' or 'plugins'.
226     *
227     * @return void
228     */
229    protected static function normalize_extension_data( &$status, $report_data, $extension_type ) {
230        if ( ! in_array( $extension_type, array( 'plugins', 'themes' ), true ) ) {
231            return;
232        }
233
234        $installed_extensions = 'plugins' === $extension_type ? Plugins_Installer::get_plugins() : Sync_Functions::get_themes();
235        $checked_extensions   = $report_data->{ $extension_type } ?? new \stdClass();
236
237        /**
238         * Extension slug <=> threats data map.
239         *
240         * @var Extension_Model[] $extension_threats Array of Extension_Model objects indexed by slug.
241         */
242        $extension_threats = array();
243
244        // Initialize the extension threats map with all extensions currently installed on the site
245        foreach ( $installed_extensions as $slug => $installed_extension ) {
246            $extension_threats[ $slug ] = new Extension_Model(
247                array(
248                    'slug'    => $slug,
249                    'name'    => $installed_extension['Name'],
250                    'version' => $installed_extension['Version'],
251                    'type'    => $extension_type,
252                    'checked' => isset( $checked_extensions->{ $slug } ),
253                )
254            );
255        }
256
257        foreach ( $checked_extensions as $slug => $checked_extension ) {
258            $installed_extension = $installed_extensions[ $slug ] ?? null;
259
260            // extension is no longer installed on the site
261            if ( ! $installed_extension ) {
262                continue;
263            }
264
265            $extension = new Extension_Model(
266                array(
267                    'name'    => $installed_extension['Name'],
268                    'version' => $installed_extension['Version'],
269                    'slug'    => $slug,
270                    'checked' => false,
271                    'type'    => $extension_type,
272                )
273            );
274
275            // extension version has changed since the report
276            if ( $installed_extension['Version'] !== $checked_extension->version ) {
277                // maintain $status->{ themes|plugins } for backwards compatibility.
278                $extension_threats[ $slug ] = $extension;
279                continue;
280            }
281
282            $extension->checked         = true;
283            $extension_threats[ $slug ] = $extension;
284
285            if ( is_array( $checked_extension->vulnerabilities ) && ! empty( $checked_extension->vulnerabilities ) ) {
286                // normalize the vulnerabilities data
287                $vulnerabilities = array_map(
288                    function ( $vulnerability ) {
289                        return new Vulnerability_Model( $vulnerability );
290                    },
291                    $checked_extension->vulnerabilities
292                );
293
294                // convert the detected vulnerabilities into a vulnerable extension threat
295                $threat = Threat_Model::generate_from_extension_vulnerabilities( $extension, $vulnerabilities );
296
297                $threat_extension = clone $extension;
298                $extension_threat = clone $threat;
299
300                $extension_threat->extension           = null;
301                $extension_threats[ $slug ]->threats[] = $extension_threat;
302
303                $threat->extension = $threat_extension;
304                $status->threats[] = $threat;
305            }
306        }
307
308        $status->{ $extension_type } = array_values( $extension_threats );
309    }
310
311    /**
312     * Normalize the core information from the Protect Report data source.
313     *
314     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
315     *
316     * @param object $status The status object to normalize.
317     * @param object $report_data Data from the Protect Report.
318     *
319     * @return void
320     */
321    protected static function normalize_core_data( &$status, $report_data ) {
322        global $wp_version;
323
324        // Ensure the report data has the core property.
325        if ( ! isset( $report_data->core ) || ! $report_data->core
326            || ! isset( $report_data->core->version ) || ! $report_data->core->version ) {
327            $report_data->core          = new \stdClass();
328            $report_data->core->version = new \stdClass();
329        }
330
331        $core = new Extension_Model(
332            array(
333                'type'    => 'core',
334                'name'    => 'WordPress',
335                'slug'    => 'wordpress',
336                'version' => $wp_version,
337                'checked' => false,
338            )
339        );
340
341        // Core version has changed since the report.
342        if ( $report_data->core->version !== $wp_version ) {
343            // Maintain $status->core for backwards compatibility.
344            $status->core = $core;
345            return;
346        }
347
348        // If we've made it this far, the core version has been checked.
349        $core->checked = true;
350
351        // Generate a threat from core vulnerabilities.
352        if ( is_array( $report_data->core->vulnerabilities ) && ! empty( $report_data->core->vulnerabilities ) ) {
353            // normalize the vulnerabilities data
354            $vulnerabilities = array_map(
355                function ( $vulnerability ) {
356                    return new Vulnerability_Model( $vulnerability );
357                },
358                $report_data->core->vulnerabilities
359            );
360
361            // convert the detected vulnerabilities into a vulnerable extension threat
362            $threat = Threat_Model::generate_from_extension_vulnerabilities( $core, $vulnerabilities );
363
364            $threat_extension = clone $core;
365            $extension_threat = clone $threat;
366
367            $core->threats[]   = $extension_threat;
368            $threat->extension = $threat_extension;
369
370            $status->threats[] = $threat;
371        }
372
373        $status->core = $core;
374    }
375
376    /**
377     * Sort By Threats
378     *
379     * @param array<Extension_Model> $threats Array of threats to sort.
380     *
381     * @return array<Extension_Model> The sorted $threats array.
382     */
383    protected static function sort_threats( $threats ) {
384        usort(
385            $threats,
386            function ( $a, $b ) {
387                return count( $a->threats ) - count( $b->threats );
388            }
389        );
390
391        return $threats;
392    }
393}