PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / trunk
Defender Security – Malware Scanner, Login Security & Firewall vtrunk
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 / controller / class-scan.php

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

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