Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.47% covered (warning)
86.47%
115 / 133
92.00% covered (success)
92.00%
23 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Protect
87.02% covered (warning)
87.02%
114 / 131
92.00% covered (success)
92.00%
23 / 25
42.32
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
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
 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\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 6.10.0
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     * Whether this product requires a user connection
138     *
139     * @var string
140     */
141    public static $requires_user_connection = false;
142
143    /**
144     * Whether this product has a free offering
145     *
146     * @var bool
147     */
148    public static $has_free_offering = true;
149
150    /**
151     * Protect has a standalone plugin
152     *
153     * @var bool
154     */
155    public static $has_standalone_plugin = true;
156
157    /**
158     * The feature slug that identifies the paid plan
159     *
160     * @var string
161     */
162    public static $feature_identifying_paid_plan = 'scan';
163
164    /**
165     * Setup Protect REST API endpoints
166     *
167     * @return void
168     */
169    public static function register_endpoints(): void {
170        parent::register_endpoints();
171        // Get Jetpack Protect data.
172        register_rest_route(
173            'my-jetpack/v1',
174            '/site/protect/data',
175            array(
176                'methods'             => \WP_REST_Server::READABLE,
177                'callback'            => __CLASS__ . '::get_site_protect_data',
178                'permission_callback' => __CLASS__ . '::permissions_callback',
179            )
180        );
181    }
182
183    /**
184     * Checks if the user has the correct permissions
185     */
186    public static function permissions_callback() {
187        return current_user_can( 'edit_posts' );
188    }
189
190    /**
191     * Get the product name
192     *
193     * @return string
194     */
195    public static function get_name() {
196        return 'Protect';
197    }
198
199    /**
200     * Get the product title
201     *
202     * @return string
203     */
204    public static function get_title() {
205        return 'Jetpack Protect';
206    }
207
208    /**
209     * Get the internationalized product description
210     *
211     * @return string
212     */
213    public static function get_description() {
214        return __( 'Guard against malware and bad actors 24/7', 'jetpack-my-jetpack' );
215    }
216
217    /**
218     * Get the internationalized product long description
219     *
220     * @return string
221     */
222    public static function get_long_description() {
223        return __( 'Protect your site from bad actors and malware 24/7. Clean up security vulnerabilities with one click.', 'jetpack-my-jetpack' );
224    }
225
226    /**
227     * Get the internationalized features list
228     *
229     * @return array Protect features list
230     */
231    public static function get_features() {
232        return array(
233            __( 'Over 20,000 listed vulnerabilities', 'jetpack-my-jetpack' ),
234            __( 'Daily automatic scans', 'jetpack-my-jetpack' ),
235            __( 'Check plugin and theme version status', 'jetpack-my-jetpack' ),
236            __( 'Easy to navigate and use', 'jetpack-my-jetpack' ),
237        );
238    }
239
240    /**
241     * Get the product's available tiers
242     *
243     * @return string[] Slugs of the available tiers
244     */
245    public static function get_tiers() {
246        return array(
247            self::UPGRADED_TIER_SLUG,
248            self::FREE_TIER_SLUG,
249        );
250    }
251
252    /**
253     * Get the internationalized comparison of free vs upgraded features
254     *
255     * @deprecated 6.10.0
256     *
257     * @return array[] Protect features comparison
258     */
259    public static function get_features_by_tier() {
260        _deprecated_function( __METHOD__, 'jetpack-my-jetpack-6.10.0' );
261
262        return array(
263            array(
264                'name'  => __( 'Scan for threats and vulnerabilities', 'jetpack-my-jetpack' ),
265                'tiers' => array(
266                    self::FREE_TIER_SLUG     => array(
267                        'included'    => true,
268                        'description' => __( 'Check items against database', 'jetpack-my-jetpack' ),
269                    ),
270                    self::UPGRADED_TIER_SLUG => array(
271                        'included'    => true,
272                        'description' => __( 'Line by line malware scanning', 'jetpack-my-jetpack' ),
273                    ),
274                ),
275            ),
276            array(
277                'name'  => __( 'Daily automated scans', 'jetpack-my-jetpack' ),
278                'tiers' => array(
279                    self::FREE_TIER_SLUG     => array( 'included' => true ),
280                    self::UPGRADED_TIER_SLUG => array(
281                        'included'    => true,
282                        'description' => __( 'Plus on-demand manual scans', 'jetpack-my-jetpack' ),
283                    ),
284                ),
285            ),
286            array(
287                'name'  => __( 'Web Application Firewall', 'jetpack-my-jetpack' ),
288                'tiers' => array(
289                    self::FREE_TIER_SLUG     => array(
290                        'included'    => false,
291                        'description' => __( 'Manual rules only', 'jetpack-my-jetpack' ),
292                    ),
293                    self::UPGRADED_TIER_SLUG => array(
294                        'included'    => true,
295                        'description' => __( 'Automatic protection and rule updates', 'jetpack-my-jetpack' ),
296                    ),
297                ),
298            ),
299            array(
300                'name'  => __( 'Brute force protection', 'jetpack-my-jetpack' ),
301                'tiers' => array(
302                    self::FREE_TIER_SLUG     => array( 'included' => true ),
303                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
304                ),
305            ),
306            array(
307                'name'  => __( 'Account protection', 'jetpack-my-jetpack' ),
308                'tiers' => array(
309                    self::FREE_TIER_SLUG     => array( 'included' => true ),
310                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
311                ),
312            ),
313            array(
314                'name'  => __( 'Access to scan on Cloud', 'jetpack-my-jetpack' ),
315                'tiers' => array(
316                    self::FREE_TIER_SLUG     => array( 'included' => false ),
317                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
318                ),
319            ),
320            array(
321                'name'  => __( 'One-click auto fixes', 'jetpack-my-jetpack' ),
322                'tiers' => array(
323                    self::FREE_TIER_SLUG     => array( 'included' => false ),
324                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
325                ),
326            ),
327            array(
328                'name'  => __( 'Notifications', 'jetpack-my-jetpack' ),
329                'tiers' => array(
330                    self::FREE_TIER_SLUG     => array( 'included' => false ),
331                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
332                ),
333            ),
334            array(
335                'name'  => __( 'Severity labels', 'jetpack-my-jetpack' ),
336                'tiers' => array(
337                    self::FREE_TIER_SLUG     => array( 'included' => false ),
338                    self::UPGRADED_TIER_SLUG => array( 'included' => true ),
339                ),
340            ),
341        );
342    }
343
344    /**
345     * Get the product pricing details
346     *
347     * @return array Pricing details
348     */
349    public static function get_pricing_for_ui() {
350        return array(
351            'tiers' => array(
352                self::FREE_TIER_SLUG     => array(
353                    'available' => true,
354                    'is_free'   => true,
355                ),
356                self::UPGRADED_TIER_SLUG => array_merge(
357                    array(
358                        'available'          => true,
359                        'wpcom_product_slug' => self::UPGRADED_TIER_PRODUCT_SLUG,
360                    ),
361                    Wpcom_Products::get_product_pricing( self::UPGRADED_TIER_PRODUCT_SLUG )
362                ),
363            ),
364        );
365    }
366
367    /**
368     * Determines whether the module/plugin/product needs the users attention.
369     * Typically due to some sort of error where user troubleshooting is needed.
370     *
371     * @return boolean|array
372     */
373    public static function does_module_need_attention() {
374        $protect_threat_status = false;
375        $scan_data             = Protect_Status::get_status();
376
377        // Check if there are scan threats.
378        $protect_data = $scan_data;
379        if ( is_wp_error( $protect_data ) ) {
380            return $protect_threat_status; // false
381        }
382        $critical_threat_count = false;
383        if ( ! empty( $protect_data->threats ) ) {
384            $critical_threat_count = array_reduce(
385                $protect_data->threats,
386                function ( $accum, $threat ) {
387                    return $threat->severity >= 5 ? ++$accum : $accum;
388                },
389                0
390            );
391
392            $protect_threat_status = array(
393                'type' => $critical_threat_count ? 'error' : 'warning',
394                'data' => array(
395                    'threat_count'          => count( $protect_data->threats ),
396                    'critical_threat_count' => $critical_threat_count,
397                    'fixable_threat_ids'    => $protect_data->fixable_threat_ids,
398                ),
399            );
400        }
401
402        return $protect_threat_status;
403    }
404
405    /**
406     * Get the product-slugs of the paid plans for this product.
407     * (Do not include bundle plans, unless it's a bundle plan itself).
408     *
409     * @return array
410     */
411    public static function get_paid_plan_product_slugs() {
412        return array(
413            'jetpack_scan',
414            'jetpack_scan_monthly',
415            'jetpack_scan_bi_yearly',
416        );
417    }
418
419    /**
420     * Checks whether the product can be upgraded - i.e. this shows the /#add-protect interstitial
421     *
422     * @return boolean
423     */
424    public static function is_upgradable() {
425        return ! self::has_paid_plan_for_product();
426    }
427
428    /**
429     * Get the URL the user is taken after purchasing the product through the checkout
430     *
431     * @return ?string
432     */
433    public static function get_post_checkout_url() {
434        return self::get_manage_url();
435    }
436
437    /**
438     * Get the URL the user is taken after purchasing the product through the checkout for each product feature
439     *
440     * @return ?array
441     */
442    public static function get_post_checkout_urls_by_feature() {
443        return array(
444            self::SCAN_FEATURE_SLUG     => self::get_post_checkout_url(),
445            self::FIREWALL_FEATURE_SLUG => admin_url( 'admin.php?page=jetpack-protect#/firewall' ),
446        );
447    }
448
449    /**
450     * Whether wp-admin has a Protect page, from the standalone plugin or the Protect package's dashboard.
451     *
452     * @since $$next-version$$
453     *
454     * @return bool
455     */
456    public static function has_protect_page() {
457        return static::is_standalone_plugin_active() || (bool) did_action( 'jetpack_protect_dashboard_initialized' );
458    }
459
460    /**
461     * Get the product info, with whether the Protect page exists and where Scan's results are listed.
462     *
463     * @return array
464     */
465    public static function get_info() {
466        return array_merge(
467            parent::get_info(),
468            array(
469                'has_protect_page' => static::has_protect_page(),
470                'scan_results_url' => Scan::get_manage_url(),
471            )
472        );
473    }
474
475    /**
476     * Get the URL where the user manages the product
477     *
478     * @return ?string
479     */
480    public static function get_manage_url() {
481        if ( static::has_protect_page() ) {
482            // Protect admin dashboard.
483            return admin_url( 'admin.php?page=jetpack-protect' );
484        }
485
486        if ( static::has_paid_plan_for_product() ) {
487            // Paid users without standalone plugin go to Jetpack Cloud Scan dashboard.
488            return Redirect::get_url( 'my-jetpack-manage-scan' );
489        }
490
491        // Free users without standalone plugin go to the Protect details page.
492        return admin_url( 'admin.php?page=my-jetpack#/protect-details' );
493    }
494
495    /**
496     * Get the URL where the user manages the product for each product feature
497     *
498     * @return ?array
499     */
500    public static function get_manage_urls_by_feature() {
501        return array(
502            self::SCAN_FEATURE_SLUG     => self::get_manage_url(),
503            self::FIREWALL_FEATURE_SLUG => admin_url( 'admin.php?page=jetpack-protect#/firewall' ),
504        );
505    }
506
507    /**
508     * Return product bundles list
509     * that supports the product.
510     *
511     * @return array Products bundle list.
512     */
513    public static function is_upgradable_by_bundle() {
514        return array( 'security', 'complete' );
515    }
516
517    /**
518     * Return site Jetpack Protect data for the REST API.
519     *
520     * @return WP_Rest_Response|WP_Error
521     */
522    public static function get_site_protect_data() {
523        $scan_data = Protect_Status::get_status();
524
525        $waf_config     = array();
526        $waf_supported  = false;
527        $is_waf_enabled = false;
528
529        if ( class_exists( 'Automattic\Jetpack\Waf\Waf_Runner' ) ) {
530            $waf_config     = Waf_Runner::get_config();
531            $is_waf_enabled = Waf_Runner::is_enabled();
532            $waf_supported  = Waf_Runner::is_supported_environment();
533        }
534
535        return rest_ensure_response(
536            array(
537                'scanData'  => self::filter_scan_data_by_capability( $scan_data ),
538                'wafConfig' => array_merge(
539                    self::filter_waf_config_by_capability( $waf_config ),
540                    array(
541                        'waf_supported' => $waf_supported,
542                        'waf_enabled'   => $is_waf_enabled,
543                    ),
544                    array( 'blocked_logins' => (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) )
545                ),
546            )
547        );
548    }
549
550    /**
551     * Reduce the WAF configuration to the keys the current user is allowed to read.
552     *
553     * @param array $waf_config The WAF configuration as returned by Waf_Runner::get_config().
554     * @return array
555     */
556    private static function filter_waf_config_by_capability( array $waf_config ) {
557        if ( current_user_can( 'manage_options' ) ) {
558            return $waf_config;
559        }
560
561        return array_intersect_key( $waf_config, array_flip( self::NON_ADMIN_WAF_CONFIG_KEYS ) );
562    }
563
564    /**
565     * Reduce the scan status to the parts the current user is allowed to read.
566     *
567     * @param \Automattic\Jetpack\Protect_Models\Status_Model $scan_data The scan status as returned by Protect_Status::get_status().
568     * @return \Automattic\Jetpack\Protect_Models\Status_Model|array
569     */
570    private static function filter_scan_data_by_capability( $scan_data ) {
571        if ( current_user_can( 'manage_options' ) ) {
572            return $scan_data;
573        }
574
575        $filtered = array_intersect_key( (array) $scan_data, array_flip( self::NON_ADMIN_SCAN_DATA_KEYS ) );
576
577        // `files` and `database` hold Threat_Model instances, not extensions.
578        foreach ( array( 'threats', 'files', 'database' ) as $key ) {
579            $filtered[ $key ] = array_map( array( __CLASS__, 'redact_threat' ), (array) ( $filtered[ $key ] ?? array() ) );
580        }
581
582        foreach ( array( 'plugins', 'themes' ) as $key ) {
583            $filtered[ $key ] = array_map( array( __CLASS__, 'redact_extension' ), (array) ( $filtered[ $key ] ?? array() ) );
584        }
585
586        $filtered['core'] = self::redact_extension( $filtered['core'] ?? array() );
587
588        return $filtered;
589    }
590
591    /**
592     * Reduce a threat to the properties a user without `manage_options` may read.
593     *
594     * @param object|array $threat A Threat_Model instance.
595     * @return array
596     */
597    private static function redact_threat( $threat ) {
598        return array_intersect_key( (array) $threat, array_flip( self::NON_ADMIN_THREAT_KEYS ) );
599    }
600
601    /**
602     * Reduce an extension to the properties a user without `manage_options` may read.
603     *
604     * Only the nested threats survive: the extension's name, slug and installed version identify
605     * which vulnerable software the site is running.
606     *
607     * @param object|array $extension An Extension_Model instance.
608     * @return array
609     */
610    private static function redact_extension( $extension ) {
611        $threats = ( (array) $extension )['threats'] ?? array();
612
613        return array( 'threats' => array_map( array( __CLASS__, 'redact_threat' ), (array) $threats ) );
614    }
615}