Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.36% covered (success)
94.36%
184 / 195
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan_Status
94.82% covered (success)
94.82%
183 / 193
33.33% covered (danger)
33.33%
2 / 6
52.38
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_api
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 normalize_api_data
96.27% covered (success)
96.27%
129 / 134
0.00% covered (danger)
0.00%
0 / 1
28
 sort_threats
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
8.12
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     * Where a threat with no severity sorts: just above Low (below 3), as sortThreatSeverity does in js-packages/scan.
61     *
62     * @var float
63     */
64    private const UNRATED_SEVERITY_SORT_VALUE = 2.5;
65
66    /**
67     * Gets the current status of the Jetpack Protect checks
68     *
69     * @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
70     * @return Status_Model
71     */
72    public static function get_status( $refresh_from_wpcom = false ) {
73        if ( self::$status !== null ) {
74            return self::$status;
75        }
76
77        if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
78            $status = self::fetch_from_api();
79        } else {
80            $status = self::get_from_options();
81        }
82
83        if ( is_wp_error( $status ) ) {
84            $status = new Status_Model(
85                array(
86                    'error'         => true,
87                    'error_code'    => $status->get_error_code(),
88                    'error_message' => $status->get_error_message(),
89                )
90            );
91        } else {
92            $status = self::normalize_api_data( $status );
93        }
94
95        self::$status = $status;
96        return $status;
97    }
98
99    /**
100     * Gets the last Scan status stored on the site, even if it has expired, without asking WordPress.com.
101     *
102     * @since 0.9.0
103     *
104     * @return Status_Model|null Null when no status has been stored yet.
105     */
106    public static function get_cached_status() {
107        $scan_data = self::get_from_options();
108        return is_object( $scan_data ) ? self::normalize_api_data( $scan_data ) : null;
109    }
110
111    /**
112     * Gets the Scan API endpoint
113     *
114     * @return WP_Error|string
115     */
116    public static function get_api_url() {
117        $blog_id      = Jetpack_Options::get_option( 'id' );
118        $is_connected = ( new Connection_Manager() )->is_connected();
119
120        if ( ! $blog_id || ! $is_connected ) {
121            return new WP_Error( 'site_not_connected' );
122        }
123
124        $api_url = sprintf( self::SCAN_API_BASE, $blog_id );
125
126        return $api_url;
127    }
128
129    /**
130     * Fetches the status data from the Scan API
131     *
132     * @return WP_Error|array
133     */
134    public static function fetch_from_api() {
135        $api_url = self::get_api_url();
136        if ( is_wp_error( $api_url ) ) {
137            return $api_url;
138        }
139
140        $response = Client::wpcom_json_api_request_as_blog(
141            self::get_api_url(),
142            '2',
143            array(
144                'method'  => 'GET',
145                'timeout' => 30,
146            ),
147            null,
148            'wpcom'
149        );
150
151        $response_code = wp_remote_retrieve_response_code( $response );
152
153        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
154            return new WP_Error( 'failed_fetching_status', 'Failed to fetch Scan data from the server', array( 'status' => $response_code ) );
155        }
156
157        $body = json_decode( wp_remote_retrieve_body( $response ) );
158        self::update_status_option( $body );
159        return $body;
160    }
161
162    /**
163     * Normalize API Data
164     *
165     * Formats the payload from the Scan API into an instance of Status_Model.
166     *
167     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
168     *
169     * @param object $scan_data The data returned by the scan API.
170     *
171     * @return Status_Model
172     */
173    private static function normalize_api_data( $scan_data ) {
174        global $wp_version;
175
176        $installed_plugins = Plugins_Installer::get_plugins();
177        $installed_themes  = Sync_Functions::get_themes();
178
179        $plugins  = array();
180        $themes   = array();
181        $core     = new Extension_Model(
182            array(
183                'name'    => 'WordPress',
184                'slug'    => 'wordpress',
185                'version' => $wp_version,
186                'type'    => 'core',
187                'checked' => true, // to do: default to false once Scan API has manifest
188            )
189        );
190        $files    = array();
191        $database = array();
192
193        $status = new Status_Model(
194            array(
195                'data_source'         => 'scan_api',
196                'status'              => $scan_data->state ?? null,
197                'num_threats'         => 0,
198                'num_themes_threats'  => 0,
199                'num_plugins_threats' => 0,
200                'has_unchecked_items' => false,
201                'current_progress'    => $scan_data->current->progress ?? null,
202                'last_scan_failed'    => ! empty( $scan_data->most_recent->error ),
203            )
204        );
205
206        // Format the "last checked" timestamp.
207        if ( ! empty( $scan_data->most_recent->timestamp ) ) {
208            $date                 = new \DateTime( $scan_data->most_recent->timestamp );
209            $status->last_checked = $date->format( 'Y-m-d H:i:s' );
210        }
211
212        // Ensure all installed plugins and themes are represented in the status.
213        foreach ( $installed_plugins as $path => $installed_plugin ) {
214            $slug   = str_replace( '.php', '', explode( '/', $path )[0] );
215            $plugin = new Extension_Model(
216                array(
217                    'name'    => $installed_plugin['Name'],
218                    'version' => $installed_plugin['Version'],
219                    'slug'    => $slug,
220                    'type'    => 'plugins',
221                    'checked' => true, // to do: default to false once Scan API has manifest
222                )
223            );
224
225            $plugins[ $slug ] = $plugin;
226        }
227        foreach ( $installed_themes as $path => $installed_theme ) {
228            $slug  = str_replace( '.php', '', explode( '/', $path )[0] );
229            $theme = new Extension_Model(
230                array(
231                    'name'    => $installed_theme['Name'],
232                    'version' => $installed_theme['Version'],
233                    'slug'    => $slug,
234                    'type'    => 'themes',
235                    'checked' => true, // to do: default to false once Scan API has manifest
236                )
237            );
238
239            $themes[ $slug ] = $theme;
240        }
241
242        // Merge the threats into the status model.
243        if ( isset( $scan_data->threats ) && is_array( $scan_data->threats ) ) {
244            foreach ( $scan_data->threats as $scan_threat ) {
245                if ( isset( $scan_threat->fixable ) && $scan_threat->fixable ) {
246                    $status->fixable_threat_ids[] = $scan_threat->id;
247                }
248
249                $db_details = null;
250                if ( ! empty( $scan_threat->table ) ) {
251                    $db_details = (object) array_merge(
252                        array(
253                            'table'     => $scan_threat->table,
254                            'pk_column' => $scan_threat->pk_column ?? null,
255                            'pk_value'  => $scan_threat->value ?? null,
256                        ),
257                        ! empty( $scan_threat->details ) ? array( 'details' => $scan_threat->details ) : array()
258                    );
259                }
260
261                $threat = new Threat_Model(
262                    array(
263                        'id'                        => $scan_threat->id ?? null,
264                        'signature'                 => $scan_threat->signature ?? null,
265                        'title'                     => $scan_threat->title ?? null,
266                        'description'               => $scan_threat->description ?? null,
267                        'vulnerability_description' => $scan_threat->vulnerability_description ?? null,
268                        'extension'                 => $scan_threat->extension ?? null,
269                        'fix_description'           => $scan_threat->fix_description ?? null,
270                        'payload_subtitle'          => $scan_threat->payload_subtitle ?? null,
271                        'payload_description'       => $scan_threat->payload_description ?? null,
272                        'first_detected'            => $scan_threat->first_detected ?? null,
273                        'fixed_in'                  => isset( $scan_threat->fixer->fixer ) && 'update' === $scan_threat->fixer->fixer ? $scan_threat->fixer->target : null,
274                        'severity'                  => $scan_threat->severity ?? null,
275                        'fixable'                   => $scan_threat->fixer ?? null,
276                        'status'                    => $scan_threat->status ?? null,
277                        'filename'                  => $scan_threat->filename ?? null,
278                        'context'                   => $scan_threat->context ?? null,
279                        'source'                    => $scan_threat->source ?? null,
280                        'table'                     => $scan_threat->table ?? null,
281                        'details'                   => $db_details,
282                    )
283                );
284
285                // Theme and Plugin Threats
286                if ( ! empty( $scan_threat->extension ) && in_array( $scan_threat->extension->type, array( 'plugin', 'theme' ), true ) ) {
287                    $installed_extension = 'plugin' === $scan_threat->extension->type ? ( $plugins[ $scan_threat->extension->slug ] ?? null ) : ( $themes[ $scan_threat->extension->slug ] ?? null );
288
289                    // If the extension is no longer installed, skip this threat.
290                    // todo: use version_compare()
291                    if ( ! $installed_extension ) {
292                        continue;
293                    }
294
295                    // Updated to the fixed version, the vulnerability is gone, though Scan's report shows it until the next scan.
296                    if ( 'Vulnerable.WP.Extension' === $threat->signature && ! empty( $threat->fixed_in ) && version_compare( (string) $installed_extension->version, (string) $threat->fixed_in, '>=' ) ) {
297                        $installed_extension->checked = false;
298                        continue;
299                    }
300
301                    // Push the threat to the appropriate extension.
302                    switch ( $scan_threat->extension->type ) {
303                        case 'plugin':
304                            $plugins[ $scan_threat->extension->slug ]->threats[] = clone $threat;
305                            ++$status->num_plugins_threats;
306                            break;
307                        case 'theme':
308                            $themes[ $scan_threat->extension->slug ]->threats[] = clone $threat;
309                            ++$status->num_themes_threats;
310                            break;
311                        default:
312                            break;
313                    }
314
315                    $threat->extension = new Extension_Model(
316                        array(
317                            'name'    => $scan_threat->extension->name ?? null,
318                            'slug'    => $scan_threat->extension->slug ?? null,
319                            'version' => $scan_threat->extension->version ?? null,
320                            'type'    => $scan_threat->extension->type . 's',
321                            'checked' => $installed_extension->version === $scan_threat->extension->version,
322                        )
323                    );
324                } elseif ( isset( $threat->signature ) && 'Vulnerable.WP.Core' === $threat->signature ) {
325                    // Vulnerable WordPress Core Version Threats
326
327                    // If the core version has changed, skip this threat.
328                    // todo: use version_compare()
329                    if ( $scan_threat->version !== $wp_version ) {
330                        continue;
331                    }
332
333                    $core->threats[] = $threat;
334                } elseif ( ! empty( $threat->filename ) ) {
335                    // File Threats
336                    $files[] = $threat;
337                } elseif ( ! empty( $scan_threat->table ) ) {
338                    // Database Threats
339                    $database[] = $threat;
340                }
341
342                $status->threats[] = $threat;
343                ++$status->num_threats;
344            }
345        }
346
347        $status->threats = static::sort_threats( $status->threats );
348
349        // maintain deprecated properties for backwards compatibility
350        $status->plugins  = array_values( $plugins );
351        $status->themes   = array_values( $themes );
352        $status->core     = $core;
353        $status->files    = $files;
354        $status->database = $database;
355
356        return $status;
357    }
358
359    /**
360     * Sort By Threats
361     *
362     * @param array<Threat_Model> $threats Array of threats to sort.
363     *
364     * @return array<Threat_Model> The sorted $threats array.
365     */
366    protected static function sort_threats( $threats ) {
367        usort(
368            $threats,
369            function ( $a, $b ) {
370                // Order by active status first...
371                if ( $a->status !== $b->status ) {
372                    return 'active' === $a->status ? -1 : 1;
373                }
374
375                // ...then by severity...
376                $a_severity  = is_numeric( $a->severity ) ? $a->severity : self::UNRATED_SEVERITY_SORT_VALUE;
377                $b_severity  = is_numeric( $b->severity ) ? $b->severity : self::UNRATED_SEVERITY_SORT_VALUE;
378                $by_severity = $b_severity <=> $a_severity;
379                if ( 0 !== $by_severity ) {
380                    return $by_severity;
381                }
382
383                // ...then date added.
384                if ( $a->first_detected !== $b->first_detected ) {
385                    return strtotime( $a->first_detected ) < strtotime( $b->first_detected ) ? -1 : 1;
386                }
387
388                return 0;
389            }
390        );
391
392        return $threats;
393    }
394}