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 +344 -157 5.8.1 → 6.3.0 View file →
@@ -41,8 +41,14 @@
41 41 */
42 42 protected $table = 'defender_scan';
43 43
44 44 /**
45 + * Active scan type across page loads.
46 + */
47 + public const OPTION_SCAN_TYPE = 'wpdef_active_scan_type';
48 + public const OPTION_STATUS_MESSAGES = 'wpdef_scan_status_messages';
49 +
50 + /**
45 51 * Any valid relative Date and Time formats.
46 52 *
47 53 * @link https://www.php.net/manual/en/datetime.formats.relative.php
48 54 * @since 2.6.1
@@ -153,12 +159,24 @@
153 159 * - 'count_malware' (int): Number of suspicious/malware issues.
154 160 * - 'count_vuln' (int): Number of vulnerability issues.
155 161 * - 'count_outdated_plugin' (int): Number of outdated plugin issues.
156 162 * - 'count_closed_plugin' (int): Number of closed plugin issues.
163 + * @param string|null $scenario Pagination scenario: 'issue', 'ignored', or null (paginate both).
157 164 */
158 - public function prepare_issues( $per_page = null, $paged = null, $type = null ): array {
159 - $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
160 - $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
165 + public function prepare_issues( $per_page = null, $paged = null, $type = null, $scenario = null ): array {
166 + if ( 'issue' === $scenario ) {
167 + // Paginate active issues only; ignored items are not needed for this tab.
168 + $ignored_models = array();
169 + $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
170 + } elseif ( 'ignored' === $scenario ) {
171 + // Paginate ignored items only; active issues are not needed for this tab.
172 + $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
173 + $active_models = array();
174 + } else {
175 + // Default (initial load): paginate both statuses with the same page.
176 + $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
177 + $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
178 + }
161 179
162 180 $issues = array();
163 181 $ignored = array();
164 182 $count_total = count( $active_models );
@@ -165,28 +183,20 @@
165 183 $count_issues_filtered = 0;
166 184
167 185 $scan_item_group_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
168 186
169 - $count_issues = isset( $scan_item_group_total['all'] ) ?
170 - $scan_item_group_total['all'] : 0;
171 - $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
172 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
173 - $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
174 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
175 - $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
176 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
177 - $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
178 - $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;
179 192 // New counts since v5.5.0.
180 - $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
181 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
182 - $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
183 - $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;
184 195
185 196 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
186 197
187 - $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
188 - $scan_item_ignore_total['all'] : 0;
198 + $count_ignored = $scan_item_ignore_total['all'] ?? 0;
189 199
190 200 foreach ( $ignored_models as $model ) {
191 201 $ignored[] = $model->to_array();
192 202 }
@@ -194,10 +204,12 @@
194 204 $issues[] = $active_model->to_array();
195 205
196 206 // We will now count all issues again by type filter for pagination usage.
197 207 if ( null !== $type && 'all' !== $type ) {
198 - if ( $type === $active_model->type ) {
208 + if ( is_array( $type ) && in_array( $active_model->type, $type, true ) ) {
199 209 ++$count_issues_filtered;
210 + } elseif ( $type === $active_model->type ) {
211 + ++$count_issues_filtered;
200 212 }
201 213 } else {
202 214 ++$count_issues_filtered;
203 215 }
@@ -219,24 +231,8 @@
219 231 );
220 232 }
221 233
222 234 /**
223 - * Get all Scan types.
224 - *
225 - * @return array
226 - */
227 - private static function get_all_scan_types(): array {
228 - return array(
229 - Scan_Item::TYPE_VULNERABILITY,
230 - Scan_Item::TYPE_INTEGRITY,
231 - Scan_Item::TYPE_PLUGIN_CHECK,
232 - Scan_Item::TYPE_SUSPICIOUS,
233 - Scan_Item::TYPE_PLUGIN_CLOSED,
234 - Scan_Item::TYPE_PLUGIN_OUTDATED,
235 - );
236 - }
237 -
238 - /**
239 235 * Get abandoned plugin types.
240 236 *
241 237 * @return array
242 238 */
@@ -264,12 +260,12 @@
264 260 */
265 261 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
266 262 $orm = self::get_orm();
267 263 $builder = $orm->get_repository( Scan_Item::class )
268 - ->where( 'parent_id', $this->id );
264 + ->where( 'parent_id', $this->id );
269 265
266 + $valid_types = Scan_Item::get_all_scan_types();
270 267 if ( null !== $type ) {
271 - $valid_types = self::get_all_scan_types();
272 268 if ( is_array( $type ) ) {
273 269 $filtered_types = array_intersect( $type, $valid_types );
274 270 if ( array() !== $filtered_types ) {
275 271 $builder->where( 'type', 'IN', $filtered_types );
@@ -275,14 +271,18 @@
275 271 $builder->where( 'type', 'IN', $filtered_types );
276 272 }
277 273 } elseif ( in_array( $type, $valid_types, true ) ) {
278 274 $builder->where( 'type', $type );
275 + } elseif ( 'all' === $type ) {
276 + $builder->where( 'type', 'IN', $valid_types );
279 277 }
278 + } else {
279 + // Include all possible types.
280 + $builder->where( 'type', 'IN', $valid_types );
280 281 }
281 282
282 283 if ( null !== $status ) {
283 - static $valid_statuses = array( Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE );
284 - if ( in_array( $status, $valid_statuses, true ) ) {
284 + if ( in_array( $status, Scan_Item::get_all_scan_statuses(), true ) ) {
285 285 $builder->where( 'status', $status );
286 286 }
287 287 }
288 288
@@ -312,13 +312,11 @@
312 312
313 313 /**
314 314 * Counts the number of Scan_Item models that match the given type and status.
315 315 *
316 - * @param string|null $type The type of Scan_Item to count. Must be one of the following:
317 - * Scan_Item::TYPE_VULNERABILITY, Scan_Item::TYPE_INTEGRITY,
318 - * Scan_Item::TYPE_PLUGIN_CHECK, Scan_Item::TYPE_SUSPICIOUS.
319 - * @param string|null $status The status of the Scan_Item to count. Must be one of the following:
320 - * Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE.
316 + * @param string|array|null $type The type(s) of Scan_Item to count.
317 + * @param string|null $status The status of the Scan_Item to count. Must be one of the following:
318 + * Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE.
321 319 *
322 320 * @return mixed The number of matching Scan_Item models.
323 321 */
324 322 public function count( $type = null, $status = null ) {
@@ -324,21 +322,27 @@
324 322 public function count( $type = null, $status = null ) {
325 323 $orm = self::get_orm();
326 324 $builder = $orm->get_repository( Scan_Item::class )->where( 'parent_id', $this->id );
327 325
328 - if (
326 + $valid_types = Scan_Item::get_all_scan_types();
327 +
328 + if ( is_array( $type ) ) {
329 + $filtered_types = array_intersect( $type, $valid_types );
330 + if ( array() !== $filtered_types ) {
331 + $builder->where( 'type', 'IN', $filtered_types );
332 + }
333 + } elseif (
329 334 ! is_null( $type )
330 - && in_array(
331 - $type,
332 - self::get_all_scan_types(),
333 - true
334 - )
335 + && in_array( $type, $valid_types, true )
335 336 ) {
336 337 $builder->where( 'type', $type );
338 + } elseif ( is_null( $type ) || 'all' === $type ) {
339 + $builder->where( 'type', 'IN', $valid_types );
337 340 }
341 +
338 342 if (
339 343 ! is_null( $status )
340 - && in_array( $status, array( Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE ), true )
344 + && in_array( $status, Scan_Item::get_all_scan_statuses(), true )
341 345 ) {
342 346 $builder->where( 'status', $status );
343 347 }
344 348
@@ -344,8 +348,9 @@
344 348
345 349 return $builder->count();
346 350 }
347 351
352 +
348 353 /**
349 354 * Allow a specific issue by updating its status and removing it from the global ignore indexer.
350 355 *
351 356 * @param int $id The ID of the issue to Allow.
@@ -352,34 +357,9 @@
352 357 *
353 358 * @return bool
354 359 */
355 360 public function unignore_issue( $id ): bool {
356 - $issue = $this->get_issue( $id );
357 - if ( ! is_object( $issue ) ) {
358 - return false;
359 - }
360 - // Check if the current issue already exists in the Issues list, there is no need to add a duplicate.
361 - $current_issue_arr = $issue->to_array();
362 - foreach ( $this->get_issues( null, Scan_Item::STATUS_ACTIVE ) as $active_issue ) {
363 - $active_issue_arr = $active_issue->to_array();
364 - if ( $issue->type === $active_issue_arr['type'] && $current_issue_arr['file_name'] === $active_issue_arr['file_name'] ) {
365 - return false;
366 - }
367 - }
368 -
369 - $issue->status = Scan_Item::STATUS_ACTIVE;
370 - $issue->save();
371 -
372 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
373 - $data = $issue->raw_data;
374 - if ( isset( $data['file'] ) ) {
375 - unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
376 - } elseif ( isset( $data['slug'] ) ) {
377 - unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
378 - }
379 - $this->update_ignore_list( $ignore_lists );
380 -
381 - return true;
361 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_ACTIVE, Scan_Item::STATUS_ACTIVE );
382 362 }
383 363
384 364 /**
385 365 * Check if a slug is ignored, we use a global indexer, so we can check while
@@ -402,32 +382,9 @@
402 382 *
403 383 * @return bool
404 384 */
405 385 public function ignore_issue( $id ): bool {
406 - $issue = $this->get_issue( $id );
407 - if ( ! is_object( $issue ) ) {
408 - return false;
409 - }
410 - // Check if the current issue already exists in the Ignored list, there is no need to add a duplicate.
411 - $current_issue_arr = $issue->to_array();
412 - foreach ( $this->get_issues( null, Scan_Item::STATUS_IGNORE ) as $ignore_issue ) {
413 - $ignore_issue_arr = $ignore_issue->to_array();
414 - if ( $current_issue_arr['type'] === $ignore_issue_arr['type'] &&
415 - $current_issue_arr['full_path'] === $ignore_issue_arr['full_path']
416 - ) {
417 - return false;
418 - }
419 - }
420 -
421 - $issue->status = Scan_Item::STATUS_IGNORE;
422 - $issue->save();
423 -
424 - // Add this into a global ignored index and update the ignored list.
425 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
426 - $ignore_lists[] = $current_issue_arr['full_path'];
427 - $this->update_ignore_list( $ignore_lists );
428 -
429 - return true;
386 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_IGNORE );
430 387 }
431 388
432 389 /**
433 390 * Retrieves a Scan_Item object based on the given ID.
@@ -514,15 +471,16 @@
514 471 /**
515 472 * Converts the object to an array representation.
516 473 *
517 474 * @param int|null $per_page The number of items to retrieve per page. Default null.
518 - * @param int|null $paged The page number of items to retrieve. Default null.
519 - * @param string|null $type The type of scan issue to filter by. Default null.
475 + * @param int|null $paged The page number of items to retrieve. Default null.
476 + * @param string|null $type The type of scan issue to filter by. Default null.
477 + * @param string|null $scenario Pagination scenario ('issue', 'ignored', or null). Default null.
520 478 *
521 479 * @return array The array representation of the object.
522 480 */
523 - public function to_array( $per_page = null, $paged = null, $type = null ) {
524 - if ( ! in_array( $this->status, array( self::STATUS_ERROR, self::STATUS_FINISH, self::STATUS_IDLE ), true ) ) {
481 + public function to_array( $per_page = null, $paged = null, $type = null, $scenario = null ) {
482 + if ( ! in_array( $this->status, self::get_inactive_statuses(), true ) ) {
525 483
526 484 return array(
527 485 'status' => $this->status,
528 486 'status_text' => $this->get_status_text(),
@@ -538,58 +496,56 @@
538 496
539 497 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
540 498 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
541 499
542 - $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
543 - $scan_item_ignore_total['all'] : 0;
500 + $count_ignored = $scan_item_ignore_total['all'] ?? 0;
501 + $count_ignored_filtered = (int) $this->count( $type, Scan_Item::STATUS_IGNORE );
544 502
545 503 $total_issue_pages = 1;
546 504 $total_ignored_pages = 1;
547 - if ( ! is_null( $per_page ) && ( $total_count > $per_page ) ) {
548 - $data = $this->prepare_issues( $per_page, $paged, $type );
549 - if ( ! is_null( $paged ) ) {
550 - $total_issue_pages = ceil( $count_issues_filtered / $per_page );
551 - $total_ignored_pages = ceil( $count_ignored / $per_page );
552 - }
505 + if ( is_int( $per_page ) && $per_page > 0 ) {
506 + $total_issue_pages = max( 1, (int) ceil( $count_issues_filtered / $per_page ) );
507 + $total_ignored_pages = max( 1, (int) ceil( $count_ignored_filtered / $per_page ) );
508 + }
509 +
510 + // On the initial load both tabs are fetched, so either tab can require pagination.
511 + if ( 'ignored' === $scenario ) {
512 + $relevant_count = $count_ignored_filtered;
513 + } elseif ( 'issue' === $scenario ) {
514 + $relevant_count = $count_issues_filtered;
553 515 } else {
554 - $data = $this->prepare_issues( null, null, $type );
516 + $relevant_count = max( $count_issues_filtered, $count_ignored_filtered );
555 517 }
556 518
557 - $scan_item_group_total = wd_di()->get( Scan_Item::class )
558 - ->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
519 + if ( is_int( $per_page ) && $per_page > 0 && $relevant_count > $per_page ) {
520 + $data = $this->prepare_issues( $per_page, $paged, $type, $scenario );
521 + } else {
522 + $data = $this->prepare_issues( null, null, $type, $scenario );
523 + }
559 524
560 - $count_issues = isset( $scan_item_group_total['all'] ) ?
561 - $scan_item_group_total['all'] : 0;
562 - $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
563 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
564 - $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
565 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
566 - $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
567 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
568 - $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
569 - $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
570 - // New counts since v5.5.0.
571 - $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
572 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
573 - $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
574 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
575 -
576 525 return array(
577 526 'status' => $this->status,
578 527 'issues_items' => $data['issues'],
579 528 'ignored_items' => $data['ignored'],
580 - 'last_scan' => $this->format_date_time( $this->date_start ),
529 + 'last_scan' => $this->date_start
530 + ? sprintf(
531 + /* translators: %s: human-readable time difference, e.g. "5 minutes" */
532 + __( '%s ago', 'defender-security' ),
533 + human_time_diff( strtotime( $this->date_start ) )
534 + )
535 + : '',
581 536 'count' => array(
582 - 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
583 - 'total_filtered' => $total_filtered,
584 - 'issues_total' => $count_issues,
585 - 'issues_total_filtered' => $count_issues_filtered,
586 - 'ignored_total' => $count_ignored,
587 - 'core' => $count_core + $count_plugin,
588 - 'content' => $count_malware,
589 - 'vuln' => $count_vuln,
590 - 'outdated_plugin' => $count_outdated_plugin,
591 - 'closed_plugin' => $count_closed_plugin,
537 + 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
538 + 'total_filtered' => $total_filtered,
539 + 'issues_total' => $data['count_issues'],
540 + 'issues_total_filtered' => $count_issues_filtered,
541 + 'ignored_total' => $count_ignored,
542 + 'ignored_total_filtered' => $count_ignored_filtered,
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'],
592 548 ),
593 549 'paging' => array(
594 550 'issue' => array(
595 551 'paged' => $paged,
@@ -640,8 +596,9 @@
640 596 public function delete( $id = null ) {
641 597 if ( ! $this->is_positive_int( $id ) ) {
642 598 $id = $this->id;
643 599 }
600 + $this->clear_status_messages( $id );
644 601
645 602 // Delete all the related result items.
646 603 $orm = self::get_orm();
647 604
@@ -654,8 +611,91 @@
654 611 );
655 612 }
656 613
657 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 + /**
685 + * Get the inactive scan statuses.
686 + *
687 + * @return array
688 + */
689 + public static function get_inactive_statuses(): array {
690 + return array(
691 + self::STATUS_FINISH,
692 + self::STATUS_ERROR,
693 + self::STATUS_IDLE,
694 + );
695 + }
696 +
697 + /**
658 698 * Get the current active scan if any.
659 699 *
660 700 * @return self|null
661 701 */
@@ -662,9 +702,9 @@
662 702 public static function get_active() {
663 703 $orm = self::get_orm();
664 704
665 705 return $orm->get_repository( self::class )
666 - ->where( 'status', 'NOT IN', array( self::STATUS_FINISH, self::STATUS_ERROR, self::STATUS_IDLE ) )
706 + ->where( 'status', 'NOT IN', self::get_inactive_statuses() )
667 707 ->first();
668 708 }
669 709
670 710 /**
@@ -728,8 +768,70 @@
728 768 return $ret;
729 769 }
730 770
731 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 + /**
805 + * Persist the current scan type ('deep' or 'malware') in a site option.
806 + *
807 + * @param string $type Scan type identifier. Accepts 'deep' or 'malware'.
808 + * @return void
809 + */
810 + public static function set_scan_type( string $type ): void {
811 + update_site_option( self::OPTION_SCAN_TYPE, $type );
812 + }
813 +
814 + /**
815 + * Retrieve the persisted scan type. Returns 'malware' when nothing is stored.
816 + *
817 + * @return string
818 + */
819 + public static function get_scan_type(): string {
820 + return (string) get_site_option( self::OPTION_SCAN_TYPE, 'malware' );
821 + }
822 +
823 + /**
824 + * Delete the persisted scan type (called on scan cancel or after returning
825 + * the finished-scan response so the option does not linger).
826 + *
827 + * @return void
828 + */
829 + public static function clear_scan_type(): void {
830 + delete_site_option( self::OPTION_SCAN_TYPE );
831 + }
832 +
833 + /**
732 834 * Return current status as readable string.
733 835 *
734 836 * @return string
735 837 */
@@ -735,23 +837,23 @@
735 837 */
736 838 public function get_status_text() {
737 839 switch ( $this->status ) {
738 840 case self::STATUS_INIT:
739 - return esc_html__( 'Initializing...', 'defender-security' );
841 + return 'deep' === self::get_scan_type() ? wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) ) : wp_strip_all_tags( __( 'Initializing...', 'defender-security' ) );
740 842 case self::STEP_GATHER_INFO:
741 - return esc_html__( 'Gathering information...', 'defender-security' );
843 + return wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) );
742 844 case self::STEP_CHECK_CORE:
743 - return esc_html__( 'Analyzing WordPress Core...', 'defender-security' );
845 + return wp_strip_all_tags( __( 'Analyzing WordPress Core...', 'defender-security' ) );
744 846 case self::STEP_CHECK_PLUGIN:
745 - return esc_html__( 'Analyzing WordPress Plugins...', 'defender-security' );
847 + return wp_strip_all_tags( __( 'Analyzing WordPress Plugins...', 'defender-security' ) );
746 848 case self::STEP_VULN_CHECK:
747 - return esc_html__( 'Checking for any published vulnerabilities in your plugins and themes...', 'defender-security' );
849 + return wp_strip_all_tags( __( 'Checking for vulnerabilities in plugins and themes...', 'defender-security' ) );
748 850 case self::STEP_SUSPICIOUS_CHECK:
749 - return esc_html__( 'Analyzing WordPress Content...', 'defender-security' );
851 + return wp_strip_all_tags( __( 'Analyzing WordPress Content...', 'defender-security' ) );
750 852 case self::STEP_ABANDONED_PLUGIN_CHECK:
751 - return esc_html__( 'Checking for any outdated & removed plugins...', 'defender-security' );
853 + return wp_strip_all_tags( __( 'Checking for outdated & removed plugins...', 'defender-security' ) );
752 854 default:
753 - return esc_html__( 'The scan is running', 'defender-security' );
855 + return wp_strip_all_tags( __( 'Scanning...', 'defender-security' ) );
754 856 }
755 857 }
756 858
757 859 /**
@@ -801,17 +903,32 @@
801 903
802 904 /**
803 905 * Check if a slug is whitelisted.
804 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 + *
805 914 * @param string $slug The path to file.
806 915 *
807 916 * @return bool
808 917 */
809 918 public function is_issue_whitelisted( string $slug ): bool {
810 - $whitelisted_files = $this->whitelisted_files();
811 - foreach ( $whitelisted_files as $file ) {
812 - if ( false !== stristr( $slug, $file ) ) {
813 - 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;
814 931 }
815 932 }
816 933
817 934 return false;
@@ -903,6 +1020,76 @@
903 1020 * @since 2.6.1
904 1021 */
905 1022 private function is_positive_int( $id ): bool {
906 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;
907 1094 }
908 1095 }