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