Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.20% covered (warning)
88.20%
142 / 161
90.62% covered (success)
90.62%
29 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
Protect
88.68% covered (warning)
88.68%
141 / 159
90.62% covered (success)
90.62%
29 / 32
59.39
0.00% covered (danger)
0.00%
0 / 1
 get_module_name
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 should_use_standalone_plugin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_dashboard_module_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 do_product_specific_activation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_endpoints
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 permissions_callback
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_long_description
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_features
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_tiers
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_features_by_tier
n/a
0 / 0
n/a
0 / 0
1
 get_pricing_for_ui
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 does_module_need_attention
30.43% covered (danger)
30.43%
7 / 23
0.00% covered (danger)
0.00%
0 / 1
13.42
 get_paid_plan_product_slugs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 has_paid_plan_for_product
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_upgradable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_post_checkout_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_post_checkout_urls_by_feature
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_protect_page_module_urls
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_firewall_settings_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_protect_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_info
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_purchase_url
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 get_manage_url
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 get_manage_urls_by_feature
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_upgradable_by_bundle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_site_protect_data
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 filter_waf_config_by_capability
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filter_scan_data_by_capability
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 redact_threat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 redact_extension
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Protect product
4 *
5 * @package my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack\Products;
9
10use Automattic\Jetpack\Modules;
11use Automattic\Jetpack\My_Jetpack\Hybrid_Product;
12use Automattic\Jetpack\My_Jetpack\Wpcom_Products;
13use Automattic\Jetpack\Protect_Status\Plan;
14use Automattic\Jetpack\Protect_Status\Status as Protect_Status;
15use Automattic\Jetpack\Redirect;
16use Automattic\Jetpack\Waf\Waf_Runner;
17use WP_Error;
18use WP_REST_Response;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24/**
25 * Class responsible for handling the Protect product
26 */
27class Protect extends Hybrid_Product {
28
29    const FREE_TIER_SLUG             = 'free';
30    const UPGRADED_TIER_SLUG         = 'upgraded';
31    const UPGRADED_TIER_PRODUCT_SLUG = 'jetpack_scan';
32
33    const SCAN_FEATURE_SLUG     = 'scan';
34    const FIREWALL_FEATURE_SLUG = 'firewall';
35
36    /**
37     * Keys from Waf_Runner::get_config() that may be exposed to users without `manage_options`.
38     *
39     * My Jetpack is reachable with `edit_posts`, and the Protect card renders these two as
40     * on/off status indicators. Everything else the WAF config carries (IP allow/block lists,
41     * the bootstrap file path, data sharing settings) is administrator-only.
42     *
43     * @var string[]
44     */
45    private const NON_ADMIN_WAF_CONFIG_KEYS = array(
46        'jetpack_waf_automatic_rules',
47        'brute_force_protection',
48    );
49
50    /**
51     * Status_Model properties that may be exposed to users without `manage_options`.
52     *
53     * My Jetpack is reachable with `edit_posts`, and the Protect card renders scan counts and the
54     * last scan time from these. The rest of the status report -- the scan state, the error
55     * details, the list of fixable threat IDs -- is administrator-only.
56     *
57     * @var string[]
58     */
59    private const NON_ADMIN_SCAN_DATA_KEYS = array(
60        'last_checked',
61        'num_threats',
62        'num_plugins_threats',
63        'num_themes_threats',
64        'threats',
65        'plugins',
66        'themes',
67        'core',
68        'files',
69        'database',
70    );
71
72    /**
73     * Threat_Model properties that may be exposed to users without `manage_options`.
74     *
75     * The card counts threats and critical (severity >= 5) threats; it never renders a threat.
76     * Everything else a threat carries -- the infected file path, the surrounding source, the
77     * signature, the database table and the vulnerable extension version -- is administrator-only.
78     *
79     * @var string[]
80     */
81    private const NON_ADMIN_THREAT_KEYS = array( 'severity' );
82
83    /**
84     * The product slug
85     *
86     * @var string
87     */
88    public static $slug = 'protect';
89
90    /**
91     * The Jetpack module name, used where the `protect-dashboard` module is not on offer.
92     *
93     * @var string
94     */
95    public static $module_name = 'protect';
96
97    /**
98     * The module that loads the Protect page.
99     *
100     * @var string
101     */
102    const DASHBOARD_MODULE_NAME = 'protect-dashboard';
103
104    /**
105     * The filename (id) of the plugin associated with this product.
106     *
107     * @var string
108     */
109    public static $plugin_filename = array(
110        'jetpack-protect/jetpack-protect.php',
111        'protect/jetpack-protect.php',
112        'jetpack-protect-dev/jetpack-protect.php',
113    );
114
115    /**
116     * The slug of the plugin associated with this product.
117     *
118     * @var string
119     */
120    public static $plugin_slug = 'jetpack-protect';
121
122    /**
123     * Get the module that switches Protect on: the Protect page's, wherever the Jetpack plugin offers it.
124     *
125     * Not with the standalone plugin active: the page module would take over that plugin's dashboard (see JETPACK-3062).
126     *
127     * @since 6.12.0
128     *
129     * @return string
130     */
131    public static function get_module_name() {
132        if ( ! static::is_standalone_plugin_active() && self::is_dashboard_module_available() ) {
133            return self::DASHBOARD_MODULE_NAME;
134        }
135        return static::$module_name;
136    }
137
138    /**
139     * Leave the standalone plugin alone where the Jetpack plugin already delivers the Protect page.
140     *
141     * @since 6.12.0
142     *
143     * @return bool
144     */
145    public static function should_use_standalone_plugin() {
146        return ! ( static::is_jetpack_plugin_active() && self::is_dashboard_module_available() );
147    }
148
149    /**
150     * Whether the Jetpack plugin offers the module that loads the Protect page, which only happens while its flag is on.
151     *
152     * Not Modules::is_module(): it passes any slug.
153     *
154     * @since 6.12.0
155     *
156     * @return bool
157     */
158    public static function is_dashboard_module_available() {
159        return in_array( self::DASHBOARD_MODULE_NAME, (array) ( new Modules() )->get_available(), true );
160    }
161
162    /**
163     * Activate local product steps without scheduling connected modules offline.
164     *
165     * @since 6.10.0
166     * @param bool|WP_Error $current_result The plugin activation result.
167     * @param bool          $local          Whether to keep activation local.
168     * @return bool|WP_Error
169     */
170    public static function do_product_specific_activation( $current_result, $local = false ) {
171        if ( $local ) {
172            // Protect's deferred admin hook would activate connected modules on the next request.
173            delete_option( static::$plugin_slug . '_activated' );
174        }
175        return parent::do_product_specific_activation( $current_result, $local );
176    }
177
178    /**
179     * The category of the product
180     *
181     * @var string
182     */
183    public static $category = 'security';
184
185    /**
186     * Whether this product requires a user connection
187     *
188     * @var string
189     */
190    public static $requires_user_connection = false;
191
192    /**
193     * Whether this product has a free offering
194     *
195     * @var bool
196     */
197    public static $has_free_offering = true;
198
199    /**
200     * Protect has a standalone plugin
201     *
202     * @var bool
203     */
204    public static $has_standalone_plugin = true;
205
206    /**
207     * The feature slug that identifies the paid plan
208     *
209     * @var string
210     */
211    public static $feature_identifying_paid_plan = 'scan';
212
213    /**
214     * Setup Protect REST API endpoints
215     *
216     * @return void
217     */
218    public static function register_endpoints(): void {
219        parent::register_endpoints();
220        // Get Jetpack Protect data.
221        register_rest_route(
222            'my-jetpack/v1',
223            '/site/protect/data',
224            array(
225                'methods'             => \WP_REST_Server::READABLE,
226                'callback'            => __CLASS__ . '::get_site_protect_data',
227                'permission_callback' => __CLASS__ . '::permissions_callback',
228            )
229        );
230    }
231
232    /**
233     * Checks if the user has the correct permissions
234     */
235    public static function permissions_callback() {
236        return current_user_can( 'edit_posts' );
237    }
238
239    /**
240     * Get the product name
241     *
242     * @return string
243     */
244    public static function get_name() {
245        return 'Protect';
246    }
247
248    /**
249     * Get the product title
250     *
251     * @return string
252     */
253    public static function get_title() {
254        return 'Jetpack Protect';
255    }
256
257    /**
258     * Get the internationalized product description
259     *
260     * @return string
261     */
262    public static function get_description() {
263        return __( 'Guard against malware and bad actors 24/7', 'jetpack-my-jetpack' );
264    }
265
266    /**
267     * Get the internationalized product long description
268     *
269     * @return string
270     */
271    public static function get_long_description() {
272        return __( 'Protect your site from bad actors and malware 24/7. Clean up security vulnerabilities with one click.', 'jetpack-my-jetpack' );
273    }
274
275    /**
276     * Get the internationalized features list
277     *
278     * @return array Protect features list
279     */
280    public static function get_features() {
281        return array(
282            __( 'Over 20,000 listed vulnerabilities', 'jetpack-my-jetpack' ),
283            __( 'Daily automatic scans', 'jetpack-my-jetpack' ),
284            __( 'Check plugin and theme version status', 'jetpack-my-jetpack' ),
285            __( 'Easy to navigate and use', 'jetpack-my-jetpack' ),
286        );
287    }
288
289    /**
290     * Get the product's available tiers
291     *
292     * @return string[] Slugs of the available tiers
293     */
294    public static function get_tiers() {
295        return array(
296            self::UPGRADED_TIER_SLUG,
297            self::FREE_TIER_SLUG,
298        );
299    }
300
301    /**
302     * Get the internationalized comparison of free vs upgraded features
303     *
304     * @deprecated 6.10.0
305     *
306     * @return array[] Protect features comparison
307     */
308    public static function get_features_by_tier() {
309        _deprecated_function( __METHOD__, 'jetpack-my-jetpack-6.10.0' );
310
311        return array(
312            array(
313                'name'  => __( 'Scan for threats and vulnerabilities', 'jetpack-my-jetpack' ),
314                'tiers' => array(
315                    self::FREE_TIER_SLUG     => array(
316                        'included'    => true,
317                        'description' => __( 'Check items against database', 'jetpack-my-jetpack' ),
318                    ),
319                    self::UPGRADED_TIER_SLUG => array(
320                        'included'    => true,
321                        'description' => __( 'Line by line malware scanning', 'jetpack-my-jetpack' ),
322                    ),
323                ),
324            ),
325            array(
326                'name'  => __( 'Daily automated scans', 'jetpack-my-jetpack' ),
327                'tiers' => array(
328                    self::FREE_TIER_SLUG     => array( 'included' => true ),
329                    self::UPGRADED_TIER_SLUG => array(
330                        'included'    => true,
331                        'description' => __( 'Plus on-demand manual scans', 'jetpack-my-jetpack' ),
332                    ),
333                ),
334            ),
335            array(
336                'name'  => __( 'Web Application Firewall', 'jetpack-my-jetpack' ),
337                'tiers' => array(
338                    self::FREE_TIER_SLUG     => array(
339                        'included'    => false,
340                        'description' => __( 'Manual rules only', 'jetpack-my-jetpack' ),
341                    ),
342                    self::UPGRADED_TIER_SLUG => array(
343                        'included'    => true,
344                        'description' => __( 'Automatic protection and rule updates', 'jetpack-my-jetpack' ),
345                    ),
346                ),
347            ),
348            array(
349                'name'  => __( 'Brute force protection', 'jetpack-my-jetpack' ),
350                'tiers' => array(
351                    self::FREE_TIER_SLUG     => array( 'included' => true ),
352                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
353                ),
354            ),
355            array(
356                'name'  => __( 'Account protection', 'jetpack-my-jetpack' ),
357                'tiers' => array(
358                    self::FREE_TIER_SLUG     => array( 'included' => true ),
359                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
360                ),
361            ),
362            array(
363                'name'  => __( 'Access to scan on Cloud', 'jetpack-my-jetpack' ),
364                'tiers' => array(
365                    self::FREE_TIER_SLUG     => array( 'included' => false ),
366                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
367                ),
368            ),
369            array(
370                'name'  => __( 'One-click auto fixes', 'jetpack-my-jetpack' ),
371                'tiers' => array(
372                    self::FREE_TIER_SLUG     => array( 'included' => false ),
373                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
374                ),
375            ),
376            array(
377                'name'  => __( 'Notifications', 'jetpack-my-jetpack' ),
378                'tiers' => array(
379                    self::FREE_TIER_SLUG     => array( 'included' => false ),
380                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
381                ),
382            ),
383            array(
384                'name'  => __( 'Severity labels', 'jetpack-my-jetpack' ),
385                'tiers' => array(
386                    self::FREE_TIER_SLUG     => array( 'included' => false ),
387                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
388                ),
389            ),
390        );
391    }
392
393    /**
394     * Get the product pricing details
395     *
396     * @return array Pricing details
397     */
398    public static function get_pricing_for_ui() {
399        return array(
400            'tiers' => array(
401                self::FREE_TIER_SLUG     => array(
402                    'available' => true,
403                    'is_free'   => true,
404                ),
405                self::UPGRADED_TIER_SLUG => array_merge(
406                    array(
407                        'available'          => true,
408                        'wpcom_product_slug' => self::UPGRADED_TIER_PRODUCT_SLUG,
409                    ),
410                    Wpcom_Products::get_product_pricing( self::UPGRADED_TIER_PRODUCT_SLUG )
411                ),
412            ),
413        );
414    }
415
416    /**
417     * Determines whether the module/plugin/product needs the users attention.
418     * Typically due to some sort of error where user troubleshooting is needed.
419     *
420     * @return boolean|array
421     */
422    public static function does_module_need_attention() {
423        $protect_threat_status = false;
424        $scan_data             = Protect_Status::get_status();
425
426        // Check if there are scan threats.
427        $protect_data = $scan_data;
428        if ( is_wp_error( $protect_data ) ) {
429            return $protect_threat_status; // false
430        }
431        $critical_threat_count = false;
432        if ( ! empty( $protect_data->threats ) ) {
433            $critical_threat_count = array_reduce(
434                $protect_data->threats,
435                function ( $accum, $threat ) {
436                    return $threat->severity >= 5 ? ++$accum : $accum;
437                },
438                0
439            );
440
441            $protect_threat_status = array(
442                'type' => $critical_threat_count ? 'error' : 'warning',
443                'data' => array(
444                    'threat_count'          => count( $protect_data->threats ),
445                    'critical_threat_count' => $critical_threat_count,
446                    'fixable_threat_ids'    => $protect_data->fixable_threat_ids,
447                ),
448            );
449        }
450
451        return $protect_threat_status;
452    }
453
454    /**
455     * Get the product-slugs of the paid plans for this product.
456     * (Do not include bundle plans, unless it's a bundle plan itself).
457     *
458     * @return array
459     */
460    public static function get_paid_plan_product_slugs() {
461        return array(
462            'jetpack_scan',
463            'jetpack_scan_monthly',
464            'jetpack_scan_bi_yearly',
465        );
466    }
467
468    /**
469     * Whether the site pays for Scan, by the same check as the Protect page.
470     *
471     * @return bool
472     */
473    public static function has_paid_plan_for_product() {
474        return Plan::has_required_plan();
475    }
476
477    /**
478     * Checks whether the product can be upgraded - i.e. this shows the /#add-protect interstitial
479     *
480     * @return boolean
481     */
482    public static function is_upgradable() {
483        return ! self::has_paid_plan_for_product();
484    }
485
486    /**
487     * Get the URL the user is taken after purchasing the product through the checkout
488     *
489     * @return ?string
490     */
491    public static function get_post_checkout_url() {
492        return self::get_manage_url();
493    }
494
495    /**
496     * Get the URL the user is taken after purchasing the product through the checkout for each product feature
497     *
498     * @return ?array
499     */
500    public static function get_post_checkout_urls_by_feature() {
501        return array(
502            self::SCAN_FEATURE_SLUG     => self::get_post_checkout_url(),
503            self::FIREWALL_FEATURE_SLUG => self::get_firewall_settings_url(),
504        );
505    }
506
507    /**
508     * Where each Jetpack security module's settings live while the Protect package's dashboard is the Protect page.
509     *
510     * Screens that would offer one of these settings link there instead.
511     *
512     * @since 6.12.0
513     *
514     * @return array<string, string> Settings URLs keyed by module slug; empty without the dashboard.
515     */
516    public static function get_protect_page_module_urls() {
517        // method_exists, not class_exists: an older bundled Protect package can ship the class without it.
518        $dashboard = 'Automattic\Jetpack\Protect\Dashboard';
519        // @phan-suppress-next-line PhanUndeclaredClassReference -- optional Protect package, guarded by method_exists.
520        if ( ! did_action( 'jetpack_protect_dashboard_initialized' ) || ! method_exists( $dashboard, 'get_module_settings_urls' ) ) {
521            return array();
522        }
523
524        return (array) $dashboard::get_module_settings_urls();
525    }
526
527    /**
528     * The firewall's settings: the Protect page's Firewall card, or the standalone plugin's Firewall screen.
529     *
530     * @return string
531     */
532    private static function get_firewall_settings_url() {
533        return self::get_protect_page_module_urls()['waf'] ?? admin_url( 'admin.php?page=jetpack-protect#/firewall' );
534    }
535
536    /**
537     * Whether wp-admin has a Protect page, from the standalone plugin or the Protect package's dashboard.
538     *
539     * @since 6.11.0
540     *
541     * @return bool
542     */
543    public static function has_protect_page() {
544        return static::is_standalone_plugin_active() || (bool) did_action( 'jetpack_protect_dashboard_initialized' );
545    }
546
547    /**
548     * Get the product info, with whether the Protect page exists and where Scan's results are listed.
549     *
550     * @return array
551     */
552    public static function get_info() {
553        return array_merge(
554            parent::get_info(),
555            array(
556                'has_protect_page' => static::has_protect_page(),
557                'scan_results_url' => Scan::get_manage_url(),
558            )
559        );
560    }
561
562    /**
563     * Where the card's upgrade goes: a host's plans when it sends them, else the Protect interstitial.
564     *
565     * @return ?string
566     */
567    public static function get_purchase_url() {
568        if ( static::has_paid_plan_for_product() ) {
569            return parent::get_purchase_url();
570        }
571
572        /** This filter is documented in projects/packages/my-jetpack/src/class-main-features.php */
573        $upgrade = apply_filters(
574            'jetpack_feature_upgrade',
575            array(
576                'path'     => '/add-protect',
577                'name'     => 'Jetpack Scan', // Product name, do not translate.
578                'back_url' => admin_url( 'admin.php?page=my-jetpack' ),
579            ),
580            'protect-dashboard',
581            false
582        );
583
584        $url = is_array( $upgrade ) ? esc_url_raw( (string) ( $upgrade['url'] ?? '' ) ) : '';
585
586        return $url ? $url : parent::get_purchase_url();
587    }
588
589    /**
590     * Get the URL where the user manages the product
591     *
592     * @return ?string
593     */
594    public static function get_manage_url() {
595        if ( static::has_protect_page() ) {
596            // Protect admin dashboard.
597            return admin_url( 'admin.php?page=jetpack-protect' );
598        }
599
600        if ( static::has_paid_plan_for_product() ) {
601            // Paid users without standalone plugin go to Jetpack Cloud Scan dashboard.
602            return Redirect::get_url( 'my-jetpack-manage-scan' );
603        }
604
605        // Free users without standalone plugin go to the Protect details page.
606        return admin_url( 'admin.php?page=my-jetpack#/protect-details' );
607    }
608
609    /**
610     * Get the URL where the user manages the product for each product feature
611     *
612     * @return ?array
613     */
614    public static function get_manage_urls_by_feature() {
615        return array(
616            self::SCAN_FEATURE_SLUG     => self::get_manage_url(),
617            self::FIREWALL_FEATURE_SLUG => self::get_firewall_settings_url(),
618        );
619    }
620
621    /**
622     * Return product bundles list
623     * that supports the product.
624     *
625     * @return array Products bundle list.
626     */
627    public static function is_upgradable_by_bundle() {
628        return array( 'security', 'complete' );
629    }
630
631    /**
632     * Return site Jetpack Protect data for the REST API.
633     *
634     * @return WP_Rest_Response|WP_Error
635     */
636    public static function get_site_protect_data() {
637        $scan_data = Protect_Status::get_status();
638
639        $waf_config          = array();
640        $waf_supported       = false;
641        $is_waf_enabled      = false;
642        $is_provided_by_host = false;
643
644        if ( class_exists( 'Automattic\Jetpack\Waf\Waf_Runner' ) ) {
645            $waf_config          = Waf_Runner::get_config();
646            $is_waf_enabled      = Waf_Runner::is_enabled();
647            $waf_supported       = Waf_Runner::is_supported_environment();
648            $is_provided_by_host = method_exists( Waf_Runner::class, 'is_provided_by_host' ) && Waf_Runner::is_provided_by_host();
649        }
650
651        return rest_ensure_response(
652            array(
653                'scanData'  => self::filter_scan_data_by_capability( $scan_data ),
654                'wafConfig' => array_merge(
655                    self::filter_waf_config_by_capability( $waf_config ),
656                    array(
657                        'waf_supported'        => $waf_supported,
658                        'waf_enabled'          => $is_waf_enabled,
659                        'waf_provided_by_host' => $is_provided_by_host,
660                    ),
661                    array( 'blocked_logins' => (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) )
662                ),
663            )
664        );
665    }
666
667    /**
668     * Reduce the WAF configuration to the keys the current user is allowed to read.
669     *
670     * @param array $waf_config The WAF configuration as returned by Waf_Runner::get_config().
671     * @return array
672     */
673    private static function filter_waf_config_by_capability( array $waf_config ) {
674        if ( current_user_can( 'manage_options' ) ) {
675            return $waf_config;
676        }
677
678        return array_intersect_key( $waf_config, array_flip( self::NON_ADMIN_WAF_CONFIG_KEYS ) );
679    }
680
681    /**
682     * Reduce the scan status to the parts the current user is allowed to read.
683     *
684     * @param \Automattic\Jetpack\Protect_Models\Status_Model $scan_data The scan status as returned by Protect_Status::get_status().
685     * @return \Automattic\Jetpack\Protect_Models\Status_Model|array
686     */
687    private static function filter_scan_data_by_capability( $scan_data ) {
688        if ( current_user_can( 'manage_options' ) ) {
689            return $scan_data;
690        }
691
692        $filtered = array_intersect_key( (array) $scan_data, array_flip( self::NON_ADMIN_SCAN_DATA_KEYS ) );
693
694        // `files` and `database` hold Threat_Model instances, not extensions.
695        foreach ( array( 'threats', 'files', 'database' ) as $key ) {
696            $filtered[ $key ] = array_map( array( __CLASS__, 'redact_threat' ), (array) ( $filtered[ $key ] ?? array() ) );
697        }
698
699        foreach ( array( 'plugins', 'themes' ) as $key ) {
700            $filtered[ $key ] = array_map( array( __CLASS__, 'redact_extension' ), (array) ( $filtered[ $key ] ?? array() ) );
701        }
702
703        $filtered['core'] = self::redact_extension( $filtered['core'] ?? array() );
704
705        return $filtered;
706    }
707
708    /**
709     * Reduce a threat to the properties a user without `manage_options` may read.
710     *
711     * @param object|array $threat A Threat_Model instance.
712     * @return array
713     */
714    private static function redact_threat( $threat ) {
715        return array_intersect_key( (array) $threat, array_flip( self::NON_ADMIN_THREAT_KEYS ) );
716    }
717
718    /**
719     * Reduce an extension to the properties a user without `manage_options` may read.
720     *
721     * Only the nested threats survive: the extension's name, slug and installed version identify
722     * which vulnerable software the site is running.
723     *
724     * @param object|array $extension An Extension_Model instance.
725     * @return array
726     */
727    private static function redact_extension( $extension ) {
728        $threats = ( (array) $extension )['threats'] ?? array();
729
730        return array( 'threats' => array_map( array( __CLASS__, 'redact_threat' ), (array) $threats ) );
731    }
732}