PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-protect-status / src / class-scan-status.php

class-scan-status.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-protect-status/src/class-scan-status.php

366 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class to handle the Scan Status of Jetpack Protect
4 *
5 * @phan-suppress PhanDeprecatedFunction -- Maintaining backwards compatibility.
6 *
7 * @package automattic/jetpack-protect-status
8 */
9
10 namespace Automattic\Jetpack\Protect_Status;
11
12 use Automattic\Jetpack\Connection\Client;
13 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14 use Automattic\Jetpack\Plugins_Installer;
15 use Automattic\Jetpack\Protect_Models\Extension_Model;
16 use Automattic\Jetpack\Protect_Models\Status_Model;
17 use Automattic\Jetpack\Protect_Models\Threat_Model;
18 use Automattic\Jetpack\Sync\Functions as Sync_Functions;
19 use Jetpack_Options;
20 use WP_Error;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit( 0 );
24 }
25
26 /**
27 * Class that handles fetching of threats from the Scan API
28 */
29 class Scan_Status extends Status {
30
31 /**
32 * Scan endpoint
33 *
34 * @var string
35 */
36 const SCAN_API_BASE = '/sites/%d/scan';
37
38 /**
39 * Name of the option where status is stored
40 *
41 * @var string
42 */
43 const OPTION_NAME = 'jetpack_scan_status';
44
45 /**
46 * Name of the option where the timestamp of the status is stored
47 *
48 * @var string
49 */
50 const OPTION_TIMESTAMP_NAME = 'jetpack_scan_status_timestamp';
51
52 /**
53 * Time in seconds that the cache should last
54 *
55 * @var int
56 */
57 const OPTION_EXPIRES_AFTER = 300; // 5 minutes.
58
59 /**
60 * Gets the current status of the Jetpack Protect checks
61 *
62 * @param bool $refresh_from_wpcom Refresh the local plan and status cache from wpcom.
63 * @return Status_Model
64 */
65 public static function get_status( $refresh_from_wpcom = false ) {
66 if ( self::$status !== null ) {
67 return self::$status;
68 }
69
70 if ( $refresh_from_wpcom || ! self::should_use_cache() || self::is_cache_expired() ) {
71 $status = self::fetch_from_api();
72 } else {
73 $status = self::get_from_options();
74 }
75
76 if ( is_wp_error( $status ) ) {
77 $status = new Status_Model(
78 array(
79 'error' => true,
80 'error_code' => $status->get_error_code(),
81 'error_message' => $status->get_error_message(),
82 )
83 );
84 } else {
85 $status = self::normalize_api_data( $status );
86 }
87
88 self::$status = $status;
89 return $status;
90 }
91
92 /**
93 * Gets the Scan API endpoint
94 *
95 * @return WP_Error|string
96 */
97 public static function get_api_url() {
98 $blog_id = Jetpack_Options::get_option( 'id' );
99 $is_connected = ( new Connection_Manager() )->is_connected();
100
101 if ( ! $blog_id || ! $is_connected ) {
102 return new WP_Error( 'site_not_connected' );
103 }
104
105 $api_url = sprintf( self::SCAN_API_BASE, $blog_id );
106
107 return $api_url;
108 }
109
110 /**
111 * Fetches the status data from the Scan API
112 *
113 * @return WP_Error|array
114 */
115 public static function fetch_from_api() {
116 $api_url = self::get_api_url();
117 if ( is_wp_error( $api_url ) ) {
118 return $api_url;
119 }
120
121 $response = Client::wpcom_json_api_request_as_blog(
122 self::get_api_url(),
123 '2',
124 array(
125 'method' => 'GET',
126 'timeout' => 30,
127 ),
128 null,
129 'wpcom'
130 );
131
132 $response_code = wp_remote_retrieve_response_code( $response );
133
134 if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
135 return new WP_Error( 'failed_fetching_status', 'Failed to fetch Scan data from the server', array( 'status' => $response_code ) );
136 }
137
138 $body = json_decode( wp_remote_retrieve_body( $response ) );
139 self::update_status_option( $body );
140 return $body;
141 }
142
143 /**
144 * Normalize API Data
145 *
146 * Formats the payload from the Scan API into an instance of Status_Model.
147 *
148 * @phan-suppress PhanDeprecatedProperty -- Maintaining backwards compatibility.
149 *
150 * @param object $scan_data The data returned by the scan API.
151 *
152 * @return Status_Model
153 */
154 private static function normalize_api_data( $scan_data ) {
155 global $wp_version;
156
157 $installed_plugins = Plugins_Installer::get_plugins();
158 $installed_themes = Sync_Functions::get_themes();
159
160 $plugins = array();
161 $themes = array();
162 $core = new Extension_Model(
163 array(
164 'name' => 'WordPress',
165 'slug' => 'wordpress',
166 'version' => $wp_version,
167 'type' => 'core',
168 'checked' => true, // to do: default to false once Scan API has manifest
169 )
170 );
171 $files = array();
172 $database = array();
173
174 $status = new Status_Model(
175 array(
176 'data_source' => 'scan_api',
177 'status' => $scan_data->state ?? null,
178 'num_threats' => 0,
179 'num_themes_threats' => 0,
180 'num_plugins_threats' => 0,
181 'has_unchecked_items' => false,
182 'current_progress' => $scan_data->current->progress ?? null,
183 )
184 );
185
186 // Format the "last checked" timestamp.
187 if ( ! empty( $scan_data->most_recent->timestamp ) ) {
188 $date = new \DateTime( $scan_data->most_recent->timestamp );
189 $status->last_checked = $date->format( 'Y-m-d H:i:s' );
190 }
191
192 // Ensure all installed plugins and themes are represented in the status.
193 foreach ( $installed_plugins as $path => $installed_plugin ) {
194 $slug = str_replace( '.php', '', explode( '/', $path )[0] );
195 $plugin = new Extension_Model(
196 array(
197 'name' => $installed_plugin['Name'],
198 'version' => $installed_plugin['Version'],
199 'slug' => $slug,
200 'type' => 'plugins',
201 'checked' => true, // to do: default to false once Scan API has manifest
202 )
203 );
204
205 $plugins[ $slug ] = $plugin;
206 }
207 foreach ( $installed_themes as $path => $installed_theme ) {
208 $slug = str_replace( '.php', '', explode( '/', $path )[0] );
209 $theme = new Extension_Model(
210 array(
211 'name' => $installed_theme['Name'],
212 'version' => $installed_theme['Version'],
213 'slug' => $slug,
214 'type' => 'themes',
215 'checked' => true, // to do: default to false once Scan API has manifest
216 )
217 );
218
219 $themes[ $slug ] = $theme;
220 }
221
222 // Merge the threats into the status model.
223 if ( isset( $scan_data->threats ) && is_array( $scan_data->threats ) ) {
224 foreach ( $scan_data->threats as $scan_threat ) {
225 if ( isset( $scan_threat->fixable ) && $scan_threat->fixable ) {
226 $status->fixable_threat_ids[] = $scan_threat->id;
227 }
228
229 $db_details = null;
230 if ( ! empty( $scan_threat->table ) ) {
231 $db_details = (object) array_merge(
232 array(
233 'table' => $scan_threat->table,
234 'pk_column' => $scan_threat->pk_column ?? null,
235 'pk_value' => $scan_threat->value ?? null,
236 ),
237 ! empty( $scan_threat->details ) ? array( 'details' => $scan_threat->details ) : array()
238 );
239 }
240
241 $threat = new Threat_Model(
242 array(
243 'id' => $scan_threat->id ?? null,
244 'signature' => $scan_threat->signature ?? null,
245 'title' => $scan_threat->title ?? null,
246 'description' => $scan_threat->description ?? null,
247 'vulnerability_description' => $scan_threat->vulnerability_description ?? null,
248 'extension' => $scan_threat->extension ?? null,
249 'fix_description' => $scan_threat->fix_description ?? null,
250 'payload_subtitle' => $scan_threat->payload_subtitle ?? null,
251 'payload_description' => $scan_threat->payload_description ?? null,
252 'first_detected' => $scan_threat->first_detected ?? null,
253 'fixed_in' => isset( $scan_threat->fixer->fixer ) && 'update' === $scan_threat->fixer->fixer ? $scan_threat->fixer->target : null,
254 'severity' => $scan_threat->severity ?? null,
255 'fixable' => $scan_threat->fixer ?? null,
256 'status' => $scan_threat->status ?? null,
257 'filename' => $scan_threat->filename ?? null,
258 'context' => $scan_threat->context ?? null,
259 'source' => $scan_threat->source ?? null,
260 'table' => $scan_threat->table ?? null,
261 'details' => $db_details,
262 )
263 );
264
265 // Theme and Plugin Threats
266 if ( ! empty( $scan_threat->extension ) && in_array( $scan_threat->extension->type, array( 'plugin', 'theme' ), true ) ) {
267 $installed_extension = 'plugin' === $scan_threat->extension->type ? ( $plugins[ $scan_threat->extension->slug ] ?? null ) : ( $themes[ $scan_threat->extension->slug ] ?? null );
268
269 // If the extension is no longer installed, skip this threat.
270 // todo: use version_compare()
271 if ( ! $installed_extension ) {
272 continue;
273 }
274
275 // Push the threat to the appropriate extension.
276 switch ( $scan_threat->extension->type ) {
277 case 'plugin':
278 $plugins[ $scan_threat->extension->slug ]->threats[] = clone $threat;
279 ++$status->num_plugins_threats;
280 break;
281 case 'theme':
282 $themes[ $scan_threat->extension->slug ]->threats[] = clone $threat;
283 ++$status->num_themes_threats;
284 break;
285 default:
286 break;
287 }
288
289 $threat->extension = new Extension_Model(
290 array(
291 'name' => $scan_threat->extension->name ?? null,
292 'slug' => $scan_threat->extension->slug ?? null,
293 'version' => $scan_threat->extension->version ?? null,
294 'type' => $scan_threat->extension->type . 's',
295 'checked' => $installed_extension->version === $scan_threat->extension->version,
296 )
297 );
298 } elseif ( isset( $threat->signature ) && 'Vulnerable.WP.Core' === $threat->signature ) {
299 // Vulnerable WordPress Core Version Threats
300
301 // If the core version has changed, skip this threat.
302 // todo: use version_compare()
303 if ( $scan_threat->version !== $wp_version ) {
304 continue;
305 }
306
307 $core->threats[] = $threat;
308 } elseif ( ! empty( $threat->filename ) ) {
309 // File Threats
310 $files[] = $threat;
311 } elseif ( ! empty( $scan_threat->table ) ) {
312 // Database Threats
313 $database[] = $threat;
314 }
315
316 $status->threats[] = $threat;
317 ++$status->num_threats;
318 }
319 }
320
321 $status->threats = static::sort_threats( $status->threats );
322
323 // maintain deprecated properties for backwards compatibility
324 $status->plugins = array_values( $plugins );
325 $status->themes = array_values( $themes );
326 $status->core = $core;
327 $status->files = $files;
328 $status->database = $database;
329
330 return $status;
331 }
332
333 /**
334 * Sort By Threats
335 *
336 * @param array<Threat_Model> $threats Array of threats to sort.
337 *
338 * @return array<Threat_Model> The sorted $threats array.
339 */
340 protected static function sort_threats( $threats ) {
341 usort(
342 $threats,
343 function ( $a, $b ) {
344 // Order by active status first...
345 if ( $a->status !== $b->status ) {
346 return 'active' === $a->status ? -1 : 1;
347 }
348
349 // ...then by severity...
350 if ( $a->severity !== $b->severity ) {
351 return $a->severity > $b->severity ? -1 : 1;
352 }
353
354 // ...then date added.
355 if ( $a->first_detected !== $b->first_detected ) {
356 return strtotime( $a->first_detected ) < strtotime( $b->first_detected ) ? -1 : 1;
357 }
358
359 return 0;
360 }
361 );
362
363 return $threats;
364 }
365 }
366