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