PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.0
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / behavior / scan / class-plugin-integrity.php

class-plugin-integrity.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at src/behavior/scan/class-plugin-integrity.php

369 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles plugin integrity scan.
4 *
5 * @package WP_Defender\Behavior\Scan
6 */
7
8 namespace WP_Defender\Behavior\Scan;
9
10 use ArrayIterator;
11 use Calotes\Base\File;
12 use WP_Defender\Traits\IO;
13 use WP_Defender\Model\Scan;
14 use WP_Defender\Traits\Plugin;
15 use Calotes\Component\Behavior;
16 use WP_Defender\Component\Timer;
17 use WP_Defender\Model\Scan_Item;
18 use WP_Defender\Integrations\Smush;
19 use WP_Defender\Model\Setting\Scan as Scan_Settings;
20 use WP_Defender\Helper\Analytics\Scan as Scan_Analytics;
21 use WP_Defender\Controller\Scan as Scan_Controller;
22 use WP_Defender\Component\Scan as Scan_Component;
23
24 /**
25 * It is responsible for performing integrity checks on plugins.
26 */
27 class Plugin_Integrity extends Behavior {
28
29 use IO;
30 use Plugin;
31
32 public const URL_PLUGIN_VCS = 'https://downloads.wordpress.org/plugin-checksums/';
33 public const PLUGIN_SLUGS = 'wd_plugin_slugs_changes';
34 public const PLUGIN_SLUGS_CHECKPOINT = 'wd_plugin_slugs_changes_checkpoint';
35 public const PLUGIN_PREMIUM_SLUGS = 'wd_plugin_premium_slugs';
36 /**
37 * List of premium plugin slugs.
38 *
39 * @var array
40 */
41 private $premium_slugs = array();
42
43 /**
44 * Check if the given slug is a valid WordPress.org slug.
45 *
46 * @param string $slug The slug to check.
47 *
48 * @return bool Returns true if the slug is valid, false otherwise.
49 */
50 private function is_valid_wporg_slug( string $slug ): bool {
51 return '' !== $slug && '.' !== $slug;
52 }
53
54 /**
55 * Plucks a specific field from an array of objects or arrays and returns a new array with the plucked values.
56 *
57 * @param array $collection The array to pluck values from.
58 * @param string $prefix Optional. A prefix to add to each key in the new array. Default is an empty string.
59 *
60 * @return array The new array with the plucked values.
61 */
62 private function pluck( array $collection, $prefix = '' ): array {
63 $new_list = array();
64
65 foreach ( $collection as $key => $value ) {
66 $prefix_key = defender_replace_line( $prefix . $key );
67 if ( is_object( $value ) && property_exists( $value, 'md5' ) ) {
68 $new_list[ $prefix_key ] = $value->md5;
69 } elseif ( is_array( $value ) && isset( $value['md5'] ) ) {
70 $new_list[ $prefix_key ] = $value['md5'];
71 }
72 }
73
74 return $new_list;
75 }
76
77 /**
78 * Check if a plugin is a WPMUDEV pro plugin by checking for WDP ID header.
79 *
80 * @param string $plugin_file Plugin main file path.
81 *
82 * @return bool True if it's a WPMUDEV pro plugin, false otherwise.
83 */
84 private function is_wpmudev_pro_plugin( $plugin_file ): bool {
85 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file );
86 return array_key_exists( 'WDP ID', $plugin_data ) && '' !== $plugin_data['WDP ID'];
87 }
88
89 /**
90 * Retrieve hash for a given plugin from wp.org.
91 * Excludes premium plugins and WPMUDEV pro plugins from hash retrieval.
92 *
93 * @param string $slug Plugin folder.
94 * @param string $version Plugin version.
95 * @param string $plugin_file Plugin main file path.
96 *
97 * @return array
98 */
99 private function get_plugin_hash( string $slug, $version, $plugin_file = '' ): array {
100 if ( ! $this->is_valid_wporg_slug( $slug ) ) {
101 $this->premium_slugs[] = $slug;
102
103 return array();
104 }
105
106 // Check if this is a WPMUDEV pro plugin that upgraded from free version.
107 if ( '' !== $plugin_file && $this->is_wpmudev_pro_plugin( $plugin_file ) ) {
108 $this->premium_slugs[] = $slug;
109
110 return array();
111 }
112
113 // Get original from wp.org e.g. https://downloads.wordpress.org/plugin-checksums/hello-dolly/1.6.json.
114 $response = wp_remote_get( self::URL_PLUGIN_VCS . $slug . '/' . $version . '.json' );
115
116 if ( is_wp_error( $response ) ) {
117 $this->premium_slugs[] = $slug;
118
119 return array();
120 }
121
122 if ( 404 === (int) wp_remote_retrieve_response_code( $response ) ) {
123 // This plugin is not found on WordPress.org.
124 $this->premium_slugs[] = $slug;
125
126 return array();
127 }
128
129 $body = wp_remote_retrieve_body( $response );
130 if ( ! $body ) {
131 $this->premium_slugs[] = $slug;
132
133 return array();
134 }
135
136 $data = json_decode( $body, true );
137
138 if ( ! isset( $data['files'] ) || ! is_array( $data['files'] ) || array() === $data['files'] ) {
139 return array();
140 }
141
142 if ( ! $this->is_likely_wporg_slug( $slug ) ) {
143 $this->premium_slugs[] = $slug;
144
145 return array();
146 }
147
148 return $this->pluck( $data['files'], $slug . DIRECTORY_SEPARATOR );
149 }
150
151 /**
152 * Fetch the checksums.
153 *
154 * @return array
155 */
156 protected function plugin_checksum(): array {
157 $all_plugin_hashes = array();
158 $actioned_plugins = get_site_option( Scan_Component::PLUGINS_ACTIONED );
159
160 if ( Scan_Component::are_actioned_plugins( $actioned_plugins ) ) {
161 foreach ( $actioned_plugins as $slug => $plugin_data ) {
162 $plugin_hashes = $this->get_plugin_hash( $slug, $plugin_data['Version'], $plugin_data['Slug'] );
163
164 if ( array() !== $plugin_hashes ) {
165 $all_plugin_hashes = array_merge( $all_plugin_hashes, $plugin_hashes );
166 }
167 }
168 } else {
169 // An emergency option if for some reason the plugin's quick data is not saved in the first step.
170 foreach ( $this->get_plugins() as $plugin_file => $plugin ) {
171 $slug = $this->get_plugin_slug_by( $plugin_file );
172 $plugin_hashes = $this->get_plugin_hash( $slug, $plugin['Version'], $plugin_file );
173
174 if ( array() !== $plugin_hashes ) {
175 $all_plugin_hashes = array_merge( $all_plugin_hashes, $plugin_hashes );
176 }
177 }
178 }
179
180 return $all_plugin_hashes;
181 }
182
183 /**
184 * Performs integrity check on plugins.
185 *
186 * @return bool
187 */
188 public function plugin_integrity_check(): bool {
189 $abs_path = defender_replace_line( WP_PLUGIN_DIR );
190 $settings = wd_di()->get( Scan_Settings::class );
191 $plugins = new File(
192 $abs_path,
193 true,
194 false,
195 array(),
196 array( 'filename' => array( 'index.php' ) ),
197 true,
198 true,
199 $settings->filesize
200 );
201
202 $plugin_files = $plugins->get_dir_tree();
203 $plugin_files = array_filter(
204 $plugin_files,
205 function ( $value ) {
206 return is_string( $value ) && '' !== trim( $value );
207 }
208 );
209
210 $plugin_files = new ArrayIterator( $plugin_files );
211 $checksums = $this->plugin_checksum();
212 $timer = new Timer();
213 $model = $this->owner->scan;
214 $pos = (int) $model->task_checkpoint;
215 $plugin_files->seek( $pos );
216 if ( 0 === $pos ) {
217 delete_site_option( self::PLUGIN_SLUGS_CHECKPOINT );
218 }
219 $slugs_of_edited_plugins = array();
220 $integration_smush = wd_di()->get( Smush::class );
221 $exist_smush_images = $integration_smush->exist_image_table();
222 while ( $plugin_files->valid() ) {
223 if ( ! $timer->check() ) {
224
225 $reason = 'break out cause too long';
226
227 /**
228 * Retrieves the Scan_Analytics class.
229 *
230 * @var Scan_Analytics $scan_analytics
231 */
232 $scan_analytics = wd_di()->get( Scan_Analytics::class );
233
234 $scan_analytics->track_feature(
235 $scan_analytics::EVENT_SCAN_FAILED,
236 array(
237 $scan_analytics::EVENT_SCAN_FAILED_PROP => $scan_analytics::EVENT_SCAN_FAILED_ERROR,
238 'Error_Reason' => $reason,
239 )
240 );
241
242 $this->log( $reason, Scan_Controller::SCAN_LOG );
243 break;
244 }
245
246 if ( $model->is_issue_whitelisted( $plugin_files->current() ) ) {
247 // This is whitelisted, so do nothing.
248 $plugin_files->next();
249 continue;
250 }
251
252 if ( $model->is_issue_ignored( $plugin_files->current() ) ) {
253 // This is ignored, so do nothing.
254 $plugin_files->next();
255 continue;
256 }
257
258 if (
259 $exist_smush_images
260 && $this->file_is_valid_image( $plugin_files->current() )
261 && $integration_smush->exist_image_path( $plugin_files->current() )
262 ) {
263 $this->log(
264 sprintf( 'skip %s because of Smush optimized file', $plugin_files->current() ),
265 Scan_Controller::SCAN_LOG
266 );
267 $plugin_files->next();
268 continue;
269 }
270
271 $file = $plugin_files->current();
272 $rev_file = str_replace( $abs_path, '', $file );
273 // Remove directory separator on the left.
274 $rev_file = ltrim( $rev_file, DIRECTORY_SEPARATOR );
275 $base_slug = explode( '/', $rev_file );
276 $base_slug = array_shift( $base_slug );
277
278 // Verify files only from wp.org. No Premium-things.
279 if ( isset( $checksums[ $rev_file ] ) ) {
280 if ( ! $this->compare_hashes( $file, $checksums[ $rev_file ] ) ) {
281 $slugs_of_edited_plugins[] = $base_slug;
282 $this->log( sprintf( 'modified %s', $file ), Scan_Controller::SCAN_LOG );
283 $model->add_item(
284 Scan_Item::TYPE_PLUGIN_CHECK,
285 array(
286 'file' => $file,
287 'type' => 'modified',
288 )
289 );
290 }
291 } elseif ( ! in_array( $base_slug, $this->premium_slugs, true ) ) {
292 // Unknown file in a plugin directory.
293 $slugs_of_edited_plugins[] = $base_slug;
294 }
295
296 $model->calculate_percent( $plugin_files->key() * 100 / $plugin_files->count(), 3 );
297 if ( 0 === $plugin_files->key() % 100 ) {
298 // We should update the model percent each 100 files so we have some progress on the screen.
299 $model->save();
300 }
301 $plugin_files->next();
302 }
303 if ( $plugin_files->valid() ) {
304 // Save the current progress and quit.
305 $model->task_checkpoint = $plugin_files->key();
306 } else {
307 // We will check if we have any ignore issue from last scan, so we can bring it here.
308 $last = Scan::get_last();
309 if ( is_object( $last ) ) {
310 $ignored_issues = $last->get_issues( Scan_Item::TYPE_PLUGIN_CHECK, Scan_Item::STATUS_IGNORE );
311 $model->carry_forward_ignored_issues( Scan_Item::TYPE_PLUGIN_CHECK, $ignored_issues, 'file' );
312 }
313 // Done, reset this, so we can use later.
314 $model->task_checkpoint = '';
315 }
316 $model->save();
317
318 /**
319 * Reduce false positive reports. Check it only if enabled 'Suspicious code' option.
320 *
321 * @since 2.4.10
322 */
323 if ( ( new Scan_Settings() )->scan_malware ) {
324 $this->persist_changed_plugin_slugs( $slugs_of_edited_plugins, ! $plugin_files->valid() );
325 if ( array() !== $this->premium_slugs ) {
326 update_site_option( self::PLUGIN_PREMIUM_SLUGS, $this->premium_slugs );
327 }
328 }
329
330 return ! $plugin_files->valid();
331 }
332
333 /**
334 * Persist changed plugin slugs across checkpoint scan batches.
335 *
336 * @param array $batch_slugs Slugs found in the current batch.
337 * @param bool $is_complete Whether the plugin integrity task has completed.
338 *
339 * @return void
340 */
341 private function persist_changed_plugin_slugs( array $batch_slugs, bool $is_complete ): void {
342 $checkpoint_slugs = (array) get_site_option( self::PLUGIN_SLUGS_CHECKPOINT, array() );
343 $changed_slugs = array_values( array_unique( array_merge( $checkpoint_slugs, $batch_slugs ) ) );
344
345 if ( $is_complete ) {
346 update_site_option( self::PLUGIN_SLUGS, $changed_slugs );
347 delete_site_option( self::PLUGIN_SLUGS_CHECKPOINT );
348 } else {
349 update_site_option( self::PLUGIN_SLUGS_CHECKPOINT, $changed_slugs );
350 }
351 }
352
353 /**
354 * Checks if given file is a valid image.
355 *
356 * @param string $file_path Full path to the file.
357 *
358 * @return bool True if file is a valid image, false otherwise.
359 */
360 private function file_is_valid_image( $file_path ): bool {
361 $mime = wp_check_filetype( $file_path );
362 if ( false === $mime['type'] ) {
363 return false;
364 }
365
366 return false !== strpos( $mime['type'], 'image' );
367 }
368 }
369