Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.72% covered (success)
91.72%
133 / 145
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Protect_Status
92.31% covered (success)
92.31%
132 / 143
50.00% covered (danger)
50.00%
4 / 8
35.56
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_cached_status
100.00% covered (success)
100.00%
2 / 2
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     * 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 last Protect report stored on the site, even if it has expired, without asking WordPress.com.
88     *
89     * @since 0.9.0
90     *
91     * @return Status_Model|null Null when no report has been stored yet.
92     */
93    public static function get_cached_status() {
94        $report_data = self::get_from_options();
95        return is_object( $report_data ) ? self::normalize_protect_report_data( $report_data ) : null;
96    }
97
98    /**
99     * Gets the WPCOM API endpoint
100     *
101     * @return WP_Error|string
102     */
103    public static function get_api_url() {
104        $blog_id      = Jetpack_Options::get_option( 'id' );
105        $is_connected = ( new Connection_Manager() )->is_connected();
106
107        if ( ! $blog_id || ! $is_connected ) {
108            return new WP_Error( 'site_not_connected' );
109        }
110
111        $api_url = sprintf( self::REST_API_BASE, $blog_id );
112
113        return $api_url;
114    }
115
116    /**
117     * Fetches the status from WPCOM servers
118     *
119     * @return WP_Error|array
120     */
121    public static function fetch_from_server() {
122        $api_url = self::get_api_url();
123        if ( is_wp_error( $api_url ) ) {
124            return $api_url;
125        }
126
127        $response = Client::wpcom_json_api_request_as_blog(
128            self::get_api_url(),
129            '2',
130            array(
131                'method'  => 'GET',
132                'timeout' => 30,
133            ),
134            null,
135            'wpcom'
136        );
137
138        $response_code = wp_remote_retrieve_response_code( $response );
139
140        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
141            return new WP_Error( 'failed_fetching_status', 'Failed to fetch Protect Status data from server', array( 'status' => $response_code ) );
142        }
143
144        $body = json_decode( wp_remote_retrieve_body( $response ) );
145        self::update_status_option( $body );
146        return $body;
147    }
148
149    /**
150     * Normalize data from the Protect Report data source.
151     *
152     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
153     *
154     * @param object $report_data Data from the Protect Report.
155     * @return Status_Model
156     */
157    protected static function normalize_protect_report_data( $report_data ) {
158        $status              = new Status_Model();
159        $status->data_source = 'protect_report';
160
161        // map report data properties directly into the Status_Model
162        $status->status              = $report_data->status ?? null;
163        $status->last_checked        = $report_data->last_checked ?? null;
164        $status->num_threats         = $report_data->num_vulnerabilities ?? null;
165        $status->num_themes_threats  = $report_data->num_themes_vulnerabilities ?? null;
166        $status->num_plugins_threats = $report_data->num_plugins_vulnerabilities ?? null;
167        $status->has_unchecked_items = false;
168
169        // normalize extension information
170        self::normalize_extension_data( $status, $report_data, 'themes' );
171        self::normalize_extension_data( $status, $report_data, 'plugins' );
172        self::normalize_core_data( $status, $report_data );
173
174        // sort extensions by number of threats
175        $status->themes  = self::sort_threats( $status->themes );
176        $status->plugins = self::sort_threats( $status->plugins );
177
178        return $status;
179    }
180
181    /**
182     * Normalize theme and plugin information from the Protect Report data source.
183     *
184     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
185     *
186     * @param object $status The status object to normalize.
187     * @param object $report_data Data from the Protect Report.
188     * @param string $extension_type The type of extension to normalize. Either 'themes' or 'plugins'.
189     *
190     * @return void
191     */
192    protected static function normalize_extension_data( &$status, $report_data, $extension_type ) {
193        if ( ! in_array( $extension_type, array( 'plugins', 'themes' ), true ) ) {
194            return;
195        }
196
197        $installed_extensions = 'plugins' === $extension_type ? Plugins_Installer::get_plugins() : Sync_Functions::get_themes();
198        $checked_extensions   = $report_data->{ $extension_type } ?? new \stdClass();
199
200        /**
201         * Extension slug <=> threats data map.
202         *
203         * @var Extension_Model[] $extension_threats Array of Extension_Model objects indexed by slug.
204         */
205        $extension_threats = array();
206
207        // Initialize the extension threats map with all extensions currently installed on the site
208        foreach ( $installed_extensions as $slug => $installed_extension ) {
209            $extension_threats[ $slug ] = new Extension_Model(
210                array(
211                    'slug'    => $slug,
212                    'name'    => $installed_extension['Name'],
213                    'version' => $installed_extension['Version'],
214                    'type'    => $extension_type,
215                    'checked' => isset( $checked_extensions->{ $slug } ),
216                )
217            );
218        }
219
220        foreach ( $checked_extensions as $slug => $checked_extension ) {
221            $installed_extension = $installed_extensions[ $slug ] ?? null;
222
223            // extension is no longer installed on the site
224            if ( ! $installed_extension ) {
225                continue;
226            }
227
228            $extension = new Extension_Model(
229                array(
230                    'name'    => $installed_extension['Name'],
231                    'version' => $installed_extension['Version'],
232                    'slug'    => $slug,
233                    'checked' => false,
234                    'type'    => $extension_type,
235                )
236            );
237
238            // extension version has changed since the report
239            if ( $installed_extension['Version'] !== $checked_extension->version ) {
240                // maintain $status->{ themes|plugins } for backwards compatibility.
241                $extension_threats[ $slug ] = $extension;
242                continue;
243            }
244
245            $extension->checked         = true;
246            $extension_threats[ $slug ] = $extension;
247
248            if ( is_array( $checked_extension->vulnerabilities ) && ! empty( $checked_extension->vulnerabilities ) ) {
249                // normalize the vulnerabilities data
250                $vulnerabilities = array_map(
251                    function ( $vulnerability ) {
252                        return new Vulnerability_Model( $vulnerability );
253                    },
254                    $checked_extension->vulnerabilities
255                );
256
257                // convert the detected vulnerabilities into a vulnerable extension threat
258                $threat = Threat_Model::generate_from_extension_vulnerabilities( $extension, $vulnerabilities );
259
260                $threat_extension = clone $extension;
261                $extension_threat = clone $threat;
262
263                $extension_threat->extension           = null;
264                $extension_threats[ $slug ]->threats[] = $extension_threat;
265
266                $threat->extension = $threat_extension;
267                $status->threats[] = $threat;
268            }
269        }
270
271        $status->{ $extension_type } = array_values( $extension_threats );
272    }
273
274    /**
275     * Normalize the core information from the Protect Report data source.
276     *
277     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
278     *
279     * @param object $status The status object to normalize.
280     * @param object $report_data Data from the Protect Report.
281     *
282     * @return void
283     */
284    protected static function normalize_core_data( &$status, $report_data ) {
285        global $wp_version;
286
287        // Ensure the report data has the core property.
288        if ( ! isset( $report_data->core ) || ! $report_data->core
289            || ! isset( $report_data->core->version ) || ! $report_data->core->version ) {
290            $report_data->core          = new \stdClass();
291            $report_data->core->version = new \stdClass();
292        }
293
294        $core = new Extension_Model(
295            array(
296                'type'    => 'core',
297                'name'    => 'WordPress',
298                'slug'    => 'wordpress',
299                'version' => $wp_version,
300                'checked' => false,
301            )
302        );
303
304        // Core version has changed since the report.
305        if ( $report_data->core->version !== $wp_version ) {
306            // Maintain $status->core for backwards compatibility.
307            $status->core = $core;
308            return;
309        }
310
311        // If we've made it this far, the core version has been checked.
312        $core->checked = true;
313
314        // Generate a threat from core vulnerabilities.
315        if ( is_array( $report_data->core->vulnerabilities ) && ! empty( $report_data->core->vulnerabilities ) ) {
316            // normalize the vulnerabilities data
317            $vulnerabilities = array_map(
318                function ( $vulnerability ) {
319                    return new Vulnerability_Model( $vulnerability );
320                },
321                $report_data->core->vulnerabilities
322            );
323
324            // convert the detected vulnerabilities into a vulnerable extension threat
325            $threat = Threat_Model::generate_from_extension_vulnerabilities( $core, $vulnerabilities );
326
327            $threat_extension = clone $core;
328            $extension_threat = clone $threat;
329
330            $core->threats[]   = $extension_threat;
331            $threat->extension = $threat_extension;
332
333            $status->threats[] = $threat;
334        }
335
336        $status->core = $core;
337    }
338
339    /**
340     * Sort By Threats
341     *
342     * @param array<Extension_Model> $threats Array of threats to sort.
343     *
344     * @return array<Extension_Model> The sorted $threats array.
345     */
346    protected static function sort_threats( $threats ) {
347        usort(
348            $threats,
349            function ( $a, $b ) {
350                return count( $a->threats ) - count( $b->threats );
351            }
352        );
353
354        return $threats;
355    }
356}