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 +350 -146 5.6.0 → 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,25 +260,29 @@
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 - if ( ! empty( $filtered_types ) ) {
270 + if ( array() !== $filtered_types ) {
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,31 +348,18 @@
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 357 *
353 - * @return bool|void Returns false if the issue does not exist, otherwise void.
358 + * @return bool
354 359 */
355 - public function unignore_issue( $id ) {
356 - $issue = $this->get_issue( $id );
357 - if ( ! is_object( $issue ) ) {
358 - return false;
359 - }
360 - $issue->status = Scan_Item::STATUS_ACTIVE;
361 - $issue->save();
362 -
363 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
364 - $data = $issue->raw_data;
365 - if ( isset( $data['file'] ) ) {
366 - unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
367 - } elseif ( isset( $data['slug'] ) ) {
368 - unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
369 - }
370 - $this->update_ignore_list( $ignore_lists );
360 + public function unignore_issue( $id ): bool {
361 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_ACTIVE, Scan_Item::STATUS_ACTIVE );
371 362 }
372 363
373 364 /**
374 365 * Check if a slug is ignored, we use a global indexer, so we can check while
@@ -384,32 +375,16 @@
384 375 return in_array( $slug, $ignore_lists, true );
385 376 }
386 377
387 378 /**
388 - * Ignore a specific issue by updating its status and adding it to the global ignore indexer.
379 + * Ignore a specific issue by updating its status and adding it to the global ignored indexer.
389 380 *
390 381 * @param int $id The ID of the issue to ignore.
391 382 *
392 - * @return bool|void Returns false if the issue does not exist, otherwise void.
383 + * @return bool
393 384 */
394 - public function ignore_issue( $id ) {
395 - $issue = $this->get_issue( $id );
396 - if ( ! is_object( $issue ) ) {
397 - return false;
398 - }
399 -
400 - $issue->status = Scan_Item::STATUS_IGNORE;
401 - $issue->save();
402 -
403 - // Add this into global ignore index.
404 - $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
405 - $data = $issue->raw_data;
406 - if ( isset( $data['file'] ) ) {
407 - $ignore_lists[] = $data['file'];
408 - } elseif ( isset( $data['slug'] ) ) {
409 - $ignore_lists[] = $data['slug'];
410 - }
411 - $this->update_ignore_list( $ignore_lists );
385 + public function ignore_issue( $id ): bool {
386 + return $this->update_issue_status( (int) $id, Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_IGNORE );
412 387 }
413 388
414 389 /**
415 390 * Retrieves a Scan_Item object based on the given ID.
@@ -496,15 +471,16 @@
496 471 /**
497 472 * Converts the object to an array representation.
498 473 *
499 474 * @param int|null $per_page The number of items to retrieve per page. Default null.
500 - * @param int|null $paged The page number of items to retrieve. Default null.
501 - * @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.
502 478 *
503 479 * @return array The array representation of the object.
504 480 */
505 - public function to_array( $per_page = null, $paged = null, $type = null ) {
506 - 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 ) ) {
507 483
508 484 return array(
509 485 'status' => $this->status,
510 486 'status_text' => $this->get_status_text(),
@@ -511,9 +487,8 @@
511 487 'percent' => $this->percent,
512 488 'task_checkpoint' => $this->task_checkpoint,
513 489 // This only for hub, when a scan running.
514 490 'count' => array( 'total' => 0 ),
515 - 'run_background' => (int) get_site_transient( 'defender_run_background' ) === $this->id,
516 491 );
517 492 } elseif ( in_array( $this->status, array( self::STATUS_FINISH, self::STATUS_IDLE ), true ) ) {
518 493 $total_filtered = (int) $this->count( $type );
519 494 $count_issues_filtered = (int) $this->count( $type, Scan_Item::STATUS_ACTIVE );
@@ -521,58 +496,56 @@
521 496
522 497 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
523 498 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
524 499
525 - $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
526 - $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 );
527 502
528 503 $total_issue_pages = 1;
529 504 $total_ignored_pages = 1;
530 - if ( ! is_null( $per_page ) && ( $total_count > $per_page ) ) {
531 - $data = $this->prepare_issues( $per_page, $paged, $type );
532 - if ( ! is_null( $paged ) ) {
533 - $total_issue_pages = ceil( $count_issues_filtered / $per_page );
534 - $total_ignored_pages = ceil( $count_ignored / $per_page );
535 - }
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;
536 515 } else {
537 - $data = $this->prepare_issues( null, null, $type );
516 + $relevant_count = max( $count_issues_filtered, $count_ignored_filtered );
538 517 }
539 518
540 - $scan_item_group_total = wd_di()->get( Scan_Item::class )
541 - ->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 + }
542 524
543 - $count_issues = isset( $scan_item_group_total['all'] ) ?
544 - $scan_item_group_total['all'] : 0;
545 - $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
546 - $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
547 - $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
548 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
549 - $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
550 - $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
551 - $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
552 - $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
553 - // New counts since v5.5.0.
554 - $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
555 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
556 - $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
557 - $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
558 -
559 525 return array(
560 526 'status' => $this->status,
561 527 'issues_items' => $data['issues'],
562 528 'ignored_items' => $data['ignored'],
563 - '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 + : '',
564 536 'count' => array(
565 - 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
566 - 'total_filtered' => $total_filtered,
567 - 'issues_total' => $count_issues,
568 - 'issues_total_filtered' => $count_issues_filtered,
569 - 'ignored_total' => $count_ignored,
570 - 'core' => $count_core + $count_plugin,
571 - 'content' => $count_malware,
572 - 'vuln' => $count_vuln,
573 - 'outdated_plugin' => $count_outdated_plugin,
574 - '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'],
575 548 ),
576 549 'paging' => array(
577 550 'issue' => array(
578 551 'paged' => $paged,
@@ -623,8 +596,9 @@
623 596 public function delete( $id = null ) {
624 597 if ( ! $this->is_positive_int( $id ) ) {
625 598 $id = $this->id;
626 599 }
600 + $this->clear_status_messages( $id );
627 601
628 602 // Delete all the related result items.
629 603 $orm = self::get_orm();
630 604
@@ -637,8 +611,91 @@
637 611 );
638 612 }
639 613
640 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 + /**
641 698 * Get the current active scan if any.
642 699 *
643 700 * @return self|null
644 701 */
@@ -645,9 +702,9 @@
645 702 public static function get_active() {
646 703 $orm = self::get_orm();
647 704
648 705 return $orm->get_repository( self::class )
649 - ->where( 'status', 'NOT IN', array( self::STATUS_FINISH, self::STATUS_ERROR, self::STATUS_IDLE ) )
706 + ->where( 'status', 'NOT IN', self::get_inactive_statuses() )
650 707 ->first();
651 708 }
652 709
653 710 /**
@@ -711,8 +768,70 @@
711 768 return $ret;
712 769 }
713 770
714 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 + /**
715 834 * Return current status as readable string.
716 835 *
717 836 * @return string
718 837 */
@@ -718,23 +837,23 @@
718 837 */
719 838 public function get_status_text() {
720 839 switch ( $this->status ) {
721 840 case self::STATUS_INIT:
722 - 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' ) );
723 842 case self::STEP_GATHER_INFO:
724 - return esc_html__( 'Gathering information...', 'defender-security' );
843 + return wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) );
725 844 case self::STEP_CHECK_CORE:
726 - return esc_html__( 'Analyzing WordPress Core...', 'defender-security' );
845 + return wp_strip_all_tags( __( 'Analyzing WordPress Core...', 'defender-security' ) );
727 846 case self::STEP_CHECK_PLUGIN:
728 - return esc_html__( 'Analyzing WordPress Plugins...', 'defender-security' );
847 + return wp_strip_all_tags( __( 'Analyzing WordPress Plugins...', 'defender-security' ) );
729 848 case self::STEP_VULN_CHECK:
730 - 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' ) );
731 850 case self::STEP_SUSPICIOUS_CHECK:
732 - return esc_html__( 'Analyzing WordPress Content...', 'defender-security' );
851 + return wp_strip_all_tags( __( 'Analyzing WordPress Content...', 'defender-security' ) );
733 852 case self::STEP_ABANDONED_PLUGIN_CHECK:
734 - return esc_html__( 'Checking for any outdated & removed plugins...', 'defender-security' );
853 + return wp_strip_all_tags( __( 'Checking for outdated & removed plugins...', 'defender-security' ) );
735 854 default:
736 - return esc_html__( 'The scan is running', 'defender-security' );
855 + return wp_strip_all_tags( __( 'Scanning...', 'defender-security' ) );
737 856 }
738 857 }
739 858
740 859 /**
@@ -784,17 +903,32 @@
784 903
785 904 /**
786 905 * Check if a slug is whitelisted.
787 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 + *
788 914 * @param string $slug The path to file.
789 915 *
790 916 * @return bool
791 917 */
792 918 public function is_issue_whitelisted( string $slug ): bool {
793 - $whitelisted_files = $this->whitelisted_files();
794 - foreach ( $whitelisted_files as $file ) {
795 - if ( false !== stristr( $slug, $file ) ) {
796 - 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;
797 931 }
798 932 }
799 933
800 934 return false;
@@ -886,6 +1020,76 @@
886 1020 * @since 2.6.1
887 1021 */
888 1022 private function is_positive_int( $id ): bool {
889 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;
890 1094 }
891 1095 }