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