Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.05% covered (danger)
21.05%
52 / 247
15.00% covered (danger)
15.00%
3 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan
21.22% covered (danger)
21.22%
52 / 245
15.00% covered (danger)
15.00%
3 / 20
2396.40
0.00% covered (danger)
0.00%
0 / 1
 get_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_state
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 register_routes
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
2
 can_act_on_threats
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 ignore_threat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unignore_threat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_ignored
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 get_ignored_threats
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_history_threats
15.00% covered (danger)
15.00%
3 / 20
0.00% covered (danger)
0.00%
0 / 1
37.09
 delete_software
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 can_write_files
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 fix_threat
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 get_fix_status
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 get_threat_fix_status
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 alerts_request
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 get_scan
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 start_scan
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 claim_request_lock
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 release_request_lock
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_scan_report
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * Protect dashboard: the Scan section.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect\Sections;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Protect\Dashboard;
12use Automattic\Jetpack\Protect\Dashboard_Section;
13use Automattic\Jetpack\Protect\Dashboard_Threats;
14use Automattic\Jetpack\Protect_Status\Protect_Status;
15use Automattic\Jetpack\Protect_Status\Scan_Status;
16use Automattic\Jetpack\Protect_Status\Status;
17use Automattic\Jetpack\Redirect;
18use Jetpack_Options;
19use WP_Error;
20use WP_REST_Request;
21use WP_REST_Server;
22
23if ( ! defined( 'ABSPATH' ) ) {
24    exit( 0 );
25}
26
27/**
28 * Vulnerability results, from Scan on a paid plan or Protect's free daily check, and on-demand scans.
29 *
30 * @since 0.1.0
31 */
32class Scan implements Dashboard_Section {
33
34    /**
35     * Scan statuses that mean a scan is still running, as the Protect plugin reads them.
36     *
37     * @var string[]
38     */
39    const SCANNING_STATUSES = array( 'provisioning', 'scheduled', 'scanning' );
40
41    /**
42     * Statuses that mean the first scan has not run yet when there is no report date.
43     *
44     * @var string[]
45     */
46    const INITIALIZING_STATUSES = array( 'idle', 'unavailable' );
47
48    /**
49     * Lock held for a minute after a scan request, so repeated requests don't stack.
50     *
51     * @var string
52     */
53    const REQUEST_LOCK = 'jetpack_protect_dashboard_scan_requested';
54
55    /**
56     * Fixer statuses that mean a fix has finished, one way or the other.
57     *
58     * @var string[]
59     */
60    const FIX_DONE_STATUSES = array( 'fixed', 'not_fixed' );
61
62    /**
63     * Transient caching Scan history, which is slow to fetch; the ignored list and the History tab share it.
64     *
65     * @var string
66     */
67    const HISTORY_CACHE = 'jetpack_protect_dashboard_scan_history';
68
69    /**
70     * The section key.
71     *
72     * @return string
73     */
74    public function get_key() {
75        return 'scan';
76    }
77
78    /**
79     * The plan, where to manage or buy Scan, and the latest report.
80     *
81     * @return array
82     */
83    public function get_state() {
84        $has_plan = Dashboard::has_scan_plan();
85
86        return array_merge(
87            array(
88                'hasPlan' => $has_plan,
89                'url'     => $has_plan
90                    ? Redirect::get_url( 'my-jetpack-manage-scan' )
91                    : admin_url( 'admin.php?page=my-jetpack#/add-scan' ),
92            ),
93            self::get_scan_report( $has_plan )
94        );
95    }
96
97    /**
98     * Register `jetpack/v4/protect-dashboard/scan`.
99     *
100     * @return void
101     */
102    public function register_routes() {
103        register_rest_route(
104            'jetpack/v4',
105            '/protect-dashboard/scan',
106            array(
107                array(
108                    'methods'             => WP_REST_Server::READABLE,
109                    'callback'            => array( __CLASS__, 'get_scan' ),
110                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
111                ),
112                array(
113                    'methods'             => WP_REST_Server::CREATABLE,
114                    'callback'            => array( __CLASS__, 'start_scan' ),
115                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
116                ),
117            )
118        );
119
120        register_rest_route(
121            'jetpack/v4',
122            '/protect-dashboard/scan/threats/(?P<id>\d+)/ignore',
123            array(
124                'methods'             => WP_REST_Server::CREATABLE,
125                'callback'            => array( __CLASS__, 'ignore_threat' ),
126                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
127            )
128        );
129
130        register_rest_route(
131            'jetpack/v4',
132            '/protect-dashboard/scan/threats/(?P<id>\d+)/unignore',
133            array(
134                'methods'             => WP_REST_Server::CREATABLE,
135                'callback'            => array( __CLASS__, 'unignore_threat' ),
136                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
137            )
138        );
139
140        register_rest_route(
141            'jetpack/v4',
142            '/protect-dashboard/scan/ignored',
143            array(
144                'methods'             => WP_REST_Server::READABLE,
145                'callback'            => array( __CLASS__, 'get_ignored_threats' ),
146                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
147            )
148        );
149
150        register_rest_route(
151            'jetpack/v4',
152            '/protect-dashboard/scan/software/delete',
153            array(
154                'methods'             => WP_REST_Server::CREATABLE,
155                'callback'            => array( __CLASS__, 'delete_software' ),
156                'permission_callback' => array( Dashboard::class, 'can_manage' ),
157                'args'                => array(
158                    'type' => array(
159                        'type'     => 'string',
160                        'enum'     => array( 'plugins', 'themes' ),
161                        'required' => true,
162                    ),
163                    'slug' => array(
164                        'type'     => 'string',
165                        'required' => true,
166                    ),
167                ),
168            )
169        );
170
171        register_rest_route(
172            'jetpack/v4',
173            '/protect-dashboard/scan/threats/(?P<id>\d+)/fix',
174            array(
175                array(
176                    'methods'             => WP_REST_Server::READABLE,
177                    'callback'            => array( __CLASS__, 'get_fix_status' ),
178                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
179                ),
180                array(
181                    'methods'             => WP_REST_Server::CREATABLE,
182                    'callback'            => array( __CLASS__, 'fix_threat' ),
183                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
184                ),
185            )
186        );
187    }
188
189    /**
190     * Only Scan can fix or ignore threats; the free vulnerability check can't.
191     *
192     * @return bool|WP_Error
193     */
194    public static function can_act_on_threats() {
195        if ( ! Dashboard::can_manage() ) {
196            return false;
197        }
198        if ( ! Dashboard::has_scan_plan() ) {
199            return new WP_Error( 'scan_plan_required', __( 'Fixing and ignoring threats needs Jetpack Scan.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
200        }
201        return true;
202    }
203
204    /**
205     * Ignore a threat; the client moves it to its ignored list.
206     *
207     * @param WP_REST_Request $request The request, with the threat `id`.
208     * @return array|WP_Error
209     */
210    public static function ignore_threat( WP_REST_Request $request ) {
211        return self::set_ignored( (int) $request['id'], true );
212    }
213
214    /**
215     * Report an ignored threat again; the client moves it back to its active list.
216     *
217     * @param WP_REST_Request $request The request, with the threat `id`.
218     * @return array|WP_Error
219     */
220    public static function unignore_threat( WP_REST_Request $request ) {
221        return self::set_ignored( (int) $request['id'], false );
222    }
223
224    /**
225     * Ignore or unignore a threat, and drop the caches that list it.
226     *
227     * @param int  $id     The threat id.
228     * @param bool $ignore True to ignore, false to unignore.
229     * @return array|WP_Error
230     */
231    private static function set_ignored( $id, $ignore ) {
232        $response = self::alerts_request( '/' . $id, 'POST', array( $ignore ? 'ignore' : 'unignore' => true ) );
233        if ( is_wp_error( $response ) ) {
234            return $ignore
235                ? new WP_Error( 'threat_not_ignored', __( 'The threat couldn’t be ignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) )
236                : new WP_Error( 'threat_not_unignored', __( 'The threat couldn’t be unignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
237        }
238
239        Scan_Status::delete_option();
240        delete_transient( self::HISTORY_CACHE );
241        return array( 'ok' => true );
242    }
243
244    /**
245     * Threats the site has ignored, from Scan history.
246     *
247     * @return array|WP_Error
248     */
249    public static function get_ignored_threats() {
250        $history = self::get_history_threats();
251        if ( is_wp_error( $history ) ) {
252            return new WP_Error( 'ignored_unavailable', __( 'Ignored threats are unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
253        }
254        return array_values(
255            array_filter(
256                $history,
257                function ( $threat ) {
258                    return 'ignored' === $threat['status'];
259                }
260            )
261        );
262    }
263
264    /**
265     * Every threat Scan has fixed or ignored, cached for five minutes.
266     *
267     * @return array|WP_Error
268     */
269    public static function get_history_threats() {
270        $cached = get_transient( self::HISTORY_CACHE );
271        if ( is_array( $cached ) ) {
272            return $cached;
273        }
274
275        $api_url  = Scan_Status::get_api_url();
276        $response = is_wp_error( $api_url ) ? $api_url : Client::wpcom_json_api_request_as_blog(
277            $api_url . '/history',
278            '2',
279            array(
280                'method'  => 'GET',
281                'timeout' => 30,
282            ),
283            null,
284            'wpcom'
285        );
286        $body     = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
287        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) || ! is_object( $body ) ) {
288            return new WP_Error( 'history_unavailable', __( 'Scan history is unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
289        }
290
291        $threats = Dashboard_Threats::format_all( (array) ( $body->threats ?? array() ) );
292        set_transient( self::HISTORY_CACHE, $threats, 5 * MINUTE_IN_SECONDS );
293        return $threats;
294    }
295
296    /**
297     * Delete an inactive plugin or a theme the site doesn't use, so its threats go with it.
298     *
299     * @param WP_REST_Request $request The request, with the extension `type` and `slug`.
300     * @return array|WP_Error
301     */
302    public static function delete_software( WP_REST_Request $request ) {
303        $type = $request['type'];
304        $slug = $request['slug'];
305
306        require_once ABSPATH . 'wp-admin/includes/file.php';
307        require_once ABSPATH . 'wp-admin/includes/plugin.php';
308        require_once ABSPATH . 'wp-admin/includes/theme.php';
309
310        // Looked up by key, so only the slug of installed software matches and anything else is refused.
311        $file  = 'plugins' === $type ? ( Dashboard_Threats::get_plugin_files( get_plugins() )[ $slug ] ?? null ) : null;
312        $theme = 'themes' === $type ? ( wp_get_themes( array( 'errors' => null ) )[ $slug ] ?? null ) : null;
313        if ( ! Dashboard_Threats::can_delete( $type, $file, $theme ) ) {
314            return new WP_Error( 'software_not_deletable', __( 'This can’t be deleted here. It may be in use, or already gone.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
315        }
316
317        $deleted = null;
318        if ( self::can_write_files() ) {
319            $deleted = 'plugins' === $type ? delete_plugins( array( $file ) ) : delete_theme( $theme->get_stylesheet() );
320        }
321        if ( true !== $deleted ) {
322            return new WP_Error( 'software_not_deleted', is_wp_error( $deleted ) ? $deleted->get_error_message() : __( 'WordPress couldn’t delete the files. Try deleting it from the Plugins or Themes screen.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
323        }
324
325        delete_transient( self::HISTORY_CACHE );
326        if ( class_exists( Status::class ) ) {
327            Scan_Status::delete_option();
328            Protect_Status::delete_option();
329        }
330        return array( 'ok' => true );
331    }
332
333    /**
334     * Whether WordPress can delete files without asking for filesystem credentials.
335     *
336     * Core's delete functions print the credentials form and exit when it can't, which a REST request can't show.
337     *
338     * @return bool
339     */
340    private static function can_write_files() {
341        if ( 'direct' === get_filesystem_method() ) {
342            return true;
343        }
344        ob_start();
345        $has_credentials = request_filesystem_credentials( self_admin_url() );
346        ob_end_clean();
347        return (bool) $has_credentials;
348    }
349
350    /**
351     * Ask Scan to fix a threat.
352     *
353     * @param WP_REST_Request $request The request, with the threat `id`.
354     * @return array|WP_Error The threat's fixer status.
355     */
356    public static function fix_threat( WP_REST_Request $request ) {
357        $id       = (int) $request['id'];
358        $response = self::alerts_request( '/fix', 'POST', array( 'threat_ids' => array( $id ) ) );
359        if ( is_wp_error( $response ) ) {
360            return new WP_Error( 'threat_not_fixed', __( 'The fix couldn’t be started. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
361        }
362
363        Scan_Status::delete_option();
364        $status = self::get_threat_fix_status( $response, $id );
365        if ( 'fixed' === $status['status'] ) {
366            delete_transient( self::HISTORY_CACHE );
367        }
368        return $status;
369    }
370
371    /**
372     * How a fix is going, with the refreshed report once it has finished.
373     *
374     * @param WP_REST_Request $request The request, with the threat `id`.
375     * @return array|WP_Error
376     */
377    public static function get_fix_status( WP_REST_Request $request ) {
378        $id       = (int) $request['id'];
379        $response = self::alerts_request( add_query_arg( 'threat_ids', array( $id ), '/fix' ), 'GET' );
380        if ( is_wp_error( $response ) ) {
381            return new WP_Error( 'fix_status_unavailable', __( 'We couldn’t check on the fix.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
382        }
383
384        $status = self::get_threat_fix_status( $response, $id );
385        if ( in_array( $status['status'], self::FIX_DONE_STATUSES, true ) ) {
386            // A fixed threat moves into Scan history.
387            if ( 'fixed' === $status['status'] ) {
388                delete_transient( self::HISTORY_CACHE );
389            }
390            $status['scan'] = self::get_scan_report( true, true );
391        }
392        return $status;
393    }
394
395    /**
396     * One threat's status from a WordPress.com fixer response.
397     *
398     * @param object $response The decoded response, with fixer statuses under `threats`, keyed by threat id.
399     * @param int    $id       The threat id.
400     * @return array The `status`, such as `in_progress` or `fixed`, and any `error`.
401     */
402    private static function get_threat_fix_status( $response, $id ) {
403        $threat = $response->threats->{ (string) $id } ?? null;
404        return array(
405            'status' => $threat->status ?? ( empty( $threat->error ) ? 'in_progress' : 'not_fixed' ),
406            'error'  => $threat->error ?? null,
407        );
408    }
409
410    /**
411     * Call the site's WordPress.com alerts API as the current user, as the Protect plugin does.
412     *
413     * @param string     $path   Path after `/sites/{id}/alerts`.
414     * @param string     $method HTTP method.
415     * @param array|null $body   Body to send as JSON.
416     * @return object|WP_Error The decoded response.
417     */
418    private static function alerts_request( $path, $method, $body = null ) {
419        $blog_id = (int) Jetpack_Options::get_option( 'id' );
420        if ( ! $blog_id ) {
421            return new WP_Error( 'site_not_connected' );
422        }
423
424        $response = Client::wpcom_json_api_request_as_user(
425            sprintf( '/sites/%d/alerts%s', $blog_id, $path ),
426            '2',
427            array( 'method' => $method ),
428            null === $body ? null : wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
429            'wpcom'
430        );
431        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
432            return new WP_Error( 'alerts_request_failed' );
433        }
434
435        // Ignoring answers with no useful body; only the status code matters there.
436        $decoded = json_decode( wp_remote_retrieve_body( $response ) );
437        return is_object( $decoded ) ? $decoded : new \stdClass();
438    }
439
440    /**
441     * The latest report, fresh from WordPress.com, for polling while a scan runs.
442     *
443     * @return array
444     */
445    public static function get_scan() {
446        return self::get_scan_report( Dashboard::has_scan_plan(), true );
447    }
448
449    /**
450     * Start a scan: enqueue one with a Scan plan, otherwise ask for the latest vulnerability report.
451     *
452     * @return array|WP_Error
453     */
454    public static function start_scan() {
455        $has_plan = Dashboard::has_scan_plan();
456
457        if ( $has_plan && self::claim_request_lock() ) {
458            $api_url  = Scan_Status::get_api_url();
459            $response = is_wp_error( $api_url )
460                ? $api_url
461                : Client::wpcom_json_api_request_as_blog( $api_url . '/enqueue', '2', array( 'method' => 'POST' ), null, 'wpcom' );
462            if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
463                self::release_request_lock();
464                return new WP_Error( 'scan_not_started', __( 'The scan couldn’t be started. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
465            }
466            Scan_Status::delete_option();
467            delete_transient( self::HISTORY_CACHE );
468
469            /**
470             * Fires after the Protect dashboard enqueues a scan on WordPress.com.
471             *
472             * @since 0.1.0
473             */
474            do_action( 'jetpack_protect_dashboard_scan_started' );
475        }
476
477        return self::get_scan_report( $has_plan, true );
478    }
479
480    /**
481     * Claim the once-a-minute scan request slot before calling WordPress.com, so concurrent requests don't both enqueue.
482     *
483     * @return bool Whether this request got the slot.
484     */
485    private static function claim_request_lock() {
486        // `wp_cache_add()` is atomic with a persistent object cache; transients can only narrow the race.
487        if ( wp_using_ext_object_cache() ) {
488            return wp_cache_add( self::REQUEST_LOCK, time(), 'jetpack_protect', MINUTE_IN_SECONDS );
489        }
490        if ( get_transient( self::REQUEST_LOCK ) ) {
491            return false;
492        }
493        set_transient( self::REQUEST_LOCK, time(), MINUTE_IN_SECONDS );
494        return true;
495    }
496
497    /**
498     * Give the scan request slot back after a failed request, so the user can retry straight away.
499     *
500     * @return void
501     */
502    private static function release_request_lock() {
503        if ( wp_using_ext_object_cache() ) {
504            wp_cache_delete( self::REQUEST_LOCK, 'jetpack_protect' );
505        } else {
506            delete_transient( self::REQUEST_LOCK );
507        }
508    }
509
510    /**
511     * The latest vulnerability report: from Scan on a paid plan, otherwise Protect's free check.
512     *
513     * @param bool $has_plan Whether the site has a Scan plan.
514     * @param bool $refresh  Fetch the report from WordPress.com instead of the cache; the plan is never refreshed.
515     * @return array
516     */
517    private static function get_scan_report( $has_plan, $refresh = false ) {
518        $error = array(
519            'error'    => true,
520            'scanning' => false,
521        );
522        if ( ! class_exists( Status::class ) ) {
523            return $error;
524        }
525
526        if ( $refresh ) {
527            Status::$status = null;
528        }
529        $status = $has_plan ? Scan_Status::get_status( $refresh ) : Protect_Status::get_status( $refresh );
530        if ( ! empty( $status->error ) ) {
531            return $error;
532        }
533
534        if ( ! function_exists( 'get_plugins' ) ) {
535            require_once ABSPATH . 'wp-admin/includes/plugin.php';
536        }
537
538        $is_initializing = empty( $status->last_checked ) && in_array( $status->status, self::INITIALIZING_STATUSES, true );
539
540        return array(
541            'error'          => false,
542            'scanning'       => $is_initializing || in_array( $status->status, self::SCANNING_STATUSES, true ),
543            'lastChecked'    => $status->last_checked,
544            'progress'       => isset( $status->current_progress ) ? (int) $status->current_progress : null,
545            'pluginsChecked' => count( get_plugins() ),
546            'themesChecked'  => count( wp_get_themes() ),
547            'threats'        => Dashboard_Threats::format_all( $status->threats ),
548        );
549    }
550}