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