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