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