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