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
defender-security / src / model / class-scan.php

class-scan.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at src/model/class-scan.php

1,096 lines 33.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles interaction with the database for scans.
4 *
5 * @package WP_Defender\Model
6 */
7
8 namespace WP_Defender\Model;
9
10 use WP_Error;
11 use DateTime;
12 use Countable;
13 use DateTimeZone;
14 use WP_Defender\DB;
15 use WP_Defender\Traits\IO;
16 use WP_Defender\Traits\Formats;
17 use WP_Defender\Component\Error_Code;
18 use WP_Defender\Behavior\Scan_Item\Core_Integrity;
19 use WP_Defender\Behavior\Scan_Item\Plugin_Integrity;
20 use WP_Defender\Behavior\Scan_Item\Abandoned_Result;
21
22 /**
23 * Model for scan table.
24 */
25 class Scan extends DB {
26
27 use IO;
28 use Formats;
29
30 public const STATUS_INIT = 'init', STATUS_ERROR = 'error', STATUS_FINISH = 'finish';
31 // Default state.
32 public const STEP_GATHER_INFO = 'gather_info', STEP_ABANDONED_PLUGIN_CHECK = 'abandoned_plugin_check';
33 public const STEP_CHECK_CORE = 'core_integrity_check', STEP_CHECK_PLUGIN = 'plugin_integrity_check';
34 public const STEP_VULN_CHECK = 'vuln_check', STEP_SUSPICIOUS_CHECK = 'suspicious_check';
35 public const IGNORE_INDEXER = 'defender_scan_ignore_index';
36
37 /**
38 * Table name.
39 *
40 * @var string
41 */
42 protected $table = 'defender_scan';
43
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 /**
51 * Any valid relative Date and Time formats.
52 *
53 * @link https://www.php.net/manual/en/datetime.formats.relative.php
54 * @since 2.6.1
55 * @var string
56 */
57 public const THRESHOLD_PERIOD = '3 hours ago';
58
59 /**
60 * Constant to notate the scan is idle or crossed the threshold limit.
61 *
62 * @since 2.6.1
63 * @var string
64 */
65 public const STATUS_IDLE = 'idle';
66
67 /**
68 * Primary key column.
69 *
70 * @var int
71 * @defender_property
72 */
73 public $id;
74 /**
75 * Table column for the status.
76 * Possible values are,
77 * - init
78 * - error
79 * - finish
80 * - gather_fact
81 * - core_integrity_check
82 * - plugin_integrity_check
83 * - vuln_check
84 * - suspicious_check
85 * - idle
86 * - abandoned_plugin_check
87 *
88 * @var string
89 * @defender_property
90 */
91 public $status;
92 /**
93 * Table column for the start time.
94 *
95 * @var string
96 * @defender_property
97 */
98 public $date_start;
99
100 /**
101 * Table column for the current percentage.
102 *
103 * @var int
104 * @defender_property
105 */
106 public $percent = 0;
107
108 /**
109 * Table column for the total tasks.
110 * Store how many tasks we process.
111 *
112 * @var int
113 * @defender_property
114 */
115 public $total_tasks = 0;
116
117 /**
118 * Table column for the current task checkpoint.
119 *
120 * @var string
121 * @defender_property
122 */
123 public $task_checkpoint = '';
124
125 /**
126 * Table column for the end time.
127 *
128 * @var string
129 * @defender_property
130 */
131 public $date_end;
132
133 /**
134 * Table column for the scan trigger by report schedule.
135 *
136 * @var bool
137 * @defender_property
138 */
139 public $is_automation = false;
140
141 /**
142 * Prepare and fetch issues with various counts.
143 * This method retrieves active and ignored issues based on the specified type, page, and items per page.
144 * It prepares a detailed summary including total counts and filtered counts by type.
145 *
146 * @param int|null $per_page Number of items per page. Default null.
147 * @param int|null $paged Current page number. Default null.
148 * @param string|null $type Type of issues to filter. Default null.
149 *
150 * @return array An array containing the list of issues, ignored issues, and various count statistics.
151 * - 'ignored' (array): List of ignored issues.
152 * - 'issues' (array): List of active issues.
153 * - 'count_total' (int): Total number of active issues.
154 * - 'count_issues' (int): Total number of issues of all types.
155 * - 'count_issues_filtered' (int): Number of issues filtered by type.
156 * - 'count_ignored' (int): Total number of ignored issues.
157 * - 'count_core' (int): Number of core integrity issues.
158 * - 'count_plugin' (int): Number of plugin check issues.
159 * - 'count_malware' (int): Number of suspicious/malware issues.
160 * - 'count_vuln' (int): Number of vulnerability issues.
161 * - 'count_outdated_plugin' (int): Number of outdated plugin issues.
162 * - 'count_closed_plugin' (int): Number of closed plugin issues.
163 * @param string|null $scenario Pagination scenario: 'issue', 'ignored', or null (paginate both).
164 */
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 }
179
180 $issues = array();
181 $ignored = array();
182 $count_total = count( $active_models );
183 $count_issues_filtered = 0;
184
185 $scan_item_group_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
186
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;
192 // New counts since v5.5.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;
195
196 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
197
198 $count_ignored = $scan_item_ignore_total['all'] ?? 0;
199
200 foreach ( $ignored_models as $model ) {
201 $ignored[] = $model->to_array();
202 }
203 foreach ( $active_models as $active_model ) {
204 $issues[] = $active_model->to_array();
205
206 // We will now count all issues again by type filter for pagination usage.
207 if ( null !== $type && 'all' !== $type ) {
208 if ( is_array( $type ) && in_array( $active_model->type, $type, true ) ) {
209 ++$count_issues_filtered;
210 } elseif ( $type === $active_model->type ) {
211 ++$count_issues_filtered;
212 }
213 } else {
214 ++$count_issues_filtered;
215 }
216 }
217
218 return array(
219 'ignored' => $ignored,
220 'issues' => $issues,
221 'count_total' => $count_total,
222 'count_issues' => $count_issues,
223 'count_issues_filtered' => $count_issues_filtered,
224 'count_ignored' => $count_ignored,
225 'count_core' => $count_core,
226 'count_plugin' => $count_plugin,
227 'count_malware' => $count_malware,
228 'count_vuln' => $count_vuln,
229 'count_outdated_plugin' => $count_outdated_plugin,
230 'count_closed_plugin' => $count_closed_plugin,
231 );
232 }
233
234 /**
235 * Get abandoned plugin types.
236 *
237 * @return array
238 */
239 public static function get_abandoned_types(): array {
240 return array(
241 Scan_Item::TYPE_PLUGIN_CLOSED,
242 Scan_Item::TYPE_PLUGIN_OUTDATED,
243 );
244 }
245
246 /**
247 * Retrieves scan issues based on provided filters.
248 * This method fetches scan items related to the current object's ID,
249 * filtered by type, status, and pagination parameters.
250 * The retrieved items are then attached with relevant behaviors based on their type.
251 *
252 * @param string|array|null $type Optional. The type of scan issue to filter by.
253 * Accepts 'vulnerability', 'integrity', 'plugin_check', or 'suspicious'. Default null.
254 * @param string|null $status Optional. The status of the scan issue to filter by.
255 * Accepts 'ignore' or 'active'. Default null.
256 * @param int|null $per_page Optional. The number of items to retrieve per page. Default null.
257 * @param int|null $paged Optional. The page number of items to retrieve. Default null.
258 *
259 * @return array An array of scan issue models with attached behaviors.
260 */
261 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
262 $orm = self::get_orm();
263 $builder = $orm->get_repository( Scan_Item::class )
264 ->where( 'parent_id', $this->id );
265
266 $valid_types = Scan_Item::get_all_scan_types();
267 if ( null !== $type ) {
268 if ( is_array( $type ) ) {
269 $filtered_types = array_intersect( $type, $valid_types );
270 if ( array() !== $filtered_types ) {
271 $builder->where( 'type', 'IN', $filtered_types );
272 }
273 } elseif ( in_array( $type, $valid_types, true ) ) {
274 $builder->where( 'type', $type );
275 } elseif ( 'all' === $type ) {
276 $builder->where( 'type', 'IN', $valid_types );
277 }
278 } else {
279 // Include all possible types.
280 $builder->where( 'type', 'IN', $valid_types );
281 }
282
283 if ( null !== $status ) {
284 if ( in_array( $status, Scan_Item::get_all_scan_statuses(), true ) ) {
285 $builder->where( 'status', $status );
286 }
287 }
288
289 if ( is_int( $per_page ) && is_int( $paged ) && $per_page > 0 && $paged > 0 ) {
290 $offset = ( $paged - 1 ) * $per_page;
291 $builder->limit( $per_page, $offset );
292 }
293
294 $models = $builder->get();
295
296 static $behavior_map = array(
297 Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
298 Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
299 Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
300 Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
301 );
302
303 foreach ( $models as $model ) {
304 if ( isset( $behavior_map[ $model->type ] ) ) {
305 $behavior_class = $behavior_map[ $model->type ];
306 $model->attach_behavior( $behavior_class, $behavior_class );
307 }
308 }
309
310 return $models;
311 }
312
313 /**
314 * Counts the number of Scan_Item models that match the given type and status.
315 *
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.
319 *
320 * @return mixed The number of matching Scan_Item models.
321 */
322 public function count( $type = null, $status = null ) {
323 $orm = self::get_orm();
324 $builder = $orm->get_repository( Scan_Item::class )->where( 'parent_id', $this->id );
325
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 (
334 ! is_null( $type )
335 && in_array( $type, $valid_types, true )
336 ) {
337 $builder->where( 'type', $type );
338 } elseif ( is_null( $type ) || 'all' === $type ) {
339 $builder->where( 'type', 'IN', $valid_types );
340 }
341
342 if (
343 ! is_null( $status )
344 && in_array( $status, Scan_Item::get_all_scan_statuses(), true )
345 ) {
346 $builder->where( 'status', $status );
347 }
348
349 return $builder->count();
350 }
351
352
353 /**
354 * Allow a specific issue by updating its status and removing it from the global ignore indexer.
355 *
356 * @param int $id The ID of the issue to Allow.
357 *
358 * @return bool
359 */
360 public function unignore_issue( $id ): bool {
361 return $this->update_issue_status( (int) $id, Scan_Item::STATUS_ACTIVE, Scan_Item::STATUS_ACTIVE );
362 }
363
364 /**
365 * Check if a slug is ignored, we use a global indexer, so we can check while
366 * the active scan is running.
367 *
368 * @param string $slug The path to file.
369 *
370 * @return bool
371 */
372 public function is_issue_ignored( $slug ) {
373 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
374
375 return in_array( $slug, $ignore_lists, true );
376 }
377
378 /**
379 * Ignore a specific issue by updating its status and adding it to the global ignored indexer.
380 *
381 * @param int $id The ID of the issue to ignore.
382 *
383 * @return bool
384 */
385 public function ignore_issue( $id ): bool {
386 return $this->update_issue_status( (int) $id, Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_IGNORE );
387 }
388
389 /**
390 * Retrieves a Scan_Item object based on the given ID.
391 *
392 * @param int $id The ID of the Scan_Item.
393 *
394 * @return Scan_Item|null The Scan_Item object if found, null otherwise.
395 */
396 public function get_issue( $id ) {
397 $orm = self::get_orm();
398 $model = $orm->get_repository( Scan_Item::class )
399 ->where( 'id', $id )
400 ->first();
401
402 if ( is_object( $model ) ) {
403 static $behavior_map = array(
404 Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
405 Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
406 Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
407 Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
408 );
409
410 if ( isset( $behavior_map[ $model->type ] ) ) {
411 $behavior_class = $behavior_map[ $model->type ];
412 $model->attach_behavior( $behavior_class, $behavior_class );
413 }
414 }
415
416 return $model;
417 }
418
419 /**
420 * Remove an issue, this will happen when that issue is resolve, or the file link to this issue get deleted.
421 *
422 * @param int $id The ID of the issue to remove.
423 */
424 public function remove_issue( $id ) {
425 $orm = self::get_orm();
426 $orm->get_repository( Scan_Item::class )->delete( array( 'id' => $id ) );
427 }
428
429 /**
430 * Get all scan types where the target entity is the whole folder.
431 *
432 * @return array
433 */
434 private function get_scan_types_for_whole_folder(): array {
435 return array(
436 Scan_Item::TYPE_VULNERABILITY,
437 Scan_Item::TYPE_PLUGIN_CLOSED,
438 Scan_Item::TYPE_PLUGIN_OUTDATED,
439 );
440 }
441
442 /**
443 * Remove other Scan issue(-s) for the same file.
444 *
445 * @param string $path The path to file.
446 * @param string $type The type of scan issue.
447 *
448 * @return void
449 */
450 public function remove_related_issue_by( string $path, string $type ) {
451 $orm = self::get_orm();
452 $builder = $orm->get_repository( Scan_Item::class )
453 ->where( 'parent_id', $this->id );
454 // No needs to separate check VULNERABILITY, PLUGIN_CLOSED and PLUGIN_OUTDATED because we do not delete per file for such types.
455 $arr_excluded_types = $this->get_scan_types_for_whole_folder();
456 if ( '' !== $path ) {
457 $arr_excluded_types[] = $type;
458 }
459 $builder->where( 'type', 'NOT IN', $arr_excluded_types );
460 $models = $builder->get();
461
462 if ( is_array( $models ) && array() !== $models ) {
463 foreach ( $models as $model ) {
464 if ( isset( $model->raw_data['file'] ) && $model->raw_data['file'] === $path ) {
465 $this->remove_issue( $model->id );
466 }
467 }
468 }
469 }
470
471 /**
472 * Converts the object to an array representation.
473 *
474 * @param int|null $per_page The number of items to retrieve per page. 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.
478 *
479 * @return array The array representation of the object.
480 */
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 ) ) {
483
484 return array(
485 'status' => $this->status,
486 'status_text' => $this->get_status_text(),
487 'percent' => $this->percent,
488 'task_checkpoint' => $this->task_checkpoint,
489 // This only for hub, when a scan running.
490 'count' => array( 'total' => 0 ),
491 );
492 } elseif ( in_array( $this->status, array( self::STATUS_FINISH, self::STATUS_IDLE ), true ) ) {
493 $total_filtered = (int) $this->count( $type );
494 $count_issues_filtered = (int) $this->count( $type, Scan_Item::STATUS_ACTIVE );
495 $total_count = (int) $this->count( null, Scan_Item::STATUS_ACTIVE );
496
497 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
498 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
499
500 $count_ignored = $scan_item_ignore_total['all'] ?? 0;
501 $count_ignored_filtered = (int) $this->count( $type, Scan_Item::STATUS_IGNORE );
502
503 $total_issue_pages = 1;
504 $total_ignored_pages = 1;
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;
515 } else {
516 $relevant_count = max( $count_issues_filtered, $count_ignored_filtered );
517 }
518
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 }
524
525 return array(
526 'status' => $this->status,
527 'issues_items' => $data['issues'],
528 'ignored_items' => $data['ignored'],
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 : '',
536 'count' => array(
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'],
548 ),
549 'paging' => array(
550 'issue' => array(
551 'paged' => $paged,
552 'total_pages' => $total_issue_pages,
553 ),
554 'ignored' => array(
555 'paged' => $paged,
556 'total_pages' => $total_ignored_pages,
557 ),
558 'per_page' => $per_page,
559 ),
560 'task_checkpoint' => $this->task_checkpoint,
561 );
562 } else {
563 return array();
564 }
565 }
566
567 /**
568 * Creates a new record in the database.
569 *
570 * @param bool $from_report Is this a scan from report.
571 *
572 * @return bool|WP_Error|Scan
573 */
574 public static function create( $from_report = false ) {
575 $orm = self::get_orm();
576 $active = self::get_active();
577 if ( is_object( $active ) ) {
578 return new WP_Error( Error_Code::INVALID, esc_html__( 'A scan is already in progress.', 'defender-security' ) );
579 }
580 $model = new Scan();
581 $model->status = self::STATUS_INIT;
582 $model->date_start = gmdate( 'Y-m-d H:i:s' );
583 $model->date_end = gmdate( 'Y-m-d H:i:s' );
584 $model->is_automation = $from_report;
585
586 $orm->save( $model );
587
588 return $model;
589 }
590
591 /**
592 * Delete current scan.
593 *
594 * @param int|null $id Table primary key id.
595 */
596 public function delete( $id = null ) {
597 if ( ! $this->is_positive_int( $id ) ) {
598 $id = $this->id;
599 }
600 $this->clear_status_messages( $id );
601
602 // Delete all the related result items.
603 $orm = self::get_orm();
604
605 $orm->get_repository( Scan_Item::class )->delete(
606 array( 'parent_id' => $id )
607 );
608
609 $orm->get_repository( self::class )->delete(
610 array( 'id' => $id )
611 );
612 }
613
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 /**
698 * Get the current active scan if any.
699 *
700 * @return self|null
701 */
702 public static function get_active() {
703 $orm = self::get_orm();
704
705 return $orm->get_repository( self::class )
706 ->where( 'status', 'NOT IN', self::get_inactive_statuses() )
707 ->first();
708 }
709
710 /**
711 * Check if the current state is Core integrity.
712 *
713 * @return self|null
714 */
715 public static function get_core_check() {
716 $orm = self::get_orm();
717
718 return $orm->get_repository( self::class )
719 ->where( 'status', self::STEP_CHECK_CORE )
720 ->first();
721 }
722
723 /**
724 * Get last result.
725 *
726 * @return self|null
727 */
728 public static function get_last() {
729 $orm = self::get_orm();
730
731 return $orm->get_repository( self::class )
732 ->where( 'status', 'IN', array( self::STATUS_FINISH, self::STATUS_IDLE ) )
733 ->order_by( 'id', 'desc' )
734 ->first();
735 }
736
737 /**
738 * Get last results.
739 *
740 * @return array
741 */
742 public static function get_last_all() {
743 $orm = self::get_orm();
744
745 return $orm->get_repository( self::class )
746 ->where( 'status', 'IN', array( self::STATUS_FINISH, self::STATUS_IDLE ) )
747 ->order_by( 'id', 'desc' )
748 ->get();
749 }
750
751 /**
752 * Adds an item to the scan.
753 *
754 * @param mixed $type The type of the item.
755 * @param mixed $data The data of the item.
756 * @param string $status The status of the item. Default is Scan_Item::STATUS_ACTIVE.
757 *
758 * @return bool Returns true if the item is successfully added, false otherwise.
759 */
760 public function add_item( $type, $data, $status = Scan_Item::STATUS_ACTIVE ) {
761 $model = new Scan_Item();
762 $model->type = $type;
763 $model->parent_id = $this->id;
764 $model->raw_data = $data;
765 $model->status = $status;
766 $ret = $model->save();
767
768 return $ret;
769 }
770
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 /**
834 * Return current status as readable string.
835 *
836 * @return string
837 */
838 public function get_status_text() {
839 switch ( $this->status ) {
840 case self::STATUS_INIT:
841 return 'deep' === self::get_scan_type() ? wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) ) : wp_strip_all_tags( __( 'Initializing...', 'defender-security' ) );
842 case self::STEP_GATHER_INFO:
843 return wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) );
844 case self::STEP_CHECK_CORE:
845 return wp_strip_all_tags( __( 'Analyzing WordPress Core...', 'defender-security' ) );
846 case self::STEP_CHECK_PLUGIN:
847 return wp_strip_all_tags( __( 'Analyzing WordPress Plugins...', 'defender-security' ) );
848 case self::STEP_VULN_CHECK:
849 return wp_strip_all_tags( __( 'Checking for vulnerabilities in plugins and themes...', 'defender-security' ) );
850 case self::STEP_SUSPICIOUS_CHECK:
851 return wp_strip_all_tags( __( 'Analyzing WordPress Content...', 'defender-security' ) );
852 case self::STEP_ABANDONED_PLUGIN_CHECK:
853 return wp_strip_all_tags( __( 'Checking for outdated & removed plugins...', 'defender-security' ) );
854 default:
855 return wp_strip_all_tags( __( 'Scanning...', 'defender-security' ) );
856 }
857 }
858
859 /**
860 * Calculates the percentage of a task based on its progress and position.
861 *
862 * @param int $task_percent The percentage of the task completed.
863 * @param int $pos The position of the task in the list of tasks. Default is 1.
864 *
865 * @return float The calculated percentage.
866 */
867 public function calculate_percent( $task_percent, $pos = 1 ) {
868 $task_max = ( 0 !== $this->total_tasks ) ? ( 100 / $this->total_tasks ) : 0;
869 $task_base = $task_max * ( $pos - 1 );
870 $micro = $task_percent * $task_max / 100;
871 $this->percent = (int) round( $task_base + $micro, 2 );
872 if ( $this->percent > 100 ) {
873 $this->percent = 100;
874 }
875
876 return $this->percent;
877 }
878
879 /**
880 * Get list of whitelisted files.
881 *
882 * @return array
883 */
884 private function whitelisted_files() {
885 return array(
886 // Configuration files.
887 'user.ini',
888 'php.ini',
889 'robots.txt',
890 '.htaccess',
891 'nginx.conf',
892 // Hidden system files and directories.
893 '.well-known',
894 '.idea',
895 '.DS_Store',
896 '.svn',
897 '.git',
898 '.quarantine',
899 '.tmb',
900 '.vscode',
901 );
902 }
903
904 /**
905 * Check if a slug is whitelisted.
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 *
914 * @param string $slug The path to file.
915 *
916 * @return bool
917 */
918 public function is_issue_whitelisted( string $slug ): bool {
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;
931 }
932 }
933
934 return false;
935 }
936
937 /**
938 * Update ignore list.
939 *
940 * @param array $ignore_lists Items to be added to the ignore list.
941 */
942 public function update_ignore_list( $ignore_lists ) {
943 $ignore_lists = array_unique( $ignore_lists );
944 $ignore_lists = array_filter( $ignore_lists, 'strlen' );
945 update_site_option( self::IGNORE_INDEXER, $ignore_lists );
946 }
947
948 /**
949 * Get the threshold time limit as DateTime object.
950 *
951 * @return DateTime Threshold time limit as DateTime object.
952 */
953 public function threshold_date_time_object() {
954 $timezone = new DateTimeZone( 'UTC' );
955
956 /**
957 * Filter to override scan threshold period.
958 *
959 * @param string $threshold Any valid relative Date and Time formats.
960 *
961 * @link https://www.php.net/manual/en/datetime.formats.relative.php
962 * @since 2.6.1
963 */
964 $threshold = apply_filters( 'wd_scan_threshold', self::THRESHOLD_PERIOD );
965
966 return new DateTime( $threshold, $timezone );
967 }
968
969 /**
970 * Threshold time limit in mysql string format.
971 *
972 * @return string Threshold time limit as mysql string format.
973 */
974 public function threshold_date_time_mysql() {
975 $type = 'Y-m-d H:i:s';
976 $threshold_date_time_object = $this->threshold_date_time_object();
977 $mysql_format = $threshold_date_time_object->format( $type );
978
979 return $mysql_format;
980 }
981
982 /**
983 * Get the idle scan if any.
984 *
985 * @return self|null
986 */
987 public function get_idle() {
988 $orm = self::get_orm();
989
990 $mysql_date = $this->threshold_date_time_mysql();
991
992 return $orm->get_repository( self::class )
993 ->where( 'status', 'NOT IN', array( self::STATUS_FINISH, self::STATUS_ERROR ) )
994 ->where( 'date_start', '<', $mysql_date )
995 ->first();
996 }
997
998 /**
999 * Delete all idle scan and scan items
1000 *
1001 * @since 2.6.1
1002 */
1003 public function delete_idle() {
1004 $idle_scans = self::get_orm()
1005 ->get_repository( self::class )
1006 ->where( 'status', self::STATUS_IDLE )
1007 ->get();
1008
1009 foreach ( $idle_scans as $idle_scan ) {
1010 $this->delete( $idle_scan->id );
1011 }
1012 }
1013
1014 /**
1015 * Verify positive integer or not.
1016 *
1017 * @param mixed $id Argument to check for a positive number.
1018 *
1019 * @return bool Return true on positive integer else false.
1020 * @since 2.6.1
1021 */
1022 private function is_positive_int( $id ): bool {
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;
1094 }
1095 }
1096