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
← All changes | src/traits/plugin.php +594 -6 3.0.1 → 6.3.0 View file →
@@ -1,16 +1,53 @@
1 1 <?php
2 +/**
3 + * Helper functions for plugin related tasks.
4 + *
5 + * @package WP_Defender\Traits
6 + */
2 7
3 8 namespace WP_Defender\Traits;
4 9
10 +use WP_Error;
11 +use WP_Defender\Model\Scan_Item;
12 +use WP_Filesystem_Base;
13 +
5 14 trait Plugin {
6 15
7 16 /**
17 + * Version URL.
18 + *
19 + * @var string Versioned path of the plugin file.
20 + */
21 + private $url_plugin_vcs = 'https://plugins.svn.wordpress.org/%s/tags/%s/%s';
22 +
23 + /**
24 + * Trunk URL.
25 + *
26 + * @var string Trunk path of the plugin file.
27 + */
28 + private $url_plugin_vcs_trunk = 'https://plugins.svn.wordpress.org/%s/trunk/%s';
29 +
30 + /**
31 + * Key for org slugs transient.
32 + *
33 + * @var string
34 + */
35 + public static string $org_slugs = 'wp-defender-org-slugs';
36 +
37 + /**
38 + * Key for org responses transient.
39 + *
40 + * @var string
41 + */
42 + public static string $org_responses = 'wp-defender-org-responses';
43 +
44 + /**
8 45 * Get all installed plugins.
9 46 *
10 47 * @return array
11 48 */
12 - public function get_plugins() {
49 + public function get_plugins(): array {
13 50 if ( ! function_exists( 'get_plugins' ) ) {
14 51 require_once ABSPATH . 'wp-admin/includes/plugin.php';
15 52 }
16 53
@@ -18,18 +55,569 @@
18 55 return get_plugins();
19 56 }
20 57
21 58 /**
22 - * Get all slugs
59 + * Get plugin slug.
23 60 *
61 + * @param string $main_plugin_file The main plugin file name.
62 + *
63 + * @return string Plugin dir name, e.g. 'plugin-test', or a file name with the extension if this is a single plugin file, e.g. 'plugin-test.php'.
64 + */
65 + public function get_plugin_slug_by( string $main_plugin_file ): string {
66 + $dir = dirname( $main_plugin_file );
67 + if ( '.' === $dir ) {
68 + // If this is a single plugin file in the plugin root, take the file name.
69 + $slug = explode( '/', $main_plugin_file );
70 + $slug = array_shift( $slug );
71 + } else {
72 + $slug = $dir;
73 + }
74 +
75 + return $slug;
76 + }
77 +
78 + /**
79 + * Get all slugs.
80 + *
24 81 * @return array
25 82 */
26 - public function get_plugin_slugs() {
83 + public function get_plugin_slugs(): array {
27 84 $slugs = array();
28 - foreach ( $this->get_plugins() as $slug => $plugin ) {
29 - $base_slug = explode( '/', $slug );
30 - $slugs[] = array_shift( $base_slug );
85 + foreach ( $this->get_plugins() as $main_plugin_file => $plugin ) {
86 + $slugs[] = $this->get_plugin_slug_by( $main_plugin_file );
31 87 }
32 88
33 89 return $slugs;
90 + }
91 +
92 + /**
93 + * Get plugin details by given slug.
94 + *
95 + * @param string $plugin_slug The plugin slug.
96 + *
97 + * @return array
98 + */
99 + public function get_plugin_details_by( string $plugin_slug ): array {
100 + foreach ( $this->get_plugins() as $main_plugin_file => $plugin_data ) {
101 + if ( $this->get_plugin_slug_by( $main_plugin_file ) === $plugin_slug ) {
102 + $plugin_data['slug'] = $plugin_slug;
103 +
104 + return $plugin_data;
105 + }
106 + }
107 +
108 + return array();
109 + }
110 +
111 + /**
112 + * Check if a plugin exists on WordPress.org repository by slug.
113 + *
114 + * @param string $slug The plugin slug.
115 + *
116 + * @return array|WP_Error
117 + */
118 + public function ping_wp_org_by_plugin_slug( string $slug ) {
119 + $url = 'https://api.wordpress.org/plugins/info/1.0/' . $slug . '.json';
120 + $http_args = array(
121 + 'timeout' => 15,
122 + 'sslverify' => true,
123 + 'user-agent' => defender_get_own_user_agent(),
124 + );
125 +
126 + return wp_remote_get( $url, $http_args );
127 + }
128 +
129 + /**
130 + * Handle a response from wp.org by the plugin slug.
131 + *
132 + * @param string $slug The plugin slug.
133 + *
134 + * @return array
135 + */
136 + public function handle_wp_org_response_by( string $slug ): array {
137 + $default_error = esc_html__( 'Plugin unknown error.', 'defender-security' );
138 + $response = $this->ping_wp_org_by_plugin_slug( $slug );
139 + if ( is_wp_error( $response ) ) {
140 + return array(
141 + 'message' => $default_error,
142 + 'success' => false,
143 + );
144 + }
145 +
146 + $results = json_decode( wp_remote_retrieve_body( $response ), true );
147 + if ( ! is_array( $results ) ) {
148 + return array(
149 + 'message' => esc_html__( 'Plugin response is not in expected format.', 'defender-security' ),
150 + 'success' => false,
151 + );
152 + }
153 + // Sometimes wp.org can return 404th response code for an existed plugin but with 'closed' status.
154 + if ( 200 !== wp_remote_retrieve_response_code( $response )
155 + && isset( $results['error'] ) && 'closed' !== $results['error']
156 + ) {
157 + return array(
158 + 'message' => $results['description'] ?? $results['error'],
159 + 'success' => false,
160 + );
161 + }
162 +
163 + return array(
164 + 'success' => true,
165 + 'message' => '',
166 + 'body' => $results,
167 + );
168 + }
169 +
170 + /**
171 + * Check if a plugin exists on WordPress.org repository.
172 + *
173 + * @param string $slug The slug of the plugin.
174 + *
175 + * @return array Index message: describes what happened.
176 + * Index data: Plugin data from wp.org.
177 + * Index success: true if plugin in WordPress plugin repository, else false.
178 + */
179 + public function check_plugin_on_wp_org( string $slug ): array {
180 + // Check if data exists in transient.
181 + $transient = get_site_transient( self::$org_responses ) ?? array();
182 + if ( isset( $transient[ $slug ] ) ) {
183 + return array(
184 + 'message' => __( 'Plugin exists in WordPress repository.', 'defender-security' ),
185 + 'data' => $transient[ $slug ],
186 + 'success' => true,
187 + );
188 + }
189 +
190 + $results = $this->handle_wp_org_response_by( $slug );
191 + if ( ! $results['success'] ) {
192 + return $results;
193 + }
194 + // Cache the relevant data in the transient.
195 + $filtered_results = array_intersect_key( $results, array_flip( array( 'homepage', 'version', 'author' ) ) );
196 + $transient[ $slug ] = $filtered_results;
197 + set_site_transient( self::$org_responses, $transient, WEEK_IN_SECONDS );
198 +
199 + return array(
200 + 'message' => esc_html__( 'Plugin exists in WordPress repository.', 'defender-security' ),
201 + 'data' => $results,
202 + 'success' => true,
203 + );
204 + }
205 +
206 + /**
207 + * Check for readme.txt or readme.md files.
208 + * Sometimes plugins from wp.org don't have readme.txt file.
209 + *
210 + * @param string $readme_file_path Path to readme.* file.
211 + *
212 + * @return bool
213 + */
214 + public function check_by_readme_file( string $readme_file_path ): bool {
215 + if ( file_exists( $readme_file_path ) && is_readable( $readme_file_path ) ) {
216 + global $wp_filesystem;
217 + // Initialize the WP filesystem, no more using 'file-put-contents' function.
218 + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
219 + require_once ABSPATH . '/wp-admin/includes/file.php';
220 + WP_Filesystem();
221 + }
222 + $contents = trim( (string) $wp_filesystem->get_contents( $readme_file_path ) );
223 +
224 + if ( false !== strpos( $contents, '===' ) ) {
225 + return true;
226 + }
227 +
228 + if ( false !== strpos( $contents, '#' ) ) {
229 + return true;
230 + }
231 + }
232 +
233 + return false;
234 + }
235 +
236 + /**
237 + * Is this plugin likely to be a WordPress.org plugin?
238 + *
239 + * @param string|null $slug Plugin directory name.
240 + *
241 + * @return bool Return true if likely, else false.
242 + */
243 + public function is_likely_wporg_slug( ?string $slug ): bool {
244 + // Ensure slug is valid.
245 + if ( ! is_string( $slug ) || '' === $slug ) {
246 + return false;
247 + }
248 + // Check if data exists in transient.
249 + $transient = get_site_transient( self::$org_slugs );
250 + if ( ! is_array( $transient ) ) {
251 + $transient = array();
252 + }
253 + if ( isset( $transient[ $slug ] ) ) {
254 + return $transient[ $slug ];
255 + }
256 + // Criterion #1: Check if plugin name contains 'Pro' mention.
257 + $actioned_plugins = get_site_option( \WP_Defender\Component\Scan::PLUGINS_ACTIONED );
258 + if ( is_array( $actioned_plugins ) && isset( $actioned_plugins[ $slug ] )
259 + && preg_match( '/ Pro$/i', $actioned_plugins[ $slug ]['Name'] )
260 + ) {
261 + return false;
262 + }
263 +
264 + $plugin_path = $this->get_abs_plugin_path_by_slug( $slug ) . DIRECTORY_SEPARATOR;
265 + // Some plugins do not follow the readme file naming rule. Let's list the possible names.
266 + $readme_files = array(
267 + 'readme.txt',
268 + 'README.txt',
269 + 'readme.md',
270 + 'README.md',
271 + );
272 + // Criterion #2: Check if there is Readme file.
273 + foreach ( $readme_files as $readme_file ) {
274 + if ( $this->check_by_readme_file( $plugin_path . $readme_file ) ) {
275 + $transient[ $slug ] = true;
276 + // Collect plugin slugs that are on wp.org.
277 + set_site_transient( self::$org_slugs, $transient, DAY_IN_SECONDS );
278 +
279 + return true;
280 + }
281 + }
282 +
283 + return false;
284 + }
285 +
286 + /**
287 + * Check if the plugin is active.
288 + *
289 + * @param string $file_path Absolute file path to the plugin file.
290 + *
291 + * @return bool
292 + */
293 + public function is_active_plugin( $file_path ): bool {
294 + $path_data = explode( DIRECTORY_SEPARATOR, plugin_basename( $file_path ) );
295 + if ( '' !== $path_data[0] ) {
296 + $plugin_slug = $path_data[0];
297 + } else {
298 + return false;
299 + }
300 +
301 + $active = false;
302 + foreach ( $this->get_plugins() as $slug => $data ) {
303 + if ( $plugin_slug === $slug || 0 === strpos( $slug, $plugin_slug ) ) {
304 + $active = is_multisite() ? is_plugin_active_for_network( $slug ) : is_plugin_active( $slug );
305 + break;
306 + }
307 + }
308 +
309 + return $active;
310 + }
311 +
312 + /**
313 + * Get plugin header from any file of the plugin.
314 + *
315 + * @param string $file_path Absolute file path to the plugin file.
316 + *
317 + * @return array Return plugin details as array.
318 + */
319 + public function get_plugin_headers( $file_path ): array {
320 + $plugin_directory = $this->get_plugin_directory_name( $file_path );
321 +
322 + return get_plugins( DIRECTORY_SEPARATOR . $plugin_directory );
323 + }
324 +
325 + /**
326 + * Get plugin directory name.
327 + *
328 + * @param string $file_path Absolute file path to the plugin file.
329 + *
330 + * @return string Return plugin directory name.
331 + */
332 + public function get_plugin_directory_name( $file_path ): string {
333 + return (string) strtok( plugin_basename( $file_path ), '/' );
334 + }
335 +
336 + /**
337 + * Get plugin relative path.
338 + *
339 + * @param string $file_path Absolute file path to the plugin file.
340 + *
341 + * @return string Return plugin relative path.
342 + */
343 + public function get_plugin_relative_path( $file_path ): string {
344 + // Normalize the directory separators for cross-platform compatibility.
345 + $normalized_path = str_replace( '\\', '/', plugin_basename( $file_path ) );
346 + // Initialize strtok.
347 + strtok( $normalized_path, '/' );
348 + // Now fetch the next token, if available, otherwise return an empty string.
349 + return (string) strtok( '' );
350 + }
351 +
352 + /**
353 + * Get plugin absolute path.
354 + *
355 + * @param string $slug Plugin slug.
356 + *
357 + * @return string
358 + */
359 + public function get_abs_plugin_path_by_slug( string $slug = '' ): string {
360 + if ( defined( 'WP_PLUGIN_DIR' ) ) {
361 + return wp_normalize_path( WP_PLUGIN_DIR ) . DIRECTORY_SEPARATOR . $slug;
362 + }
363 +
364 + return wp_normalize_path( WP_CONTENT_DIR ) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $slug;
365 + }
366 +
367 + /**
368 + * Check file exists at wp.org svn.
369 + *
370 + * @param string $url URL of the file.
371 + *
372 + * @return boolean Return true for file exists else false.
373 + */
374 + private function is_origin_file_exists( $url ): bool {
375 + $result = wp_remote_head( $url );
376 +
377 + if ( ! is_wp_error( $result ) ) {
378 + $http_status_code = wp_remote_retrieve_response_code( $result );
379 +
380 + return 200 === $http_status_code;
381 + }
382 +
383 + return false;
384 + }
385 +
386 + /**
387 + * Generates the URL for a specific version of a plugin file.
388 + *
389 + * @param string $directory_name The name of the directory.
390 + * @param string $version The version of the file.
391 + * @param string $file_path The path of the file.
392 + *
393 + * @return string The URL of the versioned file.
394 + */
395 + private function get_version_url( string $directory_name, string $version, string $file_path ): string {
396 + return sprintf(
397 + $this->url_plugin_vcs,
398 + $directory_name,
399 + $version,
400 + $file_path
401 + );
402 + }
403 +
404 + /**
405 + * Generate the URL for the trunk of a plugin based on the directory name and file path.
406 + *
407 + * @param string $directory_name The name of the directory.
408 + * @param string $file_path The path of the file.
409 + *
410 + * @return string The URL of the trunk of the plugin.
411 + */
412 + private function get_trunk_url( string $directory_name, string $file_path ): string {
413 + return sprintf(
414 + $this->url_plugin_vcs_trunk,
415 + $directory_name,
416 + $file_path
417 + );
418 + }
419 +
420 + /**
421 + * Generate the URL for a file based on the directory name, version, and file path.
422 + *
423 + * @param string $directory_name The name of the directory.
424 + * @param string $version The version of the file.
425 + * @param string $file_path The path of the file.
426 + *
427 + * @return string The URL of the file.
428 + */
429 + private function get_file_url( string $directory_name, string $version, string $file_path ): string {
430 + // First try to use the version number provided.
431 + $file_url = $this->get_version_url( $directory_name, $version, $file_path );
432 + if ( $this->is_origin_file_exists( $file_url ) ) {
433 + return $file_url;
434 + }
435 + // If that does not exist, try to use the version number with a `.0` suffix.
436 + $file_url = $this->get_version_url( $directory_name, $version . '.0', $file_path );
437 + if ( $this->is_origin_file_exists( $file_url ) ) {
438 + return $file_url;
439 + }
440 + // If all else fails, use the trunk of the plugin.
441 + return $this->get_trunk_url( $directory_name, $file_path );
442 + }
443 +
444 + /**
445 + * Retrieves the content of a URL by downloading it and reading the contents of the downloaded file.
446 + *
447 + * @param string $url The URL to download the content from.
448 + *
449 + * @return string|WP_Error The content of the URL if successful, otherwise a WP_Error object.
450 + */
451 + private function get_url_content( $url ) {
452 + global $wp_filesystem;
453 + // Initialize the WP filesystem, no more using 'file-put-contents' function.
454 + if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
455 + require_once ABSPATH . '/wp-admin/includes/file.php';
456 + WP_Filesystem();
457 + }
458 + if ( ! function_exists( 'download_url' ) ) {
459 + $ds = DIRECTORY_SEPARATOR;
460 + require_once ABSPATH . 'wp-admin' . $ds . 'includes' . $ds . 'file.php';
461 + }
462 +
463 + $tmp = download_url( $url );
464 + if ( is_wp_error( $tmp ) ) {
465 + return $tmp;
466 + }
467 +
468 + $content = $wp_filesystem->get_contents( $tmp );
469 + wp_delete_file( $tmp );
470 +
471 + return $content;
472 + }
473 +
474 + /**
475 + * Quarantine a plugin.
476 + *
477 + * @param string $parent_action Parent action.
478 + * @param Scan_Item $scan_item Scan item model object.
479 + *
480 + * @return array|WP_Error
481 + */
482 + public function quarantine( string $parent_action, Scan_Item $scan_item ) {
483 + $quarantine_component = wd_di()->get( \WP_Defender\Component\Quarantine::class );
484 +
485 + return $quarantine_component->quarantine_file( $scan_item, $parent_action );
486 + }
487 +
488 + /**
489 + * Detect if the plugin file is quarantinable.
490 + *
491 + * @param string $file_path File path.
492 + *
493 + * @return bool Return true if file is in wp.org plugin else false.
494 + */
495 + private function is_quarantinable( $file_path ): bool {
496 + return $this->is_likely_wporg_slug(
497 + $this->get_plugin_directory_name(
498 + $file_path
499 + )
500 + );
501 + }
502 +
503 + /**
504 + * Get a list of the active plugins.
505 + *
506 + * @return array
507 + */
508 + private function get_active_and_valid_plugin_files(): array {
509 + $active_plugins = is_multisite() ? wp_get_active_network_plugins() : array();
510 + $active_plugins = array_merge( $active_plugins, wp_get_active_and_valid_plugins() );
511 +
512 + return array_unique( $active_plugins );
513 + }
514 +
515 + /**
516 + * Get a plugin name.
517 + *
518 + * @param string $plugin_file Absolute path to the main plugin file.
519 + *
520 + * @return string
521 + */
522 + private function get_plugin_name( $plugin_file ): string {
523 + $plugin_data = get_plugin_data( $plugin_file );
524 +
525 + return isset( $plugin_data['Name'] ) ? $plugin_data['Name'] : '';
526 + }
527 +
528 + /**
529 + * Get active plugin names.
530 + *
531 + * @return array
532 + */
533 + public function get_active_plugin_names(): array {
534 + $active_plugins = $this->get_active_and_valid_plugin_files();
535 + $plugin_names = array();
536 +
537 + // Convert paths to names.
538 + foreach ( $active_plugins as $plugin_file ) {
539 + $plugin_name = $this->get_plugin_name( $plugin_file );
540 + if ( '' !== $plugin_name ) {
541 + $plugin_names[] = $plugin_name;
542 + }
543 + }
544 +
545 + return $plugin_names;
546 + }
547 +
548 + /**
549 + * Data for the “update Defender” notification bubble (React).
550 + *
551 + * Uses the update_plugins site transient (WordPress.org for Free; WPMU DEV Dashboard merges
552 + * Pro updates into the same transient when that plugin is active).
553 + *
554 + * @return array{
555 + * available:bool,
556 + * newVersion:string,
557 + * learnMoreUrl:string,
558 + * updateUrl:string,
559 + * canUpdate:bool,
560 + * currentVersion:string,
561 + * releaseDate:string
562 + * }
563 + */
564 + public function get_plugin_update_notice_data(): array {
565 + $basename = DEFENDER_PLUGIN_BASENAME;
566 +
567 + $default_learn_more = defender_is_wp_org_version()
568 + ? 'https://wordpress.org/plugins/defender-security/'
569 + : 'https://wpmudev.com/project/wp-defender/#changelog_all';
570 +
571 + $learn_more_url = $default_learn_more;
572 + $new_version = '';
573 + $has_update = false;
574 +
575 + $updates = get_site_transient( 'update_plugins' );
576 + if ( is_object( $updates ) && isset( $updates->response[ $basename ] ) ) {
577 + $pkg = $updates->response[ $basename ];
578 + if ( is_object( $pkg ) ) {
579 + $has_update = true;
580 + $new_version = isset( $pkg->new_version ) ? (string) $pkg->new_version : '';
581 + if ( isset( $pkg->url ) && is_string( $pkg->url ) && '' !== $pkg->url ) {
582 + $learn_more_url = $pkg->url;
583 + }
584 + } elseif ( is_array( $pkg ) ) {
585 + $has_update = true;
586 + $new_version = isset( $pkg['new_version'] ) ? (string) $pkg['new_version'] : '';
587 + if ( isset( $pkg['url'] ) && is_string( $pkg['url'] ) && '' !== $pkg['url'] ) {
588 + $learn_more_url = $pkg['url'];
589 + }
590 + }
591 + }
592 +
593 + $can_update = $has_update && current_user_can( 'update_plugins' );
594 + $update_url = '';
595 + if ( $can_update ) {
596 + // Build a plain URL for wp_localize_script (wp_nonce_url() HTML-escapes and breaks JSON).
597 + $update_url = add_query_arg(
598 + array(
599 + '_wpnonce' => wp_create_nonce( 'upgrade-plugin_' . $basename ),
600 + 'action' => 'upgrade-plugin',
601 + 'plugin' => $basename,
602 + ),
603 + self_admin_url( 'update.php' )
604 + );
605 + }
606 +
607 + $release_date_raw = defined( 'DEFENDER_RELEASE_DATE' ) ? DEFENDER_RELEASE_DATE : '';
608 + $timestamp = strtotime( $release_date_raw );
609 + $release_date = false !== $timestamp
610 + ? date_i18n( get_option( 'date_format' ), $timestamp )
611 + : '';
612 +
613 + return array(
614 + 'available' => $has_update,
615 + 'newVersion' => $new_version,
616 + 'learnMoreUrl' => esc_url_raw( $learn_more_url ),
617 + 'updateUrl' => esc_url_raw( $update_url ),
618 + 'canUpdate' => $can_update && '' !== $update_url,
619 + 'currentVersion' => DEFENDER_VERSION,
620 + 'releaseDate' => $release_date,
621 + );
34 622 }
35 623 }