PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.3
Defender Security – Malware Scanner, Login Security & Firewall v6.2.3
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 3.1.1 All 140 releases
defender-security / src / model / class-scan.php

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

1,089 lines 33.6 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 = isset( $scan_item_group_total['all'] ) ?
188 $scan_item_group_total['all'] : 0;
189 $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
190 $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
191 $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
192 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
193 $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
194 $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
195 $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
196 $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
197 // New counts since v5.5.0.
198 $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
199 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
200 $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
201 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
202
203 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
204
205 $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
206 $scan_item_ignore_total['all'] : 0;
207
208 foreach ( $ignored_models as $model ) {
209 $ignored[] = $model->to_array();
210 }
211 foreach ( $active_models as $active_model ) {
212 $issues[] = $active_model->to_array();
213
214 // We will now count all issues again by type filter for pagination usage.
215 if ( null !== $type && 'all' !== $type ) {
216 if ( is_array( $type ) && in_array( $active_model->type, $type, true ) ) {
217 ++$count_issues_filtered;
218 } elseif ( $type === $active_model->type ) {
219 ++$count_issues_filtered;
220 }
221 } else {
222 ++$count_issues_filtered;
223 }
224 }
225
226 return array(
227 'ignored' => $ignored,
228 'issues' => $issues,
229 'count_total' => $count_total,
230 'count_issues' => $count_issues,
231 'count_issues_filtered' => $count_issues_filtered,
232 'count_ignored' => $count_ignored,
233 'count_core' => $count_core,
234 'count_plugin' => $count_plugin,
235 'count_malware' => $count_malware,
236 'count_vuln' => $count_vuln,
237 'count_outdated_plugin' => $count_outdated_plugin,
238 'count_closed_plugin' => $count_closed_plugin,
239 );
240 }
241
242 /**
243 * Get abandoned plugin types.
244 *
245 * @return array
246 */
247 public static function get_abandoned_types(): array {
248 return array(
249 Scan_Item::TYPE_PLUGIN_CLOSED,
250 Scan_Item::TYPE_PLUGIN_OUTDATED,
251 );
252 }
253
254 /**
255 * Retrieves scan issues based on provided filters.
256 * This method fetches scan items related to the current object's ID,
257 * filtered by type, status, and pagination parameters.
258 * The retrieved items are then attached with relevant behaviors based on their type.
259 *
260 * @param string|array|null $type Optional. The type of scan issue to filter by.
261 * Accepts 'vulnerability', 'integrity', 'plugin_check', or 'suspicious'. Default null.
262 * @param string|null $status Optional. The status of the scan issue to filter by.
263 * Accepts 'ignore' or 'active'. Default null.
264 * @param int|null $per_page Optional. The number of items to retrieve per page. Default null.
265 * @param int|null $paged Optional. The page number of items to retrieve. Default null.
266 *
267 * @return array An array of scan issue models with attached behaviors.
268 */
269 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
270 $orm = self::get_orm();
271 $builder = $orm->get_repository( Scan_Item::class )
272 ->where( 'parent_id', $this->id );
273
274 $valid_types = Scan_Item::get_all_scan_types();
275 if ( null !== $type ) {
276 if ( is_array( $type ) ) {
277 $filtered_types = array_intersect( $type, $valid_types );
278 if ( array() !== $filtered_types ) {
279 $builder->where( 'type', 'IN', $filtered_types );
280 }
281 } elseif ( in_array( $type, $valid_types, true ) ) {
282 $builder->where( 'type', $type );
283 } elseif ( 'all' === $type ) {
284 $builder->where( 'type', 'IN', $valid_types );
285 }
286 } else {
287 // Include all possible types.
288 $builder->where( 'type', 'IN', $valid_types );
289 }
290
291 if ( null !== $status ) {
292 if ( in_array( $status, Scan_Item::get_all_scan_statuses(), true ) ) {
293 $builder->where( 'status', $status );
294 }
295 }
296
297 if ( is_int( $per_page ) && is_int( $paged ) && $per_page > 0 && $paged > 0 ) {
298 $offset = ( $paged - 1 ) * $per_page;
299 $builder->limit( $per_page, $offset );
300 }
301
302 $models = $builder->get();
303
304 static $behavior_map = array(
305 Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
306 Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
307 Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
308 Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
309 );
310
311 foreach ( $models as $model ) {
312 if ( isset( $behavior_map[ $model->type ] ) ) {
313 $behavior_class = $behavior_map[ $model->type ];
314 $model->attach_behavior( $behavior_class, $behavior_class );
315 }
316 }
317
318 return $models;
319 }
320
321 /**
322 * Counts the number of Scan_Item models that match the given type and status.
323 *
324 * @param string|array|null $type The type(s) of Scan_Item to count.
325 * @param string|null $status The status of the Scan_Item to count. Must be one of the following:
326 * Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE.
327 *
328 * @return mixed The number of matching Scan_Item models.
329 */
330 public function count( $type = null, $status = null ) {
331 $orm = self::get_orm();
332 $builder = $orm->get_repository( Scan_Item::class )->where( 'parent_id', $this->id );
333
334 $valid_types = Scan_Item::get_all_scan_types();
335
336 if ( is_array( $type ) ) {
337 $filtered_types = array_intersect( $type, $valid_types );
338 if ( array() !== $filtered_types ) {
339 $builder->where( 'type', 'IN', $filtered_types );
340 }
341 } elseif (
342 ! is_null( $type )
343 && in_array( $type, $valid_types, true )
344 ) {
345 $builder->where( 'type', $type );
346 } elseif ( is_null( $type ) || 'all' === $type ) {
347 $builder->where( 'type', 'IN', $valid_types );
348 }
349
350 if (
351 ! is_null( $status )
352 && in_array( $status, Scan_Item::get_all_scan_statuses(), true )
353 ) {
354 $builder->where( 'status', $status );
355 }
356
357 return $builder->count();
358 }
359
360 /**
361 * Allow a specific issue by updating its status and removing it from the global ignore indexer.
362 *
363 * @param int $id The ID of the issue to Allow.
364 *
365 * @return bool
366 */
367 public function unignore_issue( $id ): bool {
368 $issue = $this->get_issue( $id );
369 if ( ! is_object( $issue ) ) {
370 return false;
371 }
372 // Check if the current issue already exists in the Issues list, there is no need to add a duplicate.
373 $current_issue_arr = $issue->to_array();
374 foreach ( $this->get_issues( null, Scan_Item::STATUS_ACTIVE ) as $active_issue ) {
375 $active_issue_arr = $active_issue->to_array();
376 if (
377 $current_issue_arr['type'] === $active_issue_arr['type']
378 && $current_issue_arr['full_path'] === $active_issue_arr['full_path']
379 ) {
380 return false;
381 }
382 }
383
384 $issue->status = Scan_Item::STATUS_ACTIVE;
385 $issue->save();
386
387 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
388 $data = $issue->raw_data;
389 if ( isset( $data['file'] ) ) {
390 unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
391 } elseif ( isset( $data['slug'] ) ) {
392 unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
393 }
394 $this->update_ignore_list( $ignore_lists );
395
396 return true;
397 }
398
399 /**
400 * Check if a slug is ignored, we use a global indexer, so we can check while
401 * the active scan is running.
402 *
403 * @param string $slug The path to file.
404 *
405 * @return bool
406 */
407 public function is_issue_ignored( $slug ) {
408 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
409
410 return in_array( $slug, $ignore_lists, true );
411 }
412
413 /**
414 * Ignore a specific issue by updating its status and adding it to the global ignored indexer.
415 *
416 * @param int $id The ID of the issue to ignore.
417 *
418 * @return bool
419 */
420 public function ignore_issue( $id ): bool {
421 $issue = $this->get_issue( $id );
422 if ( ! is_object( $issue ) ) {
423 return false;
424 }
425 // Check if the current issue already exists in the Ignored list, there is no need to add a duplicate.
426 $current_issue_arr = $issue->to_array();
427 foreach ( $this->get_issues( null, Scan_Item::STATUS_IGNORE ) as $ignore_issue ) {
428 $ignore_issue_arr = $ignore_issue->to_array();
429 if ( $current_issue_arr['type'] === $ignore_issue_arr['type'] &&
430 $current_issue_arr['full_path'] === $ignore_issue_arr['full_path']
431 ) {
432 return false;
433 }
434 }
435
436 $issue->status = Scan_Item::STATUS_IGNORE;
437 $issue->save();
438
439 // Add this into a global ignored index and update the ignored list.
440 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
441 $ignore_lists[] = $current_issue_arr['full_path'];
442 $this->update_ignore_list( $ignore_lists );
443
444 return true;
445 }
446
447 /**
448 * Retrieves a Scan_Item object based on the given ID.
449 *
450 * @param int $id The ID of the Scan_Item.
451 *
452 * @return Scan_Item|null The Scan_Item object if found, null otherwise.
453 */
454 public function get_issue( $id ) {
455 $orm = self::get_orm();
456 $model = $orm->get_repository( Scan_Item::class )
457 ->where( 'id', $id )
458 ->first();
459
460 if ( is_object( $model ) ) {
461 static $behavior_map = array(
462 Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
463 Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
464 Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
465 Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
466 );
467
468 if ( isset( $behavior_map[ $model->type ] ) ) {
469 $behavior_class = $behavior_map[ $model->type ];
470 $model->attach_behavior( $behavior_class, $behavior_class );
471 }
472 }
473
474 return $model;
475 }
476
477 /**
478 * Remove an issue, this will happen when that issue is resolve, or the file link to this issue get deleted.
479 *
480 * @param int $id The ID of the issue to remove.
481 */
482 public function remove_issue( $id ) {
483 $orm = self::get_orm();
484 $orm->get_repository( Scan_Item::class )->delete( array( 'id' => $id ) );
485 }
486
487 /**
488 * Get all scan types where the target entity is the whole folder.
489 *
490 * @return array
491 */
492 private function get_scan_types_for_whole_folder(): array {
493 return array(
494 Scan_Item::TYPE_VULNERABILITY,
495 Scan_Item::TYPE_PLUGIN_CLOSED,
496 Scan_Item::TYPE_PLUGIN_OUTDATED,
497 );
498 }
499
500 /**
501 * Remove other Scan issue(-s) for the same file.
502 *
503 * @param string $path The path to file.
504 * @param string $type The type of scan issue.
505 *
506 * @return void
507 */
508 public function remove_related_issue_by( string $path, string $type ) {
509 $orm = self::get_orm();
510 $builder = $orm->get_repository( Scan_Item::class )
511 ->where( 'parent_id', $this->id );
512 // No needs to separate check VULNERABILITY, PLUGIN_CLOSED and PLUGIN_OUTDATED because we do not delete per file for such types.
513 $arr_excluded_types = $this->get_scan_types_for_whole_folder();
514 if ( '' !== $path ) {
515 $arr_excluded_types[] = $type;
516 }
517 $builder->where( 'type', 'NOT IN', $arr_excluded_types );
518 $models = $builder->get();
519
520 if ( is_array( $models ) && array() !== $models ) {
521 foreach ( $models as $model ) {
522 if ( isset( $model->raw_data['file'] ) && $model->raw_data['file'] === $path ) {
523 $this->remove_issue( $model->id );
524 }
525 }
526 }
527 }
528
529 /**
530 * Converts the object to an array representation.
531 *
532 * @param int|null $per_page The number of items to retrieve per page. Default null.
533 * @param int|null $paged The page number of items to retrieve. Default null.
534 * @param string|null $type The type of scan issue to filter by. Default null.
535 * @param string|null $scenario Pagination scenario ('issue', 'ignored', or null). Default null.
536 *
537 * @return array The array representation of the object.
538 */
539 public function to_array( $per_page = null, $paged = null, $type = null, $scenario = null ) {
540 if ( ! in_array( $this->status, self::get_inactive_statuses(), true ) ) {
541
542 return array(
543 'status' => $this->status,
544 'status_text' => $this->get_status_text(),
545 'percent' => $this->percent,
546 'task_checkpoint' => $this->task_checkpoint,
547 // This only for hub, when a scan running.
548 'count' => array( 'total' => 0 ),
549 );
550 } elseif ( in_array( $this->status, array( self::STATUS_FINISH, self::STATUS_IDLE ), true ) ) {
551 $total_filtered = (int) $this->count( $type );
552 $count_issues_filtered = (int) $this->count( $type, Scan_Item::STATUS_ACTIVE );
553 $total_count = (int) $this->count( null, Scan_Item::STATUS_ACTIVE );
554
555 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
556 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
557
558 $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
559 $scan_item_ignore_total['all'] : 0;
560 $count_ignored_filtered = (int) $this->count( $type, Scan_Item::STATUS_IGNORE );
561
562 $total_issue_pages = 1;
563 $total_ignored_pages = 1;
564 if ( is_int( $per_page ) && $per_page > 0 ) {
565 $total_issue_pages = max( 1, (int) ceil( $count_issues_filtered / $per_page ) );
566 $total_ignored_pages = max( 1, (int) ceil( $count_ignored_filtered / $per_page ) );
567 }
568
569 // On the initial load both tabs are fetched, so either tab can require pagination.
570 if ( 'ignored' === $scenario ) {
571 $relevant_count = $count_ignored_filtered;
572 } elseif ( 'issue' === $scenario ) {
573 $relevant_count = $count_issues_filtered;
574 } else {
575 $relevant_count = max( $count_issues_filtered, $count_ignored_filtered );
576 }
577
578 if ( is_int( $per_page ) && $per_page > 0 && $relevant_count > $per_page ) {
579 $data = $this->prepare_issues( $per_page, $paged, $type, $scenario );
580 } else {
581 $data = $this->prepare_issues( null, null, $type, $scenario );
582 }
583
584 $scan_item_group_total = wd_di()->get( Scan_Item::class )
585 ->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
586
587 $count_issues = isset( $scan_item_group_total['all'] ) ?
588 $scan_item_group_total['all'] : 0;
589 $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
590 $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
591 $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
592 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
593 $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
594 $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
595 $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
596 $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
597 // New counts since v5.5.0.
598 $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
599 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
600 $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
601 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
602
603 return array(
604 'status' => $this->status,
605 'issues_items' => $data['issues'],
606 'ignored_items' => $data['ignored'],
607 'last_scan' => $this->date_start
608 ? sprintf(
609 /* translators: %s: human-readable time difference, e.g. "5 minutes" */
610 __( '%s ago', 'defender-security' ),
611 human_time_diff( strtotime( $this->date_start ) )
612 )
613 : '',
614 'count' => array(
615 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
616 'total_filtered' => $total_filtered,
617 'issues_total' => $count_issues,
618 'issues_total_filtered' => $count_issues_filtered,
619 'ignored_total' => $count_ignored,
620 'ignored_total_filtered' => $count_ignored_filtered,
621 'core' => $count_core + $count_plugin,
622 'content' => $count_malware,
623 'vuln' => $count_vuln,
624 'outdated_plugin' => $count_outdated_plugin,
625 'closed_plugin' => $count_closed_plugin,
626 ),
627 'paging' => array(
628 'issue' => array(
629 'paged' => $paged,
630 'total_pages' => $total_issue_pages,
631 ),
632 'ignored' => array(
633 'paged' => $paged,
634 'total_pages' => $total_ignored_pages,
635 ),
636 'per_page' => $per_page,
637 ),
638 'task_checkpoint' => $this->task_checkpoint,
639 );
640 } else {
641 return array();
642 }
643 }
644
645 /**
646 * Creates a new record in the database.
647 *
648 * @param bool $from_report Is this a scan from report.
649 *
650 * @return bool|WP_Error|Scan
651 */
652 public static function create( $from_report = false ) {
653 $orm = self::get_orm();
654 $active = self::get_active();
655 if ( is_object( $active ) ) {
656 return new WP_Error( Error_Code::INVALID, esc_html__( 'A scan is already in progress.', 'defender-security' ) );
657 }
658 $model = new Scan();
659 $model->status = self::STATUS_INIT;
660 $model->date_start = gmdate( 'Y-m-d H:i:s' );
661 $model->date_end = gmdate( 'Y-m-d H:i:s' );
662 $model->is_automation = $from_report;
663
664 $orm->save( $model );
665
666 return $model;
667 }
668
669 /**
670 * Delete current scan.
671 *
672 * @param int|null $id Table primary key id.
673 */
674 public function delete( $id = null ) {
675 if ( ! $this->is_positive_int( $id ) ) {
676 $id = $this->id;
677 }
678 $this->clear_status_messages( $id );
679
680 // Delete all the related result items.
681 $orm = self::get_orm();
682
683 $orm->get_repository( Scan_Item::class )->delete(
684 array( 'parent_id' => $id )
685 );
686
687 $orm->get_repository( self::class )->delete(
688 array( 'id' => $id )
689 );
690 }
691
692 /**
693 * Store a user-visible status transition for this scan.
694 *
695 * @param string $message Status transition message.
696 */
697 public function enqueue_status_message( $message ): void {
698 $message = sanitize_text_field( wp_strip_all_tags( (string) $message ) );
699 if ( '' === $message || ! $this->is_positive_int( $this->id ) ) {
700 return;
701 }
702
703 $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
704 $queues = is_array( $queues ) ? $queues : array();
705 $key = (string) $this->id;
706 $stored_queue = $queues[ $key ] ?? null;
707 $queue = is_array( $stored_queue ) ? $stored_queue : array();
708 if ( end( $queue ) !== $message ) {
709 $queue[] = $message;
710 $queues[ $key ] = $queue;
711 update_site_option( self::OPTION_STATUS_MESSAGES, $queues );
712 }
713 }
714
715 /**
716 * Return queued status messages without consuming them.
717 */
718 public function get_status_messages(): array {
719 $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
720 $queue = is_array( $queues ) ? ( $queues[ (string) $this->id ] ?? array() ) : array();
721
722 return is_array( $queue ) ? array_values( $queue ) : array();
723 }
724
725 /**
726 * Return and remove queued status messages.
727 */
728 public function drain_status_messages(): array {
729 $messages = $this->get_status_messages();
730 $this->clear_status_messages();
731
732 return $messages;
733 }
734
735 /**
736 * Remove queued messages for one scan.
737 *
738 * @param int|null $id Table primary key ID.
739 */
740 public function clear_status_messages( $id = null ): void {
741 $id = $this->is_positive_int( $id ) ? $id : $this->id;
742 if ( ! $this->is_positive_int( $id ) ) {
743 return;
744 }
745 $queues = get_site_option( self::OPTION_STATUS_MESSAGES, array() );
746 if ( ! is_array( $queues ) || ! array_key_exists( (string) $id, $queues ) ) {
747 return;
748 }
749 unset( $queues[ (string) $id ] );
750 array() === $queues
751 ? delete_site_option( self::OPTION_STATUS_MESSAGES )
752 : update_site_option( self::OPTION_STATUS_MESSAGES, $queues );
753 }
754
755 /**
756 * Remove status queues for all scans.
757 */
758 public static function clear_all_status_messages(): void {
759 delete_site_option( self::OPTION_STATUS_MESSAGES );
760 }
761
762 /**
763 * Get the inactive scan statuses.
764 *
765 * @return array
766 */
767 public static function get_inactive_statuses(): array {
768 return array(
769 self::STATUS_FINISH,
770 self::STATUS_ERROR,
771 self::STATUS_IDLE,
772 );
773 }
774
775 /**
776 * Get the current active scan if any.
777 *
778 * @return self|null
779 */
780 public static function get_active() {
781 $orm = self::get_orm();
782
783 return $orm->get_repository( self::class )
784 ->where( 'status', 'NOT IN', self::get_inactive_statuses() )
785 ->first();
786 }
787
788 /**
789 * Check if the current state is Core integrity.
790 *
791 * @return self|null
792 */
793 public static function get_core_check() {
794 $orm = self::get_orm();
795
796 return $orm->get_repository( self::class )
797 ->where( 'status', self::STEP_CHECK_CORE )
798 ->first();
799 }
800
801 /**
802 * Get last result.
803 *
804 * @return self|null
805 */
806 public static function get_last() {
807 $orm = self::get_orm();
808
809 return $orm->get_repository( self::class )
810 ->where( 'status', 'IN', array( self::STATUS_FINISH, self::STATUS_IDLE ) )
811 ->order_by( 'id', 'desc' )
812 ->first();
813 }
814
815 /**
816 * Get last results.
817 *
818 * @return array
819 */
820 public static function get_last_all() {
821 $orm = self::get_orm();
822
823 return $orm->get_repository( self::class )
824 ->where( 'status', 'IN', array( self::STATUS_FINISH, self::STATUS_IDLE ) )
825 ->order_by( 'id', 'desc' )
826 ->get();
827 }
828
829 /**
830 * Adds an item to the scan.
831 *
832 * @param mixed $type The type of the item.
833 * @param mixed $data The data of the item.
834 * @param string $status The status of the item. Default is Scan_Item::STATUS_ACTIVE.
835 *
836 * @return bool Returns true if the item is successfully added, false otherwise.
837 */
838 public function add_item( $type, $data, $status = Scan_Item::STATUS_ACTIVE ) {
839 $model = new Scan_Item();
840 $model->type = $type;
841 $model->parent_id = $this->id;
842 $model->raw_data = $data;
843 $model->status = $status;
844 $ret = $model->save();
845
846 return $ret;
847 }
848
849 /**
850 * Carry previously-ignored issues forward into this scan, without duplicating
851 * any that were already re-detected as active in the current pass.
852 *
853 * @param array|string $type Scan item type(s) the issues belong to.
854 * @param array $issues Scan_Item objects (status ignore) to carry forward.
855 * @param string $key_field Field inside raw_data used to match issues, e.g. 'file' or 'slug'.
856 */
857 public function carry_forward_ignored_issues( $type, array $issues, string $key_field ) {
858 if ( array() === $issues ) {
859 return;
860 }
861
862 $active_by_key = array();
863 foreach ( $this->get_issues( $type, Scan_Item::STATUS_ACTIVE ) as $active_item ) {
864 $key = $active_item->raw_data[ $key_field ] ?? null;
865 if ( null !== $key ) {
866 $active_by_key[ $key ] = $active_item;
867 }
868 }
869
870 foreach ( $issues as $issue ) {
871 $key = $issue->raw_data[ $key_field ] ?? null;
872 if ( null !== $key && isset( $active_by_key[ $key ] ) ) {
873 // Already re-detected as active in this scan; keep it ignored, don't duplicate.
874 $active_by_key[ $key ]->status = Scan_Item::STATUS_IGNORE;
875 $active_by_key[ $key ]->save();
876 continue;
877 }
878 $this->add_item( $issue->type, $issue->raw_data, Scan_Item::STATUS_IGNORE );
879 }
880 }
881
882 /**
883 * Persist the current scan type ('deep' or 'malware') in a site option.
884 *
885 * @param string $type Scan type identifier. Accepts 'deep' or 'malware'.
886 * @return void
887 */
888 public static function set_scan_type( string $type ): void {
889 update_site_option( self::OPTION_SCAN_TYPE, $type );
890 }
891
892 /**
893 * Retrieve the persisted scan type. Returns 'malware' when nothing is stored.
894 *
895 * @return string
896 */
897 public static function get_scan_type(): string {
898 return (string) get_site_option( self::OPTION_SCAN_TYPE, 'malware' );
899 }
900
901 /**
902 * Delete the persisted scan type (called on scan cancel or after returning
903 * the finished-scan response so the option does not linger).
904 *
905 * @return void
906 */
907 public static function clear_scan_type(): void {
908 delete_site_option( self::OPTION_SCAN_TYPE );
909 }
910
911 /**
912 * Return current status as readable string.
913 *
914 * @return string
915 */
916 public function get_status_text() {
917 switch ( $this->status ) {
918 case self::STATUS_INIT:
919 return 'deep' === self::get_scan_type() ? wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) ) : wp_strip_all_tags( __( 'Initializing...', 'defender-security' ) );
920 case self::STEP_GATHER_INFO:
921 return wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) );
922 case self::STEP_CHECK_CORE:
923 return wp_strip_all_tags( __( 'Analyzing WordPress Core...', 'defender-security' ) );
924 case self::STEP_CHECK_PLUGIN:
925 return wp_strip_all_tags( __( 'Analyzing WordPress Plugins...', 'defender-security' ) );
926 case self::STEP_VULN_CHECK:
927 return wp_strip_all_tags( __( 'Checking for vulnerabilities in plugins and themes...', 'defender-security' ) );
928 case self::STEP_SUSPICIOUS_CHECK:
929 return wp_strip_all_tags( __( 'Analyzing WordPress Content...', 'defender-security' ) );
930 case self::STEP_ABANDONED_PLUGIN_CHECK:
931 return wp_strip_all_tags( __( 'Checking for outdated & removed plugins...', 'defender-security' ) );
932 default:
933 return wp_strip_all_tags( __( 'Scanning...', 'defender-security' ) );
934 }
935 }
936
937 /**
938 * Calculates the percentage of a task based on its progress and position.
939 *
940 * @param int $task_percent The percentage of the task completed.
941 * @param int $pos The position of the task in the list of tasks. Default is 1.
942 *
943 * @return float The calculated percentage.
944 */
945 public function calculate_percent( $task_percent, $pos = 1 ) {
946 $task_max = ( 0 !== $this->total_tasks ) ? ( 100 / $this->total_tasks ) : 0;
947 $task_base = $task_max * ( $pos - 1 );
948 $micro = $task_percent * $task_max / 100;
949 $this->percent = (int) round( $task_base + $micro, 2 );
950 if ( $this->percent > 100 ) {
951 $this->percent = 100;
952 }
953
954 return $this->percent;
955 }
956
957 /**
958 * Get list of whitelisted files.
959 *
960 * @return array
961 */
962 private function whitelisted_files() {
963 return array(
964 // Configuration files.
965 'user.ini',
966 'php.ini',
967 'robots.txt',
968 '.htaccess',
969 'nginx.conf',
970 // Hidden system files and directories.
971 '.well-known',
972 '.idea',
973 '.DS_Store',
974 '.svn',
975 '.git',
976 '.quarantine',
977 '.tmb',
978 '.vscode',
979 );
980 }
981
982 /**
983 * Check if a slug is whitelisted.
984 *
985 * @param string $slug The path to file.
986 *
987 * @return bool
988 */
989 public function is_issue_whitelisted( string $slug ): bool {
990 $whitelisted_files = $this->whitelisted_files();
991 foreach ( $whitelisted_files as $file ) {
992 if ( false !== stristr( $slug, $file ) ) {
993 return true;
994 }
995 }
996
997 return false;
998 }
999
1000 /**
1001 * Update ignore list.
1002 *
1003 * @param array $ignore_lists Items to be added to the ignore list.
1004 */
1005 public function update_ignore_list( $ignore_lists ) {
1006 $ignore_lists = array_unique( $ignore_lists );
1007 $ignore_lists = array_filter( $ignore_lists, 'strlen' );
1008 update_site_option( self::IGNORE_INDEXER, $ignore_lists );
1009 }
1010
1011 /**
1012 * Get the threshold time limit as DateTime object.
1013 *
1014 * @return DateTime Threshold time limit as DateTime object.
1015 */
1016 public function threshold_date_time_object() {
1017 $timezone = new DateTimeZone( 'UTC' );
1018
1019 /**
1020 * Filter to override scan threshold period.
1021 *
1022 * @param string $threshold Any valid relative Date and Time formats.
1023 *
1024 * @link https://www.php.net/manual/en/datetime.formats.relative.php
1025 * @since 2.6.1
1026 */
1027 $threshold = apply_filters( 'wd_scan_threshold', self::THRESHOLD_PERIOD );
1028
1029 return new DateTime( $threshold, $timezone );
1030 }
1031
1032 /**
1033 * Threshold time limit in mysql string format.
1034 *
1035 * @return string Threshold time limit as mysql string format.
1036 */
1037 public function threshold_date_time_mysql() {
1038 $type = 'Y-m-d H:i:s';
1039 $threshold_date_time_object = $this->threshold_date_time_object();
1040 $mysql_format = $threshold_date_time_object->format( $type );
1041
1042 return $mysql_format;
1043 }
1044
1045 /**
1046 * Get the idle scan if any.
1047 *
1048 * @return self|null
1049 */
1050 public function get_idle() {
1051 $orm = self::get_orm();
1052
1053 $mysql_date = $this->threshold_date_time_mysql();
1054
1055 return $orm->get_repository( self::class )
1056 ->where( 'status', 'NOT IN', array( self::STATUS_FINISH, self::STATUS_ERROR ) )
1057 ->where( 'date_start', '<', $mysql_date )
1058 ->first();
1059 }
1060
1061 /**
1062 * Delete all idle scan and scan items
1063 *
1064 * @since 2.6.1
1065 */
1066 public function delete_idle() {
1067 $idle_scans = self::get_orm()
1068 ->get_repository( self::class )
1069 ->where( 'status', self::STATUS_IDLE )
1070 ->get();
1071
1072 foreach ( $idle_scans as $idle_scan ) {
1073 $this->delete( $idle_scan->id );
1074 }
1075 }
1076
1077 /**
1078 * Verify positive integer or not.
1079 *
1080 * @param mixed $id Argument to check for a positive number.
1081 *
1082 * @return bool Return true on positive integer else false.
1083 * @since 2.6.1
1084 */
1085 private function is_positive_int( $id ): bool {
1086 return is_int( $id ) && $id > 0;
1087 }
1088 }
1089