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/model/class-scan.php +214 -99 6.0.1 → 6.3.0 View file →
@@ -43,9 +43,10 @@
43 43
44 44 /**
45 45 * Active scan type across page loads.
46 46 */
47 - public const OPTION_SCAN_TYPE = 'wpdef_active_scan_type';
47 + public const OPTION_SCAN_TYPE = 'wpdef_active_scan_type';
48 + public const OPTION_STATUS_MESSAGES = 'wpdef_scan_status_messages';
48 49
49 50 /**
50 51 * Any valid relative Date and Time formats.
51 52 *
@@ -182,28 +183,20 @@
182 183 $count_issues_filtered = 0;
183 184
184 185 $scan_item_group_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
185 186
186 - $count_issues = isset( $scan_item_group_total['all'] ) ?
187 - $scan_item_group_total['all'] : 0;
188 - $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
189 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
190 - $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
191 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
192 - $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
193 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
194 - $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
195 - $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
187 + $count_issues = $scan_item_group_total['all'] ?? 0;
188 + $count_core = $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ?? 0;
189 + $count_plugin = $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ?? 0;
190 + $count_malware = $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ?? 0;
191 + $count_vuln = $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ?? 0;
196 192 // New counts since v5.5.0.
197 - $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
198 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
199 - $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
200 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
193 + $count_outdated_plugin = $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ?? 0;
194 + $count_closed_plugin = $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ?? 0;
201 195
202 196 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
203 197
204 - $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
205 - $scan_item_ignore_total['all'] : 0;
198 + $count_ignored = $scan_item_ignore_total['all'] ?? 0;
206 199
207 200 foreach ( $ignored_models as $model ) {
208 201 $ignored[] = $model->to_array();
209 202 }
@@ -267,9 +260,9 @@
267 260 */
268 261 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
269 262 $orm = self::get_orm();
270 263 $builder = $orm->get_repository( Scan_Item::class )
271 - ->where( 'parent_id', $this->id );
264 + ->where( 'parent_id', $this->id );
272 265
273 266 $valid_types = Scan_Item::get_all_scan_types();
274 267 if ( null !== $type ) {
275 268 if ( is_array( $type ) ) {
@@ -355,8 +348,9 @@
355 348
356 349 return $builder->count();
357 350 }
358 351
352 +
359 353 /**
360 354 * Allow a specific issue by updating its status and removing it from the global ignore indexer.
361 355 *
362 356 * @param int $id The ID of the issue to Allow.
@@ -363,34 +357,9 @@
363 357 *
364 358 * @return bool
365 359 */
366 360 public function unignore_issue( $id ): bool {
367 - $issue = $this->get_issue( $id );
368 - if ( ! is_object( $issue ) ) {
369 - return false;
370 - }
371 - // Check if the current issue already exists in the Issues list, there is no need to add a duplicate.
372 - $current_issue_arr = $issue->to_array();
373 - foreach ( $this->get_issues( null, Scan_Item::STATUS_ACTIVE ) as $active_issue ) {
374 - $active_issue_arr = $active_issue->to_array();
375 - if ( $issue->type === $active_issue_arr['type'] && $current_issue_arr['file_name'] === $active_issue_arr['file_name'] ) {
376 - return false;
377 - }
378 - }
379 -
380 - $issue->status = Scan_Item::STATUS_ACTIVE;
381 - $issue->save();
382 -
383 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
384 - $data = $issue->raw_data;
385 - if ( isset( $data['file'] ) ) {
386 - unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
387 - } elseif ( isset( $data['slug'] ) ) {
388 - unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
389 - }
390 - $this->update_ignore_list( $ignore_lists );
391 -
392 - return true;
361 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_ACTIVE, Scan_Item::STATUS_ACTIVE );
393 362 }
394 363
395 364 /**
396 365 * Check if a slug is ignored, we use a global indexer, so we can check while
@@ -413,32 +382,9 @@
413 382 *
414 383 * @return bool
415 384 */
416 385 public function ignore_issue( $id ): bool {
417 - $issue = $this->get_issue( $id );
418 - if ( ! is_object( $issue ) ) {
419 - return false;
420 - }
421 - // Check if the current issue already exists in the Ignored list, there is no need to add a duplicate.
422 - $current_issue_arr = $issue->to_array();
423 - foreach ( $this->get_issues( null, Scan_Item::STATUS_IGNORE ) as $ignore_issue ) {
424 - $ignore_issue_arr = $ignore_issue->to_array();
425 - if ( $current_issue_arr['type'] === $ignore_issue_arr['type'] &&
426 - $current_issue_arr['full_path'] === $ignore_issue_arr['full_path']
427 - ) {
428 - return false;
429 - }
430 - }
431 -
432 - $issue->status = Scan_Item::STATUS_IGNORE;
433 - $issue->save();
434 -
435 - // Add this into a global ignored index and update the ignored list.
436 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
437 - $ignore_lists[] = $current_issue_arr['full_path'];
438 - $this->update_ignore_list( $ignore_lists );
439 -
440 - return true;
386 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_IGNORE );
441 387 }
442 388
443 389 /**
444 390 * Retrieves a Scan_Item object based on the given ID.
@@ -550,10 +496,9 @@
550 496
551 497 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
552 498 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
553 499
554 - $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
555 - $scan_item_ignore_total['all'] : 0;
500 + $count_ignored = $scan_item_ignore_total['all'] ?? 0;
556 501 $count_ignored_filtered = (int) $this->count( $type, Scan_Item::STATUS_IGNORE );
557 502
558 503 $total_issue_pages = 1;
559 504 $total_ignored_pages = 1;
@@ -576,27 +521,8 @@
576 521 } else {
577 522 $data = $this->prepare_issues( null, null, $type, $scenario );
578 523 }
579 524
580 - $scan_item_group_total = wd_di()->get( Scan_Item::class )
581 - ->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
582 -
583 - $count_issues = isset( $scan_item_group_total['all'] ) ?
584 - $scan_item_group_total['all'] : 0;
585 - $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
586 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
587 - $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
588 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
589 - $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
590 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
591 - $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
592 - $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
593 - // New counts since v5.5.0.
594 - $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
595 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
596 - $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
597 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
598 -
599 525 return array(
600 526 'status' => $this->status,
601 527 'issues_items' => $data['issues'],
602 528 'ignored_items' => $data['ignored'],
@@ -609,17 +535,17 @@
609 535 : '',
610 536 'count' => array(
611 537 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
612 538 'total_filtered' => $total_filtered,
613 - 'issues_total' => $count_issues,
539 + 'issues_total' => $data['count_issues'],
614 540 'issues_total_filtered' => $count_issues_filtered,
615 541 'ignored_total' => $count_ignored,
616 542 'ignored_total_filtered' => $count_ignored_filtered,
617 - 'core' => $count_core + $count_plugin,
618 - 'content' => $count_malware,
619 - 'vuln' => $count_vuln,
620 - 'outdated_plugin' => $count_outdated_plugin,
621 - 'closed_plugin' => $count_closed_plugin,
543 + 'core' => $data['count_core'] + $data['count_plugin'],
544 + 'content' => $data['count_malware'],
545 + 'vuln' => $data['count_vuln'],
546 + 'outdated_plugin' => $data['count_outdated_plugin'],
547 + 'closed_plugin' => $data['count_closed_plugin'],
622 548 ),
623 549 'paging' => array(
624 550 'issue' => array(
625 551 'paged' => $paged,
@@ -670,8 +596,9 @@
670 596 public function delete( $id = null ) {
671 597 if ( ! $this->is_positive_int( $id ) ) {
672 598 $id = $this->id;
673 599 }
600 + $this->clear_status_messages( $id );
674 601
675 602 // Delete all the related result items.
676 603 $orm = self::get_orm();
677 604
@@ -684,8 +611,78 @@
684 611 );
685 612 }
686 613
687 614 /**
615 + * Store a user-visible status transition for this scan.
616 + *
617 + * @param string $message Status transition message.
618 + */
619 + public function enqueue_status_message( $message ): void {
620 + $message = sanitize_text_field( wp_strip_all_tags( (string) $message ) );
621 + if ( '' === $message || ! $this->is_positive_int( $this->id ) ) {
622 + return;
623 + }
624 +
625 + $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
626 + $queues = is_array( $queues ) ? $queues : array();
627 + $key = (string) $this->id;
628 + $stored_queue = $queues[ $key ] ?? null;
629 + $queue = is_array( $stored_queue ) ? $stored_queue : array();
630 + if ( end( $queue ) !== $message ) {
631 + $queue[] = $message;
632 + $queues[ $key ] = $queue;
633 + update_site_option( self::OPTION_STATUS_MESSAGES, $queues );
634 + }
635 + }
636 +
637 + /**
638 + * Return queued status messages without consuming them.
639 + */
640 + public function get_status_messages(): array {
641 + $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
642 + $queue = is_array( $queues ) ? ( $queues[ (string) $this->id ] ?? array() ) : array();
643 +
644 + return is_array( $queue ) ? array_values( $queue ) : array();
645 + }
646 +
647 + /**
648 + * Return and remove queued status messages.
649 + */
650 + public function drain_status_messages(): array {
651 + $messages = $this->get_status_messages();
652 + $this->clear_status_messages();
653 +
654 + return $messages;
655 + }
656 +
657 + /**
658 + * Remove queued messages for one scan.
659 + *
660 + * @param int|null $id Table primary key ID.
661 + */
662 + public function clear_status_messages( $id = null ): void {
663 + $id = $this->is_positive_int( $id ) ? $id : $this->id;
664 + if ( ! $this->is_positive_int( $id ) ) {
665 + return;
666 + }
667 + $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
668 + if ( ! is_array( $queues ) || ! array_key_exists( (string) $id, $queues ) ) {
669 + return;
670 + }
671 + unset( $queues[ (string) $id ] );
672 + array() === $queues
673 + ? delete_site_option( self::OPTION_STATUS_MESSAGES )
674 + : update_site_option( self::OPTION_STATUS_MESSAGES, $queues );
675 + }
676 +
677 + /**
678 + * Remove status queues for all scans.
679 + */
680 + public static function clear_all_status_messages(): void {
681 + delete_site_option( self::OPTION_STATUS_MESSAGES );
682 + }
683 +
684 + /**
688 685 * Get the inactive scan statuses.
689 686 *
690 687 * @return array
691 688 */
@@ -771,8 +768,41 @@
771 768 return $ret;
772 769 }
773 770
774 771 /**
772 + * Carry previously-ignored issues forward into this scan, without duplicating
773 + * any that were already re-detected as active in the current pass.
774 + *
775 + * @param array|string $type Scan item type(s) the issues belong to.
776 + * @param array $issues Scan_Item objects (status ignore) to carry forward.
777 + * @param string $key_field Field inside raw_data used to match issues, e.g. 'file' or 'slug'.
778 + */
779 + public function carry_forward_ignored_issues( $type, array $issues, string $key_field ) {
780 + if ( array() === $issues ) {
781 + return;
782 + }
783 +
784 + $active_by_key = array();
785 + foreach ( $this->get_issues( $type, Scan_Item::STATUS_ACTIVE ) as $active_item ) {
786 + $key = $active_item->raw_data[ $key_field ] ?? null;
787 + if ( null !== $key ) {
788 + $active_by_key[ $key ] = $active_item;
789 + }
790 + }
791 +
792 + foreach ( $issues as $issue ) {
793 + $key = $issue->raw_data[ $key_field ] ?? null;
794 + if ( null !== $key && isset( $active_by_key[ $key ] ) ) {
795 + // Already re-detected as active in this scan; keep it ignored, don't duplicate.
796 + $active_by_key[ $key ]->status = Scan_Item::STATUS_IGNORE;
797 + $active_by_key[ $key ]->save();
798 + continue;
799 + }
800 + $this->add_item( $issue->type, $issue->raw_data, Scan_Item::STATUS_IGNORE );
801 + }
802 + }
803 +
804 + /**
775 805 * Persist the current scan type ('deep' or 'malware') in a site option.
776 806 *
777 807 * @param string $type Scan type identifier. Accepts 'deep' or 'malware'.
778 808 * @return void
@@ -873,17 +903,32 @@
873 903
874 904 /**
875 905 * Check if a slug is whitelisted.
876 906 *
907 + * File entries are matched only when the slug's base name equals the entry
908 + * (case-insensitive), so paths like "robots.txt.backdoor" or
909 + * "robots.txt/malware.php" are never falsely accepted.
910 + *
911 + * Directory entries are matched as complete path segments, preventing
912 + * substring bypasses like ".git-malware/payload.php" or "not.git/payload.php".
913 + *
877 914 * @param string $slug The path to file.
878 915 *
879 916 * @return bool
880 917 */
881 918 public function is_issue_whitelisted( string $slug ): bool {
882 - $whitelisted_files = $this->whitelisted_files();
883 - foreach ( $whitelisted_files as $file ) {
884 - if ( false !== stristr( $slug, $file ) ) {
885 - return true;
919 + $whitelisted_files = $this->whitelisted_files();
920 + $whitelisted_directories = array( '.well-known', '.idea', '.svn', '.git', '.quarantine', '.tmb', '.vscode' );
921 + $normalized_slug = str_replace( '\\', '/', $slug );
922 + $bounded_slug = '/' . trim( $normalized_slug, '/' ) . '/';
923 +
924 + foreach ( $whitelisted_files as $entry ) {
925 + if ( ! in_array( $entry, $whitelisted_directories, true ) ) {
926 + if ( 0 === strcasecmp( basename( $normalized_slug ), $entry ) ) {
927 + return true;
928 + }
929 + } elseif ( false !== stripos( $bounded_slug, '/' . $entry . '/' ) ) {
930 + return true;
886 931 }
887 932 }
888 933
889 934 return false;
@@ -975,6 +1020,76 @@
975 1020 * @since 2.6.1
976 1021 */
977 1022 private function is_positive_int( $id ): bool {
978 1023 return is_int( $id ) && $id > 0;
1024 + }
1025 +
1026 + /**
1027 + * Check if an issue with the given type and full_path already exists under a specific status.
1028 + * Used to prevent adding duplicate entries to the ignored or active lists.
1029 + *
1030 + * @param array $current_issue_arr Associative array representation of the issue to check.
1031 + * @param string $status The status to search within (e.g. Scan_Item::STATUS_ACTIVE or STATUS_IGNORE).
1032 + *
1033 + * @return bool True if a matching issue already exists with that status.
1034 + */
1035 + private function issue_exists_with_status( array $current_issue_arr, string $status ): bool {
1036 + foreach ( $this->get_issues( null, $status ) as $existing_issue ) {
1037 + $existing_arr = $existing_issue->to_array();
1038 + if (
1039 + $current_issue_arr['type'] === $existing_arr['type']
1040 + && $current_issue_arr['full_path'] === $existing_arr['full_path']
1041 + ) {
1042 + return true;
1043 + }
1044 + }
1045 +
1046 + return false;
1047 + }
1048 +
1049 + /**
1050 + * Update the status of an issue and sync the global ignore indexer accordingly.
1051 + *
1052 + * When $new_status is STATUS_IGNORE the issue's full_path is appended to the indexer.
1053 + * When $new_status is STATUS_ACTIVE the issue's file/slug key is removed from the indexer.
1054 + *
1055 + * @param int $id The ID of the issue to update.
1056 + * @param string $new_status The target status (Scan_Item::STATUS_IGNORE or STATUS_ACTIVE).
1057 + * @param string $guard_status The status to check for existing duplicates before updating.
1058 + *
1059 + * @return bool True on success, false if the issue does not exist or a duplicate was found.
1060 + */
1061 + private function update_issue_status( int $id, string $new_status, string $guard_status ): bool {
1062 + $issue = $this->get_issue( $id );
1063 + if ( ! is_object( $issue ) ) {
1064 + return false;
1065 + }
1066 +
1067 + $current_issue_arr = $issue->to_array();
1068 + // Bail out if an identical issue already exists under the target status.
1069 + if ( $this->issue_exists_with_status( $current_issue_arr, $guard_status ) ) {
1070 + return false;
1071 + }
1072 +
1073 + $issue->status = $new_status;
1074 + $issue->save();
1075 +
1076 + $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
1077 +
1078 + if ( Scan_Item::STATUS_IGNORE === $new_status ) {
1079 + // Add this into a global ignored index and update the ignored list.
1080 + $ignore_lists[] = $current_issue_arr['full_path'];
1081 + } else {
1082 + // Remove the entry from the global ignored index.
1083 + $data = $issue->raw_data;
1084 + if ( isset( $data['file'] ) ) {
1085 + unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
1086 + } elseif ( isset( $data['slug'] ) ) {
1087 + unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
1088 + }
1089 + }
1090 +
1091 + $this->update_ignore_list( $ignore_lists );
1092 +
1093 + return true;
979 1094 }
980 1095 }