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 / controller / class-scan.php

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

1,195 lines 33.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles all scan related actions.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use ActionScheduler;
11 use WP_Defender\Event;
12 use Valitron\Validator;
13 use Calotes\Component\Request;
14 use Calotes\Component\Response;
15 use WP_Defender\Controller\Quarantine;
16 use WP_Defender\Traits\Formats;
17 use WP_Defender\Traits\Scan_Upsell;
18 use WP_Defender\Model\Scan_Item;
19 use WP_Defender\Behavior\WPMUDEV;
20 use WP_Defender\Model\Scan as Model_Scan;
21 use WP_Defender\Behavior\Scan\Core_Integrity;
22 use WP_Defender\Component\Network_Cron_Manager;
23 use WP_Defender\Component\Scan as Scan_Component;
24 use WP_Defender\Component\Rate as Rate_Component;
25 use WP_Defender\Model\Setting\Scan as Scan_Settings;
26 use WP_Defender\Model\Notification\Malware_Report;
27 use WP_Defender\Component\Config\Config_Hub_Helper;
28 use WP_Defender\Helper\Analytics\Scan as Scan_Analytics;
29 use WP_Defender\Model\Notification\Malware_Notification;
30 use WP_Defender\Component\Quarantine as Quarantine_Component;
31 use WP_Defender\Behavior\Scan\Plugin_Integrity;
32
33 /**
34 * Contains methods for handling scans.
35 */
36 class Scan extends Event {
37
38 use Formats;
39 use Scan_Upsell;
40
41 public const SCAN_LOG = 'scan.log';
42
43 /**
44 * Records whether a scan has been started on this installation.
45 *
46 * @var string
47 */
48 public const FIRST_SCAN_STARTED = 'wp_defender_first_scan_started';
49
50 /**
51 * Default number of issue items per page on the Scan Issues UI.
52 */
53 public const DEFAULT_PER_PAGE = 10;
54
55 /**
56 * The slug identifier for this controller.
57 *
58 * @var string
59 */
60 protected $slug = 'wdf-scan';
61
62 /**
63 * The model for handling the data.
64 *
65 * @var Scan_Settings
66 */
67 protected $model;
68
69 /**
70 * Service for handling logic.
71 *
72 * @var Scan_Component
73 */
74 protected $service;
75 /**
76 * Quarantine controller.
77 *
78 * @var Quarantine
79 */
80 private $quarantine_controller;
81
82 /**
83 * Is the Malware Report option unlocked?
84 *
85 * @var bool
86 */
87 private $is_unlocked;
88
89 /**
90 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
91 */
92 public function __construct() {
93 $this->register_page(
94 $this->get_title(),
95 $this->slug,
96 array( $this, 'main_view' ),
97 $this->parent_slug
98 );
99
100 $this->model = wd_di()->get( Scan_Settings::class );
101 $this->service = wd_di()->get( Scan_Component::class );
102 $this->quarantine_controller = wd_di()->get( Quarantine::class );
103
104 $this->is_unlocked = wd_di()->get( WPMUDEV::class )->is_pro();
105
106 $this->register_routes();
107 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
108 add_action( 'wp_ajax_defender_process_scan', array( $this, 'process' ) );
109 add_action( 'wp_ajax_nopriv_defender_process_scan', array( $this, 'process' ) );
110 add_action( 'defender/async_scan', array( $this, 'process' ) );
111 // Clean up data after successful core update.
112 add_action( '_core_updated_successfully', array( $this, 'clean_up_data' ) );
113
114 global $pagenow;
115 // since 2.6.2.
116 if (
117 is_admin() &&
118 'plugins.php' === $pagenow &&
119 apply_filters( 'wd_display_vulnerability_warnings', true ) &&
120 wd_di()->get( WPMUDEV::class )->is_apikey_available()
121 ) {
122 $this->service->display_vulnerability_warnings();
123 }
124
125 /**
126 * Schedule a time to clear completed action scheduler logs.
127 *
128 * @var Network_Cron_Manager $network_cron_manager
129 */
130 $network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
131 $network_cron_manager->register_callback(
132 'wpdef_clear_scan_logs',
133 array( $this, 'clear_scan_logs' ),
134 WEEK_IN_SECONDS
135 );
136
137 add_filter( 'heartbeat_nopriv_send', array( $this, 'nopriv_heartbeat' ), 10, 2 );
138
139 add_action(
140 'action_scheduler_completed_action',
141 array( $this, 'scan_completed_analytics' )
142 );
143 }
144
145 /**
146 * Return the title of the page.
147 *
148 * @return string The title of the page.
149 */
150 public function get_title(): string {
151 return esc_html__( 'Issues', 'defender-security' );
152 }
153
154 /**
155 * Clean up data after core updating.
156 *
157 * @return void
158 */
159 public function clean_up_data(): void {
160 $this->service->clean_up();
161 }
162
163 /**
164 * Start a scan.
165 *
166 * @param Request $request Request object.
167 *
168 * @return Response
169 * @defender_route
170 * @defender_redirect
171 */
172 public function start( Request $request ): Response {
173 $data = $request->get_data(
174 array(
175 'scan_type' => array(
176 'type' => 'string',
177 'sanitize' => 'sanitize_key',
178 ),
179 )
180 );
181 $scan_type = in_array( $data['scan_type'] ?? '', array( 'deep', 'malware' ), true )
182 ? $data['scan_type']
183 : 'malware';
184
185 $model = Model_Scan::create();
186 if ( is_object( $model ) && ! is_wp_error( $model ) ) {
187 update_site_option( self::FIRST_SCAN_STARTED, '1' );
188 set_transient( 'defender_scan_triggered_by_' . $model->id, get_current_user_id(), HOUR_IN_SECONDS );
189 Model_Scan::set_scan_type( $scan_type );
190 $this->log( 'Initial ping self', self::SCAN_LOG );
191 $this->run_scan_mechanisms_from( 'scan' );
192
193 return new Response(
194 true,
195 array(
196 'status' => $model->status,
197 'status_text' => $model->get_status_text(),
198 'percent' => 0,
199 'scan_type' => $scan_type,
200 )
201 );
202 }
203
204 return new Response(
205 false,
206 array(
207 'message' => esc_html__( 'A scan is already in progress', 'defender-security' ),
208 )
209 );
210 }
211
212 /**
213 * Use this for self ping, so it can both run in background and active mode with good performance.
214 *
215 * @return void
216 * @defender_route
217 * @is_public
218 */
219 public function process() {
220 $lock_filename = $this->service->get_lock_filename();
221 if ( ! $this->service->try_create_lock( $lock_filename ) ) {
222 $this->log( 'Fallback as already a process is running', self::SCAN_LOG );
223
224 return;
225 }
226
227 // Check if the ping is from self or not.
228 $ret = $this->service->process();
229 $this->log( 'process done, queue for next', self::SCAN_LOG );
230 if ( false === $ret ) {
231 // Ping self.
232 $this->log( 'Scan not done, pinging', self::SCAN_LOG );
233 $this->service->remove_lock( $lock_filename );
234 $this->process();
235 } else {
236 $this->queue_to_sync_with_hub();
237 $this->service->remove_lock( $lock_filename );
238 }
239 }
240
241 /**
242 * Query status.
243 *
244 * @return Response
245 * @defender_route
246 * @defender_redirect
247 */
248 public function status(): Response {
249 $scan_type = Model_Scan::get_scan_type();
250 $idle_scan = wd_di()->get( Model_Scan::class )->get_idle();
251
252 if ( is_object( $idle_scan ) ) {
253 $this->service->update_idle_scan_status();
254 $response = $this->get_status_response_data( $idle_scan, $scan_type );
255
256 return new Response( true, $response );
257 }
258
259 $checksum_issue = get_site_option( Core_Integrity::ISSUE_CHECKSUMS, 'false' );
260 $checksum_scan = Model_Scan::get_core_check();
261 if ( 'false' !== $checksum_issue && is_object( $checksum_scan ) ) {
262 $this->service->update_idle_scan_status_by_checksum_issue( $checksum_scan );
263 $response = $this->get_status_response_data( $checksum_scan, $scan_type );
264
265 return new Response( true, $response );
266 }
267
268 $scan = Model_Scan::get_active();
269 if ( is_object( $scan ) ) {
270 $response = $this->get_status_response_data( $scan, $scan_type );
271
272 return new Response( true, $response );
273 }
274
275 $scan = Model_Scan::get_last();
276 if ( is_object( $scan ) && ! is_wp_error( $scan ) ) {
277 $response = array_merge( $this->get_status_response_data( $scan, $scan_type ), $this->get_last_scan_time_data( $scan ) );
278 $response['message'] = __( 'Malware scan completed successfully!', 'defender-security' );
279 $response['scan_type'] = $scan_type;
280 if ( 'deep' === $scan_type ) {
281 $security_tweaks = wd_di()->get( \WP_Defender\Controller\Security_Tweaks::class )->dashboard_widget();
282 $response['hardening_count'] = (int) ( $security_tweaks['summary']['issues_count'] ?? 0 );
283 }
284 if ( isset( $this->quarantine_controller ) ) {
285 $response['quarantine'] = $this->quarantine_controller->data_frontend()['list'] ?? array();
286 }
287
288 return new Response( true, $response );
289 }
290
291 return new Response(
292 false,
293 array(
294 'message' => esc_html__( 'Error during scanning', 'defender-security' ),
295 )
296 );
297 }
298
299 /**
300 * Build the compatible status payload and consume its queued messages.
301 *
302 * @param Model_Scan $scan Scan model used to build the status payload.
303 * @param string $scan_type Current scan type.
304 */
305 private function get_status_response_data( Model_Scan $scan, string $scan_type ): array {
306 $response = $scan->to_array();
307 $response['status_text'] = $response['status_text'] ?? $scan->get_status_text();
308 $response['percent'] = $response['percent'] ?? $scan->percent;
309 $response['scan_type'] = $scan_type;
310 $response['status_messages'] = $scan->drain_status_messages();
311
312 return $response;
313 }
314
315 /**
316 * Cancel current scan.
317 *
318 * @return Response
319 * @defender_route
320 * @defender_redirect
321 */
322 public function cancel(): Response {
323 $component = wd_di()->get( Scan_Component::class );
324 $component->cancel_a_scan();
325 Model_Scan::clear_scan_type();
326 $last = Model_Scan::get_last();
327 if ( is_object( $last ) && ! is_wp_error( $last ) ) {
328 $last = $last->to_array();
329 }
330
331 return new Response(
332 true,
333 array(
334 'scan' => $last,
335 )
336 );
337 }
338
339 /**
340 * Track scan item action analytics.
341 *
342 * @param Scan_Item $scan_item Individual item of scan issues list.
343 * @param string $intention What action is going to be executed.
344 */
345 private function item_action_analytics( Scan_Item $scan_item, string $intention ) {
346 $allowed_intentions = Scan_Component::get_intentions();
347
348 $event_name = 'def_threat_resolved';
349
350 if ( in_array( $intention, $allowed_intentions, true ) ) {
351 $intention_desc = array(
352 'resolve' => 'Safe Repair',
353 'ignore' => 'Ignore',
354 'delete' => 'Delete',
355 'unignore' => 'Unignore',
356 'quarantine' => 'Safe Repair & Quarantine',
357 );
358
359 $resolution_method = $intention_desc[ $intention ];
360 $threat_type = '';
361
362 if ( Scan_Item::TYPE_INTEGRITY === $scan_item->type ) {
363 // Track Repair-actions.
364 if ( in_array( $intention, array( 'resolve', 'quarantine' ), true ) ) {
365 $threat_type = 'core file modified';
366 } else {
367 $threat_type = 'Unknown file in WordPress core';
368 }
369 } elseif ( Scan_Item::TYPE_PLUGIN_CHECK === $scan_item->type ) {
370 $raw_data = $scan_item->raw_data;
371
372 if ( isset( $raw_data['type'] ) && 'modified' === $raw_data['type'] ) {
373 $threat_type = 'plugin file modified';
374 }
375 } elseif ( Scan_Item::TYPE_VULNERABILITY === $scan_item->type ) {
376 $threat_type = 'Vulnerability';
377
378 if ( 'resolve' === $intention ) {
379 $resolution_method = 'Update';
380 }
381 } elseif ( Scan_Item::TYPE_SUSPICIOUS === $scan_item->type ) {
382 $threat_type = 'Suspicious function';
383 } elseif (
384 in_array(
385 $scan_item->type,
386 Model_Scan::get_abandoned_types(),
387 true
388 )
389 ) {
390 $threat_type = 'Outdated & removed plugins';
391 }
392
393 $this->track_feature(
394 $event_name,
395 array(
396 'Resolution Method' => $resolution_method,
397 'Threat type' => $threat_type,
398 )
399 );
400 }
401 }
402
403 /**
404 * A central controller to pass any request from frontend to scan item.
405 *
406 * @param Request $request Request object.
407 *
408 * @return Response
409 * @defender_route
410 */
411 public function item_action( Request $request ): Response {
412 $data = $request->get_data(
413 array(
414 'id' => array(
415 'type' => 'int',
416 'sanitize' => 'sanitize_text_field',
417 ),
418 'intention' => array(
419 'type' => 'string',
420 'sanitize' => 'sanitize_text_field',
421 ),
422 'parent_action' => array(
423 'type' => 'string',
424 'sanitize' => 'sanitize_text_field',
425 ),
426 )
427 );
428 $id = $data['id'] ?? false;
429 $intention = $data['intention'] ?? false;
430 // Get allowed intentions.
431 $allowed_intentions = Scan_Component::get_intentions();
432 $allowed_intentions[] = 'pull_src';
433 if ( false === $id || false === $intention || ! in_array(
434 $intention,
435 $allowed_intentions,
436 true
437 ) ) {
438 return new Response( false, array() );
439 }
440
441 $scan = Model_Scan::get_last();
442 if ( $scan instanceof Model_Scan ) {
443 $item = $scan->get_issue( $id );
444 if ( is_object( $item ) && $item->has_method( $intention ) ) {
445 if ( 'resolve' === $intention ) {
446 $result = $item->resolve();
447 } elseif ( 'quarantine' === $intention ) {
448 $result = $item->quarantine( $data['parent_action'], $item->owner );
449 } elseif ( 'delete' === $intention ) {
450 $result = $item->delete();
451 } elseif ( 'ignore' === $intention ) {
452 $result = $item->ignore();
453 } elseif ( 'unignore' === $intention ) {
454 $result = $item->unignore();
455 } elseif ( 'pull_src' === $intention ) {
456 $result = $item->pull_src();
457 }
458
459 // Maybe track.
460 if ( $this->is_tracking_active() ) {
461 $this->item_action_analytics( $item, $intention );
462 }
463
464 if ( is_wp_error( $result ) ) {
465 return new Response(
466 false,
467 array(
468 'message' => $result->get_error_message(),
469 )
470 );
471 } elseif ( isset( $result['type_notice'] ) ) {
472 return new Response(
473 true,
474 $result
475 );
476 } elseif ( isset( $result['url'] ) ) {
477 // Without message and interval args.
478 return new Response(
479 true,
480 array( 'redirect' => $result['url'] )
481 );
482 }
483
484 $this->queue_to_sync_with_hub();
485
486 // Refresh scan instance.
487 $scan = Model_Scan::get_last();
488
489 if ( $scan instanceof Model_Scan ) {
490 $result['scan'] = $scan->to_array();
491
492 if ( 'quarantine' === $intention && isset( $this->quarantine_controller ) ) {
493 $result['quarantine'] = $this->quarantine_controller->data_frontend()['list'] ?? array();
494 }
495
496 $success = true;
497 if ( isset( $result['success'] ) && false === $result['success'] ) {
498 $success = false;
499 }
500
501 return new Response( $success, $result );
502 }
503 }
504 }
505
506 return new Response( false, array() );
507 }
508
509 /**
510 * Process for bulk action.
511 * There is no Update-intention because it is a lengthy process. There may not be enough execution time.
512 *
513 * @param Request $request Request object.
514 *
515 * @defender_route
516 * @return Response
517 */
518 public function bulk_action( Request $request ): Response {
519 $data = $request->get_data(
520 array(
521 'items' => array(
522 'type' => 'array',
523 'sanitize' => 'sanitize_text_field',
524 ),
525 'bulk' => array(
526 'type' => 'string',
527 'sanitize' => 'sanitize_text_field',
528 ),
529 )
530 );
531 $items = $data['items'] ?? array();
532 $intention = $data['bulk'] ?? false;
533
534 if (
535 ! is_array( $items )
536 || array() === $items
537 || ! in_array( $intention, array( 'ignore', 'unignore', 'delete' ), true )
538 ) {
539 return new Response( false, array() );
540 }
541 // Try to get Scan.
542 $scan = Model_Scan::get_last();
543 if ( ! is_object( $scan ) ) {
544 return new Response( false, array() );
545 }
546
547 $is_delete = false;
548 $delete_items = array();
549 $none_delete_items = array();
550 $sync_hub = false;
551 foreach ( $items as $id ) {
552 if ( 'ignore' === $intention ) {
553 $sync_hub = $scan->ignore_issue( (int) $id );
554 } elseif ( 'unignore' === $intention ) {
555 $sync_hub = $scan->unignore_issue( (int) $id );
556 } elseif ( 'delete' === $intention ) {
557 $item = $scan->get_issue( (int) $id );
558 // Work with every item.
559 if ( is_object( $item ) && $item->has_method( $intention ) ) {
560 $item_result = $item->delete();
561 if ( is_wp_error( $item_result ) ) {
562 $none_delete_items[] = $item_result->get_error_message();
563 } elseif ( isset( $item_result['type_notice'] ) ) {
564 return new Response( true, $item_result );
565 } elseif ( isset( $item_result['collect_type'] ) ) {
566 $is_delete = true;
567 $delete_items[] = $item_result['message'];
568 }
569 // If there is any error, no need to sync data.
570 $sync_hub = true;
571 }
572 }
573 }
574
575 if ( $sync_hub ) {
576 $this->queue_to_sync_with_hub();
577 }
578
579 $result = array();
580 if ( array() !== $none_delete_items ) {
581 $result['message'] = sprintf(
582 /* translators: %s: Vulnerability item(es) */
583 _n(
584 'Defender doesn\'t have enough permission to remove this file: %s',
585 'Defender doesn\'t have enough permission to remove these files: %s',
586 count( $none_delete_items ),
587 'defender-security'
588 ),
589 '<pre>' . implode( PHP_EOL, $none_delete_items ) . '</pre>'
590 );
591 } elseif ( $is_delete ) {
592 $result['message'] = sprintf(
593 /* translators: %s: Vulnerability item(es) */
594 esc_html__( '%s has (have) been deleted', 'defender-security' ),
595 implode( ', ', $delete_items )
596 );
597 }
598 // Refresh scan instance.
599 $scan = Model_Scan::get_last();
600 $result['scan'] = $scan->to_array();
601
602 return new Response( array() === $none_delete_items, $result );
603 }
604
605 /**
606 * Save settings.
607 *
608 * @param Request $request The request object containing new settings data.
609 *
610 * @return Response
611 * @since 2.7.0 Add Scheduled Scanning to Malware settings and hide it on Malware Scanning - Reporting.
612 * Also, the backward compatibility of settings for Scan and Malware_Report models.
613 * @defender_route
614 */
615 public function save_settings( Request $request ): Response {
616 $data = $request->get_data_by_model( $this->model );
617 // Prepare for the state's change.
618 $old_integrity_check_state = $this->model->integrity_check;
619 // Case#1: inherit the parent's state to nested options.
620 if ( $old_integrity_check_state !== $data['integrity_check'] ) {
621 $data['check_core'] = $data['integrity_check'];
622 $data['check_plugins'] = $data['integrity_check'];
623 }
624 // Case#2: Suspicious code is activated BUT File change detection is deactivated then show the notice.
625 if ( $data['scan_malware'] && ! $data['integrity_check'] ) {
626 $response = array(
627 'type_notice' => 'info',
628 'message' => sprintf(
629 /* translators: 1. Open tag. 2. Close tag. 3. Open tag. 4. Close tag. */
630 esc_html__(
631 'To reduce false-positive results, we recommend enabling %1$sFile change detection%2$s options for all scan types while the %3$sSuspicious code%4$s option is enabled.',
632 'defender-security'
633 ),
634 '<strong>',
635 '</strong>',
636 '<strong>',
637 '</strong>'
638 ),
639 );
640 } else {
641 $response = array(
642 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
643 'auto_close' => true,
644 );
645 }
646 $before_import_schedule = $this->model->quarantine_expire_schedule;
647
648 $this->model->import( $data );
649 if ( $this->model->validate() ) {
650 if ( class_exists( 'WP_Defender\Component\Quarantine' ) ) {
651 $quarantine_component = wd_di()->get( Quarantine_Component::class );
652 $quarantine_component->reschedule_file_expiry_cron(
653 $before_import_schedule,
654 $data['quarantine_expire_schedule']
655 );
656 }
657
658 $this->model->save();
659 Config_Hub_Helper::set_clear_active_flag();
660
661 return new Response(
662 true,
663 array_merge( $response, $this->data_frontend() )
664 );
665 } else {
666 return new Response(
667 false,
668 array_merge(
669 array(
670 'message' => $this->model->get_formatted_errors(),
671 ),
672 $this->data_frontend()
673 )
674 );
675 }
676 }
677
678 /**
679 * Get the issues mainly for pagination request.
680 *
681 * @param Request $request The request object.
682 *
683 * @return Response
684 * @defender_route
685 */
686 public function get_issues( Request $request ): Response {
687 $data = $request->get_data(
688 array(
689 'scenario' => array(
690 'type' => 'string',
691 'sanitize' => 'sanitize_text_field',
692 ),
693 'type' => array(
694 'type' => 'array',
695 'sanitize' => 'sanitize_text_field',
696 ),
697 'per_page' => array(
698 'type' => 'string',
699 'sanitize' => 'intval',
700 ),
701 'paged' => array(
702 'type' => 'int',
703 'sanitize' => 'intval',
704 ),
705 )
706 );
707
708 // Validate the request.
709 $v = new Validator( $data, array() );
710 $v->rule( 'required', array( 'scenario', 'type', 'per_page', 'paged' ) );
711 if ( ! $v->validate() ) {
712 return new Response(
713 false,
714 array(
715 'message' => esc_html__( 'Wrong scan issue data.', 'defender-security' ),
716 )
717 );
718 }
719
720 $scan = Model_Scan::get_last();
721 $issues = $scan->to_array( $data['per_page'], $data['paged'], $this->normalize_issue_types( $data['type'] ), $data['scenario'] );
722
723 $response = array(
724 'issue' => $issues['issues_items'],
725 'ignored' => $issues['ignored_items'],
726 'paging' => $issues['paging'],
727 'count' => $issues['count'],
728 );
729
730 if ( class_exists( 'WP_Defender\Controller\Quarantine' ) ) {
731 $response['quarantine'] = $this->quarantine_controller->data_frontend()['list'] ?? array();
732 }
733
734 return new Response( true, $response );
735 }
736
737 /**
738 * Normalize issue type filters from the legacy and redesign payloads.
739 *
740 * @param array|string $types Requested issue type(s).
741 *
742 * @return array|string
743 */
744 private function normalize_issue_types( $types ) {
745 $type_aliases = array(
746 'core' => Scan_Item::TYPE_INTEGRITY,
747 'plugin' => Scan_Item::TYPE_PLUGIN_CHECK,
748 'suspicious' => Scan_Item::TYPE_SUSPICIOUS,
749 'known_vulnerability' => Scan_Item::TYPE_VULNERABILITY,
750 'plugin_closed' => Scan_Item::TYPE_PLUGIN_CLOSED,
751 'plugin_outdated' => Scan_Item::TYPE_PLUGIN_OUTDATED,
752 );
753
754 $types = is_array( $types ) ? $types : array( $types );
755 $types = array_filter(
756 array_map(
757 static function ( $type ) use ( $type_aliases ) {
758 return $type_aliases[ $type ] ?? $type;
759 },
760 $types
761 ),
762 'boolval'
763 );
764 $types = array_values( array_unique( $types ) );
765
766 if ( 1 === count( $types ) ) {
767 return $types[0];
768 }
769
770 return $types;
771 }
772
773 /**
774 * Get relative and exact display times for the last completed scan.
775 *
776 * @param Model_Scan|null $last Last completed scan.
777 *
778 * @return array
779 */
780 private function get_last_scan_time_data( $last ): array {
781 if ( ! is_object( $last ) || ! isset( $last->date_start ) || '' === $last->date_start ) {
782 return array(
783 'last_scan' => '',
784 'last_scan_time' => '',
785 );
786 }
787
788 $last_scan_timestamp = strtotime( $last->date_start . ' UTC' );
789 $time_difference = time() - $last_scan_timestamp;
790 $data = array(
791 'last_scan' => sprintf(
792 /* translators: %s: human-readable time difference, e.g. "5 minutes ago" */
793 __( 'Scanned %s ago', 'defender-security' ),
794 human_time_diff( $last_scan_timestamp )
795 ),
796 );
797
798 if ( $time_difference < DAY_IN_SECONDS ) {
799 $data['last_scan_time'] = wp_date( 'g:i A', $last_scan_timestamp );
800 } elseif ( $time_difference < YEAR_IN_SECONDS ) {
801 $data['last_scan_time'] = wp_date( 'D, j M g:i A', $last_scan_timestamp );
802 } else {
803 $data['last_scan_time'] = wp_date( 'j M Y, g:i A', $last_scan_timestamp );
804 }
805
806 return $data;
807 }
808
809 /**
810 * Returns scan result data for the frontend on page load.
811 *
812 * @return array
813 */
814 public function get_initial_scan_data(): array {
815 $scan = Model_Scan::get_active();
816 $last = Model_Scan::get_last();
817 $per_page = self::DEFAULT_PER_PAGE;
818 $paged = 1;
819
820 if ( ! is_object( $scan ) && ! is_object( $last ) ) {
821 $scan_data = null;
822 } elseif ( is_object( $scan ) && is_object( $last ) ) {
823 // If an active scan exists AND there's a previous completed scan,
824 // merge the active scan's progress with the last scan's issue data.
825 // This ensures that during a page refresh while scanning, users still
826 // see the previous scan results while the new scan is in progress.
827 $scan_data = $scan->to_array( $per_page, $paged );
828 $last_data = $last->to_array( $per_page, $paged );
829 // Preserve previous scan's issue data.
830 $scan_data['issues_items'] = $last_data['issues_items'] ?? array();
831 $scan_data['ignored_items'] = $last_data['ignored_items'] ?? array();
832 $scan_data['count'] = $last_data['count'] ?? array();
833 $scan_data['paging'] = $last_data['paging'] ?? array();
834 } elseif ( is_object( $scan ) ) {
835 $scan_data = $scan->to_array( $per_page, $paged );
836 } else {
837 $scan_data = $last->to_array( $per_page, $paged );
838 }
839
840 $first_scan_started = get_site_option( self::FIRST_SCAN_STARTED, null );
841 if ( null === $first_scan_started ) {
842 // Existing installations backward compatibility.
843 $first_scan_started = is_object( $scan ) || is_object( $last );
844 } else {
845 $first_scan_started = '1' === (string) $first_scan_started;
846 }
847
848 $data = array(
849 'scan' => $scan_data,
850 'has_first_scan_started' => $first_scan_started,
851 'isEnabledScanType' => $this->service->is_any_scan_type_active(),
852 );
853
854 // Always expose the last completed scan time at the outer level so the
855 // dashboard can show it even while a new scan is actively running.
856 $data = array_merge( $data, $this->get_last_scan_time_data( $last ) );
857
858 if ( isset( $this->quarantine_controller ) ) {
859 $data['quarantine'] = array(
860 'list' => $this->quarantine_controller->data_frontend()['list'] ?? array(),
861 );
862 }
863 // Display Rate notice. Without the rating's type.
864 $data['isRatingDisplayed'] = defender_is_wp_org_version()
865 && is_array( $scan_data ) && isset( $data['scan']['issues_items'] )
866 && Rate_Component::is_displayed_in_redesigned_version( count( $data['scan']['issues_items'] ) );
867
868 return array( 'scan' => $data );
869 }
870
871 /**
872 * Render the main page.
873 *
874 * @return void
875 */
876 public function main_view(): void {
877 $this->render( 'main' );
878 }
879
880 /**
881 * Enqueues scripts and styles for this page.
882 * Only enqueues assets if the page is active.
883 */
884 public function enqueue_assets() {
885 if ( ! $this->is_page_active() ) {
886 return;
887 }
888
889 $handle = 'defender-ui-scan';
890 wp_enqueue_script(
891 $handle,
892 WP_DEFENDER_BASE_URL . 'assets/js/scan-ui.js',
893 array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
894 DEFENDER_VERSION,
895 true
896 );
897 wp_set_script_translations( $handle, 'wpdef' );
898
899 $scan_routes_data = $this->dump_routes_and_nonces();
900 $quarantine_routes_data = $this->quarantine_controller->dump_routes_and_nonces();
901 if ( defender_is_wp_org_version() ) {
902 $rate_routes_nonces = wd_di()->get( \WP_Defender\Controller\Rate::class )->dump_routes_and_nonces();
903 $rate_routes = $rate_routes_nonces['routes'];
904 $rate_nonces = $rate_routes_nonces['nonces'];
905 } else {
906 $rate_routes = array();
907 $rate_nonces = array();
908 }
909
910 wp_localize_script(
911 $handle,
912 'defenderUIData',
913 array_merge(
914 $this->get_shared_data(),
915 array(
916 'routes' => array_merge(
917 $scan_routes_data['routes'],
918 $quarantine_routes_data['routes'],
919 $rate_routes
920 ),
921 'nonces' => array_merge(
922 $scan_routes_data['nonces'],
923 $quarantine_routes_data['nonces'],
924 $rate_nonces
925 ),
926 ),
927 $this->get_initial_scan_data()
928 )
929 );
930
931 wp_enqueue_style(
932 $handle,
933 WP_DEFENDER_BASE_URL . 'assets/css/core-ui.css',
934 array(),
935 DEFENDER_VERSION
936 );
937
938 $this->enqueue_main_assets();
939 }
940
941 /**
942 * Converts the current object state to an array.
943 *
944 * @return array The array representation of the object.
945 */
946 public function to_array(): array {
947 $scan = Model_Scan::get_active();
948 $last = Model_Scan::get_last();
949 if ( ! is_object( $scan ) && ! is_object( $last ) ) {
950 $scan = null;
951 } else {
952 $scan = is_object( $scan ) ? $scan->to_array() : $last->to_array();
953 }
954
955 return array_merge(
956 array(
957 'scan' => $scan,
958 'report' => array(
959 'enabled' => true,
960 'frequency' => 'weekly',
961 ),
962 ),
963 $this->dump_routes_and_nonces()
964 );
965 }
966
967 /**
968 * Removes settings for all submodules.
969 */
970 public function remove_settings(): void {
971 ( new Scan_Settings() )->delete();
972 }
973
974 /**
975 * Delete all the data & the cache.
976 */
977 public function remove_data(): void {
978 delete_site_option( self::FIRST_SCAN_STARTED );
979 delete_site_option( Model_Scan::IGNORE_INDEXER );
980 delete_site_option( Model_Scan::OPTION_SCAN_TYPE );
981 Model_Scan::clear_all_status_messages();
982 delete_site_option( Core_Integrity::ISSUE_CHECKSUMS );
983 delete_site_transient( Plugin_Integrity::$org_slugs );
984 delete_site_transient( Plugin_Integrity::$org_responses );
985 }
986
987 /**
988 * Provides data for the frontend.
989 *
990 * @return array An array of data for the frontend.
991 */
992 public function data_frontend(): array {
993 $scan = Model_Scan::get_active();
994 $last = Model_Scan::get_last();
995 $per_page = 10;
996 $paged = 1;
997 if ( ! is_object( $scan ) && ! is_object( $last ) ) {
998 $scan = null;
999 } else {
1000 $scan = is_object( $scan ) ? $scan->to_array( $per_page, $paged ) : $last->to_array( $per_page, $paged );
1001 }
1002 $settings = new Scan_Settings();
1003 $report = wd_di()->get( Malware_Report::class );
1004
1005 $scan['isEnabledScanType'] = $this->service->is_any_scan_type_active();
1006
1007 $data = array(
1008 'scan' => $scan,
1009 'settings' => $settings->export(),
1010 'notification' => $report->to_string(),
1011 'misc' => array(
1012 'labels' => $settings->labels(),
1013 ),
1014 );
1015 $data['quarantine'] = $this->quarantine_controller->data_frontend();
1016
1017 return array_merge( $data, $this->dump_routes_and_nonces() );
1018 }
1019
1020 /**
1021 * Imports data into the model.
1022 *
1023 * @param array $data Data to be imported into the model.
1024 */
1025 public function import_data( array $data ) {
1026 $model = $this->model;
1027 if ( array() === $data ) {
1028 $model->scheduled_scanning = false;
1029 $model->frequency = 'weekly';
1030 $model->day_n = 1;
1031 $model->day = 'sunday';
1032 $model->time = '4:00';
1033 $model->save();
1034 } else {
1035 $model->import( $data );
1036 if ( $model->validate() ) {
1037 $model->save();
1038 }
1039 }
1040 }
1041
1042 /**
1043 * Exports strings.
1044 *
1045 * @return array An array of strings.
1046 */
1047 public function export_strings(): array {
1048 $strings = array();
1049 if ( $this->service->is_any_scan_type_active() ) {
1050 $strings[] = esc_html__( 'Active', 'defender-security' );
1051 } else {
1052 $strings[] = esc_html__( 'Inactive', 'defender-security' );
1053 }
1054
1055 $scan_notification = new Malware_Notification();
1056 if ( 'enabled' === $scan_notification->status ) {
1057 $strings[] = esc_html__( 'Email notifications active', 'defender-security' );
1058 }
1059 $strings[] = sprintf(
1060 /* translators: %s: Html for Pro-tag. */
1061 esc_html__( 'Scheduled scan inactive %s', 'defender-security' ),
1062 '<span class="sui-tag sui-tag-pro">Pro</span>'
1063 );
1064
1065 return $strings;
1066 }
1067
1068 /**
1069 * Generates configuration strings based on the provided configuration.
1070 *
1071 * @param array $config Configuration data.
1072 *
1073 * @return array Returns an array of configuration strings.
1074 */
1075 public function config_strings( array $config ): array {
1076 $strings = array();
1077 $strings[] = $this->service->check_scan_active_by( $config )
1078 ? esc_html__( 'Active', 'defender-security' )
1079 : esc_html__( 'Inactive', 'defender-security' );
1080
1081 if ( 'enabled' === $config['notification'] ) {
1082 $strings[] = esc_html__( 'Email notifications active', 'defender-security' );
1083 }
1084 // No Pro membership and No hosted site.
1085 if ( ! $this->is_unlocked ) {
1086 $strings[] = sprintf(
1087 /* translators: %s: Html for Pro-tag. */
1088 esc_html__( 'Scheduled scan inactive %s', 'defender-security' ),
1089 '<span class="sui-tag sui-tag-pro">Pro</span>'
1090 );
1091 }
1092
1093 return $strings;
1094 }
1095
1096 /**
1097 * Run different scan actions based on the scan location.
1098 *
1099 * @param string $type Denotes type of the scan from the following 4 possible values: scan, install, hub or report.
1100 *
1101 * @return void
1102 */
1103 public function run_scan_mechanisms_from( $type ) {
1104 $this->service->gather_actioned_plugin_details();
1105 $this->do_async_scan( $type );
1106 }
1107
1108 /**
1109 * Triggers the asynchronous scan.
1110 *
1111 * @param string $type Denotes type of the scan from the following 4 possible values: scan, install, hub or report.
1112 *
1113 * @return void
1114 */
1115 public function do_async_scan( string $type ): void {
1116 wd_di()->get( Model_Scan::class )->delete_idle();
1117 // Delete the slug from the previous scan.
1118 delete_site_option( Core_Integrity::ISSUE_CHECKSUMS );
1119
1120 if ( function_exists( 'as_enqueue_async_action' ) ) {
1121 as_enqueue_async_action(
1122 'defender/async_scan',
1123 array(
1124 'type' => $type,
1125 ),
1126 'defender'
1127 );
1128 }
1129 }
1130
1131 /**
1132 * Clear completed action scheduler logs.
1133 *
1134 * @return void
1135 * @since 2.6.5
1136 */
1137 public function clear_scan_logs(): void {
1138 $scan_component = wd_di()->get( Scan_Component::class );
1139 $result = $scan_component::clear_logs();
1140
1141 if ( isset( $result['error'] ) ) {
1142 $this->log( 'WP CRON Error : ' . $result['error'], self::SCAN_LOG );
1143 }
1144 }
1145
1146 /**
1147 * When user session is expired and scan is running, then don't login via heartbeat modal.
1148 *
1149 * @param array $response The no-priv Heartbeat response.
1150 * @param string $screen_id The screen id.
1151 *
1152 * @return mixed
1153 * @since 3.11.0
1154 */
1155 public function nopriv_heartbeat( $response, $screen_id ) {
1156 if ( false !== strpos( $screen_id, $this->slug ) ) {
1157 $scan = Model_Scan::get_active();
1158
1159 if ( is_object( $scan ) ) {
1160 $response['wp-auth-check'] = true;
1161 }
1162 }
1163
1164 return $response;
1165 }
1166
1167 /**
1168 * Triggers and send analytics data on scan completed.
1169 *
1170 * @param int $action_id Action ID.
1171 *
1172 * @return void
1173 */
1174 public function scan_completed_analytics( $action_id ) {
1175 if (
1176 class_exists( ActionScheduler::class )
1177 && method_exists( ActionScheduler::class, 'store' )
1178 && 'defender' === ActionScheduler::store()->fetch_action( $action_id )->get_group()
1179 ) {
1180 $scan_analytics = wd_di()->get( Scan_Analytics::class );
1181
1182 $scan_model = wd_di()->get( Model_Scan::class );
1183 $analytics_data = $scan_analytics->scan_completed( $scan_model );
1184 if ( array() === $analytics_data ) {
1185 return;
1186 }
1187
1188 $this->track_feature(
1189 $analytics_data['event'],
1190 $analytics_data['data']
1191 );
1192 }
1193 }
1194 }
1195