Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.86% covered (success)
90.86%
169 / 186
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan_Status
91.30% covered (success)
91.30%
168 / 184
20.00% covered (danger)
20.00%
1 / 5
52.71
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_api
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 normalize_api_data
93.08% covered (success)
93.08%
121 / 130
0.00% covered (danger)
0.00%
0 / 1
30.30
 sort_threats
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
8.43
1<?php
2/**
3 * Class to handle the Scan Status of Jetpack Protect
4 *
5 * @phan-suppress PhanDeprecatedFunction -- 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\Sync\Functions as Sync_Functions;
19use Jetpack_Options;
20use WP_Error;
21
22if ( ! defined( 'ABSPATH' ) ) {
23    exit( 0 );
24}
25
26/**
27 * Class that handles fetching of threats from the Scan API
28 */
29class Scan_Status extends Status {
30
31    /**
32     * Scan endpoint
33     *
34     * @var string
35     */
36    const SCAN_API_BASE = '/sites/%d/scan';
37
38    /**
39     * Name of the option where status is stored
40     *
41     * @var string
42     */
43    const OPTION_NAME = 'jetpack_scan_status';
44
45    /**
46     * Name of the option where the timestamp of the status is stored
47     *
48     * @var string
49     */
50    const OPTION_TIMESTAMP_NAME = 'jetpack_scan_status_timestamp';
51
52    /**
53     * Time in seconds that the cache should last
54     *
55     * @var int
56     */
57    const OPTION_EXPIRES_AFTER = 300; // 5 minutes.
58
59    /**
60     * Gets the current status of the Jetpack Protect checks
61     *
62     * @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
63     * @return Status_Model
64     */
65    public static function get_status( $refresh_from_wpcom = false ) {
66        if ( self::$status !== null ) {
67            return self::$status;
68        }
69
70        if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
71            $status = self::fetch_from_api();
72        } else {
73            $status = self::get_from_options();
74        }
75
76        if ( is_wp_error( $status ) ) {
77            $status = new Status_Model(
78                array(
79                    'error'         => true,
80                    'error_code'    => $status->get_error_code(),
81                    'error_message' => $status->get_error_message(),
82                )
83            );
84        } else {
85            $status = self::normalize_api_data( $status );
86        }
87
88        self::$status = $status;
89        return $status;
90    }
91
92    /**
93     * Gets the Scan API endpoint
94     *
95     * @return WP_Error|string
96     */
97    public static function get_api_url() {
98        $blog_id      = Jetpack_Options::get_option( 'id' );
99        $is_connected = ( new Connection_Manager() )->is_connected();
100
101        if ( ! $blog_id || ! $is_connected ) {
102            return new WP_Error( 'site_not_connected' );
103        }
104
105        $api_url = sprintf( self::SCAN_API_BASE, $blog_id );
106
107        return $api_url;
108    }
109
110    /**
111     * Fetches the status data from the Scan API
112     *
113     * @return WP_Error|array
114     */
115    public static function fetch_from_api() {
116        $api_url = self::get_api_url();
117        if ( is_wp_error( $api_url ) ) {
118            return $api_url;
119        }
120
121        $response = Client::wpcom_json_api_request_as_blog(
122            self::get_api_url(),
123            '2',
124            array(
125                'method'  => 'GET',
126                'timeout' => 30,
127            ),
128            null,
129            'wpcom'
130        );
131
132        $response_code = wp_remote_retrieve_response_code( $response );
133
134        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
135            return new WP_Error( 'failed_fetching_status', 'Failed to fetch Scan data from the server', array( 'status' => $response_code ) );
136        }
137
138        $body = json_decode( wp_remote_retrieve_body( $response ) );
139        self::update_status_option( $body );
140        return $body;
141    }
142
143    /**
144     * Normalize API Data
145     *
146     * Formats the payload from the Scan API into an instance of Status_Model.
147     *
148     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
149     *
150     * @param object $scan_data The data returned by the scan API.
151     *
152     * @return Status_Model
153     */
154    private static function normalize_api_data( $scan_data ) {
155        global $wp_version;
156
157        $installed_plugins = Plugins_Installer::get_plugins();
158        $installed_themes  = Sync_Functions::get_themes();
159
160        $plugins  = array();
161        $themes   = array();
162        $core     = new Extension_Model(
163            array(
164                'name'    => 'WordPress',
165                'slug'    => 'wordpress',
166                'version' => $wp_version,
167                'type'    => 'core',
168                'checked' => true, // to do: default to false once Scan API has manifest
169            )
170        );
171        $files    = array();
172        $database = array();
173
174        $status = new Status_Model(
175            array(
176                'data_source'         => 'scan_api',
177                'status'              => isset( $scan_data->state ) ? $scan_data->state : null,
178                'num_threats'         => 0,
179                'num_themes_threats'  => 0,
180                'num_plugins_threats' => 0,
181                'has_unchecked_items' => false,
182                'current_progress'    => isset( $scan_data->current->progress ) ? $scan_data->current->progress : null,
183            )
184        );
185
186        // Format the "last checked" timestamp.
187        if ( ! empty( $scan_data->most_recent->timestamp ) ) {
188            $date                 = new \DateTime( $scan_data->most_recent->timestamp );
189            $status->last_checked = $date->format( 'Y-m-d H:i:s' );
190        }
191
192        // Ensure all installed plugins and themes are represented in the status.
193        foreach ( $installed_plugins as $path => $installed_plugin ) {
194            $slug   = str_replace( '.php', '', explode( '/', $path )[0] );
195            $plugin = new Extension_Model(
196                array(
197                    'name'    => $installed_plugin['Name'],
198                    'version' => $installed_plugin['Version'],
199                    'slug'    => $slug,
200                    'type'    => 'plugins',
201                    'checked' => true, // to do: default to false once Scan API has manifest
202                )
203            );
204
205            $plugins[ $slug ] = $plugin;
206        }
207        foreach ( $installed_themes as $path => $installed_theme ) {
208            $slug  = str_replace( '.php', '', explode( '/', $path )[0] );
209            $theme = new Extension_Model(
210                array(
211                    'name'    => $installed_theme['Name'],
212                    'version' => $installed_theme['Version'],
213                    'slug'    => $slug,
214                    'type'    => 'themes',
215                    'checked' => true, // to do: default to false once Scan API has manifest
216                )
217            );
218
219            $themes[ $slug ] = $theme;
220        }
221
222        // Merge the threats into the status model.
223        if ( isset( $scan_data->threats ) && is_array( $scan_data->threats ) ) {
224            foreach ( $scan_data->threats as $scan_threat ) {
225                if ( isset( $scan_threat->fixable ) && $scan_threat->fixable ) {
226                    $status->fixable_threat_ids[] = $scan_threat->id;
227                }
228
229                $db_details = null;
230                if ( ! empty( $scan_threat->table ) ) {
231                    $db_details = (object) array_merge(
232                        array(
233                            'table'     => $scan_threat->table,
234                            'pk_column' => $scan_threat->pk_column ?? null,
235                            'pk_value'  => $scan_threat->value ?? null,
236                        ),
237                        ! empty( $scan_threat->details ) ? array( 'details' => $scan_threat->details ) : array()
238                    );
239                }
240
241                $threat = new Threat_Model(
242                    array(
243                        'id'                        => $scan_threat->id ?? null,
244                        'signature'                 => $scan_threat->signature ?? null,
245                        'title'                     => $scan_threat->title ?? null,
246                        'description'               => $scan_threat->description ?? null,
247                        'vulnerability_description' => $scan_threat->vulnerability_description ?? null,
248                        'extension'                 => $scan_threat->extension ?? null,
249                        'fix_description'           => $scan_threat->fix_description ?? null,
250                        'payload_subtitle'          => $scan_threat->payload_subtitle ?? null,
251                        'payload_description'       => $scan_threat->payload_description ?? null,
252                        'first_detected'            => $scan_threat->first_detected ?? null,
253                        'fixed_in'                  => isset( $scan_threat->fixer->fixer ) && 'update' === $scan_threat->fixer->fixer ? $scan_threat->fixer->target : null,
254                        'severity'                  => $scan_threat->severity ?? null,
255                        'fixable'                   => $scan_threat->fixer ?? null,
256                        'status'                    => $scan_threat->status ?? null,
257                        'filename'                  => $scan_threat->filename ?? null,
258                        'context'                   => $scan_threat->context ?? null,
259                        'source'                    => $scan_threat->source ?? null,
260                        'table'                     => $scan_threat->table ?? null,
261                        'details'                   => $db_details,
262                    )
263                );
264
265                // Theme and Plugin Threats
266                if ( ! empty( $scan_threat->extension ) && in_array( $scan_threat->extension->type, array( 'plugin', 'theme' ), true ) ) {
267                    $installed_extension = 'plugin' === $scan_threat->extension->type ? ( $plugins[ $scan_threat->extension->slug ] ?? null ) : ( $themes[ $scan_threat->extension->slug ] ?? null );
268
269                    // If the extension is no longer installed, skip this threat.
270                    // todo: use version_compare()
271                    if ( ! $installed_extension ) {
272                        continue;
273                    }
274
275                    // Push the threat to the appropriate extension.
276                    switch ( $scan_threat->extension->type ) {
277                        case 'plugin':
278                            $plugins[ $scan_threat->extension->slug ]->threats[] = clone $threat;
279                            ++$status->num_plugins_threats;
280                            break;
281                        case 'theme':
282                            $themes[ $scan_threat->extension->slug ]->threats[] = clone $threat;
283                            ++$status->num_themes_threats;
284                            break;
285                        default:
286                            break;
287                    }
288
289                    $threat->extension = new Extension_Model(
290                        array(
291                            'name'    => isset( $scan_threat->extension->name ) ? $scan_threat->extension->name : null,
292                            'slug'    => isset( $scan_threat->extension->slug ) ? $scan_threat->extension->slug : null,
293                            'version' => isset( $scan_threat->extension->version ) ? $scan_threat->extension->version : null,
294                            'type'    => $scan_threat->extension->type . 's',
295                            'checked' => $installed_extension->version === $scan_threat->extension->version,
296                        )
297                    );
298                } elseif ( isset( $threat->signature ) && 'Vulnerable.WP.Core' === $threat->signature ) {
299                    // Vulnerable WordPress Core Version Threats
300
301                    // If the core version has changed, skip this threat.
302                    // todo: use version_compare()
303                    if ( $scan_threat->version !== $wp_version ) {
304                        continue;
305                    }
306
307                    $core->threats[] = $threat;
308                } elseif ( ! empty( $threat->filename ) ) {
309                    // File Threats
310                    $files[] = $threat;
311                } elseif ( ! empty( $scan_threat->table ) ) {
312                    // Database Threats
313                    $database[] = $threat;
314                }
315
316                $status->threats[] = $threat;
317                ++$status->num_threats;
318            }
319        }
320
321        $status->threats = static::sort_threats( $status->threats );
322
323        // maintain deprecated properties for backwards compatibility
324        $status->plugins  = array_values( $plugins );
325        $status->themes   = array_values( $themes );
326        $status->core     = $core;
327        $status->files    = $files;
328        $status->database = $database;
329
330        return $status;
331    }
332
333    /**
334     * Sort By Threats
335     *
336     * @param array<Threat_Model> $threats Array of threats to sort.
337     *
338     * @return array<Threat_Model> The sorted $threats array.
339     */
340    protected static function sort_threats( $threats ) {
341        usort(
342            $threats,
343            function ( $a, $b ) {
344                // Order by active status first...
345                if ( $a->status !== $b->status ) {
346                    return 'active' === $a->status ? -1 : 1;
347                }
348
349                // ...then by severity...
350                if ( $a->severity !== $b->severity ) {
351                    return $a->severity > $b->severity ? -1 : 1;
352                }
353
354                // ...then date added.
355                if ( $a->first_detected !== $b->first_detected ) {
356                    return strtotime( $a->first_detected ) < strtotime( $b->first_detected ) ? -1 : 1;
357                }
358
359                return 0;
360            }
361        );
362
363        return $threats;
364    }
365}