Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.10% covered (warning)
72.10%
261 / 362
35.48% covered (danger)
35.48%
11 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan
72.50% covered (warning)
72.50%
261 / 360
35.48% covered (danger)
35.48%
11 / 31
431.54
0.00% covered (danger)
0.00%
0 / 1
 get_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_state
80.77% covered (warning)
80.77%
21 / 26
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%
95 / 95
100.00% covered (success)
100.00%
1 / 1
1
 allows_ignoring
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_ignore_threats
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 can_act_on_threats
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 ignore_threat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unignore_threat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set_ignored
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 get_ignored_threats
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_history_threats
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 get_scan_history
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
 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
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
23.46
 get_scan
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 start_scan
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 get_enqueue_error
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 claim_request_lock
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 release_request_lock
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_scan_report
37.50% covered (danger)
37.50%
9 / 24
0.00% covered (danger)
0.00%
0 / 1
34.41
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_Status\Protect_Status;
16use Automattic\Jetpack\Protect_Status\Scan_Status;
17use Automattic\Jetpack\Protect_Status\Status;
18use Automattic\Jetpack\Redirect;
19use Automattic\Jetpack\Status\Host;
20use Jetpack_Options;
21use WP_Error;
22use WP_REST_Request;
23use WP_REST_Server;
24
25if ( ! defined( 'ABSPATH' ) ) {
26    exit( 0 );
27}
28
29/**
30 * Vulnerability results, from Scan on a paid plan or Protect's free daily check, and on-demand scans.
31 *
32 * @since 0.1.0
33 */
34class Scan implements Dashboard_Section {
35
36    /**
37     * Scan statuses that mean a scan is still running, as the Protect plugin reads them.
38     *
39     * @var string[]
40     */
41    const SCANNING_STATUSES = array( 'provisioning', 'scheduled', 'scanning' );
42
43    /**
44     * How many copy variants the free-plan upsell tests.
45     *
46     * @var int
47     */
48    const UPSELL_VARIANTS = 8;
49
50    /**
51     * Statuses that mean the first scan has not run yet when there is no report date.
52     *
53     * @var string[]
54     */
55    const INITIALIZING_STATUSES = array( 'idle', 'unavailable' );
56
57    /**
58     * Lock held for a minute after a scan request, so repeated requests don't stack.
59     *
60     * @var string
61     */
62    const REQUEST_LOCK = 'jetpack_protect_dashboard_scan_requested';
63
64    /**
65     * Fixer statuses that mean a fix has finished, one way or the other.
66     *
67     * @var string[]
68     */
69    const FIX_DONE_STATUSES = array( 'fixed', 'not_fixed' );
70
71    /**
72     * Transient caching Scan history, which is slow to fetch; the ignored list and the History tab share it.
73     *
74     * @var string
75     */
76    const HISTORY_CACHE = 'jetpack_protect_dashboard_scan_history';
77
78    /**
79     * How many characters of each column a threat's database row shows.
80     *
81     * @var int
82     */
83    const ROW_VALUE_LIMIT = 2000;
84
85    /**
86     * The section key.
87     *
88     * @return string
89     */
90    public function get_key() {
91        return 'scan';
92    }
93
94    /**
95     * The plan, where to set Scan's server credentials or buy Scan, and the latest report.
96     *
97     * @return array
98     */
99    public function get_state() {
100        $has_plan = Dashboard::has_scan_plan();
101        // Fetched first: has_credentials() reads the report this caches.
102        $report = self::get_scan_report( $has_plan );
103
104        if ( ! $has_plan ) {
105            $plan_state = array(
106                'url'                 => Dashboard::get_scan_upgrade_url(),
107                'upsellVariant'       => self::get_upsell_variant(),
108                'upsellVariantForced' => (bool) has_filter( 'jetpack_protect_dashboard_scan_upsell_variant' ),
109            );
110        } elseif ( self::needs_credentials() ) {
111            $plan_state = array(
112                'url'              => Redirect::get_url( 'jetpack-settings-security-credentials' ),
113                'needsCredentials' => true,
114                'hasCredentials'   => self::has_credentials(),
115            );
116        } else {
117            $plan_state = array(
118                'url'              => '',
119                'needsCredentials' => false,
120            );
121        }
122
123        return array_merge(
124            array(
125                'hasPlan'   => $has_plan,
126                'canIgnore' => self::allows_ignoring(),
127            ),
128            $plan_state,
129            $report
130        );
131    }
132
133    /**
134     * Whether Scan needs server credentials to auto-fix threats; not on WoA, where WordPress.com auto-fixes without them.
135     *
136     * @return bool
137     */
138    private static function needs_credentials() {
139        return ! ( new Host() )->is_woa_site();
140    }
141
142    /**
143     * Whether Scan has the server credentials it needs to auto-fix threats, from the cached Scan report.
144     *
145     * @return bool|null Null when there's no report to say.
146     */
147    private static function has_credentials() {
148        $report = class_exists( Scan_Status::class ) ? Scan_Status::get_from_options() : null;
149        if ( ! is_object( $report ) || ! isset( $report->credentials ) || ! is_array( $report->credentials ) ) {
150            return null;
151        }
152        return ! empty( $report->credentials );
153    }
154
155    /**
156     * Where this site's upsell copy rotation starts, spread across sites by blog ID.
157     *
158     * @return int From 1 to UPSELL_VARIANTS.
159     */
160    public static function get_upsell_variant() {
161        $blog_id = (int) Jetpack_Options::get_option( 'id' );
162        $variant = $blog_id > 0 ? ( $blog_id % self::UPSELL_VARIANTS ) + 1 : 1;
163
164        /**
165         * Filters which free-plan Scan upsell copy the Protect dashboard shows; a filtered one isn't rotated.
166         *
167         * @since 0.3.0
168         *
169         * @param int $variant From 1 to 8.
170         */
171        $variant = (int) apply_filters( 'jetpack_protect_dashboard_scan_upsell_variant', $variant );
172
173        return $variant >= 1 && $variant <= self::UPSELL_VARIANTS ? $variant : 1;
174    }
175
176    /**
177     * Register `jetpack/v4/protect-dashboard/scan`.
178     *
179     * @return void
180     */
181    public function register_routes() {
182        register_rest_route(
183            'jetpack/v4',
184            '/protect-dashboard/scan',
185            array(
186                array(
187                    'methods'             => WP_REST_Server::READABLE,
188                    'callback'            => array( __CLASS__, 'get_scan' ),
189                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
190                ),
191                array(
192                    'methods'             => WP_REST_Server::CREATABLE,
193                    'callback'            => array( __CLASS__, 'start_scan' ),
194                    'permission_callback' => array( Dashboard::class, 'can_manage' ),
195                ),
196            )
197        );
198
199        register_rest_route(
200            'jetpack/v4',
201            '/protect-dashboard/scan/threats/(?P<id>\d+)/ignore',
202            array(
203                'methods'             => WP_REST_Server::CREATABLE,
204                'callback'            => array( __CLASS__, 'ignore_threat' ),
205                'permission_callback' => array( __CLASS__, 'can_ignore_threats' ),
206            )
207        );
208
209        register_rest_route(
210            'jetpack/v4',
211            '/protect-dashboard/scan/threats/(?P<id>\d+)/unignore',
212            array(
213                'methods'             => WP_REST_Server::CREATABLE,
214                'callback'            => array( __CLASS__, 'unignore_threat' ),
215                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
216            )
217        );
218
219        register_rest_route(
220            'jetpack/v4',
221            '/protect-dashboard/scan/ignored',
222            array(
223                'methods'             => WP_REST_Server::READABLE,
224                'callback'            => array( __CLASS__, 'get_ignored_threats' ),
225                'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
226            )
227        );
228
229        register_rest_route(
230            'jetpack/v4',
231            '/protect-dashboard/scan/software/delete',
232            array(
233                'methods'             => WP_REST_Server::CREATABLE,
234                'callback'            => array( __CLASS__, 'delete_software' ),
235                'permission_callback' => array( Dashboard::class, 'can_manage' ),
236                'args'                => array(
237                    'type' => array(
238                        'type'     => 'string',
239                        'enum'     => array( 'plugins', 'themes' ),
240                        'required' => true,
241                    ),
242                    'slug' => array(
243                        'type'     => 'string',
244                        'required' => true,
245                    ),
246                ),
247            )
248        );
249
250        register_rest_route(
251            'jetpack/v4',
252            '/protect-dashboard/scan/threats/(?P<id>\d+)/row',
253            array(
254                array(
255                    'methods'             => WP_REST_Server::READABLE,
256                    'callback'            => array( __CLASS__, 'get_threat_row' ),
257                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
258                ),
259                array(
260                    'methods'             => WP_REST_Server::DELETABLE,
261                    'callback'            => array( __CLASS__, 'delete_threat_row' ),
262                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
263                ),
264            )
265        );
266
267        register_rest_route(
268            'jetpack/v4',
269            '/protect-dashboard/scan/threats/(?P<id>\d+)/fix',
270            array(
271                array(
272                    'methods'             => WP_REST_Server::READABLE,
273                    'callback'            => array( __CLASS__, 'get_fix_status' ),
274                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
275                ),
276                array(
277                    'methods'             => WP_REST_Server::CREATABLE,
278                    'callback'            => array( __CLASS__, 'fix_threat' ),
279                    'permission_callback' => array( __CLASS__, 'can_act_on_threats' ),
280                ),
281            )
282        );
283    }
284
285    /**
286     * Whether the host lets site owners ignore threats.
287     *
288     * @since 0.4.0
289     *
290     * @return bool
291     */
292    public static function allows_ignoring() {
293        /**
294         * Filters whether site owners can ignore threats; they can still unignore them. Hosts that fix threats themselves can turn this off.
295         *
296         * @since 0.4.0
297         *
298         * @param bool $allowed Whether threats can be ignored. Default true.
299         */
300        return (bool) apply_filters( 'jetpack_protect_threat_ignoring_enabled', true );
301    }
302
303    /**
304     * Ignoring needs everything acting on a threat does, and a host that allows it.
305     *
306     * @return bool|WP_Error
307     */
308    public static function can_ignore_threats() {
309        $allowed = self::can_act_on_threats();
310        if ( true !== $allowed || self::allows_ignoring() ) {
311            return $allowed;
312        }
313        return new WP_Error( 'threat_ignoring_disabled', __( 'Threats can’t be ignored on this site.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
314    }
315
316    /**
317     * Only Scan can fix or ignore threats; the free vulnerability check can't.
318     *
319     * @return bool|WP_Error
320     */
321    public static function can_act_on_threats() {
322        if ( ! Dashboard::can_manage() ) {
323            return false;
324        }
325        if ( ! Dashboard::has_scan_plan() ) {
326            return new WP_Error( 'scan_plan_required', __( 'Fixing and ignoring threats needs Jetpack Scan.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) );
327        }
328        return true;
329    }
330
331    /**
332     * Ignore a threat; the client moves it to its ignored list.
333     *
334     * @param WP_REST_Request $request The request, with the threat `id`.
335     * @return array|WP_Error
336     */
337    public static function ignore_threat( WP_REST_Request $request ) {
338        return self::set_ignored( (int) $request['id'], true );
339    }
340
341    /**
342     * Report an ignored threat again; the client moves it back to its active list.
343     *
344     * @param WP_REST_Request $request The request, with the threat `id`.
345     * @return array|WP_Error
346     */
347    public static function unignore_threat( WP_REST_Request $request ) {
348        return self::set_ignored( (int) $request['id'], false );
349    }
350
351    /**
352     * Ignore or unignore a threat, and drop the caches that list it.
353     *
354     * @param int  $id     The threat id.
355     * @param bool $ignore True to ignore, false to unignore.
356     * @return array|WP_Error
357     */
358    private static function set_ignored( $id, $ignore ) {
359        $response = self::alerts_request( '/' . $id, 'POST', array( $ignore ? 'ignore' : 'unignore' => true ) );
360        if ( is_wp_error( $response ) ) {
361            return $ignore
362                ? new WP_Error( 'threat_not_ignored', __( 'The threat couldn’t be ignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) )
363                : new WP_Error( 'threat_not_unignored', __( 'The threat couldn’t be unignored. Try again in a few minutes.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
364        }
365
366        Scan_Status::delete_option();
367        delete_transient( self::HISTORY_CACHE );
368        return array( 'ok' => true );
369    }
370
371    /**
372     * Threats the site has ignored, from Scan history.
373     *
374     * @return array|WP_Error
375     */
376    public static function get_ignored_threats() {
377        $history = self::get_history_threats();
378        if ( is_wp_error( $history ) ) {
379            return new WP_Error( 'ignored_unavailable', __( 'Ignored threats are unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
380        }
381        return array_values(
382            array_filter(
383                $history,
384                function ( $threat ) {
385                    return 'ignored' === $threat['status'];
386                }
387            )
388        );
389    }
390
391    /**
392     * Every threat Scan has fixed or ignored, and those the site resolved itself.
393     *
394     * @return array|WP_Error
395     */
396    public static function get_history_threats() {
397        $threats = self::get_scan_history();
398        if ( is_wp_error( $threats ) ) {
399            return $threats;
400        }
401
402        // Threats the site resolved itself, until Scan's next scan lists them as fixed.
403        $status = class_exists( Status::class ) ? Status::get_cached_status() : null;
404        $listed = array_flip( array_map( 'strval', array_column( $threats, 'id' ) ) );
405        return array_merge( array_values( array_diff_key( Dashboard_Threats::get_resolved( $status ? $status->last_checked : null ), $listed ) ), $threats );
406    }
407
408    /**
409     * Scan's own history, cached for five minutes.
410     *
411     * @return array|WP_Error
412     */
413    private static function get_scan_history() {
414        $cached = get_transient( self::HISTORY_CACHE );
415        if ( is_array( $cached ) ) {
416            return $cached;
417        }
418
419        $api_url  = Scan_Status::get_api_url();
420        $response = is_wp_error( $api_url ) ? $api_url : Client::wpcom_json_api_request_as_blog(
421            $api_url . '/history',
422            '2',
423            array(
424                'method'  => 'GET',
425                'timeout' => 30,
426            ),
427            null,
428            'wpcom'
429        );
430        $body     = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
431        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) || ! is_object( $body ) ) {
432            return new WP_Error( 'history_unavailable', __( 'Scan history is unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
433        }
434
435        $threats = Dashboard_Threats::format_all( (array) ( $body->threats ?? array() ) );
436        set_transient( self::HISTORY_CACHE, $threats, 5 * MINUTE_IN_SECONDS );
437        return $threats;
438    }
439
440    /**
441     * Delete an inactive plugin or a theme the site doesn't use, so its threats go with it.
442     *
443     * @param WP_REST_Request $request The request, with the extension `type` and `slug`.
444     * @return array|WP_Error
445     */
446    public static function delete_software( WP_REST_Request $request ) {
447        $type = $request['type'];
448        $slug = $request['slug'];
449
450        require_once ABSPATH . 'wp-admin/includes/file.php';
451        require_once ABSPATH . 'wp-admin/includes/plugin.php';
452        require_once ABSPATH . 'wp-admin/includes/theme.php';
453
454        // Looked up by key, so only the slug of installed software matches and anything else is refused.
455        $file  = 'plugins' === $type ? ( Dashboard_Threats::get_plugin_files( get_plugins() )[ $slug ] ?? null ) : null;
456        $theme = 'themes' === $type ? ( wp_get_themes( array( 'errors' => null ) )[ $slug ] ?? null ) : null;
457        if ( ! Dashboard_Threats::can_delete( $type, $file, $theme ) ) {
458            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 ) );
459        }
460
461        $deleted = null;
462        if ( self::can_write_files() ) {
463            $deleted = 'plugins' === $type ? delete_plugins( array( $file ) ) : delete_theme( $theme->get_stylesheet() );
464        }
465        if ( true !== $deleted ) {
466            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 ) );
467        }
468
469        delete_transient( self::HISTORY_CACHE );
470        if ( class_exists( Status::class ) ) {
471            Scan_Status::delete_option();
472            Protect_Status::delete_option();
473        }
474        return array( 'ok' => true );
475    }
476
477    /**
478     * The database row a current threat was found in, with long values cut short.
479     *
480     * @param WP_REST_Request $request The request, with the threat `id`.
481     * @return array|WP_Error The row's `columns`, and whether `canDelete` it here.
482     */
483    public static function get_threat_row( WP_REST_Request $request ) {
484        $found = self::find_threat_row( $request['id'] );
485        if ( is_wp_error( $found ) ) {
486            return $found;
487        }
488
489        $columns = array();
490        foreach ( $found['row'] as $column => $value ) {
491            $columns[ $column ] = is_string( $value ) && mb_strlen( $value ) > self::ROW_VALUE_LIMIT ? mb_substr( $value, 0, self::ROW_VALUE_LIMIT ) . '…' : $value;
492        }
493        return array(
494            'columns'   => (object) $columns,
495            'canDelete' => null !== self::get_deletable_option( $found ),
496        );
497    }
498
499    /**
500     * Delete the option a current threat was found in, and mark the threat fixed. Nothing keeps a copy of the option.
501     *
502     * @param WP_REST_Request $request The request, with the threat `id`.
503     * @return array|WP_Error The fixed `threat`.
504     */
505    public static function delete_threat_row( WP_REST_Request $request ) {
506        $found = self::find_threat_row( $request['id'] );
507        if ( is_wp_error( $found ) ) {
508            return $found;
509        }
510
511        $name = self::get_deletable_option( $found );
512        if ( null === $name ) {
513            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 ) );
514        }
515        if ( ! delete_option( $name ) ) {
516            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 ) );
517        }
518
519        return array( 'threat' => Dashboard_Threats::resolve( Dashboard_Threats::format( $found['threat'] ) ) );
520    }
521
522    /**
523     * Read a current threat's database row, found through the threat so only rows Scan flagged can be reached.
524     *
525     * @param int|string $id The threat id.
526     * @return array|WP_Error The `threat`, and its row's `table`, `pk_column` and `row`.
527     */
528    private static function find_threat_row( $id ) {
529        global $wpdb;
530
531        $threat = null;
532        if ( class_exists( Status::class ) ) {
533            foreach ( (array) ( Scan_Status::get_status()->threats ?? array() ) as $item ) {
534                if ( (string) ( $item->id ?? '' ) === (string) $id ) {
535                    $threat = $item;
536                    break;
537                }
538            }
539        }
540
541        $details = $threat->details ?? null;
542        $table   = $details->table ?? null;
543        $column  = $details->pk_column ?? null;
544        // This site's own tables only: on multisite, the shared users tables hold other sites' password hashes.
545        if ( ! is_string( $table ) || ! is_string( $column ) || ! isset( $details->pk_value ) || ! in_array( $table, $wpdb->tables( 'blog' ), true ) ) {
546            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 ) );
547        }
548
549        // A key column the table lacks is a query error, which must not print into the REST response.
550        $suppressed = $wpdb->suppress_errors();
551        $row        = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE %i = %s', $table, $column, $details->pk_value ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
552        $wpdb->suppress_errors( $suppressed );
553        if ( ! $row ) {
554            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 ) );
555        }
556        return array(
557            'threat'    => $threat,
558            'table'     => $table,
559            'pk_column' => $column,
560            'row'       => $row,
561        );
562    }
563
564    /**
565     * The option a threat's row holds, when the dashboard may delete it.
566     *
567     * @param array $found From find_threat_row().
568     * @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.
569     */
570    private static function get_deletable_option( $found ) {
571        global $wpdb;
572
573        $name = $found['row']['option_name'] ?? null;
574        if ( $wpdb->options !== $found['table'] || 'option_id' !== $found['pk_column'] || ! is_string( $name ) ) {
575            return null;
576        }
577        // delete_option() trims the name, so " siteurl" would delete siteurl instead of the flagged row.
578        if ( trim( $name ) !== $name || Protected_Options::is_protected( $name ) ) {
579            return null;
580        }
581        // The key may now hold another option, as after a database import.
582        $flagged = $found['threat']->details->details->option_name ?? null;
583        if ( null !== $flagged && $flagged !== $name ) {
584            return null;
585        }
586        return $name;
587    }
588
589    /**
590     * Whether WordPress can delete files without asking for filesystem credentials.
591     *
592     * Core's delete functions print the credentials form and exit when it can't, which a REST request can't show.
593     *
594     * @return bool
595     */
596    private static function can_write_files() {
597        if ( 'direct' === get_filesystem_method() ) {
598            return true;
599        }
600        ob_start();
601        $has_credentials = request_filesystem_credentials( self_admin_url() );
602        ob_end_clean();
603        return (bool) $has_credentials;
604    }
605
606    /**
607     * Ask Scan to fix a threat.
608     *
609     * @param WP_REST_Request $request The request, with the threat `id`.
610     * @return array|WP_Error The threat's fixer status.
611     */
612    public static function fix_threat( WP_REST_Request $request ) {
613        $id       = (int) $request['id'];
614        $response = self::alerts_request( '/fix', 'POST', array( 'threat_ids' => array( $id ) ) );
615        if ( is_wp_error( $response ) ) {
616            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 ) );
617        }
618
619        Scan_Status::delete_option();
620        $status = self::get_threat_fix_status( $response, $id );
621        if ( 'fixed' === $status['status'] ) {
622            delete_transient( self::HISTORY_CACHE );
623        }
624        return $status;
625    }
626
627    /**
628     * How a fix is going, with the refreshed report once it has finished.
629     *
630     * @param WP_REST_Request $request The request, with the threat `id`.
631     * @return array|WP_Error
632     */
633    public static function get_fix_status( WP_REST_Request $request ) {
634        $id       = (int) $request['id'];
635        $response = self::alerts_request( add_query_arg( 'threat_ids', array( $id ), '/fix' ), 'GET' );
636        if ( is_wp_error( $response ) ) {
637            return new WP_Error( 'fix_status_unavailable', __( 'We couldn’t check on the fix.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) );
638        }
639
640        $status = self::get_threat_fix_status( $response, $id );
641        if ( in_array( $status['status'], self::FIX_DONE_STATUSES, true ) ) {
642            // A fixed threat moves into Scan history.
643            if ( 'fixed' === $status['status'] ) {
644                delete_transient( self::HISTORY_CACHE );
645            }
646            $status['scan'] = self::get_scan_report( true, true );
647        }
648        return $status;
649    }
650
651    /**
652     * One threat's status from a WordPress.com fixer response.
653     *
654     * @param object $response The decoded response, with fixer statuses under `threats`, keyed by threat id.
655     * @param int    $id       The threat id.
656     * @return array The `status`, such as `in_progress` or `fixed`, and any `error`.
657     */
658    private static function get_threat_fix_status( $response, $id ) {
659        $threat = $response->threats->{ (string) $id } ?? null;
660        return array(
661            'status' => $threat->status ?? ( empty( $threat->error ) ? 'in_progress' : 'not_fixed' ),
662            'error'  => $threat->error ?? null,
663        );
664    }
665
666    /**
667     * Call the site's WordPress.com alerts API as the current user, as the Protect plugin does.
668     *
669     * @param string     $path   Path after `/sites/{id}/alerts`.
670     * @param string     $method HTTP method.
671     * @param array|null $body   Body to send as JSON.
672     * @return object|WP_Error The decoded response.
673     */
674    private static function alerts_request( $path, $method, $body = null ) {
675        $blog_id = (int) Jetpack_Options::get_option( 'id' );
676        if ( ! $blog_id ) {
677            return new WP_Error( 'site_not_connected' );
678        }
679
680        $response = Client::wpcom_json_api_request_as_user(
681            sprintf( '/sites/%d/alerts%s', $blog_id, $path ),
682            '2',
683            array( 'method' => $method ),
684            null === $body ? null : wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
685            'wpcom'
686        );
687        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
688            return new WP_Error( 'alerts_request_failed' );
689        }
690
691        // Ignoring answers with no useful body; only the status code matters there.
692        $decoded = json_decode( wp_remote_retrieve_body( $response ) );
693        return is_object( $decoded ) ? $decoded : new \stdClass();
694    }
695
696    /**
697     * The latest report, fresh from WordPress.com, for polling while a scan runs.
698     *
699     * @return array
700     */
701    public static function get_scan() {
702        return self::get_scan_report( Dashboard::has_scan_plan(), true );
703    }
704
705    /**
706     * Start a scan: enqueue one with a Scan plan, otherwise ask for the latest vulnerability report.
707     *
708     * @return array|WP_Error
709     */
710    public static function start_scan() {
711        $has_plan = Dashboard::has_scan_plan();
712
713        if ( $has_plan && self::claim_request_lock() ) {
714            $api_url  = Scan_Status::get_api_url();
715            $response = is_wp_error( $api_url )
716                ? $api_url
717                : Client::wpcom_json_api_request_as_blog( $api_url . '/enqueue', '2', array( 'method' => 'POST' ), null, 'wpcom' );
718            $error    = self::get_enqueue_error( $response );
719            if ( $error ) {
720                self::release_request_lock();
721                return $error;
722            }
723            Scan_Status::delete_option();
724            delete_transient( self::HISTORY_CACHE );
725
726            /**
727             * Fires after the Protect dashboard enqueues a scan on WordPress.com.
728             *
729             * @since 0.1.0
730             */
731            do_action( 'jetpack_protect_dashboard_scan_started' );
732        }
733
734        return self::get_scan_report( $has_plan, true );
735    }
736
737    /**
738     * Why WordPress.com didn't enqueue a scan, including a `success: false` it answers with a 200.
739     *
740     * @param array|WP_Error $response The enqueue response.
741     * @return WP_Error|null The error, with WordPress.com's reason when it gives one, or null once enqueued.
742     */
743    private static function get_enqueue_error( $response ) {
744        $body = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ) );
745        if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) && false !== ( $body->success ?? null ) ) {
746            return null;
747        }
748
749        $reasons = (array) ( $body->error->errors ?? array() );
750        $reason  = $reasons ? (array) reset( $reasons ) : array();
751        $reason  = $reason[0] ?? $body->message ?? null;
752        $message = is_string( $reason ) && '' !== $reason
753            /* translators: %s is the reason WordPress.com gave, such as "Unable to find a valid site." */
754            ? sprintf( __( 'The scan couldn’t be started: %s', 'jetpack-protect-pkg' ), $reason )
755            : __( 'The scan couldn’t be started. Try again in a few minutes.', 'jetpack-protect-pkg' );
756
757        return new WP_Error( 'scan_not_started', $message, array( 'status' => 502 ) );
758    }
759
760    /**
761     * Claim the once-a-minute scan request slot before calling WordPress.com, so concurrent requests don't both enqueue.
762     *
763     * @return bool Whether this request got the slot.
764     */
765    private static function claim_request_lock() {
766        // `wp_cache_add()` is atomic with a persistent object cache; transients can only narrow the race.
767        if ( wp_using_ext_object_cache() ) {
768            return wp_cache_add( self::REQUEST_LOCK, time(), 'jetpack_protect', MINUTE_IN_SECONDS );
769        }
770        if ( get_transient( self::REQUEST_LOCK ) ) {
771            return false;
772        }
773        set_transient( self::REQUEST_LOCK, time(), MINUTE_IN_SECONDS );
774        return true;
775    }
776
777    /**
778     * Give the scan request slot back after a failed request, so the user can retry straight away.
779     *
780     * @return void
781     */
782    private static function release_request_lock() {
783        if ( wp_using_ext_object_cache() ) {
784            wp_cache_delete( self::REQUEST_LOCK, 'jetpack_protect' );
785        } else {
786            delete_transient( self::REQUEST_LOCK );
787        }
788    }
789
790    /**
791     * The latest vulnerability report: from Scan on a paid plan, otherwise Protect's free check.
792     *
793     * @param bool $has_plan Whether the site has a Scan plan.
794     * @param bool $refresh  Fetch the report from WordPress.com instead of the cache; the plan is never refreshed.
795     * @return array
796     */
797    private static function get_scan_report( $has_plan, $refresh = false ) {
798        $error = array(
799            'error'    => true,
800            'scanning' => false,
801        );
802        if ( ! class_exists( Status::class ) ) {
803            return $error;
804        }
805
806        if ( $refresh ) {
807            Status::$status = null;
808        }
809        $status = $has_plan ? Scan_Status::get_status( $refresh ) : Protect_Status::get_status( $refresh );
810        if ( ! empty( $status->error ) ) {
811            return $error;
812        }
813
814        if ( ! function_exists( 'get_plugins' ) ) {
815            require_once ABSPATH . 'wp-admin/includes/plugin.php';
816        }
817
818        $is_initializing = empty( $status->last_checked ) && empty( $status->last_scan_failed ) && in_array( $status->status, self::INITIALIZING_STATUSES, true );
819
820        return array(
821            'error'          => false,
822            'scanning'       => $is_initializing || in_array( $status->status, self::SCANNING_STATUSES, true ),
823            'lastChecked'    => $status->last_checked,
824            'lastScanFailed' => ! empty( $status->last_scan_failed ),
825            'progress'       => isset( $status->current_progress ) ? (int) $status->current_progress : null,
826            'pluginsChecked' => count( get_plugins() ),
827            'themesChecked'  => count( wp_get_themes() ),
828            'threats'        => Dashboard_Threats::format_all( Dashboard_Threats::without_resolved( $status->threats, $status->last_checked ?? null ) ),
829        );
830    }
831}