Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
24.85% covered (danger)
24.85%
81 / 326
19.23% covered (danger)
19.23%
5 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan
25.00% covered (danger)
25.00%
81 / 324
19.23% covered (danger)
19.23%
5 / 26
5121.30
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 / 88
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_threat_file
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 get_threat_filename
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 locate_threat_file
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
10.55
 get_site_relative_path
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 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 largest file the dashboard shows in full, in bytes.
78     *
79     * @var int
80     */
81    const MAX_FILE_BYTES = 512 * KB_IN_BYTES;
82
83    /**
84     * The section key.
85     *
86     * @return string
87     */
88    public function get_key() {
89        return 'scan';
90    }
91
92    /**
93     * The plan, where to manage or buy Scan, and the latest report.
94     *
95     * @return array
96     */
97    public function get_state() {
98        $has_plan = Dashboard::has_scan_plan();
99
100        return array_merge(
101            array(
102                'hasPlan' => $has_plan,
103                'url'     => $has_plan
104                    ? Redirect::get_url( 'my-jetpack-manage-scan' )
105                    : Dashboard::get_scan_upgrade_url(),
106            ),
107            $has_plan ? array() : array(
108                'upsellVariant'       => self::get_upsell_variant(),
109                'upsellVariantForced' => (bool) has_filter( 'jetpack_protect_dashboard_scan_upsell_variant' ),
110            ),
111            self::get_scan_report( $has_plan )
112        );
113    }
114
115    /**
116     * Where this site's upsell copy rotation starts, spread across sites by blog ID.
117     *
118     * @return int From 1 to UPSELL_VARIANTS.
119     */
120    public static function get_upsell_variant() {
121        $blog_id = (int) Jetpack_Options::get_option( 'id' );
122        $variant = $blog_id > 0 ? ( $blog_id % self::UPSELL_VARIANTS ) + 1 : 1;
123
124        /**
125         * Filters which free-plan Scan upsell copy the Protect dashboard shows; a filtered one isn't rotated.
126         *
127         * @since $$next-version$$
128         *
129         * @param int $variant From 1 to 8.
130         */
131        $variant = (int) apply_filters( 'jetpack_protect_dashboard_scan_upsell_variant', $variant );
132
133        return $variant >= 1 && $variant <= self::UPSELL_VARIANTS ? $variant : 1;
134    }
135
136    /**
137     * Register `jetpack/v4/protect-dashboard/scan`.
138     *
139     * @return void
140     */
141    public function register_routes() {
142        register_rest_route(
143            'jetpack/v4',
144            '/protect-dashboard/scan',
145            array(
146                array(
147                    'methods'             => WP_REST_Server::READABLE,
148                    'callback'            => array( __CLASS__, 'get_scan' ),
149                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
150                ),
151                array(
152                    'methods'             => WP_REST_Server::CREATABLE,
153                    'callback'            => array( __CLASS__, 'start_scan' ),
154                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
155                ),
156            )
157        );
158
159        register_rest_route(
160            'jetpack/v4',
161            '/protect-dashboard/scan/threats/(?P<id>\d+)/ignore',
162            array(
163                'methods'             => WP_REST_Server::CREATABLE,
164                'callback'            => array( __CLASS__, 'ignore_threat' ),
165                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
166            )
167        );
168
169        register_rest_route(
170            'jetpack/v4',
171            '/protect-dashboard/scan/threats/(?P<id>\d+)/unignore',
172            array(
173                'methods'             => WP_REST_Server::CREATABLE,
174                'callback'            => array( __CLASS__, 'unignore_threat' ),
175                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
176            )
177        );
178
179        register_rest_route(
180            'jetpack/v4',
181            '/protect-dashboard/scan/threats/(?P<id>\d+)/file',
182            array(
183                'methods'             => WP_REST_Server::READABLE,
184                'callback'            => array( __CLASS__, 'get_threat_file' ),
185                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
186            )
187        );
188
189        register_rest_route(
190            'jetpack/v4',
191            '/protect-dashboard/scan/ignored',
192            array(
193                'methods'             => WP_REST_Server::READABLE,
194                'callback'            => array( __CLASS__, 'get_ignored_threats' ),
195                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
196            )
197        );
198
199        register_rest_route(
200            'jetpack/v4',
201            '/protect-dashboard/scan/software/delete',
202            array(
203                'methods'             => WP_REST_Server::CREATABLE,
204                'callback'            => array( __CLASS__, 'delete_software' ),
205                'permission_callback' => array( Dashboard::class, 'can_manage' ),
206                'args'                => array(
207                    'type' => array(
208                        'type'     => 'string',
209                        'enum'     => array( 'plugins', 'themes' ),
210                        'required' => true,
211                    ),
212                    'slug' => array(
213                        'type'     => 'string',
214                        'required' => true,
215                    ),
216                ),
217            )
218        );
219
220        register_rest_route(
221            'jetpack/v4',
222            '/protect-dashboard/scan/threats/(?P<id>\d+)/fix',
223            array(
224                array(
225                    'methods'             => WP_REST_Server::READABLE,
226                    'callback'            => array( __CLASS__, 'get_fix_status' ),
227                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
228                ),
229                array(
230                    'methods'             => WP_REST_Server::CREATABLE,
231                    'callback'            => array( __CLASS__, 'fix_threat' ),
232                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
233                ),
234            )
235        );
236    }
237
238    /**
239     * Only Scan can fix or ignore threats; the free vulnerability check can't.
240     *
241     * @return bool|WP_Error
242     */
243    public static function can_act_on_threats() {
244        if ( ! Dashboard::can_manage() ) {
245            return false;
246        }
247        if ( ! Dashboard::has_scan_plan() ) {
248            return new WP_Error( 'scan_plan_required', __( 'Fixing and ignoring threats needs Jetpack Scan.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
249        }
250        return true;
251    }
252
253    /**
254     * Ignore a threat; the client moves it to its ignored list.
255     *
256     * @param WP_REST_Request $request The request, with the threat `id`.
257     * @return array|WP_Error
258     */
259    public static function ignore_threat( WP_REST_Request $request ) {
260        return self::set_ignored( (int) $request['id'], true );
261    }
262
263    /**
264     * Report an ignored threat again; the client moves it back to its active list.
265     *
266     * @param WP_REST_Request $request The request, with the threat `id`.
267     * @return array|WP_Error
268     */
269    public static function unignore_threat( WP_REST_Request $request ) {
270        return self::set_ignored( (int) $request['id'], false );
271    }
272
273    /**
274     * Ignore or unignore a threat, and drop the caches that list it.
275     *
276     * @param int  $id     The threat id.
277     * @param bool $ignore True to ignore, false to unignore.
278     * @return array|WP_Error
279     */
280    private static function set_ignored( $id, $ignore ) {
281        $response = self::alerts_request( '/' . $id, 'POST', array( $ignore ? 'ignore' : 'unignore' => true ) );
282        if ( is_wp_error( $response ) ) {
283            return $ignore
284                ? new WP_Error( 'threat_not_ignored', __( 'The threat couldn’t be ignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) )
285                : new WP_Error( 'threat_not_unignored', __( 'The threat couldn’t be unignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
286        }
287
288        Scan_Status::delete_option();
289        delete_transient( self::HISTORY_CACHE );
290        return array( 'ok' => true );
291    }
292
293    /**
294     * Threats the site has ignored, from Scan history.
295     *
296     * @return array|WP_Error
297     */
298    public static function get_ignored_threats() {
299        $history = self::get_history_threats();
300        if ( is_wp_error( $history ) ) {
301            return new WP_Error( 'ignored_unavailable', __( 'Ignored threats are unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
302        }
303        return array_values(
304            array_filter(
305                $history,
306                function ( $threat ) {
307                    return 'ignored' === $threat['status'];
308                }
309            )
310        );
311    }
312
313    /**
314     * The whole file a threat was found in, looked up by threat so the client can't name a path.
315     *
316     * @param WP_REST_Request $request The request, with the threat `id`.
317     * @return array|WP_Error The file's site-relative `path`, `modified` time and `contents`.
318     */
319    public static function get_threat_file( WP_REST_Request $request ) {
320        $filename = self::get_threat_filename( (int) $request['id'] );
321        $path     = $filename ? self::locate_threat_file( $filename ) : null;
322        if ( ! $path ) {
323            return new WP_Error( 'threat_file_not_found', __( 'This file is no longer on your site.', 'jetpack-protect-pkg' ), array( 'status' => 404 ) );
324        }
325        if ( filesize( $path ) > self::MAX_FILE_BYTES ) {
326            return new WP_Error( 'threat_file_too_large', __( 'This file is too large to show here.', 'jetpack-protect-pkg' ), array( 'status' => 413 ) );
327        }
328
329        $contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local file.
330        if ( false === $contents ) {
331            return new WP_Error( 'threat_file_unreadable', __( 'This file couldn’t be read.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
332        }
333        if ( false !== strpos( $contents, "\0" ) ) {
334            return new WP_Error( 'threat_file_binary', __( 'This is a binary file, so it can’t be shown here.', 'jetpack-protect-pkg' ), array( 'status' => 415 ) );
335        }
336
337        $modified = filemtime( $path );
338        return array(
339            'path'     => self::get_site_relative_path( $path ),
340            'modified' => $modified ? gmdate( 'c', $modified ) : null,
341            // JSON can't carry invalid UTF-8, which obfuscated code often has.
342            'contents' => wp_check_invalid_utf8( $contents, true ),
343        );
344    }
345
346    /**
347     * The file Scan reported a threat in, from the current report or, for an ignored threat, history.
348     *
349     * @param int $id The threat id.
350     * @return string|null The path as Scan names it, or null when the threat isn't in a file.
351     */
352    private static function get_threat_filename( $id ) {
353        $threats = class_exists( Status::class ) ? (array) ( Scan_Status::get_status()->threats ?? array() ) : array();
354        foreach ( $threats as $threat ) {
355            if ( (int) ( $threat->id ?? 0 ) === $id ) {
356                return $threat->filename ?? null;
357            }
358        }
359
360        $history = self::get_history_threats();
361        if ( ! is_array( $history ) ) {
362            return null;
363        }
364        foreach ( $history as $threat ) {
365            if ( (int) $threat['id'] === $id ) {
366                return $threat['filename'] ?? null;
367            }
368        }
369        return null;
370    }
371
372    /**
373     * Where a file Scan reported is on this server, since Scan names it from its own root, such as `/htdocs/wp-content/…`.
374     *
375     * @param string $filename The path as Scan names it.
376     * @return string|null The file's real path, only ever inside the site's directories.
377     */
378    private static function locate_threat_file( $filename ) {
379        $roots = array_unique( array_filter( array( realpath( ABSPATH ), realpath( dirname( WP_CONTENT_DIR ) ) ) ) );
380        $parts = array_values( array_filter( explode( '/', wp_normalize_path( $filename ) ), 'strlen' ) );
381
382        // Anchored at wp-content, so a deleted plugin file can't fall back to a same-named file higher up.
383        $anchor     = array_search( 'wp-content', $parts, true );
384        $candidates = array();
385        if ( false !== $anchor ) {
386            $candidates[] = WP_CONTENT_DIR . '/' . implode( '/', array_slice( $parts, $anchor + 1 ) );
387        } else {
388            // Scan's root has a different path here, so try the longest tail first.
389            for ( $i = 0, $count = count( $parts ); $i < $count; $i++ ) {
390                foreach ( $roots as $root ) {
391                    $candidates[] = $root . '/' . implode( '/', array_slice( $parts, $i ) );
392                }
393            }
394        }
395
396        foreach ( $candidates as $candidate ) {
397            $path = realpath( $candidate );
398            if ( ! $path || ! is_file( $path ) ) {
399                continue;
400            }
401            foreach ( array_merge( $roots, array( realpath( WP_CONTENT_DIR ) ) ) as $root ) {
402                if ( $root && 0 === strpos( $path, trailingslashit( $root ) ) ) {
403                    return $path;
404                }
405            }
406        }
407        return null;
408    }
409
410    /**
411     * A file's path from the site's root, such as `wp-content/uploads/evil.php`.
412     *
413     * @param string $path The file's real path, inside the site.
414     * @return string
415     */
416    private static function get_site_relative_path( $path ) {
417        foreach ( array( dirname( WP_CONTENT_DIR ), ABSPATH ) as $root ) {
418            $root = realpath( $root );
419            if ( $root && 0 === strpos( $path, trailingslashit( $root ) ) ) {
420                return substr( $path, strlen( trailingslashit( $root ) ) );
421            }
422        }
423        return basename( $path );
424    }
425
426    /**
427     * Every threat Scan has fixed or ignored, cached for five minutes.
428     *
429     * @return array|WP_Error
430     */
431    public static function get_history_threats() {
432        $cached = get_transient( self::HISTORY_CACHE );
433        if ( is_array( $cached ) ) {
434            return $cached;
435        }
436
437        $api_url  = Scan_Status::get_api_url();
438        $response = is_wp_error( $api_url ) ? $api_url : Client::wpcom_json_api_request_as_blog(
439            $api_url . '/history',
440            '2',
441            array(
442                'method'  => 'GET',
443                'timeout' => 30,
444            ),
445            null,
446            'wpcom'
447        );
448        $body     = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
449        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) || ! is_object( $body ) ) {
450            return new WP_Error( 'history_unavailable', __( 'Scan history is unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
451        }
452
453        $threats = Dashboard_Threats::format_all( (array) ( $body->threats ?? array() ) );
454        set_transient( self::HISTORY_CACHE, $threats, 5 * MINUTE_IN_SECONDS );
455        return $threats;
456    }
457
458    /**
459     * Delete an inactive plugin or a theme the site doesn't use, so its threats go with it.
460     *
461     * @param WP_REST_Request $request The request, with the extension `type` and `slug`.
462     * @return array|WP_Error
463     */
464    public static function delete_software( WP_REST_Request $request ) {
465        $type = $request['type'];
466        $slug = $request['slug'];
467
468        require_once ABSPATH . 'wp-admin/includes/file.php';
469        require_once ABSPATH . 'wp-admin/includes/plugin.php';
470        require_once ABSPATH . 'wp-admin/includes/theme.php';
471
472        // Looked up by key, so only the slug of installed software matches and anything else is refused.
473        $file  = 'plugins' === $type ? ( Dashboard_Threats::get_plugin_files( get_plugins() )[ $slug ] ?? null ) : null;
474        $theme = 'themes' === $type ? ( wp_get_themes( array( 'errors' => null ) )[ $slug ] ?? null ) : null;
475        if ( ! Dashboard_Threats::can_delete( $type, $file, $theme ) ) {
476            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 ) );
477        }
478
479        $deleted = null;
480        if ( self::can_write_files() ) {
481            $deleted = 'plugins' === $type ? delete_plugins( array( $file ) ) : delete_theme( $theme->get_stylesheet() );
482        }
483        if ( true !== $deleted ) {
484            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 ) );
485        }
486
487        delete_transient( self::HISTORY_CACHE );
488        if ( class_exists( Status::class ) ) {
489            Scan_Status::delete_option();
490            Protect_Status::delete_option();
491        }
492        return array( 'ok' => true );
493    }
494
495    /**
496     * Whether WordPress can delete files without asking for filesystem credentials.
497     *
498     * Core's delete functions print the credentials form and exit when it can't, which a REST request can't show.
499     *
500     * @return bool
501     */
502    private static function can_write_files() {
503        if ( 'direct' === get_filesystem_method() ) {
504            return true;
505        }
506        ob_start();
507        $has_credentials = request_filesystem_credentials( self_admin_url() );
508        ob_end_clean();
509        return (bool) $has_credentials;
510    }
511
512    /**
513     * Ask Scan to fix a threat.
514     *
515     * @param WP_REST_Request $request The request, with the threat `id`.
516     * @return array|WP_Error The threat's fixer status.
517     */
518    public static function fix_threat( WP_REST_Request $request ) {
519        $id       = (int) $request['id'];
520        $response = self::alerts_request( '/fix', 'POST', array( 'threat_ids' => array( $id ) ) );
521        if ( is_wp_error( $response ) ) {
522            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 ) );
523        }
524
525        Scan_Status::delete_option();
526        $status = self::get_threat_fix_status( $response, $id );
527        if ( 'fixed' === $status['status'] ) {
528            delete_transient( self::HISTORY_CACHE );
529        }
530        return $status;
531    }
532
533    /**
534     * How a fix is going, with the refreshed report once it has finished.
535     *
536     * @param WP_REST_Request $request The request, with the threat `id`.
537     * @return array|WP_Error
538     */
539    public static function get_fix_status( WP_REST_Request $request ) {
540        $id       = (int) $request['id'];
541        $response = self::alerts_request( add_query_arg( 'threat_ids', array( $id ), '/fix' ), 'GET' );
542        if ( is_wp_error( $response ) ) {
543            return new WP_Error( 'fix_status_unavailable', __( 'We couldn’t check on the fix.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
544        }
545
546        $status = self::get_threat_fix_status( $response, $id );
547        if ( in_array( $status['status'], self::FIX_DONE_STATUSES, true ) ) {
548            // A fixed threat moves into Scan history.
549            if ( 'fixed' === $status['status'] ) {
550                delete_transient( self::HISTORY_CACHE );
551            }
552            $status['scan'] = self::get_scan_report( true, true );
553        }
554        return $status;
555    }
556
557    /**
558     * One threat's status from a WordPress.com fixer response.
559     *
560     * @param object $response The decoded response, with fixer statuses under `threats`, keyed by threat id.
561     * @param int    $id       The threat id.
562     * @return array The `status`, such as `in_progress` or `fixed`, and any `error`.
563     */
564    private static function get_threat_fix_status( $response, $id ) {
565        $threat = $response->threats->{ (string) $id } ?? null;
566        return array(
567            'status' => $threat->status ?? ( empty( $threat->error ) ? 'in_progress' : 'not_fixed' ),
568            'error'  => $threat->error ?? null,
569        );
570    }
571
572    /**
573     * Call the site's WordPress.com alerts API as the current user, as the Protect plugin does.
574     *
575     * @param string     $path   Path after `/sites/{id}/alerts`.
576     * @param string     $method HTTP method.
577     * @param array|null $body   Body to send as JSON.
578     * @return object|WP_Error The decoded response.
579     */
580    private static function alerts_request( $path, $method, $body = null ) {
581        $blog_id = (int) Jetpack_Options::get_option( 'id' );
582        if ( ! $blog_id ) {
583            return new WP_Error( 'site_not_connected' );
584        }
585
586        $response = Client::wpcom_json_api_request_as_user(
587            sprintf( '/sites/%d/alerts%s', $blog_id, $path ),
588            '2',
589            array( 'method' => $method ),
590            null === $body ? null : wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
591            'wpcom'
592        );
593        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
594            return new WP_Error( 'alerts_request_failed' );
595        }
596
597        // Ignoring answers with no useful body; only the status code matters there.
598        $decoded = json_decode( wp_remote_retrieve_body( $response ) );
599        return is_object( $decoded ) ? $decoded : new \stdClass();
600    }
601
602    /**
603     * The latest report, fresh from WordPress.com, for polling while a scan runs.
604     *
605     * @return array
606     */
607    public static function get_scan() {
608        return self::get_scan_report( Dashboard::has_scan_plan(), true );
609    }
610
611    /**
612     * Start a scan: enqueue one with a Scan plan, otherwise ask for the latest vulnerability report.
613     *
614     * @return array|WP_Error
615     */
616    public static function start_scan() {
617        $has_plan = Dashboard::has_scan_plan();
618
619        if ( $has_plan && self::claim_request_lock() ) {
620            $api_url  = Scan_Status::get_api_url();
621            $response = is_wp_error( $api_url )
622                ? $api_url
623                : Client::wpcom_json_api_request_as_blog( $api_url . '/enqueue', '2', array( 'method' => 'POST' ), null, 'wpcom' );
624            $error    = self::get_enqueue_error( $response );
625            if ( $error ) {
626                self::release_request_lock();
627                return $error;
628            }
629            Scan_Status::delete_option();
630            delete_transient( self::HISTORY_CACHE );
631
632            /**
633             * Fires after the Protect dashboard enqueues a scan on WordPress.com.
634             *
635             * @since 0.1.0
636             */
637            do_action( 'jetpack_protect_dashboard_scan_started' );
638        }
639
640        return self::get_scan_report( $has_plan, true );
641    }
642
643    /**
644     * Why WordPress.com didn't enqueue a scan, including a `success: false` it answers with a 200.
645     *
646     * @param array|WP_Error $response The enqueue response.
647     * @return WP_Error|null The error, with WordPress.com's reason when it gives one, or null once enqueued.
648     */
649    private static function get_enqueue_error( $response ) {
650        $body = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
651        if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) && false !== ( $body->success ?? null ) ) {
652            return null;
653        }
654
655        $reasons = (array) ( $body->error->errors ?? array() );
656        $reason  = $reasons ? (array) reset( $reasons ) : array();
657        $reason  = $reason[0] ?? $body->message ?? null;
658        $message = is_string( $reason ) && '' !== $reason
659            /* translators: %s is the reason WordPress.com gave, such as "Unable to find a valid site." */
660            ? sprintf( __( 'The scan couldn’t be started: %s', 'jetpack-protect-pkg' ), $reason )
661            : __( 'The scan couldn’t be started. Try again in a few minutes.', 'jetpack-protect-pkg' );
662
663        return new WP_Error( 'scan_not_started', $message, array( 'status' => 502 ) );
664    }
665
666    /**
667     * Claim the once-a-minute scan request slot before calling WordPress.com, so concurrent requests don't both enqueue.
668     *
669     * @return bool Whether this request got the slot.
670     */
671    private static function claim_request_lock() {
672        // `wp_cache_add()` is atomic with a persistent object cache; transients can only narrow the race.
673        if ( wp_using_ext_object_cache() ) {
674            return wp_cache_add( self::REQUEST_LOCK, time(), 'jetpack_protect', MINUTE_IN_SECONDS );
675        }
676        if ( get_transient( self::REQUEST_LOCK ) ) {
677            return false;
678        }
679        set_transient( self::REQUEST_LOCK, time(), MINUTE_IN_SECONDS );
680        return true;
681    }
682
683    /**
684     * Give the scan request slot back after a failed request, so the user can retry straight away.
685     *
686     * @return void
687     */
688    private static function release_request_lock() {
689        if ( wp_using_ext_object_cache() ) {
690            wp_cache_delete( self::REQUEST_LOCK, 'jetpack_protect' );
691        } else {
692            delete_transient( self::REQUEST_LOCK );
693        }
694    }
695
696    /**
697     * The latest vulnerability report: from Scan on a paid plan, otherwise Protect's free check.
698     *
699     * @param bool $has_plan Whether the site has a Scan plan.
700     * @param bool $refresh  Fetch the report from WordPress.com instead of the cache; the plan is never refreshed.
701     * @return array
702     */
703    private static function get_scan_report( $has_plan, $refresh = false ) {
704        $error = array(
705            'error'    => true,
706            'scanning' => false,
707        );
708        if ( ! class_exists( Status::class ) ) {
709            return $error;
710        }
711
712        if ( $refresh ) {
713            Status::$status = null;
714        }
715        $status = $has_plan ? Scan_Status::get_status( $refresh ) : Protect_Status::get_status( $refresh );
716        if ( ! empty( $status->error ) ) {
717            return $error;
718        }
719
720        if ( ! function_exists( 'get_plugins' ) ) {
721            require_once ABSPATH . 'wp-admin/includes/plugin.php';
722        }
723
724        $is_initializing = empty( $status->last_checked ) && in_array( $status->status, self::INITIALIZING_STATUSES, true );
725
726        return array(
727            'error'          => false,
728            'scanning'       => $is_initializing || in_array( $status->status, self::SCANNING_STATUSES, true ),
729            'lastChecked'    => $status->last_checked,
730            'progress'       => isset( $status->current_progress ) ? (int) $status->current_progress : null,
731            'pluginsChecked' => count( get_plugins() ),
732            'themesChecked'  => count( wp_get_themes() ),
733            'threats'        => Dashboard_Threats::format_all( $status->threats ),
734        );
735    }
736}