Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scan_History
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 12
2070
0.00% covered (danger)
0.00%
0 / 1
 is_cache_expired
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 should_use_cache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 get_from_options
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 update_history_option
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 delete_option
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_scan_history
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 get_api_url
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 fetch_from_api
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 normalize_api_data
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 handle_extension_threats
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 handle_core_threats
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 handle_additional_threats
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Class to handle the Scan Status of Jetpack Protect
4 *
5 * @package automattic/jetpack-protect-plugin
6 */
7
8namespace Automattic\Jetpack\Protect;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Protect_Models\Extension_Model;
13use Automattic\Jetpack\Protect_Models\History_Model;
14use Automattic\Jetpack\Protect_Models\Threat_Model;
15use Automattic\Jetpack\Protect_Status\Plan;
16use Jetpack_Options;
17use WP_Error;
18
19/**
20 * Class that handles fetching of threats from the Scan API
21 */
22class Scan_History {
23    /**
24     * Scan endpoint
25     *
26     * @var string
27     */
28    const SCAN_HISTORY_API_BASE = '/sites/%d/scan/history';
29
30    /**
31     * Name of the option where history is stored
32     *
33     * @var string
34     */
35    const OPTION_NAME = 'jetpack_scan_history';
36
37    /**
38     * Name of the option where the timestamp of the history is stored
39     *
40     * @var string
41     */
42    const OPTION_TIMESTAMP_NAME = 'jetpack_scan_history_timestamp';
43
44    /**
45     * Time in seconds that the cache should last
46     *
47     * @var int
48     */
49    const OPTION_EXPIRES_AFTER = 300; // 5 minutes.
50
51    /**
52     * Memoization for the current history
53     *
54     * @var null|History_Model
55     */
56    public static $history = null;
57
58    /**
59     * Checks if the current cached history is expired and should be renewed
60     *
61     * @return boolean
62     */
63    public static function is_cache_expired() {
64        $option_timestamp = get_option( static::OPTION_TIMESTAMP_NAME );
65
66        if ( ! $option_timestamp ) {
67            return true;
68        }
69
70        return time() > (int) $option_timestamp;
71    }
72
73    /**
74     * Checks if we should consider the stored cache or bypass it
75     *
76     * @return boolean
77     */
78    public static function should_use_cache() {
79        return ! ( ( defined( 'JETPACK_PROTECT_DEV__BYPASS_CACHE' ) && JETPACK_PROTECT_DEV__BYPASS_CACHE ) );
80    }
81
82    /**
83     * Gets the current cached history
84     *
85     * @return bool|array False if value is not found. Array with values if cache is found.
86     */
87    public static function get_from_options() {
88        return maybe_unserialize( get_option( static::OPTION_NAME ) );
89    }
90
91    /**
92     * Updated the cached history and its timestamp
93     *
94     * @param array $history The new history to be cached.
95     * @return void
96     */
97    public static function update_history_option( $history ) {
98        // TODO: Sanitize $history.
99        update_option( static::OPTION_NAME, maybe_serialize( $history ) );
100        update_option( static::OPTION_TIMESTAMP_NAME, time() + static::OPTION_EXPIRES_AFTER );
101    }
102
103    /**
104     * Delete the cached history and its timestamp
105     *
106     * @return bool Whether all related history options were successfully deleted.
107     */
108    public static function delete_option() {
109        $option_deleted           = delete_option( static::OPTION_NAME );
110        $option_timestamp_deleted = delete_option( static::OPTION_TIMESTAMP_NAME );
111
112        return $option_deleted && $option_timestamp_deleted;
113    }
114
115    /**
116     * Gets the current history of the Jetpack Protect checks
117     *
118     * @param bool $refresh_from_wpcom Refresh the local plan and history cache from wpcom.
119     * @return History_Model|bool
120     */
121    public static function get_scan_history( $refresh_from_wpcom = false ) {
122        $has_required_plan = Plan::has_required_plan();
123        if ( ! $has_required_plan ) {
124            return false;
125        }
126
127        if ( self::$history !== null ) {
128            return self::$history;
129        }
130
131        if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
132            $history = self::fetch_from_api();
133        } else {
134            $history = self::get_from_options();
135        }
136
137        if ( is_wp_error( $history ) ) {
138            $history = new History_Model(
139                array(
140                    'error'         => true,
141                    'error_code'    => $history->get_error_code(),
142                    'error_message' => $history->get_error_message(),
143                )
144            );
145        } else {
146            $history = self::normalize_api_data( $history );
147        }
148
149        self::$history = $history;
150        return $history;
151    }
152
153    /**
154     * Gets the Scan API endpoint
155     *
156     * @return WP_Error|string
157     */
158    public static function get_api_url() {
159        $blog_id      = Jetpack_Options::get_option( 'id' );
160        $is_connected = ( new Connection_Manager() )->is_connected();
161
162        if ( ! $blog_id || ! $is_connected ) {
163            return new WP_Error( 'site_not_connected' );
164        }
165
166        $api_url = sprintf( self::SCAN_HISTORY_API_BASE, $blog_id );
167
168        return $api_url;
169    }
170
171    /**
172     * Fetches the history data from the Scan API
173     *
174     * @return WP_Error|array
175     */
176    public static function fetch_from_api() {
177        $api_url = self::get_api_url();
178        if ( is_wp_error( $api_url ) ) {
179            return $api_url;
180        }
181
182        $response = Client::wpcom_json_api_request_as_blog(
183            $api_url,
184            '2',
185            array(
186                'method'  => 'GET',
187                'timeout' => 30,
188            ),
189            null,
190            'wpcom'
191        );
192
193        $response_code = wp_remote_retrieve_response_code( $response );
194
195        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
196            return new WP_Error( 'failed_fetching_status', 'Failed to fetch Scan history from the server', array( 'status' => $response_code ) );
197        }
198
199        $body               = json_decode( wp_remote_retrieve_body( $response ) );
200        $body->last_checked = ( new \DateTime() )->format( 'Y-m-d H:i:s' );
201        self::update_history_option( $body );
202
203        return $body;
204    }
205
206    /**
207     * Normalize API Data
208     * Formats the payload from the Scan API into an instance of History_Model.
209     *
210     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
211     *
212     * @param object $scan_data The data returned by the scan API.
213     * @return History_Model
214     */
215    private static function normalize_api_data( $scan_data ) {
216        $history                      = new History_Model();
217        $history->num_threats         = 0;
218        $history->num_core_threats    = 0;
219        $history->num_plugins_threats = 0;
220        $history->num_themes_threats  = 0;
221
222        $history->last_checked = $scan_data->last_checked;
223
224        if ( empty( $scan_data->threats ) || ! is_array( $scan_data->threats ) ) {
225            return $history;
226        }
227
228        foreach ( $scan_data->threats as $threat ) {
229            if ( isset( $threat->extension->type ) ) {
230                if ( 'plugin' === $threat->extension->type ) {
231                    self::handle_extension_threats( $threat, $history, 'plugin' );
232                    continue;
233                }
234
235                if ( 'theme' === $threat->extension->type ) {
236                    self::handle_extension_threats( $threat, $history, 'theme' );
237                    continue;
238                }
239            }
240
241            if ( 'Vulnerable.WP.Core' === $threat->signature ) {
242                self::handle_core_threats( $threat, $history );
243                continue;
244            }
245
246            self::handle_additional_threats( $threat, $history );
247        }
248
249        return $history;
250    }
251
252    /**
253     * Handles threats for extensions such as plugins or themes.
254     *
255     * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
256     *
257     * @param object $threat The threat object.
258     * @param object $history The history object.
259     * @param string $type The type of extension ('plugin' or 'theme').
260     * @return void
261     */
262    private static function handle_extension_threats( $threat, $history, $type ) {
263        $extension_list = $type === 'plugin' ? 'plugins' : 'themes';
264        $extensions     = &$history->{ $extension_list};
265        $found_index    = null;
266
267        // Check if the extension does not exist in the array
268        foreach ( $extensions as $index => $extension ) {
269            if ( $extension->slug === $threat->extension->slug ) {
270                $found_index = $index;
271                break;
272            }
273        }
274
275        // Add the extension if it does not yet exist in the history
276        if ( $found_index === null ) {
277            $new_extension = new Extension_Model(
278                array(
279                    'name'    => $threat->extension->name ?? null,
280                    'slug'    => $threat->extension->slug ?? null,
281                    'version' => $threat->extension->version ?? null,
282                    'type'    => $type,
283                    'checked' => true,
284                    'threats' => array(),
285                )
286            );
287            $extensions[]  = $new_extension;
288            $found_index   = array_key_last( $extensions );
289        }
290
291        // Add the threat to the found extension
292        $extensions[ $found_index ]->threats[] = new Threat_Model( $threat );
293
294        // Increment the threat counts
295        ++$history->num_threats;
296        if ( $type === 'plugin' ) {
297            ++$history->num_plugins_threats;
298        } elseif ( $type === 'theme' ) {
299            ++$history->num_themes_threats;
300        }
301    }
302
303    /**
304     * Handles core threats
305     *
306     * @param object $threat The threat object.
307     * @param object $history The history object.
308     * @return void
309     */
310    private static function handle_core_threats( $threat, $history ) {
311        // Check if the core version does not exist in the array
312        $found_index = null;
313        foreach ( $history->core as $index => $core ) {
314            if ( $core->version === $threat->version ) {
315                $found_index = $index;
316                break;
317            }
318        }
319
320        // Add the extension if it does not yet exist in the history
321        if ( null === $found_index ) {
322            $new_core        = new Extension_Model(
323                array(
324                    'name'    => 'WordPress',
325                    'version' => $threat->version,
326                    'type'    => 'core',
327                    'checked' => true,
328                    'threats' => array(),
329                )
330            );
331            $history->core[] = $new_core;
332            $found_index     = array_key_last( $history->core );
333        }
334
335        // Add the threat to the found core
336        $history->core[ $found_index ]->threats[] = new Threat_Model( $threat );
337
338        ++$history->num_threats;
339        ++$history->num_core_threats;
340    }
341
342    /**
343     * Handles additional threats that are not core, plugin or theme
344     *
345     * @param object $threat The threat object.
346     * @param object $history The history object.
347     * @return void
348     */
349    private static function handle_additional_threats( $threat, $history ) {
350        if ( ! empty( $threat->filename ) ) {
351            $history->files[] = new Threat_Model( $threat );
352            ++$history->num_threats;
353        } elseif ( ! empty( $threat->table ) ) {
354            $history->database[] = new Threat_Model( $threat );
355            ++$history->num_threats;
356        }
357    }
358}