PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.0.1
Defender Security – Malware Scanner, Login Security & Firewall v6.0.1
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.0.1, at src/model/class-scan.php

981 lines 30.2 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
49 /**
50 * Any valid relative Date and Time formats.
51 *
52 * @link https://www.php.net/manual/en/datetime.formats.relative.php
53 * @since 2.6.1
54 * @var string
55 */
56 public const THRESHOLD_PERIOD = '3 hours ago';
57
58 /**
59 * Constant to notate the scan is idle or crossed the threshold limit.
60 *
61 * @since 2.6.1
62 * @var string
63 */
64 public const STATUS_IDLE = 'idle';
65
66 /**
67 * Primary key column.
68 *
69 * @var int
70 * @defender_property
71 */
72 public $id;
73 /**
74 * Table column for the status.
75 * Possible values are,
76 * - init
77 * - error
78 * - finish
79 * - gather_fact
80 * - core_integrity_check
81 * - plugin_integrity_check
82 * - vuln_check
83 * - suspicious_check
84 * - idle
85 * - abandoned_plugin_check
86 *
87 * @var string
88 * @defender_property
89 */
90 public $status;
91 /**
92 * Table column for the start time.
93 *
94 * @var string
95 * @defender_property
96 */
97 public $date_start;
98
99 /**
100 * Table column for the current percentage.
101 *
102 * @var int
103 * @defender_property
104 */
105 public $percent = 0;
106
107 /**
108 * Table column for the total tasks.
109 * Store how many tasks we process.
110 *
111 * @var int
112 * @defender_property
113 */
114 public $total_tasks = 0;
115
116 /**
117 * Table column for the current task checkpoint.
118 *
119 * @var string
120 * @defender_property
121 */
122 public $task_checkpoint = '';
123
124 /**
125 * Table column for the end time.
126 *
127 * @var string
128 * @defender_property
129 */
130 public $date_end;
131
132 /**
133 * Table column for the scan trigger by report schedule.
134 *
135 * @var bool
136 * @defender_property
137 */
138 public $is_automation = false;
139
140 /**
141 * Prepare and fetch issues with various counts.
142 * This method retrieves active and ignored issues based on the specified type, page, and items per page.
143 * It prepares a detailed summary including total counts and filtered counts by type.
144 *
145 * @param int|null $per_page Number of items per page. Default null.
146 * @param int|null $paged Current page number. Default null.
147 * @param string|null $type Type of issues to filter. Default null.
148 *
149 * @return array An array containing the list of issues, ignored issues, and various count statistics.
150 * - 'ignored' (array): List of ignored issues.
151 * - 'issues' (array): List of active issues.
152 * - 'count_total' (int): Total number of active issues.
153 * - 'count_issues' (int): Total number of issues of all types.
154 * - 'count_issues_filtered' (int): Number of issues filtered by type.
155 * - 'count_ignored' (int): Total number of ignored issues.
156 * - 'count_core' (int): Number of core integrity issues.
157 * - 'count_plugin' (int): Number of plugin check issues.
158 * - 'count_malware' (int): Number of suspicious/malware issues.
159 * - 'count_vuln' (int): Number of vulnerability issues.
160 * - 'count_outdated_plugin' (int): Number of outdated plugin issues.
161 * - 'count_closed_plugin' (int): Number of closed plugin issues.
162 * @param string|null $scenario Pagination scenario: 'issue', 'ignored', or null (paginate both).
163 */
164 public function prepare_issues( $per_page = null, $paged = null, $type = null, $scenario = null ): array {
165 if ( 'issue' === $scenario ) {
166 // Paginate active issues only; ignored items are not needed for this tab.
167 $ignored_models = array();
168 $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
169 } elseif ( 'ignored' === $scenario ) {
170 // Paginate ignored items only; active issues are not needed for this tab.
171 $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
172 $active_models = array();
173 } else {
174 // Default (initial load): paginate both statuses with the same page.
175 $ignored_models = $this->get_issues( $type, Scan_Item::STATUS_IGNORE, $per_page, $paged );
176 $active_models = $this->get_issues( $type, Scan_Item::STATUS_ACTIVE, $per_page, $paged );
177 }
178
179 $issues = array();
180 $ignored = array();
181 $count_total = count( $active_models );
182 $count_issues_filtered = 0;
183
184 $scan_item_group_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
185
186 $count_issues = isset( $scan_item_group_total['all'] ) ?
187 $scan_item_group_total['all'] : 0;
188 $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
189 $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
190 $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
191 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
192 $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
193 $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
194 $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
195 $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
196 // New counts since v5.5.0.
197 $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
198 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
199 $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
200 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
201
202 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
203
204 $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
205 $scan_item_ignore_total['all'] : 0;
206
207 foreach ( $ignored_models as $model ) {
208 $ignored[] = $model->to_array();
209 }
210 foreach ( $active_models as $active_model ) {
211 $issues[] = $active_model->to_array();
212
213 // We will now count all issues again by type filter for pagination usage.
214 if ( null !== $type && 'all' !== $type ) {
215 if ( is_array( $type ) && in_array( $active_model->type, $type, true ) ) {
216 ++$count_issues_filtered;
217 } elseif ( $type === $active_model->type ) {
218 ++$count_issues_filtered;
219 }
220 } else {
221 ++$count_issues_filtered;
222 }
223 }
224
225 return array(
226 'ignored' => $ignored,
227 'issues' => $issues,
228 'count_total' => $count_total,
229 'count_issues' => $count_issues,
230 'count_issues_filtered' => $count_issues_filtered,
231 'count_ignored' => $count_ignored,
232 'count_core' => $count_core,
233 'count_plugin' => $count_plugin,
234 'count_malware' => $count_malware,
235 'count_vuln' => $count_vuln,
236 'count_outdated_plugin' => $count_outdated_plugin,
237 'count_closed_plugin' => $count_closed_plugin,
238 );
239 }
240
241 /**
242 * Get abandoned plugin types.
243 *
244 * @return array
245 */
246 public static function get_abandoned_types(): array {
247 return array(
248 Scan_Item::TYPE_PLUGIN_CLOSED,
249 Scan_Item::TYPE_PLUGIN_OUTDATED,
250 );
251 }
252
253 /**
254 * Retrieves scan issues based on provided filters.
255 * This method fetches scan items related to the current object's ID,
256 * filtered by type, status, and pagination parameters.
257 * The retrieved items are then attached with relevant behaviors based on their type.
258 *
259 * @param string|array|null $type Optional. The type of scan issue to filter by.
260 * Accepts 'vulnerability', 'integrity', 'plugin_check', or 'suspicious'. Default null.
261 * @param string|null $status Optional. The status of the scan issue to filter by.
262 * Accepts 'ignore' or 'active'. Default null.
263 * @param int|null $per_page Optional. The number of items to retrieve per page. Default null.
264 * @param int|null $paged Optional. The page number of items to retrieve. Default null.
265 *
266 * @return array An array of scan issue models with attached behaviors.
267 */
268 public function get_issues( $type = null, $status = null, $per_page = null, $paged = null ) {
269 $orm = self::get_orm();
270 $builder = $orm->get_repository( Scan_Item::class )
271 ->where( 'parent_id', $this->id );
272
273 $valid_types = Scan_Item::get_all_scan_types();
274 if ( null !== $type ) {
275 if ( is_array( $type ) ) {
276 $filtered_types = array_intersect( $type, $valid_types );
277 if ( array() !== $filtered_types ) {
278 $builder->where( 'type', 'IN', $filtered_types );
279 }
280 } elseif ( in_array( $type, $valid_types, true ) ) {
281 $builder->where( 'type', $type );
282 } elseif ( 'all' === $type ) {
283 $builder->where( 'type', 'IN', $valid_types );
284 }
285 } else {
286 // Include all possible types.
287 $builder->where( 'type', 'IN', $valid_types );
288 }
289
290 if ( null !== $status ) {
291 if ( in_array( $status, Scan_Item::get_all_scan_statuses(), true ) ) {
292 $builder->where( 'status', $status );
293 }
294 }
295
296 if ( is_int( $per_page ) && is_int( $paged ) && $per_page > 0 && $paged > 0 ) {
297 $offset = ( $paged - 1 ) * $per_page;
298 $builder->limit( $per_page, $offset );
299 }
300
301 $models = $builder->get();
302
303 static $behavior_map = array(
304 Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
305 Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
306 Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
307 Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
308 );
309
310 foreach ( $models as $model ) {
311 if ( isset( $behavior_map[ $model->type ] ) ) {
312 $behavior_class = $behavior_map[ $model->type ];
313 $model->attach_behavior( $behavior_class, $behavior_class );
314 }
315 }
316
317 return $models;
318 }
319
320 /**
321 * Counts the number of Scan_Item models that match the given type and status.
322 *
323 * @param string|array|null $type The type(s) of Scan_Item to count.
324 * @param string|null $status The status of the Scan_Item to count. Must be one of the following:
325 * Scan_Item::STATUS_IGNORE, Scan_Item::STATUS_ACTIVE.
326 *
327 * @return mixed The number of matching Scan_Item models.
328 */
329 public function count( $type = null, $status = null ) {
330 $orm = self::get_orm();
331 $builder = $orm->get_repository( Scan_Item::class )->where( 'parent_id', $this->id );
332
333 $valid_types = Scan_Item::get_all_scan_types();
334
335 if ( is_array( $type ) ) {
336 $filtered_types = array_intersect( $type, $valid_types );
337 if ( array() !== $filtered_types ) {
338 $builder->where( 'type', 'IN', $filtered_types );
339 }
340 } elseif (
341 ! is_null( $type )
342 && in_array( $type, $valid_types, true )
343 ) {
344 $builder->where( 'type', $type );
345 } elseif ( is_null( $type ) || 'all' === $type ) {
346 $builder->where( 'type', 'IN', $valid_types );
347 }
348
349 if (
350 ! is_null( $status )
351 && in_array( $status, Scan_Item::get_all_scan_statuses(), true )
352 ) {
353 $builder->where( 'status', $status );
354 }
355
356 return $builder->count();
357 }
358
359 /**
360 * Allow a specific issue by updating its status and removing it from the global ignore indexer.
361 *
362 * @param int $id The ID of the issue to Allow.
363 *
364 * @return bool
365 */
366 public function unignore_issue( $id ): bool {
367 $issue = $this->get_issue( $id );
368 if ( ! is_object( $issue ) ) {
369 return false;
370 }
371 // Check if the current issue already exists in the Issues list, there is no need to add a duplicate.
372 $current_issue_arr = $issue->to_array();
373 foreach ( $this->get_issues( null, Scan_Item::STATUS_ACTIVE ) as $active_issue ) {
374 $active_issue_arr = $active_issue->to_array();
375 if ( $issue->type === $active_issue_arr['type'] && $current_issue_arr['file_name'] === $active_issue_arr['file_name'] ) {
376 return false;
377 }
378 }
379
380 $issue->status = Scan_Item::STATUS_ACTIVE;
381 $issue->save();
382
383 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
384 $data = $issue->raw_data;
385 if ( isset( $data['file'] ) ) {
386 unset( $ignore_lists[ array_search( $data['file'], $ignore_lists, true ) ] );
387 } elseif ( isset( $data['slug'] ) ) {
388 unset( $ignore_lists[ array_search( $data['slug'], $ignore_lists, true ) ] );
389 }
390 $this->update_ignore_list( $ignore_lists );
391
392 return true;
393 }
394
395 /**
396 * Check if a slug is ignored, we use a global indexer, so we can check while
397 * the active scan is running.
398 *
399 * @param string $slug The path to file.
400 *
401 * @return bool
402 */
403 public function is_issue_ignored( $slug ) {
404 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
405
406 return in_array( $slug, $ignore_lists, true );
407 }
408
409 /**
410 * Ignore a specific issue by updating its status and adding it to the global ignored indexer.
411 *
412 * @param int $id The ID of the issue to ignore.
413 *
414 * @return bool
415 */
416 public function ignore_issue( $id ): bool {
417 $issue = $this->get_issue( $id );
418 if ( ! is_object( $issue ) ) {
419 return false;
420 }
421 // Check if the current issue already exists in the Ignored list, there is no need to add a duplicate.
422 $current_issue_arr = $issue->to_array();
423 foreach ( $this->get_issues( null, Scan_Item::STATUS_IGNORE ) as $ignore_issue ) {
424 $ignore_issue_arr = $ignore_issue->to_array();
425 if ( $current_issue_arr['type'] === $ignore_issue_arr['type'] &&
426 $current_issue_arr['full_path'] === $ignore_issue_arr['full_path']
427 ) {
428 return false;
429 }
430 }
431
432 $issue->status = Scan_Item::STATUS_IGNORE;
433 $issue->save();
434
435 // Add this into a global ignored index and update the ignored list.
436 $ignore_lists = get_site_option( self::IGNORE_INDEXER, array() );
437 $ignore_lists[] = $current_issue_arr['full_path'];
438 $this->update_ignore_list( $ignore_lists );
439
440 return true;
441 }
442
443 /**
444 * Retrieves a Scan_Item object based on the given ID.
445 *
446 * @param int $id The ID of the Scan_Item.
447 *
448 * @return Scan_Item|null The Scan_Item object if found, null otherwise.
449 */
450 public function get_issue( $id ) {
451 $orm = self::get_orm();
452 $model = $orm->get_repository( Scan_Item::class )
453 ->where( 'id', $id )
454 ->first();
455
456 if ( is_object( $model ) ) {
457 static $behavior_map = array(
458 Scan_Item::TYPE_INTEGRITY => Core_Integrity::class,
459 Scan_Item::TYPE_PLUGIN_CHECK => Plugin_Integrity::class,
460 Scan_Item::TYPE_PLUGIN_CLOSED => Abandoned_Result::class,
461 Scan_Item::TYPE_PLUGIN_OUTDATED => Abandoned_Result::class,
462 );
463
464 if ( isset( $behavior_map[ $model->type ] ) ) {
465 $behavior_class = $behavior_map[ $model->type ];
466 $model->attach_behavior( $behavior_class, $behavior_class );
467 }
468 }
469
470 return $model;
471 }
472
473 /**
474 * Remove an issue, this will happen when that issue is resolve, or the file link to this issue get deleted.
475 *
476 * @param int $id The ID of the issue to remove.
477 */
478 public function remove_issue( $id ) {
479 $orm = self::get_orm();
480 $orm->get_repository( Scan_Item::class )->delete( array( 'id' => $id ) );
481 }
482
483 /**
484 * Get all scan types where the target entity is the whole folder.
485 *
486 * @return array
487 */
488 private function get_scan_types_for_whole_folder(): array {
489 return array(
490 Scan_Item::TYPE_VULNERABILITY,
491 Scan_Item::TYPE_PLUGIN_CLOSED,
492 Scan_Item::TYPE_PLUGIN_OUTDATED,
493 );
494 }
495
496 /**
497 * Remove other Scan issue(-s) for the same file.
498 *
499 * @param string $path The path to file.
500 * @param string $type The type of scan issue.
501 *
502 * @return void
503 */
504 public function remove_related_issue_by( string $path, string $type ) {
505 $orm = self::get_orm();
506 $builder = $orm->get_repository( Scan_Item::class )
507 ->where( 'parent_id', $this->id );
508 // No needs to separate check VULNERABILITY, PLUGIN_CLOSED and PLUGIN_OUTDATED because we do not delete per file for such types.
509 $arr_excluded_types = $this->get_scan_types_for_whole_folder();
510 if ( '' !== $path ) {
511 $arr_excluded_types[] = $type;
512 }
513 $builder->where( 'type', 'NOT IN', $arr_excluded_types );
514 $models = $builder->get();
515
516 if ( is_array( $models ) && array() !== $models ) {
517 foreach ( $models as $model ) {
518 if ( isset( $model->raw_data['file'] ) && $model->raw_data['file'] === $path ) {
519 $this->remove_issue( $model->id );
520 }
521 }
522 }
523 }
524
525 /**
526 * Converts the object to an array representation.
527 *
528 * @param int|null $per_page The number of items to retrieve per page. Default null.
529 * @param int|null $paged The page number of items to retrieve. Default null.
530 * @param string|null $type The type of scan issue to filter by. Default null.
531 * @param string|null $scenario Pagination scenario ('issue', 'ignored', or null). Default null.
532 *
533 * @return array The array representation of the object.
534 */
535 public function to_array( $per_page = null, $paged = null, $type = null, $scenario = null ) {
536 if ( ! in_array( $this->status, self::get_inactive_statuses(), true ) ) {
537
538 return array(
539 'status' => $this->status,
540 'status_text' => $this->get_status_text(),
541 'percent' => $this->percent,
542 'task_checkpoint' => $this->task_checkpoint,
543 // This only for hub, when a scan running.
544 'count' => array( 'total' => 0 ),
545 );
546 } elseif ( in_array( $this->status, array( self::STATUS_FINISH, self::STATUS_IDLE ), true ) ) {
547 $total_filtered = (int) $this->count( $type );
548 $count_issues_filtered = (int) $this->count( $type, Scan_Item::STATUS_ACTIVE );
549 $total_count = (int) $this->count( null, Scan_Item::STATUS_ACTIVE );
550
551 $scan_item_ignore_total = wd_di()->get( Scan_Item::class )
552 ->get_types_total( $this->id, Scan_Item::STATUS_IGNORE );
553
554 $count_ignored = isset( $scan_item_ignore_total['all'] ) ?
555 $scan_item_ignore_total['all'] : 0;
556 $count_ignored_filtered = (int) $this->count( $type, Scan_Item::STATUS_IGNORE );
557
558 $total_issue_pages = 1;
559 $total_ignored_pages = 1;
560 if ( is_int( $per_page ) && $per_page > 0 ) {
561 $total_issue_pages = max( 1, (int) ceil( $count_issues_filtered / $per_page ) );
562 $total_ignored_pages = max( 1, (int) ceil( $count_ignored_filtered / $per_page ) );
563 }
564
565 // On the initial load both tabs are fetched, so either tab can require pagination.
566 if ( 'ignored' === $scenario ) {
567 $relevant_count = $count_ignored_filtered;
568 } elseif ( 'issue' === $scenario ) {
569 $relevant_count = $count_issues_filtered;
570 } else {
571 $relevant_count = max( $count_issues_filtered, $count_ignored_filtered );
572 }
573
574 if ( is_int( $per_page ) && $per_page > 0 && $relevant_count > $per_page ) {
575 $data = $this->prepare_issues( $per_page, $paged, $type, $scenario );
576 } else {
577 $data = $this->prepare_issues( null, null, $type, $scenario );
578 }
579
580 $scan_item_group_total = wd_di()->get( Scan_Item::class )
581 ->get_types_total( $this->id, Scan_Item::STATUS_ACTIVE );
582
583 $count_issues = isset( $scan_item_group_total['all'] ) ?
584 $scan_item_group_total['all'] : 0;
585 $count_core = isset( $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] ) ?
586 $scan_item_group_total[ Scan_Item::TYPE_INTEGRITY ] : 0;
587 $count_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] ) ?
588 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CHECK ] : 0;
589 $count_malware = isset( $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] ) ?
590 $scan_item_group_total[ Scan_Item::TYPE_SUSPICIOUS ] : 0;
591 $count_vuln = isset( $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] ) ?
592 $scan_item_group_total[ Scan_Item::TYPE_VULNERABILITY ] : 0;
593 // New counts since v5.5.0.
594 $count_outdated_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] ) ?
595 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_OUTDATED ] : 0;
596 $count_closed_plugin = isset( $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] ) ?
597 $scan_item_group_total[ Scan_Item::TYPE_PLUGIN_CLOSED ] : 0;
598
599 return array(
600 'status' => $this->status,
601 'issues_items' => $data['issues'],
602 'ignored_items' => $data['ignored'],
603 'last_scan' => $this->date_start
604 ? sprintf(
605 /* translators: %s: human-readable time difference, e.g. "5 minutes" */
606 __( '%s ago', 'defender-security' ),
607 human_time_diff( strtotime( $this->date_start ) )
608 )
609 : '',
610 'count' => array(
611 'total' => is_array( $data['issues'] ) || $data['issues'] instanceof Countable ? count( $data['issues'] ) : 0,
612 'total_filtered' => $total_filtered,
613 'issues_total' => $count_issues,
614 'issues_total_filtered' => $count_issues_filtered,
615 'ignored_total' => $count_ignored,
616 'ignored_total_filtered' => $count_ignored_filtered,
617 'core' => $count_core + $count_plugin,
618 'content' => $count_malware,
619 'vuln' => $count_vuln,
620 'outdated_plugin' => $count_outdated_plugin,
621 'closed_plugin' => $count_closed_plugin,
622 ),
623 'paging' => array(
624 'issue' => array(
625 'paged' => $paged,
626 'total_pages' => $total_issue_pages,
627 ),
628 'ignored' => array(
629 'paged' => $paged,
630 'total_pages' => $total_ignored_pages,
631 ),
632 'per_page' => $per_page,
633 ),
634 'task_checkpoint' => $this->task_checkpoint,
635 );
636 } else {
637 return array();
638 }
639 }
640
641 /**
642 * Creates a new record in the database.
643 *
644 * @param bool $from_report Is this a scan from report.
645 *
646 * @return bool|WP_Error|Scan
647 */
648 public static function create( $from_report = false ) {
649 $orm = self::get_orm();
650 $active = self::get_active();
651 if ( is_object( $active ) ) {
652 return new WP_Error( Error_Code::INVALID, esc_html__( 'A scan is already in progress.', 'defender-security' ) );
653 }
654 $model = new Scan();
655 $model->status = self::STATUS_INIT;
656 $model->date_start = gmdate( 'Y-m-d H:i:s' );
657 $model->date_end = gmdate( 'Y-m-d H:i:s' );
658 $model->is_automation = $from_report;
659
660 $orm->save( $model );
661
662 return $model;
663 }
664
665 /**
666 * Delete current scan.
667 *
668 * @param int|null $id Table primary key id.
669 */
670 public function delete( $id = null ) {
671 if ( ! $this->is_positive_int( $id ) ) {
672 $id = $this->id;
673 }
674
675 // Delete all the related result items.
676 $orm = self::get_orm();
677
678 $orm->get_repository( Scan_Item::class )->delete(
679 array( 'parent_id' => $id )
680 );
681
682 $orm->get_repository( self::class )->delete(
683 array( 'id' => $id )
684 );
685 }
686
687 /**
688 * Get the inactive scan statuses.
689 *
690 * @return array
691 */
692 public static function get_inactive_statuses(): array {
693 return array(
694 self::STATUS_FINISH,
695 self::STATUS_ERROR,
696 self::STATUS_IDLE,
697 );
698 }
699
700 /**
701 * Get the current active scan if any.
702 *
703 * @return self|null
704 */
705 public static function get_active() {
706 $orm = self::get_orm();
707
708 return $orm->get_repository( self::class )
709 ->where( 'status', 'NOT IN', self::get_inactive_statuses() )
710 ->first();
711 }
712
713 /**
714 * Check if the current state is Core integrity.
715 *
716 * @return self|null
717 */
718 public static function get_core_check() {
719 $orm = self::get_orm();
720
721 return $orm->get_repository( self::class )
722 ->where( 'status', self::STEP_CHECK_CORE )
723 ->first();
724 }
725
726 /**
727 * Get last result.
728 *
729 * @return self|null
730 */
731 public static function get_last() {
732 $orm = self::get_orm();
733
734 return $orm->get_repository( self::class )
735 ->where( 'status', 'IN', array( self::STATUS_FINISH, self::STATUS_IDLE ) )
736 ->order_by( 'id', 'desc' )
737 ->first();
738 }
739
740 /**
741 * Get last results.
742 *
743 * @return array
744 */
745 public static function get_last_all() {
746 $orm = self::get_orm();
747
748 return $orm->get_repository( self::class )
749 ->where( 'status', 'IN', array( self::STATUS_FINISH, self::STATUS_IDLE ) )
750 ->order_by( 'id', 'desc' )
751 ->get();
752 }
753
754 /**
755 * Adds an item to the scan.
756 *
757 * @param mixed $type The type of the item.
758 * @param mixed $data The data of the item.
759 * @param string $status The status of the item. Default is Scan_Item::STATUS_ACTIVE.
760 *
761 * @return bool Returns true if the item is successfully added, false otherwise.
762 */
763 public function add_item( $type, $data, $status = Scan_Item::STATUS_ACTIVE ) {
764 $model = new Scan_Item();
765 $model->type = $type;
766 $model->parent_id = $this->id;
767 $model->raw_data = $data;
768 $model->status = $status;
769 $ret = $model->save();
770
771 return $ret;
772 }
773
774 /**
775 * Persist the current scan type ('deep' or 'malware') in a site option.
776 *
777 * @param string $type Scan type identifier. Accepts 'deep' or 'malware'.
778 * @return void
779 */
780 public static function set_scan_type( string $type ): void {
781 update_site_option( self::OPTION_SCAN_TYPE, $type );
782 }
783
784 /**
785 * Retrieve the persisted scan type. Returns 'malware' when nothing is stored.
786 *
787 * @return string
788 */
789 public static function get_scan_type(): string {
790 return (string) get_site_option( self::OPTION_SCAN_TYPE, 'malware' );
791 }
792
793 /**
794 * Delete the persisted scan type (called on scan cancel or after returning
795 * the finished-scan response so the option does not linger).
796 *
797 * @return void
798 */
799 public static function clear_scan_type(): void {
800 delete_site_option( self::OPTION_SCAN_TYPE );
801 }
802
803 /**
804 * Return current status as readable string.
805 *
806 * @return string
807 */
808 public function get_status_text() {
809 switch ( $this->status ) {
810 case self::STATUS_INIT:
811 return 'deep' === self::get_scan_type() ? wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) ) : wp_strip_all_tags( __( 'Initializing...', 'defender-security' ) );
812 case self::STEP_GATHER_INFO:
813 return wp_strip_all_tags( __( 'Gathering information...', 'defender-security' ) );
814 case self::STEP_CHECK_CORE:
815 return wp_strip_all_tags( __( 'Analyzing WordPress Core...', 'defender-security' ) );
816 case self::STEP_CHECK_PLUGIN:
817 return wp_strip_all_tags( __( 'Analyzing WordPress Plugins...', 'defender-security' ) );
818 case self::STEP_VULN_CHECK:
819 return wp_strip_all_tags( __( 'Checking for vulnerabilities in plugins and themes...', 'defender-security' ) );
820 case self::STEP_SUSPICIOUS_CHECK:
821 return wp_strip_all_tags( __( 'Analyzing WordPress Content...', 'defender-security' ) );
822 case self::STEP_ABANDONED_PLUGIN_CHECK:
823 return wp_strip_all_tags( __( 'Checking for outdated & removed plugins...', 'defender-security' ) );
824 default:
825 return wp_strip_all_tags( __( 'Scanning...', 'defender-security' ) );
826 }
827 }
828
829 /**
830 * Calculates the percentage of a task based on its progress and position.
831 *
832 * @param int $task_percent The percentage of the task completed.
833 * @param int $pos The position of the task in the list of tasks. Default is 1.
834 *
835 * @return float The calculated percentage.
836 */
837 public function calculate_percent( $task_percent, $pos = 1 ) {
838 $task_max = ( 0 !== $this->total_tasks ) ? ( 100 / $this->total_tasks ) : 0;
839 $task_base = $task_max * ( $pos - 1 );
840 $micro = $task_percent * $task_max / 100;
841 $this->percent = (int) round( $task_base + $micro, 2 );
842 if ( $this->percent > 100 ) {
843 $this->percent = 100;
844 }
845
846 return $this->percent;
847 }
848
849 /**
850 * Get list of whitelisted files.
851 *
852 * @return array
853 */
854 private function whitelisted_files() {
855 return array(
856 // Configuration files.
857 'user.ini',
858 'php.ini',
859 'robots.txt',
860 '.htaccess',
861 'nginx.conf',
862 // Hidden system files and directories.
863 '.well-known',
864 '.idea',
865 '.DS_Store',
866 '.svn',
867 '.git',
868 '.quarantine',
869 '.tmb',
870 '.vscode',
871 );
872 }
873
874 /**
875 * Check if a slug is whitelisted.
876 *
877 * @param string $slug The path to file.
878 *
879 * @return bool
880 */
881 public function is_issue_whitelisted( string $slug ): bool {
882 $whitelisted_files = $this->whitelisted_files();
883 foreach ( $whitelisted_files as $file ) {
884 if ( false !== stristr( $slug, $file ) ) {
885 return true;
886 }
887 }
888
889 return false;
890 }
891
892 /**
893 * Update ignore list.
894 *
895 * @param array $ignore_lists Items to be added to the ignore list.
896 */
897 public function update_ignore_list( $ignore_lists ) {
898 $ignore_lists = array_unique( $ignore_lists );
899 $ignore_lists = array_filter( $ignore_lists, 'strlen' );
900 update_site_option( self::IGNORE_INDEXER, $ignore_lists );
901 }
902
903 /**
904 * Get the threshold time limit as DateTime object.
905 *
906 * @return DateTime Threshold time limit as DateTime object.
907 */
908 public function threshold_date_time_object() {
909 $timezone = new DateTimeZone( 'UTC' );
910
911 /**
912 * Filter to override scan threshold period.
913 *
914 * @param string $threshold Any valid relative Date and Time formats.
915 *
916 * @link https://www.php.net/manual/en/datetime.formats.relative.php
917 * @since 2.6.1
918 */
919 $threshold = apply_filters( 'wd_scan_threshold', self::THRESHOLD_PERIOD );
920
921 return new DateTime( $threshold, $timezone );
922 }
923
924 /**
925 * Threshold time limit in mysql string format.
926 *
927 * @return string Threshold time limit as mysql string format.
928 */
929 public function threshold_date_time_mysql() {
930 $type = 'Y-m-d H:i:s';
931 $threshold_date_time_object = $this->threshold_date_time_object();
932 $mysql_format = $threshold_date_time_object->format( $type );
933
934 return $mysql_format;
935 }
936
937 /**
938 * Get the idle scan if any.
939 *
940 * @return self|null
941 */
942 public function get_idle() {
943 $orm = self::get_orm();
944
945 $mysql_date = $this->threshold_date_time_mysql();
946
947 return $orm->get_repository( self::class )
948 ->where( 'status', 'NOT IN', array( self::STATUS_FINISH, self::STATUS_ERROR ) )
949 ->where( 'date_start', '<', $mysql_date )
950 ->first();
951 }
952
953 /**
954 * Delete all idle scan and scan items
955 *
956 * @since 2.6.1
957 */
958 public function delete_idle() {
959 $idle_scans = self::get_orm()
960 ->get_repository( self::class )
961 ->where( 'status', self::STATUS_IDLE )
962 ->get();
963
964 foreach ( $idle_scans as $idle_scan ) {
965 $this->delete( $idle_scan->id );
966 }
967 }
968
969 /**
970 * Verify positive integer or not.
971 *
972 * @param mixed $id Argument to check for a positive number.
973 *
974 * @return bool Return true on positive integer else false.
975 * @since 2.6.1
976 */
977 private function is_positive_int( $id ): bool {
978 return is_int( $id ) && $id > 0;
979 }
980 }
981