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

class-upgrader.php in Defender Security – Malware Scanner, Login Security & Firewall 6.1.0, at src/class-upgrader.php

2,256 lines 71.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * This file contains the logic for updating the WP Defender plugin.
4 *
5 * @package WP_Defender
6 */
7
8 namespace WP_Defender;
9
10 use wpdb;
11 use WP_User_Query;
12 use WP_Filesystem_Base;
13 use WP_Defender\Traits\IO;
14 use WP_Defender\Traits\User;
15 use WP_Defender\Component\Crypt;
16 use WP_Defender\Component\Firewall;
17 use WP_Defender\Component\Webauthn;
18 use WP_Defender\Model\Notification;
19 use Safe\Exceptions\SodiumException;
20 use WP_Defender\Component\Feature_Modal;
21 use WP_Defender\Controller\Data_Tracking;
22 use WP_Defender\Component\Backup_Settings;
23 use WP_Defender\Traits\Defender_Bootstrap;
24 use WP_Defender\Component\Legacy_Versions;
25 use WP_Defender\Controller\Security_Tweaks;
26 use WP_Defender\Model\Setting\Security_Headers;
27 use WP_Defender\Model\Setting\Notfound_Lockout;
28 use WP_Defender\Model\Notification\Audit_Report;
29 use WP_Defender\Model\Setting\Global_Ip_Lockout;
30 use WP_Defender\Component\Config\Config_Adapter;
31 use WP_Defender\Model\Setting\User_Agent_Lockout;
32 use WP_Defender\Integrations\MaxMind_Geolocation;
33 use WP_Defender\Traits\Webauthn as Webauthn_Trait;
34 use WP_Defender\Model\Notification\Malware_Report;
35 use WP_Defender\Model\Notification\Tweak_Reminder;
36 use WP_Defender\Component\Config\Config_Hub_Helper;
37 use WP_Defender\Model\Notification\Firewall_Report;
38 use WP_Defender\Model\Setting\Scan as Scan_Settings;
39 use WP_Defender\Component\Two_Fa as Two_Fa_Component;
40 use WP_Defender\Component\Two_Factor\Providers\Totp;
41 use WP_Defender\Model\Setting\Two_Fa as Two_Fa_Settings;
42 use WP_Defender\Component\Security_Tweaks\Security_Key;
43 use WP_Defender\Model\Notification\Malware_Notification;
44 use WP_Defender\Model\Notification\Firewall_Notification;
45 use WP_Defender\Component\Two_Factor\Providers\Fallback_Email;
46 use WP_Defender\Helper\Analytics\Firewall as Firewall_Analytics;
47 use WP_Defender\Model\Setting\Blacklist_Lockout as Model_Blacklist_Lockout;
48 use WP_Defender\Model\Setting\Firewall as Model_Firewall;
49 use WP_Defender\Component\User_Agent as User_Agent_Service;
50 use function WP_Filesystem;
51
52 /**
53 * Upgrade the WP Defender plugin to the latest version.
54 */
55 class Upgrader {
56
57 use User;
58 use Webauthn_Trait;
59 use IO;
60 use Defender_Bootstrap;
61
62 /**
63 * Migrate old security headers from security tweaks. Trigger it once time.
64 *
65 * @return void
66 */
67 public function migrate_security_headers(): void {
68 $model = wd_di()->get( Security_Headers::class );
69 $option = get_site_option( $model->get_table() );
70
71 if ( null === $option || false === $option || '' === $option || '0' === $option || array() === $option ) {
72 // Part of Security tweaks data.
73 $old_key = 'wd_hardener_settings';
74 $old_settings = get_site_option( $old_key );
75 if ( ! is_array( $old_settings ) ) {
76 $old_settings = json_decode( $old_settings, true );
77 if ( is_array( $old_settings ) && isset( $old_settings['data'] ) && is_array( $old_settings['data'] ) && array() !== $old_settings['data'] ) {
78 // Exist 'X-Frame-Options'.
79 if ( isset( $old_settings['data']['sh_xframe'] ) && is_array( $old_settings['data']['sh_xframe'] ) && array() !== $old_settings['data']['sh_xframe'] ) {
80 $header_data = $old_settings['data']['sh_xframe'];
81
82 $mode = ( isset( $header_data['mode'] ) && '' !== trim( $header_data['mode'] ) )
83 ? strtolower( $header_data['mode'] )
84 : false;
85 /**
86 * Directive ALLOW-FROM is deprecated. If header directive is ALLOW-FROM then set 'sameorigin'.
87 *
88 * @since 2.5.0
89 */
90 if ( 'allow-from' === $mode ) {
91 $model->sh_xframe_mode = 'sameorigin';
92 } elseif ( in_array( $mode, array( 'sameorigin', 'deny' ), true ) ) {
93 $model->sh_xframe_mode = $mode;
94 }
95 $model->sh_xframe = true;
96 }
97
98 // Exist 'X-XSS-Protection'.
99 if (
100 isset( $old_settings['data']['sh_xss_protection'] )
101 && is_array( $old_settings['data']['sh_xss_protection'] )
102 && array() !== $old_settings['data']['sh_xss_protection']
103 ) {
104 $header_data = $old_settings['data']['sh_xss_protection'];
105
106 if ( isset( $header_data['mode'] )
107 && '' !== trim( $header_data['mode'] )
108 && in_array( $header_data['mode'], array( 'sanitize', 'block' ), true )
109 ) {
110 $model->sh_xss_protection_mode = $header_data['mode'];
111 $model->sh_xss_protection = true;
112 }
113 }
114
115 // Exist 'X-Content-Type-Options'.
116 if (
117 isset( $old_settings['data']['sh_content_type_options'] )
118 && is_array( $old_settings['data']['sh_content_type_options'] )
119 && array() !== $old_settings['data']['sh_content_type_options']
120 ) {
121 $header_data = $old_settings['data']['sh_content_type_options'];
122
123 if ( isset( $header_data['mode'] ) && '' !== trim( $header_data['mode'] ) ) {
124 $model->sh_content_type_options_mode = $header_data['mode'];
125 $model->sh_content_type_options = true;
126 }
127 }
128
129 // Exist 'Strict Transport'.
130 if (
131 isset( $old_settings['data']['sh_strict_transport'] )
132 && is_array( $old_settings['data']['sh_strict_transport'] )
133 && array() !== $old_settings['data']['sh_strict_transport']
134 ) {
135 $header_data = $old_settings['data']['sh_strict_transport'];
136
137 $hsts_preload = isset( $header_data['hsts_preload'] ) ? (int) $header_data['hsts_preload'] : 0;
138 if ( 0 < $hsts_preload ) {
139 $model->hsts_preload = $hsts_preload;
140 }
141 if (
142 isset( $header_data['include_subdomain'] )
143 && ! is_null( $header_data['include_subdomain'] )
144 && '' !== $header_data['include_subdomain']
145 && '0' !== $header_data['include_subdomain']
146 && false !== $header_data['include_subdomain']
147 && ( ! is_int( $header_data['include_subdomain'] ) || 0 < $header_data['include_subdomain'] )
148 ) {
149 $model->include_subdomain = in_array(
150 $header_data['include_subdomain'],
151 array( 'true', '1', 1 ),
152 true
153 ) ? 1 : 0;
154 }
155 $cache_duration = isset( $header_data['hsts_cache_duration'] ) ? trim( $header_data['hsts_cache_duration'] ) : '';
156 if ( '' !== $cache_duration ) {
157 $model->hsts_cache_duration = $cache_duration;
158 }
159 $model->sh_strict_transport = true;
160 }
161
162 // Exist 'Referrer Policy'.
163 if (
164 isset( $old_settings['data']['sh_referrer_policy'] )
165 && is_array( $old_settings['data']['sh_referrer_policy'] )
166 && array() !== $old_settings['data']['sh_referrer_policy']
167 ) {
168 $header_data = $old_settings['data']['sh_referrer_policy'];
169
170 if ( isset( $header_data['mode'] ) && '' !== trim( $header_data['mode'] ) ) {
171 $model->sh_referrer_policy_mode = $header_data['mode'];
172 $model->sh_referrer_policy = true;
173 }
174 }
175
176 // Exist 'Feature-Policy'.
177 if (
178 isset( $old_settings['data']['sh_feature_policy'] )
179 && is_array( $old_settings['data']['sh_feature_policy'] )
180 && array() !== $old_settings['data']['sh_feature_policy']
181 ) {
182 $header_data = $old_settings['data']['sh_feature_policy'];
183
184 if ( isset( $header_data['mode'] ) && '' !== trim( $header_data['mode'] ) ) {
185 $mode = strtolower( $header_data['mode'] );
186 $model->sh_feature_policy_mode = $mode;
187 if (
188 'origins' === $mode
189 && isset( $header_data['values'] )
190 && (
191 ( is_array( $header_data['values'] ) && array() !== $header_data['values'] )
192 || ( is_string( $header_data['values'] ) && '' !== trim( $header_data['values'] ) )
193 )
194 ) {
195 // The values differ from the values of the 'X-Frame-Options' key, because they may be an array.
196 if ( is_array( $header_data['values'] ) ) {
197 $model->sh_feature_policy_urls = implode( PHP_EOL, $header_data['values'] );
198 // Otherwise.
199 } elseif ( is_string( $header_data['values'] ) ) {
200 $urls = explode( ' ', $header_data['values'] );
201 $model->sh_feature_policy_urls = implode( PHP_EOL, $urls );
202 }
203 }
204 $model->sh_feature_policy = true;
205 }
206 }
207 // Save.
208 $model->save();
209 }
210 }
211 }
212 }
213
214 /**
215 * If user upgrade from an older version to the latest version.
216 *
217 * @param mixed $current_version The current version.
218 *
219 * @return void
220 */
221 public function maybe_show_new_features( $current_version ) {
222 if ( false === $current_version ) {
223 // Do nothing.
224 return;
225 }
226
227 // Set the version where we have added the new feature.
228 // Update it when you want to show modal on a specific version.
229 if ( version_compare( $current_version, '2.4', '<' ) ) {
230 update_site_option( 'wd_show_new_feature', true );
231 }
232 }
233
234 /**
235 * Migrate configs for latest versions.
236 *
237 * @param mixed $current_version The current version.
238 *
239 * @return void
240 * @since 2.4
241 */
242 public function migrate_configs( $current_version ) {
243 if (
244 version_compare( $current_version, '2.2', '>=' )
245 && version_compare( $current_version, '2.4', '<' )
246 ) {
247 $config_component = wd_di()->get( Backup_Settings::class );
248 $prev_data = $config_component->backup_data();
249 if ( ! is_array( $prev_data ) || array() === $prev_data ) {
250 return;
251 }
252 $adapter = wd_di()->get( Config_Adapter::class );
253 $migrated_data = $adapter->upgrade( $prev_data );
254 $config_component->restore_data( $migrated_data, 'migration' );
255 // Hide Onboard page.
256 update_site_option( 'wp_defender_shown_activator', true );
257
258 $configs = $config_component->get_configs();
259 if ( array() !== $configs ) {
260 foreach ( $configs as $k => $config ) {
261 if (
262 $config_component->verify_config_data( $config )
263 && ! $config_component->check_for_new_structure( $config['configs'] )
264 ) {
265 $new_data = $config;
266 $new_data['configs'] = $adapter->upgrade( $config['configs'] );
267 // Import config 'strings' and the active tag if a config has it.
268 if ( isset( $config['is_active'] ) ) {
269 $new_data['is_active'] = $config['is_active'];
270 }
271 $new_data['strings'] = $config_component->import_module_strings( $new_data );
272 // Update config data.
273 update_site_option( $k, $new_data );
274 }
275 }
276 }
277 }
278 // For older versions we do not use old models, e.g. for version < 2.2. So the default values will be used.
279 }
280
281 /**
282 * Upgrade to version 2.4.2.
283 *
284 * @return void
285 */
286 private function upgrade_2_4_2(): void {
287 // Update Scan settings.
288 $model_settings = wd_di()->get( Scan_Settings::class );
289 $option = get_site_option( $model_settings->get_table() );
290 if ( is_string( $option ) && '' !== trim( $option ) ) {
291 $old_settings = json_decode( $option, true );
292 if ( is_array( $old_settings ) && isset( $old_settings['max_filesize'] ) ) {
293 $model_settings->filesize = (int) $old_settings['max_filesize'];
294 $model_settings->save();
295 }
296 }
297 // Update 'reminder_duration' value inside the Security Key tweak.
298 $old_settings = get_site_option( 'wd_hardener_settings' );
299 if ( is_string( $old_settings ) && '' !== trim( $old_settings ) ) {
300 $old_settings = json_decode( $old_settings, true );
301 $tweak_sec_key = wd_di()->get( Security_Key::class );
302
303 if ( is_array( $old_settings ) && isset( $old_settings['data']['securityReminderDuration'] )
304 && in_array(
305 $old_settings['data']['securityReminderDuration'],
306 $tweak_sec_key->reminder_frequencies(),
307 true
308 )
309 ) {
310 $tweak_sec_key->update_option( 'reminder_duration', $old_settings['data']['securityReminderDuration'] );
311 }
312 }
313 }
314
315 /**
316 * Migrate value of scan setting from 'integrity_check' to 'check_core'.
317 *
318 * @return void
319 * @since 2.4.7
320 */
321 private function migrate_scan_integrity_check(): void {
322 $model = new Scan_Settings();
323 $model->check_core = $model->integrity_check;
324 $model->save();
325 }
326
327 /**
328 * Run an upgrade/installation.
329 *
330 * @return void
331 * @throws SodiumException If the encryption fails.
332 */
333 public function run() {
334 // Sometimes multiple requests come at the same time. So we will only count the web requests.
335 if ( defined( 'DOING_AJAX' ) || defined( 'DOING_CRON' ) ) {
336 return;
337 }
338
339 $db_version = get_site_option( 'wd_db_version' );
340 if ( ! is_string( $db_version ) || '' === $db_version ) {
341 self::cache_event_time( 'plugin_installed' );
342 $this->create_database_tables_common();
343 update_site_option( 'wd_db_version', DEFENDER_DB_VERSION );
344 update_site_option( Feature_Modal::FEATURE_SLUG, true );
345
346 return;
347 }
348
349 if ( DEFENDER_DB_VERSION === $db_version ) {
350 // Check if we need to create pro tables (Free to Pro upgrade scenario).
351 if ( class_exists( Bootstrap::class ) && method_exists( Bootstrap::class, 'create_database_tables' ) && ! get_site_option( 'wd_pro_tables_created' ) ) {
352 wd_di()->get( Bootstrap::class )->create_database_tables();
353 }
354 return;
355 }
356 // Cache last updated time.
357 self::cache_event_time( 'plugin_upgraded' );
358
359 $this->create_database_tables_common();
360 if ( class_exists( Bootstrap::class ) && method_exists( Bootstrap::class, 'create_database_tables' ) ) {
361 wd_di()->get( Bootstrap::class )->create_database_tables();
362 }
363 $this->maybe_show_new_features( $db_version );
364 $this->migrate_configs( $db_version );
365
366 if ( version_compare( $db_version, '2.2.9', '<' ) ) {
367 $this->migrate_security_headers();
368 }
369 if ( version_compare( $db_version, '2.4.2', '<' ) ) {
370 $this->upgrade_2_4_2();
371 }
372 if ( version_compare( $db_version, '2.4.7', '<' ) ) {
373 $this->index_database();
374 $this->migrate_scan_integrity_check();
375 }
376 if ( version_compare( $db_version, '2.4.10', '<' ) ) {
377 $this->upgrade_2_4_10();
378 }
379 if ( version_compare( $db_version, '2.5.0', '<' ) ) {
380 $this->upgrade_2_5_0();
381 }
382 if ( version_compare( $db_version, '2.5.6', '<' ) ) {
383 $this->upgrade_2_5_6();
384 }
385 if ( version_compare( $db_version, '2.6.1', '<' ) ) {
386 $this->upgrade_2_6_1();
387 }
388 if ( version_compare( $db_version, '2.7.0', '<' ) ) {
389 $this->upgrade_2_7_0();
390 }
391 if ( version_compare( $db_version, '2.8.0', '<' ) ) {
392 $this->upgrade_2_8_0();
393 }
394 if ( version_compare( $db_version, '2.8.3', '<' ) ) {
395 $this->upgrade_2_8_3();
396 }
397 if ( version_compare( $db_version, '3.2.0', '<' ) ) {
398 $this->upgrade_3_2_0();
399 }
400 if ( version_compare( $db_version, '3.3.0', '<' ) ) {
401 $this->upgrade_3_3_0();
402 }
403 if ( version_compare( $db_version, '3.3.1', '<' ) ) {
404 $this->upgrade_3_3_1();
405 }
406 if ( version_compare( $db_version, '3.3.3', '<' ) ) {
407 $this->upgrade_3_3_3();
408 }
409 if ( version_compare( $db_version, '3.5.0', '<' ) ) {
410 $this->upgrade_3_5_0();
411 }
412 if ( version_compare( $db_version, '3.8.0', '<' ) ) {
413 $this->upgrade_3_8_0();
414 }
415 if ( version_compare( $db_version, '3.8.2', '<' ) ) {
416 $this->upgrade_3_8_2();
417 }
418 if ( version_compare( $db_version, '3.9.0', '<' ) ) {
419 $this->upgrade_3_9_0();
420 }
421 if ( version_compare( $db_version, '3.11.0', '<' ) ) {
422 $this->upgrade_3_11_0();
423 }
424 if ( version_compare( $db_version, '3.12.0', '<' ) ) {
425 $this->upgrade_3_12_0();
426 }
427 if ( version_compare( $db_version, '4.0.0', '<' ) ) {
428 $this->upgrade_4_0_0();
429 }
430 if ( version_compare( $db_version, '4.1.0', '<' ) ) {
431 $this->upgrade_4_1_0();
432 }
433 if ( version_compare( $db_version, '4.2.0', '<' ) ) {
434 $this->upgrade_4_2_0();
435 }
436 if ( version_compare( $db_version, '4.5.1', '<' ) ) {
437 $this->upgrade_4_5_1();
438 }
439 if ( version_compare( $db_version, '4.6.0', '<' ) ) {
440 $this->upgrade_4_6_0();
441 }
442 if ( version_compare( $db_version, '4.7.2', '<' ) ) {
443 $this->upgrade_4_7_2();
444 }
445 if ( version_compare( $db_version, '4.8.2', '<' ) ) {
446 $this->upgrade_4_8_2();
447 }
448 if ( version_compare( $db_version, '4.9.0', '<' ) ) {
449 $this->upgrade_4_9_0();
450 }
451 if ( version_compare( $db_version, '5.0.0', '<' ) ) {
452 $this->upgrade_5_0_0();
453 }
454 if ( version_compare( $db_version, '5.0.2', '<' ) ) {
455 $this->upgrade_5_0_2();
456 }
457 if ( version_compare( $db_version, '5.1.1', '<' ) ) {
458 $this->upgrade_5_1_1();
459 }
460 if ( version_compare( $db_version, '5.2.0', '<' ) ) {
461 $this->upgrade_5_2_0();
462 }
463 if ( version_compare( $db_version, '5.3.0', '<' ) ) {
464 $this->upgrade_5_3_0();
465 }
466 if ( version_compare( $db_version, '5.3.1', '<' ) ) {
467 $this->upgrade_5_3_1();
468 }
469 if ( version_compare( $db_version, '5.4.0', '<' ) ) {
470 $this->upgrade_5_4_0();
471 }
472 if ( version_compare( $db_version, '5.5.0', '<' ) ) {
473 $this->upgrade_5_5_0();
474 }
475 if ( version_compare( $db_version, '5.6.0', '<' ) ) {
476 $this->upgrade_5_6_0();
477 }
478 if ( version_compare( $db_version, '5.7.0', '<' ) ) {
479 $this->upgrade_5_7_0();
480 }
481 if ( version_compare( $db_version, '6.0.0', '<' ) ) {
482 $this->upgrade_6_0_0();
483 }
484 if ( version_compare( $db_version, '6.1.0', '<' ) ) {
485 $this->upgrade_6_1_0();
486 }
487 // This is not a new installation. Make a mark.
488 defender_no_fresh_install();
489 // Don't run any function below this line.
490 update_site_option( 'wd_db_version', DEFENDER_DB_VERSION );
491 }
492
493 /**
494 * Cache the event time.
495 *
496 * @param string $event Event key.
497 */
498 private static function cache_event_time( $event ) {
499 $option_key = \WP_Defender\Helper\Analytics\Deactivation_Survey::EVENT_DATA_OPTION;
500 $event_times = get_site_option( $option_key, array() );
501 $event_times[ $event ] = time();
502 update_site_option( $option_key, $event_times );
503 }
504
505 /**
506 * Cache the last activated time.
507 */
508 public static function date_activated() {
509 self::cache_event_time( 'plugin_activated' );
510 }
511
512 /**
513 * Index necessary columns.
514 * Sometimes this function call twice that's why we have to check index already exists or not.
515 * `dbDelta` not work on `ALTER TABLE` query, so we had to use $wpdb->query().
516 *
517 * @return void
518 * @since 2.4.7
519 */
520 private function index_database(): void {
521 global $wpdb;
522 $wpdb->hide_errors();
523
524 $this->add_index_to_defender_email_log( $wpdb );
525 $this->add_index_to_defender_audit_log( $wpdb );
526 $this->add_index_to_defender_scan_item( $wpdb );
527 $this->add_index_to_defender_lockout_log( $wpdb );
528 $this->add_index_to_defender_lockout( $wpdb );
529 }
530
531 /**
532 * Add index to defender_email_log.
533 *
534 * @param wpdb $wpdb WordPress Database object.
535 *
536 * @return void
537 * @since 2.4.7
538 */
539 private function add_index_to_defender_email_log( $wpdb ) {
540 // Check index already exists or not.
541 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_email_log WHERE Key_name = 'source';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
542 if ( is_array( $result ) ) {
543 return;
544 }
545
546 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_email_log ADD INDEX `source` (`source`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
547 }
548
549 /**
550 * Add index to defender_audit_log.
551 *
552 * @param wpdb $wpdb WordPress Database object.
553 *
554 * @return void
555 * @since 2.4.7
556 */
557 private function add_index_to_defender_audit_log( $wpdb ) {
558 // Check index already exists or not.
559 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_audit_log WHERE Key_name = 'event_type';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
560 if ( is_array( $result ) ) {
561 return;
562 }
563
564 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_audit_log ADD INDEX `event_type` (`event_type`), ADD INDEX `action_type` (`action_type`), ADD INDEX `user_id` (`user_id`), ADD INDEX `context` (`context`), ADD INDEX `ip` (`ip`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
565 }
566
567 /**
568 * Add index to defender_scan_item.
569 *
570 * @param wpdb $wpdb WordPress Database object.
571 *
572 * @return void
573 * @since 2.4.7
574 */
575 private function add_index_to_defender_scan_item( $wpdb ) {
576 // Check index already exists or not.
577 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_scan_item WHERE Key_name = 'type';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
578 if ( is_array( $result ) ) {
579 return;
580 }
581
582 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_scan_item ADD INDEX `type` (`type`), ADD INDEX `status` (`status`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
583 }
584
585 /**
586 * Add index to defender_lockout_log.
587 *
588 * @param wpdb $wpdb WordPress Database object.
589 *
590 * @return void
591 * @since 2.4.7
592 */
593 private function add_index_to_defender_lockout_log( $wpdb ) {
594 // Check index already exists or not.
595 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_lockout_log WHERE Key_name = 'ip';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
596 if ( is_array( $result ) ) {
597 return;
598 }
599
600 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_lockout_log ADD INDEX `ip` (`ip`), ADD INDEX `type` (`type`), ADD INDEX `tried` (`tried`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
601 }
602
603 /**
604 * Add index to defender_lockout.
605 *
606 * @param wpdb $wpdb WordPress Database object.
607 *
608 * @return void
609 * @since 2.4.7
610 */
611 private function add_index_to_defender_lockout( $wpdb ) {
612 // Check index already exists or not.
613 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_lockout WHERE Key_name = 'ip';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
614 if ( is_array( $result ) ) {
615 return;
616 }
617
618 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_lockout ADD INDEX `ip` (`ip`), ADD INDEX `status` (`status`), ADD INDEX `attempt` (`attempt`), ADD INDEX `attempt_404` (`attempt_404`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
619 }
620
621 /**
622 * Upgrade to 2.4.10.
623 *
624 * @return void
625 * @since 2.4.10
626 */
627 private function upgrade_2_4_10(): void {
628 $service = wd_di()->get( Backup_Settings::class );
629 $configs = Config_Hub_Helper::get_configs( $service );
630 $deprecated_keys = array(
631 // Reason: updated or removed some tweak slugs.
632 'security_key',
633 'wp-rest-api',
634 // Reason: moved the security headers to a separate module.
635 'sh-referrer-policy',
636 'sh-strict-transport',
637 'sh-xframe',
638 'sh-content-security',
639 'sh-content-type-options',
640 'sh-feature-policy',
641 'sh-xss-protection',
642 );
643 foreach ( $configs as $key => $config ) {
644 $is_updated = false;
645 // Remove deprecated 'data' key inside Security tweaks.
646 if ( isset( $config['configs']['security_tweaks']['data'] ) ) {
647 unset( $configs[ $key ]['configs']['security_tweaks']['data'] );
648 $is_updated = true;
649 }
650 // Remove deprecated keys in 'issues'.
651 if ( isset( $config['configs']['security_tweaks']['issues'] ) ) {
652 foreach ( $config['configs']['security_tweaks']['issues'] as $iss_key => $issue ) {
653 if ( in_array( $issue, $deprecated_keys, true ) ) {
654 unset( $configs[ $key ]['configs']['security_tweaks']['issues'][ $iss_key ] );
655 $is_updated = true;
656 }
657 }
658 }
659 // In 'ignore'.
660 if ( isset( $config['configs']['security_tweaks']['ignore'] ) ) {
661 foreach ( $config['configs']['security_tweaks']['ignore'] as $ign_key => $issue ) {
662 if ( in_array( $issue, $deprecated_keys, true ) ) {
663 unset( $configs[ $key ]['configs']['security_tweaks']['ignore'][ $ign_key ] );
664 $is_updated = true;
665 }
666 }
667 }
668 // In 'fixed'.
669 if ( isset( $config['configs']['security_tweaks']['fixed'] ) ) {
670 foreach ( $config['configs']['security_tweaks']['fixed'] as $fix_key => $issue ) {
671 if ( in_array( $issue, $deprecated_keys, true ) ) {
672 unset( $configs[ $key ]['configs']['security_tweaks']['fixed'][ $fix_key ] );
673 $is_updated = true;
674 }
675 }
676 }
677
678 if ( $is_updated ) {
679 update_site_option( $key, $configs[ $key ] );
680 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
681 if ( isset( $config['is_active'] ) ) {
682 $model = new Model\Setting\Security_Tweaks();
683 $model->issues = $configs[ $key ]['configs']['security_tweaks']['issues'];
684 $model->ignore = $configs[ $key ]['configs']['security_tweaks']['ignore'];
685 $model->fixed = $configs[ $key ]['configs']['security_tweaks']['fixed'];
686 $model->save();
687 }
688 }
689 }
690 }
691
692 /**
693 * Upgrade to 2.5.0.
694 *
695 * @return void
696 * @since 2.5.0
697 */
698 private function upgrade_2_5_0(): void {
699 $model = wd_di()->get( Security_Headers::class );
700 // Directive ALLOW-FROM is deprecated. If header directive is ALLOW-FROM then set 'sameorigin'.
701 if ( isset( $model->sh_xframe_mode ) && 'allow-from' === $model->sh_xframe_mode ) {
702 $model->sh_xframe_mode = 'sameorigin';
703 $model->save();
704 }
705 /**
706 * Uncheck 'File change detection' option if there was checked only child 'Scan theme files' option and save
707 * settings. Also remove items for 'Scan theme files' without run Scan.
708 */
709 $scan_settings = new Scan_Settings();
710 if (
711 $scan_settings->integrity_check
712 && ! $scan_settings->check_core
713 && ! $scan_settings->check_plugins
714 ) {
715 $scan_settings->integrity_check = false;
716 $scan_settings->save();
717 }
718 }
719
720 /**
721 * Forces the addition of default lockout exclusions to the Notfound Lockout settings.
722 *
723 * @return void
724 */
725 private function force_nf_lockout_exclusions(): void {
726 $nf_settings = new Notfound_Lockout();
727 $allowlist = $nf_settings->get_lockout_list( 'allowlist' );
728 $default_allowlist = array( '/cdn-cgi/challenge-platform/' );
729 $is_save = false;
730 if ( array() !== $allowlist ) {
731 foreach ( $default_allowlist as $item ) {
732 if ( ! in_array( $item, $allowlist, true ) ) {
733 $allowlist[] = $item;
734 $is_save = true;
735 }
736 }
737 $nf_settings->whitelist = implode( "\n", $allowlist );
738 } else {
739 $nf_settings->whitelist = ".css\n.js\n.map\n/cdn-cgi/challenge-platform/";
740 $is_save = true;
741 }
742 // Save it.
743 if ( $is_save ) {
744 $nf_settings->save();
745 }
746 }
747
748 /**
749 * Upgrade to 2.5.6.
750 *
751 * @return void
752 * @since 2.5.6
753 */
754 private function upgrade_2_5_6(): void {
755 $adapted_component = wd_di()->get( Legacy_Versions::class );
756 $issue_list = $adapted_component->get_scan_issue_data();
757 $ignored_list = $adapted_component->get_scan_ignored_data();
758 if (
759 ( is_array( $issue_list ) && array() !== $issue_list )
760 || ( is_array( $ignored_list ) && array() !== $ignored_list ) ) {
761 $adapted_component->migrate_scan_data( $issue_list, $ignored_list );
762 $adapted_component->remove_old_scan_data( $issue_list, $ignored_list );
763 $adapted_component->change_onboarding_status();
764 }
765
766 // Trigger recurring cron schedule for autogenerate security salt.
767 $security_tweaks = wd_di()->get( Security_Tweaks::class );
768 $security_tweaks->get_security_key()->cron_schedule();
769 // Add some lockout extension to old installations forced.
770 $this->force_nf_lockout_exclusions();
771 }
772
773 /**
774 * Updates the body of the scan error email template.
775 *
776 * @param mixed $model The model containing the email template configuration.
777 *
778 * @return void
779 */
780 private function update_scan_error_send_body( $model ): void {
781 $body = isset( $model->configs['template']['error']['body'] ) ? trim( $model->configs['template']['error']['body'] ) : '';
782
783 if ( '' !== $body ) {
784 $needle = '{follow this link} and check the logs to see what casued the failure';
785 if ( false !== stripos( $body, $needle ) ) {
786 $model->configs['template']['error']['body'] = str_replace(
787 $needle,
788 'visit your site and run a manual scan',
789 $body
790 );
791 $model->save();
792 }
793 }
794 }
795
796 /**
797 * Upgrade to 2.6.1.
798 *
799 * @return void
800 * @since 2.6.1
801 */
802 private function upgrade_2_6_1(): void {
803 // Update the title of the basic config.
804 $config_component = wd_di()->get( Backup_Settings::class );
805 $configs = $config_component->get_configs();
806 if ( array() !== $configs ) {
807 foreach ( $configs as $k => $config ) {
808 if ( 0 === strcmp( $config['name'], esc_html__( 'Basic config', 'defender-security' ) ) ) {
809 $config['name'] = esc_html__( 'Basic Config', 'defender-security' );
810
811 update_site_option( $k, $config );
812 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
813 break;
814 }
815 }
816 }
817 // Update scan emails for 'When failed to scan' option.
818 $malware_notification = wd_di()->get( Malware_Notification::class );
819 $this->update_scan_error_send_body( $malware_notification );
820 $malware_report = wd_di()->get( Malware_Report::class );
821 $this->update_scan_error_send_body( $malware_report );
822 }
823
824 /**
825 * Update 2FA email template.
826 *
827 * @return void
828 */
829 private function update_2fa_send_body(): void {
830 $model = wd_di()->get( Two_Fa_Settings::class );
831 if ( isset( $model->email_body ) ) {
832 $model->email_body = 'Hi {{display_name}},
833
834 Your temporary password is {{passcode}}. To finish logging in, copy and paste the temporary password into the Password field on the login screen.';
835 $model->save();
836 }
837 }
838
839 /**
840 * Update Malware Scan email template.
841 *
842 * @return void
843 * @since 2.7.0
844 */
845 private function update_malware_scan_send_body(): void {
846 $models = array(
847 wd_di()->get( Malware_Notification::class ),
848 wd_di()->get( Malware_Report::class ),
849 );
850
851 foreach ( $models as $model ) {
852 $is_updated = false;
853 $body = isset( $model->configs['template']['error']['body'] ) ? trim( $model->configs['template']['error']['body'] ) : '';
854 if ( '' !== $body ) {
855 $subject = $this->replace_scan_email_content_error( $body );
856
857 if ( $body !== $subject ) {
858 $is_updated = true;
859
860 $model->configs['template']['error']['body'] = $subject;
861 }
862 }
863
864 $body = isset( $model->configs['template']['found']['body'] ) ? trim( $model->configs['template']['found']['body'] ) : '';
865 if ( '' !== $body ) {
866 $subject = $this->replace_scan_email_content_issue_found( $body );
867
868 if ( $body !== $subject ) {
869 $is_updated = true;
870
871 $model->configs['template']['found']['body'] = $subject;
872 }
873 }
874
875 $body = isset( $model->configs['template']['not_found']['body'] ) ? trim( $model->configs['template']['not_found']['body'] ) : '';
876 if ( '' !== $body ) {
877 $subject = $this->replace_scan_email_content_issue_not_found( $body );
878
879 if ( $body !== $subject ) {
880 $is_updated = true;
881
882 $model->configs['template']['not_found']['body'] = $subject;
883 }
884 }
885
886 if ( true === $is_updated ) {
887 $model->save();
888 }
889 }
890 }
891
892 /**
893 * Update Malware Scan email template common parts.
894 *
895 * @param string $subject Email subject.
896 *
897 * @return string
898 * @since 2.7.0
899 */
900 private function update_malware_scan_send_body_common( $subject ): string {
901 $subject = preg_replace( '/(\R+)WP Defender here, reporting back from the front\./i', '', $subject );
902
903 return preg_replace( '/(\R+)Stay Safe,(\R+)WP Defender(\R+)WPMU DEV Superhero(\s*)$/i', '', $subject );
904 }
905
906 /**
907 * Upgrade to 2.7.0.
908 *
909 * @return void
910 * @since 2.7.0
911 */
912 private function upgrade_2_7_0(): void {
913 $malware_report = wd_di()->get( Malware_Report::class );
914 $scan_settings = wd_di()->get( Scan_Settings::class );
915 // Migrate data from Malware_Report to Scan settings.
916 $scan_settings->scheduled_scanning = Notification::STATUS_ACTIVE === $malware_report->status;
917 $scan_settings->frequency = $malware_report->frequency;
918 $scan_settings->day = $malware_report->day;
919 $scan_settings->day_n = (int) $malware_report->day_n;
920 $scan_settings->time = $malware_report->time;
921 $scan_settings->save();
922
923 $this->update_2fa_send_body();
924 $this->update_malware_scan_send_body();
925 }
926
927 /**
928 * Update in_house recipients empty role.
929 *
930 * @return void
931 * @since 2.8.0
932 */
933 private function update_in_house_recipients_empty_role(): void {
934 $models = array(
935 wd_di()->get( Audit_Report::class ),
936 wd_di()->get( Firewall_Notification::class ),
937 wd_di()->get( Firewall_Report::class ),
938 wd_di()->get( Malware_Notification::class ),
939 wd_di()->get( Malware_Report::class ),
940 wd_di()->get( Tweak_Reminder::class ),
941 );
942
943 foreach ( $models as $model ) {
944 if ( is_array( $model->in_house_recipients ) && array() !== $model->in_house_recipients ) {
945 $is_updated = false;
946
947 foreach ( $model->in_house_recipients as &$recipient ) {
948 if ( ! isset( $recipient['role'] ) || ! is_string( $recipient['role'] ) || '' === trim( $recipient['role'] ) ) {
949 $is_updated = true;
950 $recipient['role'] = $this->get_current_user_role( $recipient['id'] );
951 }
952 }
953
954 if ( true === $is_updated ) {
955 $model->save();
956 }
957 }
958 }
959 }
960
961 /**
962 * Delete temp geolite file from /wp-admin directory.
963 *
964 * @return void
965 * @since 2.8.0
966 */
967 private function delete_tmp_geolite_file(): void {
968 $pattern = ABSPATH . 'wp-admin/geolite2-country*.tar.gz';
969 array_map( 'wp_delete_file', glob( $pattern ) );
970 }
971
972 /**
973 * Migrate old keys to checked options.
974 *
975 * @return void
976 * @since 2.8.0
977 */
978 private function update_2fa_methods(): void {
979 $settings = wd_di()->get( Two_Fa_Settings::class );
980 if ( $settings->enabled ) {
981 $service = wd_di()->get( Two_Fa_Component::class );
982 $query = new WP_User_Query(
983 array(
984 'blog_id' => 0,
985 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
986 array(
987 'key' => Totp::TOTP_AUTH_KEY,
988 'value' => true,
989 ),
990 ),
991 'fields' => 'ID',
992 )
993 );
994 if ( $query->get_total() > 0 ) {
995 // The data is independent of the user's data.
996 $providers = $service->get_providers();
997 $totp_slug = Totp::$slug;
998 // Add TOTP slug.
999 $enabled_providers = array( $totp_slug );
1000 // If 'Enable lost phone' option is checked then we add the slug of the Email provider too.
1001 if ( true === $settings->lost_phone ) {
1002 $enabled_providers[] = Fallback_Email::$slug;
1003 }
1004 // Add slugs to the list of the enabled providers.
1005 $enabled_providers = array_intersect( $enabled_providers, array_keys( $providers ) );
1006 foreach ( $query->get_results() as $user_id ) {
1007 // If 2FA module is configured for the current user.
1008 if ( $service->is_enabled_otp_for_user( $user_id ) ) {
1009 update_user_meta( $user_id, Two_Fa_Component::ENABLED_PROVIDERS_USER_KEY, $enabled_providers );
1010 // Set TOTP as the default auth provider.
1011 update_user_meta( $user_id, Two_Fa_Component::DEFAULT_PROVIDER_USER_KEY, $totp_slug );
1012 }
1013 }
1014 }
1015 }
1016 }
1017
1018 /**
1019 * Upgrade to 2.8.0.
1020 *
1021 * @return void
1022 * @since 2.8.0
1023 */
1024 private function upgrade_2_8_0(): void {
1025 $this->update_in_house_recipients_empty_role();
1026 $this->delete_tmp_geolite_file();
1027 $this->update_2fa_methods();
1028 }
1029
1030 /**
1031 * Add column country_iso_code and index to table.
1032 *
1033 * @return void
1034 * @since 2.8.1
1035 */
1036 private function add_country_iso_code_column(): void {
1037 if ( ! function_exists( 'maybe_add_column' ) ) {
1038 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1039 }
1040
1041 global $wpdb;
1042
1043 $table_name = $wpdb->base_prefix . 'defender_lockout_log';
1044 $column_name = 'country_iso_code';
1045 $create_ddl = "ALTER TABLE {$table_name} ADD {$column_name} CHAR(2) DEFAULT NULL";
1046
1047 maybe_add_column( $table_name, $column_name, $create_ddl );
1048
1049 $prev_val = $wpdb->hide_errors();
1050 $wpdb->query( "CREATE INDEX `country_iso_code` ON {$wpdb->base_prefix}defender_lockout_log (`country_iso_code`)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1051 $wpdb->show_errors( $prev_val );
1052 }
1053
1054 /**
1055 * Upgrade to 2.8.3.
1056 *
1057 * @return void
1058 * @since 2.8.3
1059 */
1060 private function upgrade_2_8_3(): void {
1061 $this->add_country_iso_code_column();
1062 }
1063
1064 /**
1065 * Upgrade to 3.2.0.
1066 *
1067 * @return void
1068 * @since 3.2.0
1069 */
1070 private function upgrade_3_2_0(): void {
1071 $model = wd_di()->get( Two_Fa_Settings::class );
1072
1073 if ( is_string( $model->app_title ) && '' !== $model->app_title ) {
1074 $model->app_title = wp_specialchars_decode( $model->app_title, ENT_QUOTES );
1075 }
1076
1077 $model->custom_graphic_type = Two_Fa_Settings::CUSTOM_GRAPHIC_TYPE_UPLOAD;
1078 $model->save();
1079 }
1080
1081 /**
1082 * Upgrade to 3.3.0.
1083 *
1084 * @return void
1085 * @since 3.3.0
1086 */
1087 private function upgrade_3_3_0(): void {
1088 $this->update_webauthn_user_handle();
1089 $this->add_ua_lockout_to_firewall_notification();
1090 }
1091
1092 /**
1093 * Update webauthn 'userHandle' to make it independent of salt keys.
1094 *
1095 * @return void
1096 * @since 3.3.0
1097 */
1098 private function update_webauthn_user_handle(): void {
1099 global $wpdb;
1100 $service = wd_di()->get( Webauthn::class );
1101
1102 if ( is_multisite() ) {
1103 $offset = 0;
1104 $limit = 100;
1105 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1106 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1107 ARRAY_A
1108 );
1109 while ( is_array( $blogs ) && array() !== $blogs ) {
1110 foreach ( $blogs as $blog ) {
1111 switch_to_blog( $blog['blog_id'] );
1112
1113 $this->update_webauthn_user_handle_core( $service );
1114
1115 restore_current_blog();
1116 }
1117 $offset += $limit;
1118 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1119 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1120 ARRAY_A
1121 );
1122 }
1123 } else {
1124 $this->update_webauthn_user_handle_core( $service );
1125 }
1126 }
1127
1128 /**
1129 * Core method for updating webauthn 'userHandle' to make it independent of salt keys.
1130 *
1131 * @param object $service Webauthn service instance.
1132 *
1133 * @return void
1134 * @since 3.3.0
1135 */
1136 private function update_webauthn_user_handle_core( object $service ) {
1137 $data = get_option( $this->option_prefix . $service::CREDENTIAL_OPTION_KEY );
1138 $data = is_string( $data ) ? json_decode( $data, true ) : array();
1139 delete_option( $this->option_prefix . $service::CREDENTIAL_OPTION_KEY );
1140 if ( ! is_array( $data ) || 0 === count( $data ) ) {
1141 return;
1142 }
1143
1144 $users = get_users( array( 'fields' => array( 'ID', 'user_login', 'display_name' ) ) );
1145
1146 foreach ( $users as $user ) {
1147 if ( ! is_array( $data ) || array() === $data ) {
1148 break;
1149 }
1150
1151 $user_credentials = array();
1152 foreach ( $data as $key => $item ) {
1153 $old_hash = hash( 'sha256', $user->user_login . '-' . $user->display_name . '-' . AUTH_SALT );
1154 // This is not obfuscation. Just encode the hashed value into a base64 string.
1155 $old_base64_hash = base64_encode( $old_hash ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1156 $old_base64_hash = preg_replace( '/\=+$/', '', $old_base64_hash );
1157
1158 if (
1159 isset( $item['credential_source']['userHandle'] ) &&
1160 $item['credential_source']['userHandle'] === $old_base64_hash
1161 ) {
1162 $new_hash = $this->get_user_hash( $user->user_login );
1163 // This is not obfuscation. Just encode the hashed value into a base64 string.
1164 $new_base64_hash = base64_encode( $new_hash ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1165 $new_base64_hash = preg_replace( '/\=+$/', '', $new_base64_hash );
1166 $item['user'] = $new_hash;
1167 $item['credential_source']['userHandle'] = $new_base64_hash;
1168 $user_credentials[ $key ] = $item;
1169 unset( $data[ $key ] );
1170 }
1171 }
1172
1173 if ( array() !== $user_credentials ) {
1174 $service->setCredentials( (int) $user->ID, $user_credentials );
1175 }
1176 }
1177 }
1178
1179 /**
1180 * Add the ability to send notifications about UA-lockout.
1181 *
1182 * @return void
1183 */
1184 private function add_ua_lockout_to_firewall_notification(): void {
1185 $model = wd_di()->get( Firewall_Notification::class );
1186 if ( ! isset( $model->configs['ua_lockout'] ) ) {
1187 $model->configs['ua_lockout'] = false;
1188 $model->save();
1189 }
1190 }
1191
1192 /**
1193 * Upgrade to 3.3.1: extra security steps.
1194 *
1195 * @return void
1196 */
1197 private function upgrade_3_3_1(): void {
1198 // Update tokens.
1199 $query = new WP_User_Query(
1200 array(
1201 'blog_id' => 0,
1202 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1203 array(
1204 'key' => Two_Fa_Component::TOKEN_USER_KEY,
1205 'compare' => 'EXISTS',
1206 ),
1207 ),
1208 'fields' => 'ID',
1209 )
1210 );
1211 if ( $query->get_total() > 0 ) {
1212 // Hashed tokens.
1213 foreach ( $query->get_results() as $user_id ) {
1214 $token = bin2hex( Crypt::random_bytes( 32 ) );
1215 update_user_meta( $user_id, Two_Fa_Component::TOKEN_USER_KEY, wp_hash( $user_id . $token ) );
1216 }
1217 }
1218 // Update fallback codes.
1219 $query = new WP_User_Query(
1220 array(
1221 'blog_id' => 0,
1222 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1223 array(
1224 'key' => Fallback_Email::FALLBACK_BACKUP_CODE_KEY,
1225 'compare' => 'EXISTS',
1226 ),
1227 ),
1228 'fields' => 'ID',
1229 )
1230 );
1231 if ( $query->get_total() > 0 ) {
1232 // Hashed codes.
1233 foreach ( $query->get_results() as $user_id ) {
1234 $backup_code = get_user_meta( $user_id, Fallback_Email::FALLBACK_BACKUP_CODE_KEY, true );
1235 if ( isset( $backup_code['code'], $backup_code['time'] ) ) {
1236 update_user_meta(
1237 $user_id,
1238 Fallback_Email::FALLBACK_BACKUP_CODE_KEY,
1239 array(
1240 'code' => wp_hash( $backup_code['code'] ),
1241 'time' => $backup_code['time'],
1242 )
1243 );
1244 }
1245 }
1246 }
1247 }
1248
1249 /**
1250 * Upgrade to 3.3.3: update 2FA flow for secret keys.
1251 *
1252 * @return void
1253 * @throws SodiumException If the encryption fails.
1254 * @since 3.3.3
1255 */
1256 private function upgrade_3_3_3(): void {
1257 // Create a file with a random key if it doesn't exist.
1258 if ( ( new Crypt() )->create_key_file() ) {
1259 // Get user data.
1260 $query = new WP_User_Query(
1261 array(
1262 'blog_id' => 0,
1263 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1264 array(
1265 'key' => Totp::TOTP_SODIUM_SECRET_KEY,
1266 'compare' => 'EXISTS',
1267 ),
1268 ),
1269 'fields' => 'ID',
1270 )
1271 );
1272 if ( $query->get_total() > 0 ) {
1273 $service = wd_di()->get( Two_Fa_Component::class );
1274 foreach ( $query->get_results() as $user_id ) {
1275 // If we have the old states (cleartext, pub key) then re-encrypt via Sodium and save it.
1276 if ( ! $service->maybe_update( $user_id ) ) {
1277 // Otherwise just encrypt it via Sodium.
1278 $plaintext = defender_generate_random_string( Totp::TOTP_LENGTH, TOTP::TOTP_CHARACTERS );
1279 $new_key = Crypt::get_encrypted_data( $plaintext );
1280 if ( ! is_wp_error( $new_key ) ) {
1281 // Remove an old key.
1282 delete_user_meta( $user_id, Totp::TOTP_SECRET_KEY );
1283 // Update a new one.
1284 update_user_meta( $user_id, Totp::TOTP_SODIUM_SECRET_KEY, $new_key );
1285 }
1286 }
1287 }
1288 }
1289 }
1290 }
1291
1292 /**
1293 * Upgrade to 3.5.0: Delete config transient.
1294 *
1295 * @return void
1296 */
1297 private function upgrade_3_5_0(): void {
1298 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1299 }
1300
1301 /**
1302 * Upgrade to 3.8.0: Remove permanently banned ip record from `defender_lockout` table.
1303 *
1304 * @return void
1305 * @since 3.8.0
1306 */
1307 private function upgrade_3_8_0(): void {
1308 global $wpdb;
1309
1310 $blacklist_settings = new Model_Blacklist_Lockout();
1311 $blacklist = $blacklist_settings->get_list();
1312
1313 $offset = 0;
1314 $length = 100;
1315 $table = $wpdb->base_prefix . 'defender_lockout';
1316 // Variable within condition is for comparison.
1317 while ( $blacklist_chunk = array_slice( $blacklist, $offset, $length ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
1318 $sql = "DELETE FROM {$table} WHERE status = 'blocked' AND ip IN (" . implode( ', ', array_fill( 0, count( $blacklist_chunk ), '%s' ) ) . ')';
1319
1320 $query = call_user_func_array(
1321 array( $wpdb, 'prepare' ),
1322 array_merge( array( $sql ), $blacklist_chunk )
1323 );
1324 // SQL is prepared here. So we don't need to prepare it again.
1325 $wpdb->query( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
1326
1327 $offset += $length;
1328 }
1329
1330 $this->update_malware_notification_config();
1331 $this->update_malware_notification_setting();
1332 }
1333
1334 /**
1335 * Update malware notification config.
1336 *
1337 * @return void
1338 * @since 3.8.0
1339 */
1340 private function update_malware_notification_config(): void {
1341 $service = wd_di()->get( Backup_Settings::class );
1342 $configs = Config_Hub_Helper::get_configs( $service );
1343
1344 foreach ( $configs as $key => $config ) {
1345 $is_updated = false;
1346
1347 if ( isset( $config['configs']['scan']['email_subject_issue_found'] ) ) {
1348 $subject = $this->replace_scan_email_subject_issue_found( $config['configs']['scan']['email_subject_issue_found'] );
1349 $configs[ $key ]['configs']['scan']['email_subject_issue_found'] = $subject;
1350 $is_updated = true;
1351 }
1352 if ( isset( $config['configs']['scan']['email_content_issue_found'] ) ) {
1353 $content = $this->replace_scan_email_content_issue_found( $config['configs']['scan']['email_content_issue_found'] );
1354 $content = $this->replace_scan_email_content_issue_found_new( $content );
1355 $configs[ $key ]['configs']['scan']['email_content_issue_found'] = $content;
1356 $is_updated = true;
1357 }
1358
1359 if ( isset( $config['configs']['scan']['email_content_issue_not_found'] ) ) {
1360 $content = $this->replace_scan_email_content_issue_not_found( $config['configs']['scan']['email_content_issue_not_found'] );
1361 $configs[ $key ]['configs']['scan']['email_content_issue_not_found'] = $content;
1362 $is_updated = true;
1363 }
1364
1365 if ( isset( $config['configs']['scan']['email_content_error'] ) ) {
1366 $content = $this->replace_scan_email_content_error( $config['configs']['scan']['email_content_error'] );
1367 $configs[ $key ]['configs']['scan']['email_content_error'] = $content;
1368 $is_updated = true;
1369 }
1370
1371 if ( true === $is_updated ) {
1372 update_site_option( $key, $configs[ $key ] );
1373 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
1374 }
1375 }
1376
1377 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1378 }
1379
1380 /**
1381 * Update malware notification setting.
1382 *
1383 * @return void
1384 * @since 3.8.0
1385 */
1386 private function update_malware_notification_setting(): void {
1387 $models = array(
1388 wd_di()->get( Malware_Notification::class ),
1389 );
1390
1391 foreach ( $models as $model ) {
1392 $is_updated = false;
1393
1394 if ( isset( $model->configs['template']['found']['subject'] ) ) {
1395 $subject = $this->replace_scan_email_subject_issue_found( $model->configs['template']['found']['subject'] );
1396 $model->configs['template']['found']['subject'] = $subject;
1397 $is_updated = true;
1398 }
1399 if ( isset( $model->configs['template']['found']['body'] ) ) {
1400 $content = $this->replace_scan_email_content_issue_found( $model->configs['template']['found']['body'] );
1401 $content = $this->replace_scan_email_content_issue_found_new( $content );
1402 $model->configs['template']['found']['body'] = $content;
1403 $is_updated = true;
1404 }
1405
1406 if ( isset( $model->configs['template']['not_found']['body'] ) ) {
1407 $content = $this->replace_scan_email_content_issue_not_found( $model->configs['template']['not_found']['body'] );
1408 $model->configs['template']['not_found']['body'] = $content;
1409 $is_updated = true;
1410 }
1411
1412 if ( isset( $model->configs['template']['error']['body'] ) ) {
1413 $content = $this->replace_scan_email_content_error( $model->configs['template']['error']['body'] );
1414 $model->configs['template']['error']['body'] = $content;
1415 $is_updated = true;
1416 }
1417
1418 if ( true === $is_updated ) {
1419 $model->save();
1420 }
1421 }
1422 }
1423
1424 /**
1425 * Replace scan issue found email subject.
1426 *
1427 * @param string $subject Email subject.
1428 *
1429 * @return string
1430 * @since 3.8.0
1431 */
1432 private function replace_scan_email_subject_issue_found( string $subject ): string {
1433 $subject = str_replace(
1434 esc_html__( 'Scan of {SITE_URL} complete.', 'defender-security' ),
1435 esc_html__( 'Malware Scan of {SITE_URL} is complete.', 'defender-security' ),
1436 $subject
1437 );
1438
1439 return str_replace(
1440 esc_html__( '{ISSUES_COUNT} issues found.', 'defender-security' ),
1441 esc_html__( '{ISSUES_COUNT} issue(s) found.', 'defender-security' ),
1442 $subject
1443 );
1444 }
1445
1446 /**
1447 * Replace scan issue found email content.
1448 *
1449 * @param string $content Email content.
1450 *
1451 * @return string
1452 * @since 3.8.0
1453 */
1454 private function replace_scan_email_content_issue_found( string $content ): string {
1455 $content = $this->update_malware_scan_send_body_common( $content );
1456 $content = preg_replace(
1457 "/I(’|'|\\\')ve finished scanning \{SITE_URL\} for vulnerabilities and I found \{ISSUES_COUNT\} issues that you should take a closer look at!/i",
1458 '{ISSUES_COUNT} vulnerabilities were identified for {SITE_URL} during a Malware Scan. See details for each issue below.',
1459 $content
1460 );
1461
1462 return preg_replace( '/(\R+){ISSUES_LIST}/i', "\n\n{ISSUES_LIST}", $content );
1463 }
1464
1465 /**
1466 * Replace scan issue found email with new content.
1467 *
1468 * @param string $content Email content.
1469 *
1470 * @return string
1471 * @since 3.8.0
1472 */
1473 private function replace_scan_email_content_issue_found_new( string $content ): string {
1474 $content = str_replace(
1475 esc_html__( '{ISSUES_COUNT} vulnerabilities were identified for {SITE_URL} during a Malware Scan.', 'defender-security' ),
1476 esc_html__( 'Malware Scan identified {ISSUES_COUNT} issue(s) on {SITE_URL}.', 'defender-security' ),
1477 $content
1478 );
1479
1480 return str_replace(
1481 esc_html__( 'See details for each issue below.', 'defender-security' ),
1482 esc_html__( 'The identified issue(s) is/are listed below.', 'defender-security' ),
1483 $content
1484 );
1485 }
1486
1487 /**
1488 * Replace scan issue not found email content.
1489 *
1490 * @param string $content Email content.
1491 *
1492 * @return string
1493 * @since 3.8.0
1494 */
1495 private function replace_scan_email_content_issue_not_found( string $content ): string {
1496 $content = $this->update_malware_scan_send_body_common( $content );
1497 $content = preg_replace(
1498 "/I(’|'|\\\')ve finished scanning \{SITE_URL\} for vulnerabilities and I found nothing\. Well done for running such a tight ship!/i",
1499 'No vulnerabilities have been found for {SITE_URL}.',
1500 $content
1501 );
1502
1503 return preg_replace(
1504 "/(\R+)Keep up the good work! With regular security scans and a well-hardened installation you\'ll be just fine\./i",
1505 '',
1506 $content
1507 );
1508 }
1509
1510 /**
1511 * Replace scan error email content.
1512 *
1513 * @param string $content Email content.
1514 *
1515 * @return string
1516 * @since 3.8.0
1517 */
1518 private function replace_scan_email_content_error( string $content ): string {
1519 $content = $this->update_malware_scan_send_body_common( $content );
1520
1521 return str_replace( 'I couldn', 'We couldn', $content );
1522 }
1523
1524 /**
1525 * Upgrade to 3.8.2: Check XSS in existing configs.
1526 *
1527 * @return void
1528 */
1529 private function upgrade_3_8_2(): void {
1530 $service = wd_di()->get( Backup_Settings::class );
1531 $configs = Config_Hub_Helper::get_configs( $service );
1532
1533 foreach ( $configs as $key => $config ) {
1534 $is_updated = false;
1535
1536 if ( isset( $config['name'] ) ) {
1537 $prev_name = $config['name'];
1538 // Sanitize data.
1539 $new_name = sanitize_text_field( $config['name'] );
1540 if ( $prev_name !== $new_name ) {
1541 $is_updated = true;
1542 }
1543 $prev_desc = $config['description'];
1544 $new_desc = ! is_string( $config['description'] ) || '' === trim( $config['description'] ) ? '' : sanitize_textarea_field( $config['description'] );
1545 if ( $prev_desc !== $new_desc ) {
1546 $is_updated = true;
1547 }
1548 }
1549
1550 if ( true === $is_updated ) {
1551 update_site_option( $key, $configs[ $key ] );
1552 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
1553 }
1554 }
1555
1556 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1557 }
1558
1559 /**
1560 * Upgrade to 3.9.0.
1561 *
1562 * @return void
1563 */
1564 private function upgrade_3_9_0(): void {
1565 global $wpdb;
1566
1567 $prev_val = $wpdb->hide_errors();
1568 // Changes for Lockout table.
1569 $wpdb->query( "ALTER TABLE{$wpdb->base_prefix}defender_lockout MODIFY COLUMN ip VARCHAR(45)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1570 // Changes for Lockout log table.
1571 $wpdb->query( "ALTER TABLE{$wpdb->base_prefix}defender_lockout_log MODIFY COLUMN ip VARCHAR(45)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1572 $wpdb->show_errors( $prev_val );
1573 }
1574
1575 /**
1576 * Upgrade to 3.11.0.
1577 *
1578 * @return void
1579 */
1580 private function upgrade_3_11_0(): void {
1581 // Move Global IP settings.
1582 $option = get_site_option( wd_di()->get( Model_Blacklist_Lockout::class )->get_table() );
1583 if ( is_string( $option ) && '' !== trim( $option ) ) {
1584 $old_settings = json_decode( $option, true );
1585 if ( is_array( $old_settings ) && isset( $old_settings['global_ip_list'] ) ) {
1586 $model_global_ip = wd_di()->get( Global_Ip_Lockout::class );
1587 $model_global_ip->enabled = (bool) $old_settings['global_ip_list'];
1588 $model_global_ip->save();
1589 }
1590 }
1591 }
1592
1593 /**
1594 * Upgrade to 3.12.0.
1595 *
1596 * @return void
1597 */
1598 private function upgrade_3_12_0(): void {
1599 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1600
1601 $service = wd_di()->get( Backup_Settings::class );
1602 $configs = Config_Hub_Helper::get_configs( $service );
1603
1604 foreach ( $configs as $key => &$config ) {
1605 if ( ! is_array( $config ) ) {
1606 continue;
1607 }
1608
1609 $config['is_removable'] = $config['is_removable'] ?? true;
1610
1611 // Update config data.
1612 update_site_option( $key, $config );
1613 }
1614
1615 set_site_transient(
1616 Config_Hub_Helper::CONFIGS_TRANSIENT_KEY,
1617 $configs,
1618 Config_Hub_Helper::CONFIGS_TRANSIENT_TIME
1619 );
1620 update_site_option( Config_Hub_Helper::CONFIGS_TRANSIENT_TIME_KEY, time() );
1621
1622 // Clear Global IP schedule hook to update event frequency.
1623 wp_clear_scheduled_hook( 'wpdef_fetch_global_ip_list' );
1624 }
1625
1626 /**
1627 * Upgrade to 4.0.0: Creates quarantine table.
1628 *
1629 * @return void
1630 */
1631 private function upgrade_4_0_0(): void {
1632 $bootstrap = wd_di()->get( Bootstrap::class );
1633 $bootstrap->create_table_quarantine();
1634 }
1635
1636 /**
1637 * Upgrade to 4.1.0.
1638 *
1639 * @return void
1640 */
1641 private function upgrade_4_1_0() {
1642 // Remove Maxmind DB directory from multisite subsites.
1643 if ( is_multisite() ) {
1644 global $wpdb, $wp_filesystem;
1645
1646 if ( is_null( $wp_filesystem ) ) {
1647 if ( ! function_exists( 'WP_Filesystem' ) ) {
1648 require_once ABSPATH . 'wp-admin/includes/file.php';
1649 }
1650 WP_Filesystem();
1651 }
1652 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
1653 return;
1654 }
1655
1656 $main_site_id = get_main_site_id();
1657 $offset = 0;
1658 $limit = 100;
1659 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1660 $wpdb->prepare(
1661 "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id != %d LIMIT %d, %d",
1662 $main_site_id,
1663 $offset,
1664 $limit
1665 ),
1666 ARRAY_A
1667 );
1668 while ( is_array( $blogs ) && array() !== $blogs ) {
1669 foreach ( $blogs as $blog ) {
1670 switch_to_blog( $blog['blog_id'] );
1671
1672 $maxmind_dir = $this->get_tmp_path() . DIRECTORY_SEPARATOR . MaxMind_Geolocation::DB_DIRECTORY;
1673 $wp_filesystem->delete( $maxmind_dir, true );
1674
1675 restore_current_blog();
1676 }
1677 $offset += $limit;
1678 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1679 $wpdb->prepare(
1680 "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id != %d LIMIT %d, %d",
1681 $main_site_id,
1682 $offset,
1683 $limit
1684 ),
1685 ARRAY_A
1686 );
1687 }
1688 }
1689 }
1690
1691 /**
1692 * Upgrade to 4.2.0.
1693 *
1694 * @return void
1695 */
1696 private function upgrade_4_2_0(): void {
1697 // Add the Tracking modal.
1698 update_site_option( Data_Tracking::TRACKING_SLUG, true );
1699 }
1700
1701 /**
1702 * Upgrade to 4.5.1.
1703 *
1704 * @return void
1705 */
1706 private function upgrade_4_5_1(): void {
1707 $service = wd_di()->get( Firewall::class );
1708 $service->auto_switch_ip_detection_option();
1709 $service->maybe_show_misconfigured_ip_detection_option_notice();
1710 }
1711
1712 /**
1713 * Upgrade to 4.6.0.
1714 *
1715 * @return void
1716 */
1717 private function upgrade_4_6_0(): void {
1718 // Create Unlockout table.
1719 $bootstrap = wd_di()->get( Bootstrap::class );
1720 $bootstrap->create_table_unlockout();
1721 }
1722
1723 /**
1724 * Upgrade to 4.7.2.
1725 *
1726 * @return void
1727 */
1728 private function upgrade_4_7_2(): void {
1729 $model = wd_di()->get( Two_Fa_Settings::class );
1730
1731 if ( isset( $model->email_body ) ) {
1732 $model->email_body = 'Hi {{display_name}},
1733
1734 Your temporary password is {{passcode}}
1735 To complete your login, copy and paste the temporary password into the Password field on the login screen.';
1736 $model->save();
1737 }
1738 }
1739
1740 /**
1741 * Upgrade to 4.8.2.
1742 *
1743 * @return void
1744 */
1745 private function upgrade_4_8_2(): void {
1746 $xff = defender_get_data_from_request( 'HTTP_X_FORWARDED_FOR', 's' );
1747 if (
1748 ! ( is_string( $xff ) && 0 < strlen( $xff ) ) &&
1749 Firewall::is_switched_ip_detection_notice( Firewall::IP_DETECTION_XFF_SHOW_SLUG )
1750 ) {
1751 delete_site_option( Firewall::IP_DETECTION_XFF_SHOW_SLUG );
1752 }
1753 }
1754
1755 /**
1756 * Upgrade to 4.9.0.
1757 *
1758 * @return void
1759 */
1760 private function upgrade_4_9_0(): void {
1761 $model = wd_di()->get( Model_Firewall::class );
1762
1763 $model->ip_detection_type = 'manual';
1764 $model->save();
1765
1766 // Add tracking.
1767 $firewall_analytics = wd_di()->get( Firewall_Analytics::class );
1768 $detection_method = Firewall_Analytics::get_detection_method_label(
1769 $model->ip_detection_type,
1770 $model->http_ip_header
1771 );
1772
1773 $firewall_analytics->track_feature(
1774 Firewall_Analytics::EVENT_IP_DETECTION,
1775 array( Firewall_Analytics::PROP_IP_DETECTION => $detection_method )
1776 );
1777 }
1778
1779 /**
1780 * Upgrade.
1781 *
1782 * @return void
1783 */
1784 private function upgrade_5_0_0(): void {
1785 update_site_option( \WP_Defender\Component\IP\Antibot_Global_Firewall::NOTICE_SLUG, true );
1786 }
1787
1788 /**
1789 * Upgrade to 5.0.2: Clear the blocklist count. Also set the whitelist server public IP.
1790 *
1791 * @return void
1792 */
1793 private function upgrade_5_0_2(): void {
1794 delete_site_transient( 'wpdef_antibot_global_firewall_db_blocklist_count' );
1795 wd_di()->get( Firewall::class )->set_whitelist_server_public_ip();
1796 }
1797
1798 /**
1799 * Upgrade to 5.1.1: Migrate IP detection option to Automatic from Manual > All headers.
1800 *
1801 * @return void
1802 */
1803 private function upgrade_5_1_1(): void {
1804 $model = wd_di()->get( Model_Firewall::class );
1805 if ( 'manual' === $model->ip_detection_type && '' === $model->http_ip_header ) {
1806 $model->ip_detection_type = 'automatic';
1807 $model->http_ip_header = 'REMOTE_ADDR';
1808 $model->save();
1809 wd_di()->get( \WP_Defender\Component\Smart_Ip_Detection::class )->smart_ip_detection_ping();
1810 // Add tracking.
1811 $firewall_analytics = wd_di()->get( Firewall_Analytics::class );
1812 $detection_method = Firewall_Analytics::get_detection_method_label( 'automatic', '' );
1813
1814 $firewall_analytics->track_feature(
1815 Firewall_Analytics::EVENT_IP_DETECTION,
1816 array( Firewall_Analytics::PROP_IP_DETECTION => $detection_method )
1817 );
1818 }
1819 }
1820 /**
1821 * Update UA blocklist.
1822 *
1823 * @return void
1824 */
1825 private function update_ua_blocklist(): void {
1826 $settings = wd_di()->get( User_Agent_Lockout::class );
1827 $blacklist = $settings->get_lockout_list( 'blocklist', false );
1828 if ( array() === $blacklist ) {
1829 return;
1830 }
1831 $blacklist = array_filter(
1832 $blacklist,
1833 function ( $agent ) {
1834 return false === stripos( $agent, 'ahrefsbot' ) && false === stripos( $agent, 'semrushbot' );
1835 }
1836 );
1837 $settings->blacklist = implode( "\n", $blacklist ); // Convert back to string.
1838 $settings->save();
1839 }
1840
1841 /**
1842 * Upgrade to 5.2.0.
1843 *
1844 * @return void
1845 */
1846 private function upgrade_5_2_0(): void {
1847 $this->update_ua_blocklist();
1848 // Add the "What's new" modal.
1849 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1850 }
1851
1852 /**
1853 * Upgrade to 5.3.0.
1854 *
1855 * @return void
1856 */
1857 private function upgrade_5_3_0(): void {
1858 // Add the "What's new" modal.
1859 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1860 // Add composite index to the defender_lockout table.
1861 global $wpdb;
1862 // Check if the index already exists.
1863 $wpdb->query( "SHOW INDEX FROM {$wpdb->base_prefix}defender_lockout_log WHERE Key_name = 'idx_ip_date'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1864 if ( 0 === $wpdb->num_rows ) {
1865 // If the index does not exist, create it.
1866 $prev_val = $wpdb->hide_errors();
1867 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_lockout_log ADD INDEX idx_ip_date (ip, date DESC);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1868 $wpdb->show_errors( $prev_val );
1869 }
1870 }
1871
1872 /**
1873 * Upgrade to 5.3.1.
1874 *
1875 * @return void
1876 */
1877 private function upgrade_5_3_1(): void {
1878 delete_site_transient( \WP_Defender\Component\IP\Antibot_Global_Firewall::BLOCKLIST_STATS_KEY );
1879 }
1880
1881 /**
1882 * Improve UA Blocklist.
1883 *
1884 * @return void
1885 */
1886 private function improve_ua_blocklist(): void {
1887 $settings = wd_di()->get( User_Agent_Lockout::class );
1888 $blocklist_custom = $settings->get_lockout_list( 'blocklist' );
1889 if ( array() === $blocklist_custom ) {
1890 return;
1891 }
1892 // Get 'Blocklist Presets', check and remove duplicates on 'Custom User Agents'.
1893 $blocklist_presets = User_Agent_Service::get_nested_keys_of_blocklist_presets();
1894 $common_result = array_intersect( $blocklist_custom, $blocklist_presets );
1895 if ( array() !== $common_result ) {
1896 $blocklist_custom = User_Agent_Service::check_and_remove_duplicates(
1897 $blocklist_custom,
1898 $common_result
1899 );
1900 // Convert back to string.
1901 $settings->blacklist = implode( PHP_EOL, $blocklist_custom );
1902 // Enable option with nested suboptions.
1903 $settings->blocklist_presets = true;
1904 $settings->blocklist_preset_values = $common_result;
1905 }
1906 // The same, but for 'Script Presets'.
1907 $script_presets = array_keys( User_Agent_Service::get_script_presets() );
1908 $common_result = array_intersect( $blocklist_custom, $script_presets );
1909 if ( array() !== $common_result ) {
1910 $blocklist_custom = User_Agent_Service::check_and_remove_duplicates(
1911 $blocklist_custom,
1912 $common_result
1913 );
1914 // Convert back to string.
1915 $settings->blacklist = implode( PHP_EOL, $blocklist_custom );
1916 // Enable option with nested suboptions.
1917 $settings->script_presets = true;
1918 $settings->script_preset_values = $common_result;
1919 }
1920 $settings->save();
1921 }
1922
1923 /**
1924 * Upgrade to 5.4.0.
1925 *
1926 * @return void
1927 */
1928 private function upgrade_5_4_0(): void {
1929 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1930
1931 $this->improve_ua_blocklist();
1932 }
1933
1934 /**
1935 * Upgrade to 5.5.0.
1936 *
1937 * @return void
1938 */
1939 private function upgrade_5_5_0(): void {
1940 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1941 }
1942
1943 /**
1944 * Change lockout log mentions from fake_bot to malicious_bot. Also move BotTrap settings.
1945 *
1946 * @return void
1947 */
1948 private function change_to_malicious_bot(): void {
1949 global $wpdb;
1950
1951 $table_name = $wpdb->base_prefix . 'defender_lockout_log';
1952
1953 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1954 $wpdb->prepare(
1955 "UPDATE $table_name SET type = %s, log = %s WHERE type = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1956 'malicious_bot',
1957 'Lockout occurred: Bot ignored robots.txt rules.',
1958 'bot_trap'
1959 )
1960 );
1961 // Clear schedule as the name has changed to 'wpdef_rotate_malicious_bot_secret_hash'.
1962 wp_clear_scheduled_hook( 'wpdef_rotate_bot_trap_secret_hash' );
1963 // Move the BotTrap settings to new Malicious Bot settings.
1964 $settings = wd_di()->get( User_Agent_Lockout::class );
1965 if ( isset( $settings->bot_trap_enabled ) ) {
1966 $settings->malicious_bot_enabled = $settings->bot_trap_enabled;
1967 $settings->malicious_bot_lockout_type = $settings->bot_trap_lockout_type;
1968 $settings->malicious_bot_lockout_duration = $settings->bot_trap_lockout_duration;
1969 $settings->malicious_bot_lockout_duration_unit = $settings->bot_trap_lockout_duration_unit;
1970 $settings->save();
1971 }
1972 }
1973
1974 /**
1975 * Upgrade to 5.6.0.
1976 *
1977 * @return void
1978 */
1979 private function upgrade_5_6_0(): void {
1980 $this->change_to_malicious_bot();
1981 // Add the "What's new" modal.
1982 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1983 }
1984
1985 /**
1986 * Upgrade to 5.7.0.
1987 *
1988 * @return void
1989 */
1990 private function upgrade_5_7_0(): void {
1991 // Remove old objects from ncm options.
1992 wd_di()->get( \WP_Defender\Component\Network_Cron_Manager::class )->remove_data();
1993 // Add the "What's new" modal.
1994 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1995 // Add Cloudflare challenge-platform to 404 whitelist.
1996 $this->force_nf_lockout_exclusions();
1997 // Remove the prev Breadcrumbs.
1998 wd_di()->get( \WP_Defender\Component\Breadcrumbs::class )->delete_previous_meta();
1999 }
2000
2001 /**
2002 * Migrates the uninstall settings from v5.11 to v6.0.
2003 *
2004 * During the upgrade:
2005 * - If at least one of the three options is set to Preserve/Keep,
2006 * all three options are set to Preserve/Keep.
2007 */
2008 private function combine_all_preserve_settings_to_one() {
2009 $main_settings = wd_di()->get( \WP_Defender\Model\Setting\Main_Setting::class );
2010
2011 if (
2012 'preserve' === $main_settings->uninstall_settings ||
2013 'keep' === $main_settings->uninstall_data ||
2014 'keep' === $main_settings->uninstall_quarantine
2015 ) {
2016 $main_settings->uninstall_settings = 'preserve';
2017 $main_settings->uninstall_data = 'keep';
2018 $main_settings->uninstall_quarantine = 'keep';
2019 $main_settings->save();
2020 }
2021 }
2022
2023 /**
2024 * Since with version 6.0.0, two nested options:
2025 * - Scan core files
2026 * - Scan plugin files,
2027 * are hidden, and automatically take the value of the parent "File change detection" option.
2028 */
2029 private function combine_all_file_change_detection_settings_to_one() {
2030 $scan_settings = wd_di()->get( Scan_Settings::class );
2031 if ( $scan_settings->integrity_check ) {
2032 $scan_settings->check_core = true;
2033 $scan_settings->check_plugins = true;
2034 } else {
2035 $scan_settings->check_core = false;
2036 $scan_settings->check_plugins = false;
2037 }
2038 $scan_settings->save();
2039 }
2040
2041 /**
2042 * Upgrade to 6.0.0: Ensure quarantine table exists for Free HC users and other changes.
2043 * The quarantine feature is now available in the free version.
2044 *
2045 * @return void
2046 */
2047 private function upgrade_6_0_0(): void {
2048 $bootstrap = wd_di()->get( Bootstrap::class );
2049 $bootstrap->create_table_quarantine();
2050 $this->combine_all_preserve_settings_to_one();
2051 $this->combine_all_file_change_detection_settings_to_one();
2052 $this->update_issues_report_email_template();
2053 $this->fix_scheduled_scanning_left_disabled();
2054 $this->migrate_tweak_reminder_schedule();
2055 $this->migrate_two_fa_force_auth_roles();
2056 // Add the "What's new" modal.
2057 update_site_option( Feature_Modal::FEATURE_SLUG, true );
2058 }
2059
2060 /**
2061 * Upgrade to 6.1.0: Fix Tweak Reminder est_timestamp left as 0 by the 6.0.0 migration.
2062 * Also sync all report schedules to match Malware Report.
2063 *
2064 * @return void
2065 */
2066 private function upgrade_6_1_0(): void {
2067 $model = wd_di()->get( Tweak_Reminder::class );
2068 if ( 'report' === $model->type && (int) $model->est_timestamp <= 0 ) {
2069 // Calling save() recovers the correct state: last_sent is
2070 // reset to now if it is 0, then est_timestamp is recomputed via get_next_run().
2071 $model->save();
2072 }
2073
2074 $this->sync_all_report_schedules();
2075 }
2076
2077 /**
2078 * Sync all report schedules to match Malware Report.
2079 *
2080 * Since 6.0.0 the UI enforces a single shared schedule for all reports via sync_report_schedule.
2081 * Sites upgrading from older versions may have each report on a different schedule.
2082 * This migration copies Malware_Report's schedule to Scan_Settings (the UI master) and to
2083 * all four report models, then recomputes est_timestamp on each so all report emails
2084 * arrive at the same time.
2085 *
2086 * @return void
2087 */
2088 private function sync_all_report_schedules(): void {
2089 // Get the schedule from Malware_Report.
2090 $malware_report = wd_di()->get( Malware_Report::class );
2091 $frequency = $malware_report->frequency;
2092 $day = $malware_report->day;
2093 $day_n = (int) $malware_report->day_n;
2094 $time = $malware_report->time;
2095
2096 // Update Scan_Settings so the schedule pill shows the correct label.
2097 $scan_settings = wd_di()->get( Scan_Settings::class );
2098 $scan_settings->frequency = $frequency;
2099 $scan_settings->day = $day;
2100 $scan_settings->day_n = $day_n;
2101 $scan_settings->time = $time;
2102 $scan_settings->save();
2103
2104 // Apply the same schedule to all report models and recompute est_timestamp
2105 // so every report fires at the same next occurrence.
2106 $report_models = array(
2107 $malware_report,
2108 wd_di()->get( Firewall_Report::class ),
2109 wd_di()->get( Audit_Report::class ),
2110 wd_di()->get( Tweak_Reminder::class ),
2111 );
2112
2113 foreach ( $report_models as $model ) {
2114 $model->frequency = $frequency;
2115 $model->day = $day;
2116 $model->day_n = $day_n;
2117 $model->time = $time;
2118
2119 $next_run = $model->get_next_run();
2120 if ( $next_run instanceof \DateTime ) {
2121 $model->est_timestamp = $next_run->getTimestamp();
2122 }
2123
2124 $model->save();
2125 }
2126
2127 // Show a one-time dashboard bubble so users know about the shared schedule.
2128 update_site_option( 'wd_show_report_schedule_notice', true );
2129 }
2130
2131 /**
2132 * Heal 'Scheduled Scanning' left disabled while its related report stayed active.
2133 *
2134 * The bundled "Basic Config" preset stored 'scheduled_scanning' out of sync with its
2135 * 'report' flag, so applying it left scans disabled. Corrects the live setting and any
2136 * already-stored config presets with the same mismatch.
2137 *
2138 * @return void
2139 */
2140 private function fix_scheduled_scanning_left_disabled(): void {
2141 $scan_settings = wd_di()->get( Scan_Settings::class );
2142 $malware_report = wd_di()->get( Malware_Report::class );
2143 if ( ! $scan_settings->scheduled_scanning && Notification::STATUS_ACTIVE === $malware_report->status ) {
2144 $scan_settings->scheduled_scanning = true;
2145 $scan_settings->save();
2146 }
2147
2148 $service = wd_di()->get( Backup_Settings::class );
2149 $configs = Config_Hub_Helper::get_configs( $service );
2150
2151 foreach ( $configs as $key => $config ) {
2152 $scan = $config['configs']['scan'] ?? null;
2153 if ( ! is_array( $scan ) || ! isset( $scan['report'], $scan['scheduled_scanning'] ) ) {
2154 continue;
2155 }
2156
2157 if ( 'enabled' === $scan['report'] && false === $scan['scheduled_scanning'] ) {
2158 $configs[ $key ]['configs']['scan']['scheduled_scanning'] = true;
2159 update_site_option( $key, $configs[ $key ] );
2160 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
2161 }
2162 }
2163
2164 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
2165 }
2166
2167 /**
2168 * Clear force_auth_roles when user_roles is empty.
2169 *
2170 * @return void
2171 */
2172 private function migrate_two_fa_force_auth_roles(): void {
2173 $model = wd_di()->get( Two_Fa_Settings::class );
2174 if ( count( $model->user_roles ) === 0 ) {
2175 $model->force_auth = false;
2176 $model->force_auth_roles = array();
2177 $model->save();
2178 }
2179 }
2180
2181 /**
2182 * Migrate the Hardening report (Tweak Reminder) schedule from 5.11 to 6.0.0.
2183 *
2184 * In 5.11, this report was saved with type 'notification' and its frequency was kept in
2185 * `configs['reminder']`. In 6.0.0, it's saved with type 'report' and uses new, dedicated
2186 * fields instead: `frequency`, `day`, `day_n`, `time`, `est_timestamp`.
2187 *
2188 * Without this migration, a site updated from 5.11 keeps the old type 'notification', so the
2189 * new scheduling code can't calculate a valid next-send date and the report email gets sent
2190 * on every cron run instead of on the configured schedule.
2191 *
2192 * @return void
2193 */
2194 private function migrate_tweak_reminder_schedule(): void {
2195 $model = wd_di()->get( Tweak_Reminder::class );
2196 if ( 'notification' !== $model->type ) {
2197 return;
2198 }
2199
2200 $model->type = 'report';
2201
2202 $valid_frequencies = array( 'daily', 'weekly', 'monthly' );
2203 $reminder = $model->configs['reminder'] ?? '';
2204 $model->frequency = in_array( $reminder, $valid_frequencies, true ) ? $reminder : 'weekly';
2205
2206 if ( null === $model->day || '' === $model->day ) {
2207 $model->day = 'sunday';
2208 }
2209 if ( null === $model->time || '' === $model->time ) {
2210 $model->time = '4:00';
2211 }
2212 if ( ! is_int( $model->day_n ) || $model->day_n < 1 ) {
2213 $model->day_n = 1;
2214 }
2215 // Compute est_timestamp.
2216 $next_run = $model->get_next_run();
2217 if ( $next_run instanceof \DateTime ) {
2218 $model->est_timestamp = $next_run->getTimestamp();
2219 }
2220 // Clear the old notification style configs key.
2221 unset( $model->configs['reminder'] );
2222 $model->save();
2223 }
2224
2225 /**
2226 * Update Issues Report email subject and body to match the new copy.
2227 *
2228 * @return void
2229 */
2230 private function update_issues_report_email_template(): void {
2231 $models = array(
2232 wd_di()->get( Malware_Notification::class ),
2233 wd_di()->get( Malware_Report::class ),
2234 );
2235
2236 $default_subject = esc_html__( 'Issues Report for {SITE_URL}: {ISSUES_COUNT} issue(s) found.', 'defender-security' );
2237 $default_body = esc_html__(
2238 'Hi {USER_NAME},
2239
2240 A scan of {SITE_URL} identified {ISSUES_COUNT} issue(s). The issue(s) found is/are listed below.
2241
2242 {ISSUES_LIST}',
2243 'defender-security'
2244 );
2245
2246 foreach ( $models as $model ) {
2247 if ( ! isset( $model->configs['template']['found'] ) ) {
2248 continue;
2249 }
2250 $model->configs['template']['found']['subject'] = $default_subject;
2251 $model->configs['template']['found']['body'] = $default_body;
2252 $model->save();
2253 }
2254 }
2255 }
2256