PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.0
Defender Security – Malware Scanner, Login Security & Firewall v6.2.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-security-tweaks.php

class-security-tweaks.php in Defender Security – Malware Scanner, Login Security & Firewall 6.2.0, at src/controller/class-security-tweaks.php

1,274 lines 34.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles security tweaks operations.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use Countable;
11 use WP_Defender\Event;
12 use Calotes\Component\Request;
13 use Calotes\Component\Response;
14 use Calotes\Helper\Array_Cache;
15 use WP_Defender\Behavior\WPMUDEV;
16 use WP_Defender\Model\Notification\Tweak_Reminder;
17 use WP_Defender\Component\Config\Config_Hub_Helper;
18 use WP_Defender\Component\Security_Tweaks\Hide_Error;
19 use WP_Defender\Component\Security_Tweaks\WP_Version;
20 use WP_Defender\Component\Security_Tweaks\PHP_Version;
21 use WP_Defender\Component\Security_Tweaks\Prevent_PHP;
22 use WP_Defender\Component\Security_Tweaks\Change_Admin;
23 use WP_Defender\Component\Security_Tweaks\Security_Key;
24 use WP_Defender\Component\Security_Tweaks\Login_Duration;
25 use WP_Defender\Component\Security_Tweaks\Servers\Server;
26 use WP_Defender\Component\Security_Tweaks\Disable_XML_RPC;
27 use WP_Defender\Component\Security_Tweaks\Disable_Trackback;
28 use WP_Defender\Component\Security_Tweaks\Prevent_Enum_Users;
29 use WP_Defender\Component\Security_Tweaks\Disable_File_Editor;
30 use WP_Defender\Component\Security_Tweaks\Protect_Information;
31 use WP_Defender\Model\Setting\Security_Tweaks as Model_Security_Tweaks;
32
33 /**
34 * Contains methods for processing, reverting, ignoring security recommendations, and updating security reminders.
35 */
36 class Security_Tweaks extends Event {
37
38 /**
39 * The slug identifier for this controller.
40 *
41 * @var string
42 */
43 public $slug = 'wdf-hardener';
44
45 /**
46 * The model for handling the data.
47 *
48 * @var Model_Security_Tweaks
49 */
50 protected $model;
51
52 /**
53 * Components instance array.
54 *
55 * @var array
56 */
57 private $component_instances;
58
59 /**
60 * Instance of Security_Key.
61 *
62 * @var Security_Key
63 */
64 private $security_key;
65
66 /**
67 * Instance of Prevent_Enum_Users.
68 *
69 * @var Prevent_Enum_Users
70 */
71 private $prevent_enum_users;
72
73 public const STATUS_ISSUES = 'issues', STATUS_RESOLVE = 'fixed', STATUS_IGNORE = 'ignore', STATUS_RESTORE = 'restore';
74
75 /**
76 * Maps tweak slugs to frontend category identifiers.
77 *
78 * @var array<string,string>
79 */
80 private const TWEAK_CATEGORIES = array(
81 'disable-xml-rpc' => 'network',
82 'wp-version' => 'updates',
83 'hide-error' => 'error',
84 'php-version' => 'updates',
85 'replace-admin-username' => 'auth',
86 'security-key' => 'updates',
87 'login-duration' => 'auth',
88 'disable-trackback' => 'network',
89 'prevent-enum-users' => 'error',
90 'disable-file-editor' => 'access',
91 'protect-information' => 'error',
92 'prevent-php-executed' => 'access',
93 );
94
95 /**
96 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
97 */
98 public function __construct() {
99 $this->register_page(
100 esc_html__( 'Hardening', 'defender-security' ),
101 $this->slug,
102 array( $this, 'main_view' ),
103 $this->parent_slug
104 );
105 $this->model = wd_di()->get( Model_Security_Tweaks::class );
106 $this->register_routes();
107
108 // Init all the tweaks, should happen one time.
109 $this->component_instances = $this->init_tweaks();
110
111 $this->security_key = $this->component_instances['security-key'];
112 $this->prevent_enum_users = $this->component_instances['prevent-enum-users'];
113
114 // Now shield up.
115 $this->boot();
116 // Add addition hooks.
117 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
118 add_action( 'wp_loaded', array( $this, 'should_output_error' ) );
119 }
120
121 /**
122 * Dummy function for testing a check.
123 */
124 public function should_output_error() {
125 $test_err_reporting = defender_get_data_from_request( 'defender_test_error_reporting', 'g' );
126 if ( ! is_string( $test_err_reporting ) || '' === trim( $test_err_reporting ) ) {
127 return;
128 }
129 // It should be triggered only by admin.
130 if ( ! $this->check_permission() ) {
131 return;
132 }
133
134 // This should output a warning. Ignored by PHPCS as it's a test.
135 // @phpstan-ignore-next-line.
136 echo $undefined_variable; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Intentionally outputs an undefined variable to test warning handling.
137 exit();
138 }
139
140 /**
141 * Process.
142 *
143 * @param Request $request Request object.
144 *
145 * @return Response
146 * @defender_route
147 */
148 public function process( Request $request ) {
149 $data = $request->get_data(
150 array(
151 'slug' => array(
152 'type' => 'string',
153 'sanitize' => 'sanitize_text_field',
154 ),
155 'current_server' => array(
156 'type' => 'string',
157 'sanitize' => 'sanitize_text_field',
158 ),
159 'username' => array(
160 'type' => 'string',
161 'sanitize' => 'sanitize_user',
162 ),
163 )
164 );
165
166 $slug = $data['slug'] ?? false;
167 $tweak = $this->get_tweak( $slug );
168
169 if ( ! is_object( $tweak ) ) {
170 return new Response(
171 false,
172 array( 'message' => esc_html__( 'Invalid request', 'defender-security' ) )
173 );
174 }
175
176 if ( 'prevent-enum-users' === $slug ) {
177 $pe_data = $request->get_data();
178 $enabled_enums = $pe_data['enabled_user_enums'] ?? null;
179 $all_enums = array( 'enum-rest', 'enum-oembed', 'enum-sitemap' );
180 $this->prevent_enum_users->set_enabled_user_enums(
181 is_array( $enabled_enums ) ? $enabled_enums : $all_enums
182 );
183 }
184
185 if ( 'security-key' === $slug ) {
186 $sk_data = $request->get_data();
187 $remind_date = sanitize_text_field( $sk_data['remind_date'] ?? '' );
188 if ( $remind_date ) {
189 $is_autogen = filter_var( $sk_data['is_autogenerate_keys'] ?? false, FILTER_VALIDATE_BOOLEAN );
190 $values = get_site_option( 'defender_security_tweaks_' . $this->security_key->slug, array() );
191 $values['reminder_duration'] = $remind_date;
192 $values['reminder_date'] = strtotime( '+' . $remind_date, time() );
193 $values['is_autogenerate_keys'] = $is_autogen;
194 update_site_option( 'defender_security_tweaks_' . $this->security_key->slug, $values );
195 if ( true === $is_autogen ) {
196 $this->security_key->cron_schedule();
197 } else {
198 $this->security_key->cron_unschedule();
199 }
200 }
201 }
202
203 if ( 'replace-admin-username' === $slug && isset( $data['username'] ) && '' !== trim( $data['username'] ) ) {
204 $_POST['username'] = $data['username'];
205 }
206
207 if ( in_array( $slug, array( 'prevent-php-executed', 'protect-information' ), true ) ) {
208 $current_server = $data['current_server'] ?? false;
209 if ( ! $current_server ) {
210 return new Response(
211 false,
212 array( 'message' => esc_html__( 'Invalid request', 'defender-security' ) )
213 );
214 }
215
216 $ret = $tweak->process( $current_server );
217 } else {
218 $ret = $tweak->process();
219 }
220
221 if ( true === $ret ) {
222 Config_Hub_Helper::set_clear_active_flag();
223 $this->model->mark( self::STATUS_RESOLVE, $slug );
224 // Track.
225 $this->track_tweak( $tweak->get_label(), 'Actioned' );
226 // Response.
227 $this->ajax_response( esc_html__( 'Security recommendation successfully resolved.', 'defender-security' ) );
228 }
229 if ( $ret instanceof Response && true === $ret->status ) {
230 Config_Hub_Helper::set_clear_active_flag();
231 $this->model->mark( self::STATUS_RESOLVE, $slug );
232 $this->track_tweak( $tweak->get_label(), 'Actioned' );
233 return $ret;
234 }
235 if ( is_wp_error( $ret ) ) {
236 $this->ajax_response( $ret->get_error_message(), false );
237 }
238
239 return new Response(
240 false,
241 array( 'message' => esc_html__( 'Invalid request', 'defender-security' ) )
242 );
243 }
244
245 /**
246 * Revert.
247 *
248 * @param Request $request Request object.
249 *
250 * @return Response
251 * @defender_route
252 */
253 public function revert( Request $request ) {
254 $data = $request->get_data(
255 array(
256 'slug' => array(
257 'type' => 'string',
258 'sanitize' => 'sanitize_text_field',
259 ),
260 'current_server' => array(
261 'type' => 'string',
262 'sanitize' => 'sanitize_text_field',
263 ),
264 )
265 );
266 $slug = $data['slug'] ?? false;
267 $tweak = $this->get_tweak( $slug );
268 $invalid = array( 'message' => esc_html__( 'Invalid request', 'defender-security' ) );
269 if ( ! is_object( $tweak ) ) {
270 return new Response(
271 false,
272 $invalid
273 );
274 }
275 if ( in_array( $slug, array( 'prevent-php-executed', 'protect-information' ), true ) ) {
276 $current_server = $data['current_server'] ?? false;
277 if ( ! $current_server ) {
278 return new Response(
279 false,
280 $invalid
281 );
282 }
283 $ret = $tweak->revert( $current_server );
284 } else {
285 $ret = $tweak->revert();
286 }
287
288 if ( is_wp_error( $ret ) ) {
289 $this->ajax_response( $ret->get_error_message(), false );
290 }
291 if ( true === $ret ) {
292 Config_Hub_Helper::set_clear_active_flag();
293 $this->model->mark( self::STATUS_ISSUES, $slug );
294 // Track.
295 $this->track_tweak( $tweak->get_label(), 'Reverted' );
296 // Response.
297 $this->ajax_response( esc_html__( 'Security recommendation successfully reverted.', 'defender-security' ) );
298 }
299
300 return new Response(
301 false,
302 $invalid
303 );
304 }
305
306 /**
307 * Ignore.
308 *
309 * @param Request $request Request object.
310 *
311 * @return Response|void
312 * @defender_route
313 */
314 public function ignore( Request $request ) {
315 $data = $request->get_data(
316 array(
317 'slug' => array(
318 'type' => 'string',
319 'sanitize' => 'sanitize_text_field',
320 ),
321 )
322 );
323 $slug = $data['slug'] ?? false;
324 $tweak = $this->get_tweak( $slug );
325 if ( ! is_object( $tweak ) ) {
326 return new Response(
327 false,
328 array( 'message' => esc_html__( 'Invalid request', 'defender-security' ) )
329 );
330 }
331 $this->model->mark( self::STATUS_IGNORE, $slug );
332 // Track.
333 $this->track_tweak( $tweak->get_label(), 'Ignored' );
334 if ( 'security-key' === $slug ) {
335 $this->security_key->cron_unschedule();
336 }
337
338 $this->ajax_response( esc_html__( 'Security recommendation successfully ignored.', 'defender-security' ) );
339 }
340
341 /**
342 * Restore.
343 *
344 * @param Request $request Request object.
345 *
346 * @return Response|void
347 * @defender_route
348 */
349 public function restore( Request $request ) {
350 $data = $request->get_data(
351 array(
352 'slug' => array(
353 'type' => 'string',
354 'sanitize' => 'sanitize_text_field',
355 ),
356 )
357 );
358 $slug = $data['slug'] ?? false;
359 $tweak = $this->get_tweak( $slug );
360 if ( ! is_object( $tweak ) ) {
361 return new Response(
362 false,
363 array( 'message' => esc_html__( 'Invalid request', 'defender-security' ) )
364 );
365 }
366 $this->model->mark( self::STATUS_RESTORE, $slug );
367 // Track.
368 $this->track_tweak( $tweak->get_label(), 'Restored' );
369
370 if ( $this->security_key->get_is_autogenerate_keys() ) {
371 $this->security_key->cron_schedule();
372 }
373
374 $this->ajax_response( esc_html__( 'Security recommendation successfully restored.', 'defender-security' ) );
375 }
376
377 /**
378 * Recheck.
379 *
380 * @param Request $request Request object.
381 *
382 * @return Response
383 * @defender_route
384 */
385 public function recheck( Request $request ): Response {
386 $data = $request->get_data(
387 array(
388 'slug' => array(
389 'type' => 'string',
390 'sanitize' => 'sanitize_text_field',
391 ),
392 )
393 );
394 $slug = $data['slug'] ?? false;
395 $tweak = $this->get_tweak( $slug );
396
397 if ( ! is_object( $tweak ) ) {
398 return new Response(
399 false,
400 array(
401 'message' => esc_html__(
402 'The status cannot be verified as the request contains an invalid slug.',
403 'defender-security'
404 ),
405 )
406 );
407 }
408
409 $ret = $tweak->check();
410
411 if ( true === $ret ) {
412 $this->ajax_response( esc_html__( 'Security recommendation successfully resolved.', 'defender-security' ), true, 1 );
413 }
414
415 if ( is_wp_error( $ret ) ) {
416 return new Response(
417 false,
418 array( 'message' => $ret->get_error_message() )
419 );
420 }
421
422 $msg = sprintf(
423 /* translators: %s: Tweak title. */
424 esc_html__( '%s is not actioned. Please ensure that all the instructions are followed.', 'defender-security' ),
425 $tweak->get_label() ?? ''
426 );
427
428 return new Response(
429 false,
430 array( 'message' => $msg )
431 );
432 }
433
434 /**
435 * Update security reminder.
436 *
437 * @param Request $request Request object.
438 *
439 * @return Response
440 * @defender_route
441 */
442 public function update_security_reminder( Request $request ): Response {
443 $data = $request->get_data();
444 $remind_date = $data['remind_date'] ?? false;
445
446 $is_autogen_flag = isset( $data['is_autogenerate_keys'] ) ?
447 filter_var( $data['is_autogenerate_keys'], FILTER_VALIDATE_BOOLEAN ) :
448 false;
449
450 if ( ! $remind_date ) {
451 return new Response(
452 false,
453 array( 'message' => esc_html__( 'Invalid Reminder frequency', 'defender-security' ) )
454 );
455 }
456
457 $values = array(
458 'reminder_duration' => $remind_date,
459 'reminder_date' => strtotime( '+' . $remind_date, time() ),
460 'is_autogenerate_keys' => $is_autogen_flag,
461 );
462
463 if ( update_site_option( 'defender_security_tweaks_' . $this->security_key->slug, $values ) ) {
464
465 if ( true === $is_autogen_flag ) {
466 $this->security_key->cron_schedule();
467 } else {
468 $this->security_key->cron_unschedule();
469 }
470
471 return new Response(
472 true,
473 array( 'message' => esc_html__( 'Security recommendation successfully updated.', 'defender-security' ) )
474 );
475 } else {
476 return new Response(
477 false,
478 array( 'message' => esc_html__( 'Error while updating.', 'defender-security' ) )
479 );
480 }
481 }
482
483 /**
484 * Returns tweaks for a given status formatted for the React frontend.
485 *
486 * Adds `category` and remaps the backend status string to the value the
487 * frontend HARDENING_STATUS constants expect ('issue', 'resolved', 'ignored').
488 *
489 * @param string $status One of STATUS_ISSUES, STATUS_RESOLVE, STATUS_IGNORE.
490 *
491 * @return array
492 */
493 private function format_tweaks_for_frontend( string $status ): array {
494 $status_map = array(
495 self::STATUS_ISSUES => 'issue',
496 self::STATUS_RESOLVE => 'resolved',
497 self::STATUS_IGNORE => 'ignored',
498 );
499
500 $tweaks = $this->init_tweaks( $status, 'array' );
501 $frontend_status = $status_map[ $status ] ?? $status;
502
503 foreach ( $tweaks as &$tweak ) {
504 $tweak['status'] = $frontend_status;
505 $tweak['category'] = self::TWEAK_CATEGORIES[ $tweak['slug'] ] ?? 'access';
506 }
507 unset( $tweak );
508
509 return $tweaks;
510 }
511
512 /**
513 * AJAX Response handler.
514 *
515 * @param string $message The message to be displayed in the response.
516 * @param bool $is_success Whether the response should have a success status.
517 * @param bool|int $interval The cron interval for the security key autogeneration. Default is false.
518 *
519 * @return Response The AJAX response.
520 */
521 private function ajax_response( $message, $is_success = true, $interval = false ): Response {
522 global $wp_version;
523
524 // Creating a new Model_Security_Tweaks() here risks reading stale data from the
525 // DB when the WP options cache hasn't flushed the write yet.
526 $data = array(
527 'message' => $message,
528 'summary' => array(
529 'issues_count' => count( $this->model->issues ),
530 'fixed_count' => count( $this->model->fixed ),
531 'ignore_count' => count( $this->model->ignore ),
532 'php_version' => PHP_VERSION,
533 'wp_version' => $wp_version,
534 ),
535 'issues' => $this->build_tweaks_response( $this->model->issues, 'issue' ),
536 'fixed' => $this->build_tweaks_response( $this->model->fixed, 'resolved' ),
537 'ignored' => $this->build_tweaks_response( $this->model->ignore, 'ignored' ),
538 'issues_slugs' => $this->model->issues,
539 );
540 if ( $interval ) {
541 $data['interval'] = $interval;
542 }
543
544 return new Response( $is_success, $data );
545 }
546
547 /**
548 * Builds the frontend-shaped tweak array from a list of slugs.
549 *
550 * Uses the in-memory tweak object cache rather than re-querying the model,
551 * so the result is always consistent with the state after mark() was called.
552 *
553 * @param array $slugs Slugs to include.
554 * @param string $frontend_status Frontend status string ('issue', 'resolved', 'ignored').
555 *
556 * @return array
557 */
558 private function build_tweaks_response( array $slugs, string $frontend_status ): array {
559 $tweaks = Array_Cache::get( 'tweaks', 'tweaks' );
560 $result = array();
561
562 foreach ( $slugs as $slug ) {
563 if ( ! isset( $tweaks[ $slug ] ) ) {
564 continue;
565 }
566 $arr = $tweaks[ $slug ]->to_array();
567 $arr['status'] = $frontend_status;
568 $arr['category'] = self::TWEAK_CATEGORIES[ $slug ] ?? 'access';
569 $result[ $slug ] = $arr;
570 }
571
572 return $result;
573 }
574
575 /**
576 * Enqueues scripts and styles for this page.
577 * Only enqueues assets if the page is active.
578 */
579 public function enqueue_assets() {
580 if ( ! $this->is_page_active() ) {
581 return;
582 }
583
584 $handle = 'defender-ui-hardening';
585 wp_enqueue_script(
586 $handle,
587 WP_DEFENDER_BASE_URL . 'assets/js/hardening-ui.js',
588 array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
589 DEFENDER_VERSION,
590 true
591 );
592 wp_set_script_translations( $handle, 'wpdef' );
593
594 wp_localize_script(
595 $handle,
596 'defenderUIData',
597 array_merge(
598 $this->get_shared_data(),
599 $this->data_frontend()
600 )
601 );
602
603 wp_enqueue_style(
604 $handle,
605 WP_DEFENDER_BASE_URL . 'assets/css/showcase.css',
606 array(),
607 DEFENDER_VERSION
608 );
609
610 $this->enqueue_main_assets();
611 }
612
613 /**
614 * Provides data for the dashboard widget.
615 *
616 * @return array
617 */
618 public function dashboard_widget(): array {
619 $tweak_arr = $this->model->get_tweak_types();
620 $data = array();
621 foreach ( $this->init_tweaks( self::STATUS_ISSUES ) as $slug => $tweak ) {
622 $data[] = array(
623 'title' => $tweak->get_label(),
624 'slug' => $slug,
625 );
626 }
627
628 return array(
629 'summary' => array(
630 'fixed_count' => $tweak_arr['count_fixed'],
631 'ignore_count' => $tweak_arr['count_ignored'],
632 'issues_count' => $tweak_arr['count_issues'],
633 ),
634 'issues' => $data,
635 );
636 }
637
638 /**
639 * Provides data for the frontend.
640 *
641 * @return array An array of data for the frontend.
642 */
643 public function data_frontend(): array {
644 $this->refresh_tweaks_status();
645 global $wp_version;
646
647 $not_allowed_bulk = array(
648 'php-version',
649 'replace-admin-username',
650 );
651 if ( 'nginx' === Server::get_current_server() ) {
652 $not_allowed_bulk[] = 'protect-information';
653 $not_allowed_bulk[] = 'prevent-php-executed';
654 }
655
656 $tweak_arr = $this->model->get_tweak_types();
657
658 $hardening_data = array(
659 'summary' => array(
660 'fixed_count' => $tweak_arr['count_fixed'],
661 'ignore_count' => $tweak_arr['count_ignored'],
662 'issues_count' => $tweak_arr['count_issues'],
663 'php_version' => PHP_VERSION,
664 'wp_version' => $wp_version,
665 ),
666 'issues' => $this->format_tweaks_for_frontend( self::STATUS_ISSUES ),
667 'fixed' => $this->format_tweaks_for_frontend( self::STATUS_RESOLVE ),
668 'ignored' => $this->format_tweaks_for_frontend( self::STATUS_IGNORE ),
669 'not_allowed_bulk' => $not_allowed_bulk,
670 'is_autogenerate_keys' => $this->security_key->get_is_autogenerate_keys(),
671 'reminder_frequencies' => $this->security_key->reminder_frequencies(),
672 'enabled_user_enums' => $this->prevent_enum_users->get_enabled_user_enums(),
673 );
674
675 return array(
676 'hardening' => array_merge( $hardening_data, $this->dump_routes_and_nonces() ),
677 );
678 }
679
680 /**
681 * Renders the main view for this page.
682 */
683 public function main_view() {
684 $this->render( 'main' );
685 }
686
687 /**
688 * Bulk action for security tweaks.
689 * Handles bulk resolving, ignoring, or reverting security tweaks.
690 *
691 * @param Request $request The request object.
692 *
693 * @return Response
694 * @defender_route
695 */
696 public function bulk_action( Request $request ) {
697 $data = $request->get_data(
698 array(
699 'slugs' => array(
700 'type' => 'array',
701 'sanitize' => 'sanitize_text_field',
702 ),
703 'intention' => array(
704 'type' => 'string',
705 'sanitize' => 'sanitize_text_field',
706 ),
707 )
708 );
709 $slugs = $data['slugs'] ?? array();
710 $intention = $data['intention'] ?? false;
711 // Get processed and unprocessed tweaks.
712 [ $processed, $unprocessed, $error_message ] = $this->security_tweaks_auto_action( $slugs, $intention );
713
714 if ( null !== $error_message ) {
715 return $this->ajax_response( $error_message, false );
716 }
717
718 $message = sprintf(
719 /* translators: 1: Either ignored or resolved, 2: Count security recommendations */
720 esc_html__( 'You have bulk %1$s %2$s security recommendations.', 'defender-security' ),
721 'ignore' === $intention ? 'ignored' : 'resolved',
722 $processed
723 );
724
725 if ( isset( $unprocessed ) && $unprocessed > 0 ) {
726 // If we have this case this mean the intention is resolved.
727 $message = sprintf(
728 /* translators: %d: Count security tweaks */
729 esc_html__(
730 'You have bulk actioned %d security recommendations. You still have a few unresolved security recommendations, which cannot be bulk actioned automatically, so please address them below.',
731 'defender-security'
732 ),
733 $processed
734 );
735
736 Config_Hub_Helper::set_clear_active_flag();
737 }
738
739 return $this->ajax_response( $message );
740 }
741
742 /**
743 * Mass processing.
744 *
745 * @param array $slugs Tweak slugs.
746 * @param string $intention Status.
747 *
748 * @return array
749 */
750 public function security_tweaks_auto_action( $slugs, $intention ): array {
751 $processed = 0;
752 $unprocessed = 0;
753 $error_message = null;
754
755 foreach ( $slugs as $slug ) {
756 $tweak = $this->get_tweak( $slug );
757 if ( 'ignore' === $intention ) {
758 $this->model->mark( self::STATUS_IGNORE, $slug );
759 // Track.
760 $this->track_tweak( $tweak->get_label(), 'Ignored' );
761 } elseif ( 'resolve' === $intention ) {
762 $wont_do = array(
763 'replace-admin-username',
764 'prevent-php-executed',
765 'wp-version',
766 'php-version',
767 'protect-information',
768 );
769 if ( in_array( $slug, $wont_do, true ) ) {
770 ++$unprocessed;
771 continue;
772 }
773 if ( $tweak->has_method( 'bulk_process' ) ) {
774 $ret = $tweak->bulk_process();
775 } else {
776 $ret = $tweak->process();
777 }
778 if ( is_wp_error( $ret ) ) {
779 $error_message = sprintf(
780 /* translators: 1: Security tweak title, 2: Error message */
781 esc_html__(
782 'There is an error while processing recommendation %1$s, error message: %2$s',
783 'defender-security'
784 ),
785 $tweak->get_label(),
786 $ret->get_error_message()
787 );
788 ++$unprocessed;
789 // Don't mark a failed tweak as resolved; stop on first error (same as original behavior).
790 break;
791 }
792 $this->model->mark( self::STATUS_RESOLVE, $slug );
793 // Track.
794 $this->track_tweak( $tweak->get_label(), 'Actioned' );
795 } elseif ( 'restore' === $intention ) {
796 // New bulk action since V6.
797 $this->model->mark( self::STATUS_ISSUES, $slug );
798 // Track.
799 $this->track_tweak( $tweak->get_label(), 'Restored' );
800 }
801 ++$processed;
802 }
803
804 return array( $processed, $unprocessed, $error_message );
805 }
806
807 /**
808 * Refresh the tweak status and save their state.
809 *
810 * @return void
811 */
812 public function refresh_tweaks_status() {
813 $tweaks = $this->init_tweaks();
814 $settings = new Model_Security_Tweaks();
815 $fixed = array();
816 $issues = array();
817
818 foreach ( $tweaks as $slug => $class ) {
819 if ( $settings->is_tweak_ignore( $slug ) ) {
820 continue;
821 }
822
823 $is_resolved = $class->check();
824
825 if ( $is_resolved ) {
826 $fixed[] = $slug;
827 } else {
828 $issues[] = $slug;
829 }
830 }
831
832 $settings->fixed = $fixed;
833 $settings->issues = $issues;
834 $settings->save();
835 }
836
837 /**
838 * This function for shield every active tweaks up, we will use the cached result.
839 * No check function trigger in this init runtime.
840 */
841 private function boot() {
842 $tweaks = $this->init_tweaks( self::STATUS_RESOLVE );
843 foreach ( $tweaks as $tweak ) {
844 $tweak->shield_up();
845 }
846 }
847
848 /**
849 * Instance all the tweaks, happen one time in init runtime.
850 *
851 * @param string $type Type of tweaks.
852 * @param string $format Object for internal use, array for frontend use.
853 *
854 * @return array
855 */
856 public function init_tweaks( $type = '', $format = 'object' ): array {
857 $classes = array(
858 Disable_XML_RPC::class,
859 WP_Version::class,
860 Hide_Error::class,
861 PHP_Version::class,
862 Change_Admin::class,
863 Security_Key::class,
864 Login_Duration::class,
865 Disable_Trackback::class,
866 Prevent_Enum_Users::class,
867 Disable_File_Editor::class,
868 );
869 if ( ! defender_is_wp_cli() ) {
870 // We don't load this in cli, as clearly no server is running.
871 $classes = array_merge(
872 $classes,
873 array(
874 Protect_Information::class,
875 Prevent_PHP::class,
876 )
877 );
878 }
879
880 $tweaks = Array_Cache::get( 'tweaks', 'tweaks' );
881
882 if ( ! is_array( $tweaks ) ) {
883 foreach ( $classes as $class ) {
884 $obj = new $class();
885 $tweaks[ $obj->slug ] = $obj;
886 }
887 Array_Cache::set( 'tweaks', $tweaks, 'tweaks' );
888 }
889 $tmp = array();
890 if ( ! is_string( $type ) || '' === trim( $type ) ) {
891 $tmp = $tweaks;
892 } else {
893 $settings = new Model_Security_Tweaks();
894
895 try {
896 $compare = $settings->get_collection( $type );
897 } catch ( \InvalidArgumentException $e ) {
898 $compare = array();
899 }
900
901 foreach ( $compare as $slug ) {
902 if ( isset( $tweaks[ $slug ] ) ) {
903 $tmp[ $slug ] = $tweaks[ $slug ];
904 }
905 }
906 }
907
908 if ( 'array' === $format ) {
909 // We need to parse this as array.
910 foreach ( $tmp as $slug => $obj ) {
911 $arr = $obj->to_array();
912 $arr['status'] = $type;
913 $tmp[ $slug ] = $arr;
914 }
915 }
916
917 return $tmp;
918 }
919
920 /**
921 * Get tweak object from cache by slug.
922 *
923 * @param string $slug Tweak slug.
924 *
925 * @return mixed Tweak object if exists, else null.
926 */
927 private function get_tweak( $slug ) {
928 $tweaks = Array_Cache::get( 'tweaks', 'tweaks' );
929
930 return $tweaks[ $slug ] ?? null;
931 }
932
933 /**
934 * Converts the current object state to an array.
935 *
936 * @return array The array representation of the object.
937 */
938 public function to_array(): array {
939 $this->refresh_tweaks_status();
940 $settings = new Model_Security_Tweaks();
941
942 return array(
943 'count' => array(
944 'issues' => count( $settings->issues ),
945 'resolved' => count( $settings->fixed ),
946 'total' => count( $this->init_tweaks() ),
947 ),
948 );
949 }
950
951 /**
952 * Removes settings for all submodules.
953 */
954 public function remove_settings() {
955 // Revert it first.
956 $tweaks = $this->init_tweaks( self::STATUS_RESOLVE );
957 // Assign this so internal can use the current server.
958 $_POST['current_server'] = Server::get_current_server();
959 foreach ( $tweaks as $tweak ) {
960 $tweak->revert();
961
962 if ( method_exists( $tweak, 'delete_all_option' ) ) {
963 $tweak->delete_all_option();
964 }
965 }
966
967 ( new Model_Security_Tweaks() )->delete();
968
969 delete_site_transient( Server::CACHE_CURRENT_SERVER );
970 delete_site_transient( \WP_Defender\Component\Security_Tweaks\Servers\Apache::CACHE_APACHE_VERSION );
971 $this->security_key->cron_unschedule();
972 }
973
974 /**
975 * Delete all the data & the cache.
976 */
977 public function remove_data() {
978 // Remove cached data.
979 Array_Cache::remove( 'tweaks', 'tweaks' );
980 delete_site_option( Security_Key::REGENERATE_SALT_NEXT_RUN_OPTION );
981 }
982
983 /**
984 * Automate the process of resolving, reverting, and ignoring security tweaks based on the provided data.
985 *
986 * @param array $data The data containing information about fixed, issues, and ignored security tweaks.
987 * @param string $request_reason The reason for the request, e.g., 'hub'.
988 *
989 * @return mixed Whether a reauthentication is needed after processing the tweaks.
990 * @since 2.8.1 Add $request_reason param. If there's a request from Hub the plugin doesn't send the error message.
991 */
992 public function automate( $data, $request_reason ) {
993 $this->refresh_tweaks_status();
994 $need_reauth = false;
995 // Resolve tweaks.
996 if ( isset( $data['fixed'] ) && is_array( $data['fixed'] ) && array() !== $data['fixed'] ) {
997 // There are some tweak that need manual apply, as files based, or change admin.
998 $manual_done = array(
999 'replace-admin-username',
1000 'prevent-php-executed',
1001 'wp-version',
1002 'php-version',
1003 'protect-information',
1004 );
1005 if ( 'hub' === $request_reason ) {
1006 $manual_done[] = 'security-key';
1007 }
1008
1009 $diff_keys = array_diff( $data['fixed'], $this->model->fixed, $manual_done );
1010 if ( array() !== $diff_keys ) {
1011 foreach ( $diff_keys as $slug ) {
1012 $tweak = $this->get_tweak( $slug );
1013 if ( $tweak->has_method( 'bulk_process' ) ) {
1014 $ret = $tweak->bulk_process();
1015 } else {
1016 $ret = $tweak->process();
1017 }
1018
1019 if ( is_wp_error( $ret ) ) {
1020 if ( 'hub' === $request_reason ) {
1021 continue;
1022 }
1023
1024 return sprintf(
1025 /* translators: 1: Security tweak title, 2: Error message */
1026 esc_html__(
1027 'There is an error while processing recommendation %1$s, error message: %2$s',
1028 'defender-security'
1029 ),
1030 $tweak->get_label(),
1031 $ret->get_error_message()
1032 );
1033 }
1034
1035 $this->model->mark( self::STATUS_RESOLVE, $slug );
1036 }
1037 if ( in_array( 'security-key', $diff_keys, true ) ) {
1038 $need_reauth = true;
1039 }
1040 }
1041 }
1042 // Revert tweaks.
1043 if ( isset( $data['issues'] ) && is_array( $data['issues'] ) && array() !== $data['issues'] ) {
1044 $diff_keys = array_diff( $data['issues'], $this->model->issues );
1045
1046 if ( array() !== $diff_keys ) {
1047 // Issues.
1048 foreach ( $diff_keys as $slug ) {
1049 $tweak = $this->get_tweak( $slug );
1050 $ret = $tweak->revert();
1051 if ( is_wp_error( $ret ) ) {
1052 if ( 'hub' === $request_reason ) {
1053 continue;
1054 }
1055
1056 return sprintf(
1057 /* translators: 1: Security tweak title, 2: Error message */
1058 esc_html__(
1059 'There is an error while processing recommendation %1$s, error message: %2$s',
1060 'defender-security'
1061 ),
1062 $tweak->get_label(),
1063 $ret->get_error_message()
1064 );
1065 }
1066 $this->model->mark( self::STATUS_ISSUES, $slug );
1067 }
1068 }
1069 }
1070 // Ignore tweaks.
1071 if ( isset( $data['ignore'] ) && is_array( $data['ignore'] ) && array() !== $data['ignore'] ) {
1072 $diff_keys = array_diff( $data['ignore'], $this->model->ignore );
1073 if ( array() !== $diff_keys ) {
1074 foreach ( $diff_keys as $slug ) {
1075 $this->model->mark( self::STATUS_IGNORE, $slug );
1076 }
1077 }
1078 }
1079
1080 return $need_reauth;
1081 }
1082
1083 /**
1084 * Imports data into the model.
1085 *
1086 * @param array $data Data to be imported into the model.
1087 */
1088 public function import_data( array $data ) {
1089 $enabled_user_enums = array();
1090
1091 if ( isset( $data['enabled_user_enums'] ) ) {
1092 $enabled_user_enums = (array) $data['enabled_user_enums'];
1093
1094 unset( $data['enabled_user_enums'] );
1095 }
1096
1097 $this->prevent_enum_users->set_enabled_user_enums( $enabled_user_enums );
1098
1099 if ( isset( $data['security_key'] ) && is_array( $data['security_key'] ) && array() !== $data['security_key'] ) {
1100 $this->security_key->update_all_option( $data['security_key'] );
1101 }
1102
1103 $model = new Model_Security_Tweaks();
1104
1105 $model->import( $data );
1106 if ( $model->validate() ) {
1107 $model->save();
1108 }
1109 }
1110
1111 /**
1112 * Exports strings.
1113 *
1114 * @return array An array of strings.
1115 */
1116 public function export_strings(): array {
1117 $this->refresh_tweaks_status();
1118 $settings = new Model_Security_Tweaks();
1119 $strings = array();
1120 $count_all = count( $settings->fixed ) + count( $settings->issues ) + count( $settings->ignore );
1121
1122 if ( ! is_array( $settings->issues ) || array() === $settings->issues ) {
1123 $strings[] = esc_html__( 'All available recommendations activated', 'defender-security' );
1124 } else {
1125 $strings[] = sprintf(
1126 /* translators: 1: Total security tweaks activated, 2: Total security tweaks */
1127 esc_html__( '%1$d/%2$d recommendations activated', 'defender-security' ),
1128 count( $settings->fixed ),
1129 $count_all
1130 );
1131 }
1132
1133 return $strings;
1134 }
1135
1136 /**
1137 * Config strings.
1138 *
1139 * @param array $config Settings.
1140 *
1141 * @return array Strings to be displayed in the settings page.
1142 */
1143 public function config_strings( $config ): array {
1144 $strings = array();
1145 if (
1146 ! isset( $config['issues'] )
1147 || ! ( is_array( $config['issues'] ) || $config['issues'] instanceof Countable )
1148 || 0 === count( $config['issues'] )
1149 ) {
1150 $strings[] = esc_html__( 'All available recommendations activated', 'defender-security' );
1151 } else {
1152 $strings[] = sprintf(
1153 /* translators: 1: Total security tweaks activated, 2: Total security tweaks */
1154 esc_html__( '%1$d/%2$d recommendations activated', 'defender-security' ),
1155 is_array( $config['fixed'] ) || $config['fixed'] instanceof Countable ? count( $config['fixed'] ) : 0,
1156 ( is_array( $config['fixed'] ) || $config['fixed'] instanceof Countable ? count( $config['fixed'] ) : 0 )
1157 + ( is_array( $config['issues'] ) || $config['issues'] instanceof Countable ? count( $config['issues'] ) : 0 )
1158 + ( is_array( $config['ignore'] ) || $config['ignore'] instanceof Countable ? count( $config['ignore'] ) : 0 )
1159 );
1160 }
1161 if ( 'enabled' === $config['notification'] ) {
1162 $strings[] = esc_html__( 'Email notifications active', 'defender-security' );
1163 }
1164
1165 return $strings;
1166 }
1167
1168 /**
1169 * Update auto generate flag.
1170 *
1171 * @param Request $request Request object.
1172 *
1173 * @defender_route
1174 */
1175 public function update_autogenerate_flag( Request $request ): Response {
1176 $data = $request->get_data();
1177
1178 $is_autogen_flag = isset( $data['is_autogenerate_keys'] ) ?
1179 filter_var( $data['is_autogenerate_keys'], FILTER_VALIDATE_BOOLEAN ) :
1180 false;
1181
1182 $is_success = false;
1183 $message = esc_html__( 'An error occurred, try again.', 'defender-security' );
1184
1185 if ( $this->security_key->set_is_autogenrate_keys( $is_autogen_flag ) ) {
1186 $is_success = true;
1187
1188 if ( $is_autogen_flag ) {
1189 $this->security_key->cron_schedule();
1190 $message = esc_html__( 'Security key/salt autogenerate enabled.', 'defender-security' );
1191 } else {
1192 $this->security_key->cron_unschedule();
1193 $message = esc_html__( 'Security key/salt autogenerate disabled.', 'defender-security' );
1194 }
1195 }
1196
1197 return new Response(
1198 $is_success,
1199 array( 'message' => $message )
1200 );
1201 }
1202
1203 /**
1204 * Get component security key instance.
1205 *
1206 * @return Security_Key
1207 */
1208 public function get_security_key() {
1209 return $this->security_key;
1210 }
1211
1212 /**
1213 * Update enabled user enums list.
1214 *
1215 * @param Request $request Request object.
1216 *
1217 * @return Response
1218 * @defender_route
1219 */
1220 public function update_enabled_user_enums( Request $request ): Response {
1221 $data = $request->get_data();
1222 $data = ! is_array( $data ) ? (array) $data : $data;
1223 $enabled_user_enums = $data['enabled_user_enums'];
1224 $is_success = false;
1225 $message = esc_html__( 'An error occurred, try again.', 'defender-security' );
1226
1227 if ( $this->prevent_enum_users->set_enabled_user_enums( $enabled_user_enums ) ) {
1228 $is_success = true;
1229 $message = esc_html__( 'User enumeration option(s) updated successfully.', 'defender-security' );
1230 }
1231
1232 return new Response(
1233 $is_success,
1234 array( 'message' => $message )
1235 );
1236 }
1237
1238 /**
1239 * Check XML-RPC status.
1240 *
1241 * @return Response
1242 * @defender_route
1243 */
1244 public function check_xml_rpc() {
1245 $tweak_xml_rpc = wd_di()->get( Disable_XML_RPC::class );
1246 // If this tweak is ON, block XML-RPC.
1247 if ( $tweak_xml_rpc->recheck() ) {
1248 $tweak_xml_rpc->add_hooks();
1249 }
1250
1251 return new Response( true, array() );
1252 }
1253
1254 /**
1255 * Tracks a tweak recommendation.
1256 *
1257 * @param string $title The title of the tweak recommendation.
1258 * @param string $status The status of the tweak recommendation.
1259 *
1260 * @return void
1261 */
1262 private function track_tweak( string $title, string $status ) {
1263 if ( ! defender_is_wp_cli() ) {
1264 $this->track_feature(
1265 'def_recommendation_applied',
1266 array(
1267 'Recommendation Name' => $title,
1268 'Status' => $status,
1269 )
1270 );
1271 }
1272 }
1273 }
1274