Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.74% covered (warning)
85.74%
457 / 533
43.75% covered (danger)
43.75%
21 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan
86.06% covered (warning)
86.06%
457 / 531
43.75% covered (danger)
43.75%
21 / 48
314.53
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
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
3.06
 needs_credentials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
121 / 121
100.00% covered (success)
100.00%
1 / 1
2
 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
 can_view_threat_files
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_act_on_threat_files
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 get_ignored_threats
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_threat_file
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 delete_threat_file
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 restore_threat_file
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
7.46
 resolve_file_threat
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_other_threats_in_file
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 history_unavailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_other_threat_in_file
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
5
 resolve_file_to_change
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 resolve_threat_file
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 get_stored_report
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 find_threat
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 locate_threat_file
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 get_site_roots
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_site_root
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_site_relative_path
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_history_threats
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 get_scan_history
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
7.17
 delete_software
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 get_threat_row
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 delete_threat_row
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
4.59
 find_threat_row
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
9
 get_deletable_option
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 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
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
8.07
 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
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
12.25
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\Protected_Options;
15use Automattic\Jetpack\Protect\Threat_File;
16use Automattic\Jetpack\Protect_Status\Protect_Status;
17use Automattic\Jetpack\Protect_Status\Scan_Status;
18use Automattic\Jetpack\Protect_Status\Status;
19use Automattic\Jetpack\Redirect;
20use Automattic\Jetpack\Status\Host;
21use Jetpack_Options;
22use WP_Error;
23use WP_REST_Request;
24use WP_REST_Server;
25
26if ( ! defined( 'ABSPATH' ) ) {
27    exit( 0 );
28}
29
30/**
31 * Vulnerability results, from Scan on a paid plan or Protect's free daily check, and on-demand scans.
32 *
33 * @since 0.1.0
34 */
35class Scan implements Dashboard_Section {
36
37    /**
38     * Scan statuses that mean a scan is still running, as the Protect plugin reads them.
39     *
40     * @var string[]
41     */
42    const SCANNING_STATUSES = array( 'provisioning', 'scheduled', 'scanning' );
43
44    /**
45     * How many copy variants the free-plan upsell tests.
46     *
47     * @var int
48     */
49    const UPSELL_VARIANTS = 8;
50
51    /**
52     * Statuses that mean the first scan has not run yet when there is no report date.
53     *
54     * @var string[]
55     */
56    const INITIALIZING_STATUSES = array( 'idle', 'unavailable' );
57
58    /**
59     * Lock held for a minute after a scan request, so repeated requests don't stack.
60     *
61     * @var string
62     */
63    const REQUEST_LOCK = 'jetpack_protect_dashboard_scan_requested';
64
65    /**
66     * Fixer statuses that mean a fix has finished, one way or the other.
67     *
68     * @var string[]
69     */
70    const FIX_DONE_STATUSES = array( 'fixed', 'not_fixed' );
71
72    /**
73     * Transient caching Scan history, which is slow to fetch; the ignored list and the History tab share it.
74     *
75     * @var string
76     */
77    const HISTORY_CACHE = 'jetpack_protect_dashboard_scan_history';
78
79    /**
80     * How many characters of each column a threat's database row shows.
81     *
82     * @var int
83     */
84    const ROW_VALUE_LIMIT = 2000;
85
86    /**
87     * The largest file the dashboard shows in full, in bytes.
88     *
89     * @var int
90     */
91    const MAX_FILE_BYTES = 512 * KB_IN_BYTES;
92
93    /**
94     * The section key.
95     *
96     * @return string
97     */
98    public function get_key() {
99        return 'scan';
100    }
101
102    /**
103     * The plan, where to set Scan's server credentials or buy Scan, and the latest report.
104     *
105     * @return array
106     */
107    public function get_state() {
108        $has_plan = Dashboard::has_scan_plan();
109        // Fetched first: has_credentials() reads the report this caches.
110        $report = self::get_scan_report( $has_plan );
111
112        if ( ! $has_plan ) {
113            $plan_state = array(
114                'url'                 => Dashboard::get_scan_upgrade_url(),
115                'upsellVariant'       => self::get_upsell_variant(),
116                'upsellVariantForced' => (bool) has_filter( 'jetpack_protect_dashboard_scan_upsell_variant' ),
117            );
118        } elseif ( self::needs_credentials() ) {
119            $plan_state = array(
120                'url'              => Redirect::get_url( 'jetpack-settings-security-credentials' ),
121                'needsCredentials' => true,
122                'hasCredentials'   => self::has_credentials(),
123            );
124        } else {
125            $plan_state = array(
126                'url'              => '',
127                'needsCredentials' => false,
128            );
129        }
130
131        return array_merge(
132            array(
133                'hasPlan'      => $has_plan,
134                'canIgnore'    => self::allows_ignoring(),
135                'canViewFiles' => self::can_view_threat_files(),
136            ),
137            $plan_state,
138            $report
139        );
140    }
141
142    /**
143     * Whether Scan needs server credentials to auto-fix threats; not on WoA, where WordPress.com auto-fixes without them.
144     *
145     * @return bool
146     */
147    private static function needs_credentials() {
148        return ! ( new Host() )->is_woa_site();
149    }
150
151    /**
152     * Whether Scan has the server credentials it needs to auto-fix threats, from the cached Scan report.
153     *
154     * @return bool|null Null when there's no report to say.
155     */
156    private static function has_credentials() {
157        $report = class_exists( Scan_Status::class ) ? Scan_Status::get_from_options() : null;
158        if ( ! is_object( $report ) || ! isset( $report->credentials ) || ! is_array( $report->credentials ) ) {
159            return null;
160        }
161        return ! empty( $report->credentials );
162    }
163
164    /**
165     * Where this site's upsell copy rotation starts, spread across sites by blog ID.
166     *
167     * @return int From 1 to UPSELL_VARIANTS.
168     */
169    public static function get_upsell_variant() {
170        $blog_id = (int) Jetpack_Options::get_option( 'id' );
171        $variant = $blog_id > 0 ? ( $blog_id % self::UPSELL_VARIANTS ) + 1 : 1;
172
173        /**
174         * Filters which free-plan Scan upsell copy the Protect dashboard shows; a filtered one isn't rotated.
175         *
176         * @since 0.3.0
177         *
178         * @param int $variant From 1 to 8.
179         */
180        $variant = (int) apply_filters( 'jetpack_protect_dashboard_scan_upsell_variant', $variant );
181
182        return $variant >= 1 && $variant <= self::UPSELL_VARIANTS ? $variant : 1;
183    }
184
185    /**
186     * Register `jetpack/v4/protect-dashboard/scan`.
187     *
188     * @return void
189     */
190    public function register_routes() {
191        register_rest_route(
192            'jetpack/v4',
193            '/protect-dashboard/scan',
194            array(
195                array(
196                    'methods'             => WP_REST_Server::READABLE,
197                    'callback'            => array( __CLASS__, 'get_scan' ),
198                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
199                ),
200                array(
201                    'methods'             => WP_REST_Server::CREATABLE,
202                    'callback'            => array( __CLASS__, 'start_scan' ),
203                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
204                ),
205            )
206        );
207
208        register_rest_route(
209            'jetpack/v4',
210            '/protect-dashboard/scan/threats/(?P<id>\d+)/ignore',
211            array(
212                'methods'             => WP_REST_Server::CREATABLE,
213                'callback'            => array( __CLASS__, 'ignore_threat' ),
214                'permission_callback' => array( __CLASS__, 'can_ignore_threats' ),
215            )
216        );
217
218        register_rest_route(
219            'jetpack/v4',
220            '/protect-dashboard/scan/threats/(?P<id>\d+)/unignore',
221            array(
222                'methods'             => WP_REST_Server::CREATABLE,
223                'callback'            => array( __CLASS__, 'unignore_threat' ),
224                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
225            )
226        );
227
228        register_rest_route(
229            'jetpack/v4',
230            '/protect-dashboard/scan/threats/(?P<id>\d+)/file',
231            array(
232                'methods'             => WP_REST_Server::READABLE,
233                'callback'            => array( __CLASS__, 'get_threat_file' ),
234                'permission_callback' => array( __CLASS__, 'can_act_on_threat_files' ),
235            )
236        );
237
238        foreach ( array( 'delete', 'restore' ) as $action ) {
239            register_rest_route(
240                'jetpack/v4',
241                "/protect-dashboard/scan/threats/(?P<id>\\d+)/file/$action",
242                array(
243                    'methods'             => WP_REST_Server::CREATABLE,
244                    'callback'            => array( __CLASS__, "{$action}_threat_file" ),
245                    'permission_callback' => array( __CLASS__, 'can_act_on_threat_files' ),
246                    'args'                => array(
247                        'hash' => array(
248                            'description' => __( 'The SHA-256 hash of the file as the viewer showed it.', 'jetpack-protect-pkg' ),
249                            'type'        => 'string',
250                            'required'    => true,
251                        ),
252                    ),
253                )
254            );
255        }
256
257        register_rest_route(
258            'jetpack/v4',
259            '/protect-dashboard/scan/ignored',
260            array(
261                'methods'             => WP_REST_Server::READABLE,
262                'callback'            => array( __CLASS__, 'get_ignored_threats' ),
263                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
264            )
265        );
266
267        register_rest_route(
268            'jetpack/v4',
269            '/protect-dashboard/scan/software/delete',
270            array(
271                'methods'             => WP_REST_Server::CREATABLE,
272                'callback'            => array( __CLASS__, 'delete_software' ),
273                'permission_callback' => array( Dashboard::class, 'can_manage' ),
274                'args'                => array(
275                    'type' => array(
276                        'type'     => 'string',
277                        'enum'     => array( 'plugins', 'themes' ),
278                        'required' => true,
279                    ),
280                    'slug' => array(
281                        'type'     => 'string',
282                        'required' => true,
283                    ),
284                ),
285            )
286        );
287
288        register_rest_route(
289            'jetpack/v4',
290            '/protect-dashboard/scan/threats/(?P<id>\d+)/row',
291            array(
292                array(
293                    'methods'             => WP_REST_Server::READABLE,
294                    'callback'            => array( __CLASS__, 'get_threat_row' ),
295                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
296                ),
297                array(
298                    'methods'             => WP_REST_Server::DELETABLE,
299                    'callback'            => array( __CLASS__, 'delete_threat_row' ),
300                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
301                ),
302            )
303        );
304
305        register_rest_route(
306            'jetpack/v4',
307            '/protect-dashboard/scan/threats/(?P<id>\d+)/fix',
308            array(
309                array(
310                    'methods'             => WP_REST_Server::READABLE,
311                    'callback'            => array( __CLASS__, 'get_fix_status' ),
312                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
313                ),
314                array(
315                    'methods'             => WP_REST_Server::CREATABLE,
316                    'callback'            => array( __CLASS__, 'fix_threat' ),
317                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
318                ),
319            )
320        );
321    }
322
323    /**
324     * Whether the host lets site owners ignore threats.
325     *
326     * @since 0.4.0
327     *
328     * @return bool
329     */
330    public static function allows_ignoring() {
331        /**
332         * Filters whether site owners can ignore threats; they can still unignore them. Hosts that fix threats themselves can turn this off.
333         *
334         * @since 0.4.0
335         *
336         * @param bool $allowed Whether threats can be ignored. Default true.
337         */
338        return (bool) apply_filters( 'jetpack_protect_threat_ignoring_enabled', true );
339    }
340
341    /**
342     * Ignoring needs everything acting on a threat does, and a host that allows it.
343     *
344     * @return bool|WP_Error
345     */
346    public static function can_ignore_threats() {
347        $allowed = self::can_act_on_threats();
348        if ( true !== $allowed || self::allows_ignoring() ) {
349            return $allowed;
350        }
351        return new WP_Error( 'threat_ignoring_disabled', __( 'Threats can’t be ignored on this site.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
352    }
353
354    /**
355     * Only Scan can fix or ignore threats; the free vulnerability check can't.
356     *
357     * @return bool|WP_Error
358     */
359    public static function can_act_on_threats() {
360        if ( ! Dashboard::can_manage() ) {
361            return false;
362        }
363        if ( ! Dashboard::has_scan_plan() ) {
364            return new WP_Error( 'scan_plan_required', __( 'Fixing and ignoring threats needs Jetpack Scan.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
365        }
366        return true;
367    }
368
369    /**
370     * Whether the user may see and edit the site's code, as WordPress's plugin file editor requires.
371     *
372     * Core refuses `edit_plugins` under DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS, and on multisite to all but network admins.
373     *
374     * @return bool
375     */
376    public static function can_view_threat_files() {
377        return current_user_can( 'edit_plugins' );
378    }
379
380    /**
381     * Whether the user may view, delete or restore a threat's file.
382     *
383     * @return bool|WP_Error
384     */
385    public static function can_act_on_threat_files() {
386        if ( ! self::can_view_threat_files() ) {
387            return false;
388        }
389        return self::can_act_on_threats();
390    }
391
392    /**
393     * Ignore a threat; the client moves it to its ignored list.
394     *
395     * @param WP_REST_Request $request The request, with the threat `id`.
396     * @return array|WP_Error
397     */
398    public static function ignore_threat( WP_REST_Request $request ) {
399        return self::set_ignored( (int) $request['id'], true );
400    }
401
402    /**
403     * Report an ignored threat again; the client moves it back to its active list.
404     *
405     * @param WP_REST_Request $request The request, with the threat `id`.
406     * @return array|WP_Error
407     */
408    public static function unignore_threat( WP_REST_Request $request ) {
409        return self::set_ignored( (int) $request['id'], false );
410    }
411
412    /**
413     * Ignore or unignore a threat, and drop the caches that list it.
414     *
415     * @param int  $id     The threat id.
416     * @param bool $ignore True to ignore, false to unignore.
417     * @return array|WP_Error
418     */
419    private static function set_ignored( $id, $ignore ) {
420        $response = self::alerts_request( '/' . $id, 'POST', array( $ignore ? 'ignore' : 'unignore' => true ) );
421
422        // A request left unanswered, such as one that timed out, may still have gone through on WordPress.com.
423        if ( ! is_wp_error( $response ) || 'alerts_request_unanswered' === $response->get_error_code() ) {
424            Scan_Status::delete_option();
425            delete_transient( self::HISTORY_CACHE );
426        }
427        if ( is_wp_error( $response ) ) {
428            return $ignore
429                ? new WP_Error( 'threat_not_ignored', __( 'The threat couldn’t be ignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) )
430                : new WP_Error( 'threat_not_unignored', __( 'The threat couldn’t be unignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
431        }
432
433        return array( 'ok' => true );
434    }
435
436    /**
437     * Threats the site has ignored, from Scan history.
438     *
439     * @return array|WP_Error
440     */
441    public static function get_ignored_threats() {
442        $history = self::get_history_threats();
443        if ( is_wp_error( $history ) ) {
444            return new WP_Error( 'ignored_unavailable', __( 'Ignored threats are unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
445        }
446        return array_values(
447            array_filter(
448                $history,
449                function ( $threat ) {
450                    return 'ignored' === $threat['status'];
451                }
452            )
453        );
454    }
455
456    /**
457     * The whole file a threat was found in, looked up by threat so the client can't name a path.
458     *
459     * @param WP_REST_Request $request The request, with the threat `id`.
460     * @return array|WP_Error The file's site-relative `path`, `modified` time, `owner`, what can be done with it, whether the site `loaded` it, and its `contents` and their `hash`.
461     */
462    public static function get_threat_file( WP_REST_Request $request ) {
463        $path = self::resolve_threat_file( self::find_threat( (int) $request['id'] ) );
464        if ( is_wp_error( $path ) ) {
465            return $path;
466        }
467        if ( filesize( $path ) > self::MAX_FILE_BYTES ) {
468            return new WP_Error( 'threat_file_too_large', __( 'This file is too large to show here.', 'jetpack-protect-pkg' ), array( 'status' => 413 ) );
469        }
470
471        $contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local file.
472        if ( false === $contents ) {
473            return new WP_Error( 'threat_file_unreadable', __( 'This file couldn’t be read.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
474        }
475        if ( false !== strpos( $contents, "\0" ) ) {
476            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 ) );
477        }
478
479        $modified = filemtime( $path );
480        $owner    = Threat_File::get_owner( $path );
481        return array(
482            'path'       => self::get_site_relative_path( $path ),
483            'modified'   => $modified ? gmdate( 'c', $modified ) : null,
484            'owner'      => $owner,
485            'inUploads'  => Threat_File::is_code_in_uploads( $path ),
486            'canDelete'  => Threat_File::can_delete( $path, $owner ),
487            'canRestore' => Threat_File::can_restore( $path, $owner ),
488            'loaded'     => Threat_File::is_loaded_stray( $path, $owner ),
489            'hash'       => hash( 'sha256', $contents ),
490            // JSON can't carry invalid UTF-8, which obfuscated code often has.
491            'contents'   => wp_scrub_utf8( $contents ),
492        );
493    }
494
495    /**
496     * Delete a stray file a threat was found in, and mark the threat fixed.
497     *
498     * @param WP_REST_Request $request The request, with the threat `id`.
499     * @return array|WP_Error The fixed `threat`.
500     */
501    public static function delete_threat_file( WP_REST_Request $request ) {
502        $threat = self::find_threat( (int) $request['id'] );
503        // Before the hash check, since it can wait on WordPress.com.
504        $siblings = self::get_other_threats_in_file( $threat );
505        $path     = self::resolve_file_to_change( $threat, (string) $request['hash'] );
506        if ( is_wp_error( $path ) ) {
507            return $path;
508        }
509        if ( is_wp_error( $siblings ) ) {
510            return $siblings;
511        }
512        if ( ! Threat_File::can_delete( $path, Threat_File::get_owner( $path ) ) ) {
513            return new WP_Error( 'threat_file_not_deletable', __( 'This file belongs to WordPress, a plugin or a theme, or WordPress is using it, so it can’t be deleted here.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
514        }
515
516        $deleted = Threat_File::delete( $path );
517        if ( is_wp_error( $deleted ) ) {
518            return $deleted;
519        }
520        return self::resolve_file_threat( $threat, $siblings );
521    }
522
523    /**
524     * Put a modified WordPress core file back as WordPress.org ships it, and mark the threat fixed.
525     *
526     * @param WP_REST_Request $request The request, with the threat `id`.
527     * @return array|WP_Error The fixed `threat`.
528     */
529    public static function restore_threat_file( WP_REST_Request $request ) {
530        $threat   = self::find_threat( (int) $request['id'] );
531        $siblings = self::get_other_threats_in_file( $threat );
532        $path     = self::resolve_file_to_change( $threat, (string) $request['hash'] );
533        if ( is_wp_error( $path ) ) {
534            return $path;
535        }
536        if ( is_wp_error( $siblings ) ) {
537            return $siblings;
538        }
539        if ( ! Threat_File::can_restore( $path, Threat_File::get_owner( $path ) ) ) {
540            return new WP_Error( 'threat_file_not_restorable', __( 'Only a changed WordPress core file can be restored.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
541        }
542        $restored = Threat_File::restore( $path );
543        if ( is_wp_error( $restored ) ) {
544            return $restored;
545        }
546        return self::resolve_file_threat( $threat, $siblings );
547    }
548
549    /**
550     * Mark a threat, and the others in its file, fixed now the file was deleted or restored, since Scan only notices on its next scan.
551     *
552     * @param object|array $threat   The threat, from find_threat().
553     * @param array        $siblings The file's other open threats, from get_other_threats_in_file().
554     * @return array The fixed `threat`, and the ids of the `others` fixed with it.
555     */
556    private static function resolve_file_threat( $threat, $siblings = array() ) {
557        $others = array();
558        foreach ( $siblings as $sibling ) {
559            $others[] = Dashboard_Threats::resolve( $sibling )['id'];
560        }
561        return array(
562            'threat' => Dashboard_Threats::resolve( is_array( $threat ) ? $threat : Dashboard_Threats::format( $threat ) ),
563            'others' => $others,
564        );
565    }
566
567    /**
568     * The other open or ignored threats Scan found in the same file as a threat.
569     *
570     * @param object|array|WP_Error|null $threat The threat acted on, from find_threat().
571     * @return array[]|WP_Error The threats, formatted; an error when history is unavailable, so ignored ones aren't left behind.
572     */
573    private static function get_other_threats_in_file( $threat ) {
574        $path = self::resolve_threat_file( $threat );
575        if ( is_wp_error( $path ) ) {
576            return array();
577        }
578        $id     = (string) ( is_array( $threat ) ? $threat['id'] : $threat->id );
579        $status = self::get_stored_report();
580        $others = array();
581        foreach ( Dashboard_Threats::without_resolved( $status->threats ?? array(), Dashboard_Threats::get_scanned_at( $status ) ) as $current ) {
582            if ( self::is_other_threat_in_file( $current->id ?? null, $current->filename ?? null, $id, $path, $others ) ) {
583                $others[ (string) $current->id ] = Dashboard_Threats::format( $current );
584            }
585        }
586        $history = self::get_history_threats();
587        if ( ! is_array( $history ) ) {
588            return self::history_unavailable();
589        }
590        foreach ( $history as $past ) {
591            if ( 'ignored' === ( $past['status'] ?? null ) && self::is_other_threat_in_file( $past['id'] ?? null, $past['filename'] ?? null, $id, $path, $others ) ) {
592                $others[ (string) $past['id'] ] = $past;
593            }
594        }
595        return array_values( $others );
596    }
597
598    /**
599     * The error for a request that needs Scan history when WordPress.com can't be reached.
600     *
601     * @return WP_Error
602     */
603    private static function history_unavailable() {
604        return new WP_Error( 'threat_history_unavailable', __( 'Jetpack couldn’t look this threat up on WordPress.com. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
605    }
606
607    /**
608     * Whether a threat is another one, not yet collected, found in the file at a path.
609     *
610     * @param mixed       $id       The threat's id.
611     * @param string|null $filename The path as Scan names it.
612     * @param string      $acted_on The id of the threat acted on.
613     * @param string      $path     The file's real path.
614     * @param array       $found    The threats collected so far, keyed by id.
615     * @return bool
616     */
617    private static function is_other_threat_in_file( $id, $filename, $acted_on, $path, $found ) {
618        return null !== $id && (string) $id !== $acted_on && ! isset( $found[ (string) $id ] ) && $filename && self::locate_threat_file( $filename ) === $path;
619    }
620
621    /**
622     * The file to delete or restore, refused once the threat is fixed or the file changed since the viewer showed it.
623     *
624     * @param object|array|WP_Error|null $threat The threat, from find_threat().
625     * @param string                     $hash   The SHA-256 hash of the contents shown.
626     * @return string|WP_Error The file's real path.
627     */
628    private static function resolve_file_to_change( $threat, $hash ) {
629        // History lists fixed threats too, whose file may be a clean one by now.
630        if ( is_array( $threat ) && 'ignored' !== ( $threat['status'] ?? null ) ) {
631            return new WP_Error( 'threat_not_open', __( 'This threat is already fixed.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) );
632        }
633        $path = self::resolve_threat_file( $threat );
634        if ( is_wp_error( $path ) ) {
635            return $path;
636        }
637        if ( ! hash_equals( (string) hash_file( 'sha256', $path ), $hash ) ) {
638            return new WP_Error( 'threat_file_changed', __( 'This file changed since you opened it, so nothing was done. Open it again to see what’s in it now.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) );
639        }
640        return $path;
641    }
642
643    /**
644     * The real path of the file a threat was found in.
645     *
646     * @param object|array|WP_Error|null $threat The threat, from find_threat().
647     * @return string|WP_Error
648     */
649    private static function resolve_threat_file( $threat ) {
650        if ( is_wp_error( $threat ) ) {
651            return $threat;
652        }
653        $filename = is_array( $threat ) ? $threat['filename'] ?? null : $threat->filename ?? null;
654        $path     = $filename ? self::locate_threat_file( $filename ) : null;
655        return $path ? $path : new WP_Error( 'threat_file_not_found', __( 'This file couldn’t be found on your site. It may have been moved or deleted since the scan.', 'jetpack-protect-pkg' ), array( 'status' => 404 ) );
656    }
657
658    /**
659     * Scan's report as stored on the site, which is enough to map a threat to its file and skips a WordPress.com request.
660     *
661     * @return object|null
662     */
663    private static function get_stored_report() {
664        if ( ! class_exists( Status::class ) ) {
665            return null;
666        }
667        $status = method_exists( Scan_Status::class, 'get_cached_status' ) ? Scan_Status::get_cached_status() : null;
668        return $status ?? Scan_Status::get_status();
669    }
670
671    /**
672     * A threat from the current report or, for an ignored threat, history.
673     *
674     * @param int $id The threat id.
675     * @return object|array|WP_Error|null Scan's threat from the report, a formatted one from history, or null; an error when history is unavailable.
676     */
677    private static function find_threat( $id ) {
678        $status = self::get_stored_report();
679        // One the site resolved is still in the report until Scan's next scan, but history has it as fixed.
680        $resolved = Dashboard_Threats::get_resolved( Dashboard_Threats::get_scanned_at( $status ) );
681        foreach ( (array) ( $status->threats ?? array() ) as $threat ) {
682            if ( (int) ( $threat->id ?? 0 ) === $id && ! isset( $resolved[ (string) $id ] ) ) {
683                return $threat;
684            }
685        }
686
687        // The site's own record of one it fixed, which history only adds once WordPress.com answers.
688        if ( isset( $resolved[ (string) $id ] ) ) {
689            return $resolved[ (string) $id ];
690        }
691        $history = self::get_history_threats();
692        if ( ! is_array( $history ) ) {
693            return self::history_unavailable();
694        }
695        foreach ( $history as $threat ) {
696            if ( (int) $threat['id'] === $id ) {
697                return $threat;
698            }
699        }
700        return null;
701    }
702
703    /**
704     * Where a file Scan reported is on this server, since Scan names it from its own root, such as `/htdocs/wp-content/…`.
705     *
706     * @param string $filename The path as Scan names it.
707     * @return string|null The file's real path, only ever inside the site's directories.
708     */
709    private static function locate_threat_file( $filename ) {
710        $parts = array_values( array_filter( explode( '/', wp_normalize_path( $filename ) ), 'strlen' ) );
711        if ( ! $parts || array_intersect( $parts, array( '.', '..' ) ) ) {
712            return null;
713        }
714        $roots = array_merge( self::get_site_roots(), array( untrailingslashit( wp_normalize_path( ABSPATH ) ), wp_normalize_path( dirname( WP_CONTENT_DIR ) ) ) );
715
716        // Strip only a prefix the site's root ends with, such as `/htdocs` for `/srv/htdocs`, so a missing or nested file can't map to a same-named one elsewhere.
717        foreach ( array_unique( $roots ) as $root ) {
718            $root_parts = array_values( array_filter( explode( '/', $root ), 'strlen' ) );
719            $real_root  = realpath( $root );
720            for ( $i = min( count( $parts ) - 1, count( $root_parts ) ); $real_root && $i >= 0; $i-- ) {
721                if ( array_slice( $parts, 0, $i ) !== array_slice( $root_parts, count( $root_parts ) - $i ) ) {
722                    continue;
723                }
724                $rest = implode( '/', array_slice( $parts, $i ) );
725                $path = realpath( "$root/$rest" );
726                // A symlink below the root, such as a linked plugin folder, would hide what the file belongs to.
727                if ( $path && wp_normalize_path( $path ) === wp_normalize_path( "$real_root/$rest" ) && is_file( $path ) && self::get_site_root( $path ) ) {
728                    return $path;
729                }
730            }
731        }
732        return null;
733    }
734
735    /**
736     * The site's root and the WordPress directory, resolved, which may be the same directory.
737     *
738     * @return string[]
739     */
740    private static function get_site_roots() {
741        return array_values( array_unique( array_filter( array( realpath( dirname( WP_CONTENT_DIR ) ), realpath( ABSPATH ) ) ) ) );
742    }
743
744    /**
745     * Which of the site's directories a file is in, also allowing a content directory kept outside them.
746     *
747     * @param string $path A real path.
748     * @return string|null The directory, or null when the file is outside the site.
749     */
750    private static function get_site_root( $path ) {
751        foreach ( array_merge( self::get_site_roots(), array( WP_CONTENT_DIR ) ) as $root ) {
752            if ( null !== Threat_File::get_relative( $path, $root ) ) {
753                return $root;
754            }
755        }
756        return null;
757    }
758
759    /**
760     * A file's path from the site's root, such as `wp-content/uploads/evil.php`.
761     *
762     * @param string $path The file's real path, inside the site.
763     * @return string
764     */
765    private static function get_site_relative_path( $path ) {
766        $root = self::get_site_root( $path );
767        return $root ? Threat_File::get_relative( $path, $root ) : basename( $path );
768    }
769
770    /**
771     * Every threat Scan has fixed or ignored, and those the site resolved itself.
772     *
773     * @return array|WP_Error
774     */
775    public static function get_history_threats() {
776        $threats = self::get_scan_history();
777        if ( is_wp_error( $threats ) ) {
778            return $threats;
779        }
780
781        // Threats the site resolved itself win over Scan's entry for them until its next scan.
782        // Fetched when the cache is empty, as it is right after an ignore, so snapshots older than the last scan don't count.
783        $status   = class_exists( Status::class ) ? ( Status::get_cached_status() ?? Status::get_status() ) : null;
784        $resolved = Dashboard_Threats::get_resolved( Dashboard_Threats::get_scanned_at( $status ) );
785        $threats  = array_filter(
786            $threats,
787            function ( $threat ) use ( $resolved ) {
788                return ! isset( $resolved[ (string) $threat['id'] ] );
789            }
790        );
791        return array_merge( array_values( $resolved ), array_values( $threats ) );
792    }
793
794    /**
795     * Scan's own history, cached for five minutes.
796     *
797     * @return array|WP_Error
798     */
799    private static function get_scan_history() {
800        $cached = get_transient( self::HISTORY_CACHE );
801        if ( is_array( $cached ) ) {
802            return $cached;
803        }
804
805        $api_url  = Scan_Status::get_api_url();
806        $response = is_wp_error( $api_url ) ? $api_url : Client::wpcom_json_api_request_as_blog(
807            $api_url . '/history',
808            '2',
809            array(
810                'method'  => 'GET',
811                'timeout' => 30,
812            ),
813            null,
814            'wpcom'
815        );
816        $body     = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
817        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) || ! is_object( $body ) ) {
818            return new WP_Error( 'history_unavailable', __( 'Scan history is unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
819        }
820
821        $threats = Dashboard_Threats::format_all( (array) ( $body->threats ?? array() ) );
822        set_transient( self::HISTORY_CACHE, $threats, 5 * MINUTE_IN_SECONDS );
823        return $threats;
824    }
825
826    /**
827     * Delete an inactive plugin or a theme the site doesn't use, so its threats go with it.
828     *
829     * @param WP_REST_Request $request The request, with the extension `type` and `slug`.
830     * @return array|WP_Error
831     */
832    public static function delete_software( WP_REST_Request $request ) {
833        $type = $request['type'];
834        $slug = $request['slug'];
835
836        require_once ABSPATH . 'wp-admin/includes/file.php';
837        require_once ABSPATH . 'wp-admin/includes/plugin.php';
838        require_once ABSPATH . 'wp-admin/includes/theme.php';
839
840        // Looked up by key, so only the slug of installed software matches and anything else is refused.
841        $file  = 'plugins' === $type ? ( Dashboard_Threats::get_plugin_files( get_plugins() )[ $slug ] ?? null ) : null;
842        $theme = 'themes' === $type ? ( wp_get_themes( array( 'errors' => null ) )[ $slug ] ?? null ) : null;
843        if ( ! Dashboard_Threats::can_delete( $type, $file, $theme ) ) {
844            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 ) );
845        }
846
847        $deleted = null;
848        if ( self::can_write_files() ) {
849            $deleted = 'plugins' === $type ? delete_plugins( array( $file ) ) : delete_theme( $theme->get_stylesheet() );
850        }
851        if ( true !== $deleted ) {
852            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 ) );
853        }
854
855        delete_transient( self::HISTORY_CACHE );
856        if ( class_exists( Status::class ) ) {
857            Scan_Status::delete_option();
858            Protect_Status::delete_option();
859        }
860        return array( 'ok' => true );
861    }
862
863    /**
864     * The database row a current threat was found in, with long values cut short.
865     *
866     * @param WP_REST_Request $request The request, with the threat `id`.
867     * @return array|WP_Error The row's `columns`, and whether `canDelete` it here.
868     */
869    public static function get_threat_row( WP_REST_Request $request ) {
870        $found = self::find_threat_row( $request['id'] );
871        if ( is_wp_error( $found ) ) {
872            return $found;
873        }
874
875        $columns = array();
876        foreach ( $found['row'] as $column => $value ) {
877            $columns[ $column ] = is_string( $value ) && mb_strlen( $value ) > self::ROW_VALUE_LIMIT ? mb_substr( $value, 0, self::ROW_VALUE_LIMIT ) . '…' : $value;
878        }
879        return array(
880            'columns'   => (object) $columns,
881            'canDelete' => null !== self::get_deletable_option( $found ),
882        );
883    }
884
885    /**
886     * Delete the option a current threat was found in, and mark the threat fixed. Nothing keeps a copy of the option.
887     *
888     * @param WP_REST_Request $request The request, with the threat `id`.
889     * @return array|WP_Error The fixed `threat`.
890     */
891    public static function delete_threat_row( WP_REST_Request $request ) {
892        $found = self::find_threat_row( $request['id'] );
893        if ( is_wp_error( $found ) ) {
894            return $found;
895        }
896
897        $name = self::get_deletable_option( $found );
898        if ( null === $name ) {
899            return new WP_Error( 'threat_row_not_deletable', __( 'This row can’t be deleted here. Remove it with a database tool, or contact Jetpack support for help.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
900        }
901        if ( ! delete_option( $name ) ) {
902            return new WP_Error( 'threat_row_not_deleted', __( 'WordPress couldn’t delete the option. Try again, or contact Jetpack support for help.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) );
903        }
904
905        return array( 'threat' => Dashboard_Threats::resolve( Dashboard_Threats::format( $found['threat'] ) ) );
906    }
907
908    /**
909     * Read a current threat's database row, found through the threat so only rows Scan flagged can be reached.
910     *
911     * @param int|string $id The threat id.
912     * @return array|WP_Error The `threat`, and its row's `table`, `pk_column` and `row`.
913     */
914    private static function find_threat_row( $id ) {
915        global $wpdb;
916
917        $threat = null;
918        if ( class_exists( Status::class ) ) {
919            foreach ( (array) ( Scan_Status::get_status()->threats ?? array() ) as $item ) {
920                if ( (string) ( $item->id ?? '' ) === (string) $id ) {
921                    $threat = $item;
922                    break;
923                }
924            }
925        }
926
927        $details = $threat->details ?? null;
928        $table   = $details->table ?? null;
929        $column  = $details->pk_column ?? null;
930        // This site's own tables only: on multisite, the shared users tables hold other sites' password hashes.
931        if ( ! is_string( $table ) || ! is_string( $column ) || ! isset( $details->pk_value ) || ! in_array( $table, $wpdb->tables( 'blog' ), true ) ) {
932            return new WP_Error( 'threat_row_not_found', __( 'This threat isn’t in a database row Jetpack can read.', 'jetpack-protect-pkg' ), array( 'status' => 404 ) );
933        }
934
935        // A key column the table lacks is a query error, which must not print into the REST response.
936        $suppressed = $wpdb->suppress_errors();
937        $row        = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE %i = %s', $table, $column, $details->pk_value ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
938        $wpdb->suppress_errors( $suppressed );
939        if ( ! $row ) {
940            return new WP_Error( 'threat_row_gone', __( 'This row is no longer in the database. Run a scan to update the list.', 'jetpack-protect-pkg' ), array( 'status' => 404 ) );
941        }
942        return array(
943            'threat'    => $threat,
944            'table'     => $table,
945            'pk_column' => $column,
946            'row'       => $row,
947        );
948    }
949
950    /**
951     * The option a threat's row holds, when the dashboard may delete it.
952     *
953     * @param array $found From find_threat_row().
954     * @return string|null The option name, or null for other tables, for WordPress core's and Jetpack's options, and for a row that isn't the one Scan flagged.
955     */
956    private static function get_deletable_option( $found ) {
957        global $wpdb;
958
959        $name = $found['row']['option_name'] ?? null;
960        if ( $wpdb->options !== $found['table'] || 'option_id' !== $found['pk_column'] || ! is_string( $name ) ) {
961            return null;
962        }
963        // delete_option() trims the name, so " siteurl" would delete siteurl instead of the flagged row.
964        if ( trim( $name ) !== $name || Protected_Options::is_protected( $name ) ) {
965            return null;
966        }
967        // The key may now hold another option, as after a database import.
968        $flagged = $found['threat']->details->details->option_name ?? null;
969        if ( null !== $flagged && $flagged !== $name ) {
970            return null;
971        }
972        return $name;
973    }
974
975    /**
976     * Whether WordPress can delete files without asking for filesystem credentials.
977     *
978     * Core's delete functions print the credentials form and exit when it can't, which a REST request can't show.
979     *
980     * @return bool
981     */
982    private static function can_write_files() {
983        if ( 'direct' === get_filesystem_method() ) {
984            return true;
985        }
986        ob_start();
987        $has_credentials = request_filesystem_credentials( self_admin_url() );
988        ob_end_clean();
989        return (bool) $has_credentials;
990    }
991
992    /**
993     * Ask Scan to fix a threat.
994     *
995     * @param WP_REST_Request $request The request, with the threat `id`.
996     * @return array|WP_Error The threat's fixer status.
997     */
998    public static function fix_threat( WP_REST_Request $request ) {
999        $id       = (int) $request['id'];
1000        $response = self::alerts_request( '/fix', 'POST', array( 'threat_ids' => array( $id ) ) );
1001        if ( is_wp_error( $response ) ) {
1002            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 ) );
1003        }
1004
1005        Scan_Status::delete_option();
1006        $status = self::get_threat_fix_status( $response, $id );
1007        if ( 'fixed' === $status['status'] ) {
1008            delete_transient( self::HISTORY_CACHE );
1009        }
1010        return $status;
1011    }
1012
1013    /**
1014     * How a fix is going, with the refreshed report once it has finished.
1015     *
1016     * @param WP_REST_Request $request The request, with the threat `id`.
1017     * @return array|WP_Error
1018     */
1019    public static function get_fix_status( WP_REST_Request $request ) {
1020        $id       = (int) $request['id'];
1021        $response = self::alerts_request( add_query_arg( 'threat_ids', array( $id ), '/fix' ), 'GET' );
1022        if ( is_wp_error( $response ) ) {
1023            return new WP_Error( 'fix_status_unavailable', __( 'We couldn’t check on the fix.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
1024        }
1025
1026        $status = self::get_threat_fix_status( $response, $id );
1027        if ( in_array( $status['status'], self::FIX_DONE_STATUSES, true ) ) {
1028            // A fixed threat moves into Scan history.
1029            if ( 'fixed' === $status['status'] ) {
1030                delete_transient( self::HISTORY_CACHE );
1031            }
1032            $status['scan'] = self::get_scan_report( true, true );
1033        }
1034        return $status;
1035    }
1036
1037    /**
1038     * One threat's status from a WordPress.com fixer response.
1039     *
1040     * @param object $response The decoded response, with fixer statuses under `threats`, keyed by threat id.
1041     * @param int    $id       The threat id.
1042     * @return array The `status`, such as `in_progress` or `fixed`, and any `error`.
1043     */
1044    private static function get_threat_fix_status( $response, $id ) {
1045        $threat = $response->threats->{ (string) $id } ?? null;
1046        return array(
1047            'status' => $threat->status ?? ( empty( $threat->error ) ? 'in_progress' : 'not_fixed' ),
1048            'error'  => $threat->error ?? null,
1049        );
1050    }
1051
1052    /**
1053     * Call the site's WordPress.com alerts API as the current user, as the Protect plugin does.
1054     *
1055     * @param string     $path   Path after `/sites/{id}/alerts`.
1056     * @param string     $method HTTP method.
1057     * @param array|null $body   Body to send as JSON.
1058     * @return object|WP_Error The decoded response.
1059     */
1060    private static function alerts_request( $path, $method, $body = null ) {
1061        $blog_id = (int) Jetpack_Options::get_option( 'id' );
1062        if ( ! $blog_id ) {
1063            return new WP_Error( 'site_not_connected' );
1064        }
1065
1066        $response = Client::wpcom_json_api_request_as_user(
1067            sprintf( '/sites/%d/alerts%s', $blog_id, $path ),
1068            '2',
1069            // WordPress.com answers only once VaultPress has, which can take longer than the 10 second default.
1070            array(
1071                'method'  => $method,
1072                'timeout' => 30,
1073            ),
1074            null === $body ? null : wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
1075            'wpcom'
1076        );
1077        if ( is_wp_error( $response ) && 'http_request_failed' === $response->get_error_code() ) {
1078            return new WP_Error( 'alerts_request_unanswered' );
1079        }
1080        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
1081            return new WP_Error( 'alerts_request_failed' );
1082        }
1083
1084        // Ignoring answers with no useful body; only the status code matters there.
1085        $decoded = json_decode( wp_remote_retrieve_body( $response ) );
1086        return is_object( $decoded ) ? $decoded : new \stdClass();
1087    }
1088
1089    /**
1090     * The latest report, fresh from WordPress.com, for polling while a scan runs.
1091     *
1092     * @return array
1093     */
1094    public static function get_scan() {
1095        return self::get_scan_report( Dashboard::has_scan_plan(), true );
1096    }
1097
1098    /**
1099     * Start a scan: enqueue one with a Scan plan, otherwise ask for the latest vulnerability report.
1100     *
1101     * @return array|WP_Error
1102     */
1103    public static function start_scan() {
1104        $has_plan = Dashboard::has_scan_plan();
1105
1106        if ( $has_plan && self::claim_request_lock() ) {
1107            $api_url  = Scan_Status::get_api_url();
1108            $response = is_wp_error( $api_url )
1109                ? $api_url
1110                : Client::wpcom_json_api_request_as_blog( $api_url . '/enqueue', '2', array( 'method' => 'POST' ), null, 'wpcom' );
1111            $error    = self::get_enqueue_error( $response );
1112            if ( $error ) {
1113                self::release_request_lock();
1114                return $error;
1115            }
1116            Scan_Status::delete_option();
1117            delete_transient( self::HISTORY_CACHE );
1118
1119            /**
1120             * Fires after the Protect dashboard enqueues a scan on WordPress.com.
1121             *
1122             * @since 0.1.0
1123             */
1124            do_action( 'jetpack_protect_dashboard_scan_started' );
1125        }
1126
1127        return self::get_scan_report( $has_plan, true );
1128    }
1129
1130    /**
1131     * Why WordPress.com didn't enqueue a scan, including a `success: false` it answers with a 200.
1132     *
1133     * @param array|WP_Error $response The enqueue response.
1134     * @return WP_Error|null The error, with WordPress.com's reason when it gives one, or null once enqueued.
1135     */
1136    private static function get_enqueue_error( $response ) {
1137        $body = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
1138        if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) && false !== ( $body->success ?? null ) ) {
1139            return null;
1140        }
1141
1142        $reasons = (array) ( $body->error->errors ?? array() );
1143        $reason  = $reasons ? (array) reset( $reasons ) : array();
1144        $reason  = $reason[0] ?? $body->message ?? null;
1145        $message = is_string( $reason ) && '' !== $reason
1146            /* translators: %s is the reason WordPress.com gave, such as "Unable to find a valid site." */
1147            ? sprintf( __( 'The scan couldn’t be started: %s', 'jetpack-protect-pkg' ), $reason )
1148            : __( 'The scan couldn’t be started. Try again in a few minutes.', 'jetpack-protect-pkg' );
1149
1150        return new WP_Error( 'scan_not_started', $message, array( 'status' => 502 ) );
1151    }
1152
1153    /**
1154     * Claim the once-a-minute scan request slot before calling WordPress.com, so concurrent requests don't both enqueue.
1155     *
1156     * @return bool Whether this request got the slot.
1157     */
1158    private static function claim_request_lock() {
1159        // `wp_cache_add()` is atomic with a persistent object cache; transients can only narrow the race.
1160        if ( wp_using_ext_object_cache() ) {
1161            return wp_cache_add( self::REQUEST_LOCK, time(), 'jetpack_protect', MINUTE_IN_SECONDS );
1162        }
1163        if ( get_transient( self::REQUEST_LOCK ) ) {
1164            return false;
1165        }
1166        set_transient( self::REQUEST_LOCK, time(), MINUTE_IN_SECONDS );
1167        return true;
1168    }
1169
1170    /**
1171     * Give the scan request slot back after a failed request, so the user can retry straight away.
1172     *
1173     * @return void
1174     */
1175    private static function release_request_lock() {
1176        if ( wp_using_ext_object_cache() ) {
1177            wp_cache_delete( self::REQUEST_LOCK, 'jetpack_protect' );
1178        } else {
1179            delete_transient( self::REQUEST_LOCK );
1180        }
1181    }
1182
1183    /**
1184     * The latest vulnerability report: from Scan on a paid plan, otherwise Protect's free check.
1185     *
1186     * @param bool $has_plan Whether the site has a Scan plan.
1187     * @param bool $refresh  Fetch the report from WordPress.com instead of the cache; the plan is never refreshed.
1188     * @return array
1189     */
1190    private static function get_scan_report( $has_plan, $refresh = false ) {
1191        $error = array(
1192            'error'    => true,
1193            'scanning' => false,
1194        );
1195        if ( ! class_exists( Status::class ) ) {
1196            return $error;
1197        }
1198
1199        if ( $refresh ) {
1200            Status::$status = null;
1201        }
1202        $status = $has_plan ? Scan_Status::get_status( $refresh ) : Protect_Status::get_status( $refresh );
1203        if ( ! empty( $status->error ) ) {
1204            return $error;
1205        }
1206
1207        if ( ! function_exists( 'get_plugins' ) ) {
1208            require_once ABSPATH . 'wp-admin/includes/plugin.php';
1209        }
1210
1211        // Scan can stay idle with no scan on record, which isn't a scan running.
1212        $is_initializing = empty( $status->last_checked ) && empty( $status->last_scan_failed ) && in_array( $status->status, self::INITIALIZING_STATUSES, true )
1213            && ! ( $has_plan && 'idle' === $status->status );
1214
1215        return array(
1216            'error'          => false,
1217            'scanning'       => $is_initializing || in_array( $status->status, self::SCANNING_STATUSES, true ),
1218            'lastChecked'    => $status->last_checked,
1219            'lastScanFailed' => ! empty( $status->last_scan_failed ),
1220            'progress'       => isset( $status->current_progress ) ? (int) $status->current_progress : null,
1221            'pluginsChecked' => count( get_plugins() ),
1222            'themesChecked'  => count( wp_get_themes() ),
1223            'threats'        => Dashboard_Threats::format_all( Dashboard_Threats::without_resolved( $status->threats, Dashboard_Threats::get_scanned_at( $status ) ) ),
1224        );
1225    }
1226}