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