PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.1.0
Defender Security – Malware Scanner, Login Security & Firewall v6.1.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.1.0, at src/controller/class-scan.php

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