PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.8.1
Defender Security – Malware Scanner, Login Security & Firewall v5.8.1
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 5.8.1, at src/behavior/scan/class-plugin-integrity.php

349 lines 10.1 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_PREMIUM_SLUGS = 'wd_plugin_premium_slugs';
35 /**
36 * List of premium plugin slugs.
37 *
38 * @var array
39 */
40 private $premium_slugs = array();
41
42 /**
43 * Check if the given slug is a valid WordPress.org slug.
44 *
45 * @param string $slug The slug to check.
46 *
47 * @return bool Returns true if the slug is valid, false otherwise.
48 */
49 private function is_valid_wporg_slug( string $slug ): bool {
50 return '' !== $slug && '.' !== $slug;
51 }
52
53 /**
54 * Plucks a specific field from an array of objects or arrays and returns a new array with the plucked values.
55 *
56 * @param array $collection The array to pluck values from.
57 * @param string $prefix Optional. A prefix to add to each key in the new array. Default is an empty string.
58 *
59 * @return array The new array with the plucked values.
60 */
61 private function pluck( array $collection, $prefix = '' ): array {
62 $new_list = array();
63
64 foreach ( $collection as $key => $value ) {
65 $prefix_key = defender_replace_line( $prefix . $key );
66 if ( is_object( $value ) && property_exists( $value, 'md5' ) ) {
67 $new_list[ $prefix_key ] = $value->md5;
68 } elseif ( is_array( $value ) && isset( $value['md5'] ) ) {
69 $new_list[ $prefix_key ] = $value['md5'];
70 }
71 }
72
73 return $new_list;
74 }
75
76 /**
77 * Check if a plugin is a WPMUDEV pro plugin by checking for WDP ID header.
78 *
79 * @param string $plugin_file Plugin main file path.
80 *
81 * @return bool True if it's a WPMUDEV pro plugin, false otherwise.
82 */
83 private function is_wpmudev_pro_plugin( $plugin_file ): bool {
84 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file );
85 // @phpstan-ignore-next-line - WDP ID is a custom header used by WPMUDEV plugins.
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 $slugs_of_edited_plugins = array();
217 $integration_smush = wd_di()->get( Smush::class );
218 $exist_smush_images = $integration_smush->exist_image_table();
219 while ( $plugin_files->valid() ) {
220 if ( ! $timer->check() ) {
221
222 $reason = 'break out cause too long';
223
224 /**
225 * Retrieves the Scan_Analytics class.
226 *
227 * @var Scan_Analytics $scan_analytics
228 */
229 $scan_analytics = wd_di()->get( Scan_Analytics::class );
230
231 $scan_analytics->track_feature(
232 $scan_analytics::EVENT_SCAN_FAILED,
233 array(
234 $scan_analytics::EVENT_SCAN_FAILED_PROP => $scan_analytics::EVENT_SCAN_FAILED_ERROR,
235 'Error_Reason' => $reason,
236 )
237 );
238
239 $this->log( $reason, Scan_Controller::SCAN_LOG );
240 break;
241 }
242
243 if ( $model->is_issue_whitelisted( $plugin_files->current() ) ) {
244 // This is whitelisted, so do nothing.
245 $plugin_files->next();
246 continue;
247 }
248
249 if ( $model->is_issue_ignored( $plugin_files->current() ) ) {
250 // This is ignored, so do nothing.
251 $plugin_files->next();
252 continue;
253 }
254
255 if (
256 $exist_smush_images
257 && $this->file_is_valid_image( $plugin_files->current() )
258 && $integration_smush->exist_image_path( $plugin_files->current() )
259 ) {
260 $this->log(
261 sprintf( 'skip %s because of Smush optimized file', $plugin_files->current() ),
262 Scan_Controller::SCAN_LOG
263 );
264 $plugin_files->next();
265 continue;
266 }
267
268 $file = $plugin_files->current();
269 $rev_file = str_replace( $abs_path, '', $file );
270 // Remove directory separator on the left.
271 $rev_file = ltrim( $rev_file, DIRECTORY_SEPARATOR );
272 $base_slug = explode( '/', $rev_file );
273 $base_slug = array_shift( $base_slug );
274
275 // Verify files only from wp.org. No Premium-things.
276 if ( isset( $checksums[ $rev_file ] ) ) {
277 if ( ! $this->compare_hashes( $file, $checksums[ $rev_file ] ) ) {
278 $slugs_of_edited_plugins[] = $base_slug;
279 $this->log( sprintf( 'modified %s', $file ), Scan_Controller::SCAN_LOG );
280 $model->add_item(
281 Scan_Item::TYPE_PLUGIN_CHECK,
282 array(
283 'file' => $file,
284 'type' => 'modified',
285 )
286 );
287 }
288 } elseif ( ! in_array( $base_slug, $this->premium_slugs, true ) ) {
289 // Unknown file in a plugin directory.
290 $slugs_of_edited_plugins[] = $base_slug;
291 }
292
293 $model->calculate_percent( $plugin_files->key() * 100 / $plugin_files->count(), 3 );
294 if ( 0 === $plugin_files->key() % 100 ) {
295 // We should update the model percent each 100 files so we have some progress on the screen.
296 $model->save();
297 }
298 $plugin_files->next();
299 }
300 if ( $plugin_files->valid() ) {
301 // Save the current progress and quit.
302 $model->task_checkpoint = $plugin_files->key();
303 } else {
304 // We will check if we have any ignore issue from last scan, so we can bring it here.
305 $last = Scan::get_last();
306 if ( is_object( $last ) ) {
307 $ignored_issues = $last->get_issues( Scan_Item::TYPE_PLUGIN_CHECK, Scan_Item::STATUS_IGNORE );
308 foreach ( $ignored_issues as $issue ) {
309 $model->add_item( Scan_Item::TYPE_PLUGIN_CHECK, $issue->raw_data, Scan_Item::STATUS_IGNORE );
310 }
311 }
312 // Done, reset this, so we can use later.
313 $model->task_checkpoint = '';
314 }
315 $model->save();
316 /**
317 * Reduce false positive reports. Check it only if enabled 'Suspicious code' option.
318 *
319 * @since 2.4.10
320 */
321 if ( ( new Scan_Settings() )->scan_malware ) {
322 if ( array() !== $slugs_of_edited_plugins ) {
323 update_site_option( self::PLUGIN_SLUGS, array_unique( $slugs_of_edited_plugins ) );
324 }
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 * Checks if given file is a valid image.
335 *
336 * @param string $file_path Full path to the file.
337 *
338 * @return bool True if file is a valid image, false otherwise.
339 */
340 private function file_is_valid_image( $file_path ): bool {
341 $mime = wp_check_filetype( $file_path );
342 if ( false === $mime['type'] ) {
343 return false;
344 }
345
346 return false !== strpos( $mime['type'], 'image' );
347 }
348 }
349