PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.0.0
Defender Security – Malware Scanner, Login Security & Firewall v6.0.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.0.0, at src/class-upgrader.php

2,133 lines 67.0 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 // This is not a new installation. Make a mark.
485 defender_no_fresh_install();
486 // Don't run any function below this line.
487 update_site_option( 'wd_db_version', DEFENDER_DB_VERSION );
488 }
489
490 /**
491 * Cache the event time.
492 *
493 * @param string $event Event key.
494 */
495 private static function cache_event_time( $event ) {
496 $option_key = \WP_Defender\Helper\Analytics\Deactivation_Survey::EVENT_DATA_OPTION;
497 $event_times = get_site_option( $option_key, array() );
498 $event_times[ $event ] = time();
499 update_site_option( $option_key, $event_times );
500 }
501
502 /**
503 * Cache the last activated time.
504 */
505 public static function date_activated() {
506 self::cache_event_time( 'plugin_activated' );
507 }
508
509 /**
510 * Index necessary columns.
511 * Sometimes this function call twice that's why we have to check index already exists or not.
512 * `dbDelta` not work on `ALTER TABLE` query, so we had to use $wpdb->query().
513 *
514 * @return void
515 * @since 2.4.7
516 */
517 private function index_database(): void {
518 global $wpdb;
519 $wpdb->hide_errors();
520
521 $this->add_index_to_defender_email_log( $wpdb );
522 $this->add_index_to_defender_audit_log( $wpdb );
523 $this->add_index_to_defender_scan_item( $wpdb );
524 $this->add_index_to_defender_lockout_log( $wpdb );
525 $this->add_index_to_defender_lockout( $wpdb );
526 }
527
528 /**
529 * Add index to defender_email_log.
530 *
531 * @param wpdb $wpdb WordPress Database object.
532 *
533 * @return void
534 * @since 2.4.7
535 */
536 private function add_index_to_defender_email_log( $wpdb ) {
537 // Check index already exists or not.
538 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_email_log WHERE Key_name = 'source';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
539 if ( is_array( $result ) ) {
540 return;
541 }
542
543 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_email_log ADD INDEX `source` (`source`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
544 }
545
546 /**
547 * Add index to defender_audit_log.
548 *
549 * @param wpdb $wpdb WordPress Database object.
550 *
551 * @return void
552 * @since 2.4.7
553 */
554 private function add_index_to_defender_audit_log( $wpdb ) {
555 // Check index already exists or not.
556 $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
557 if ( is_array( $result ) ) {
558 return;
559 }
560
561 $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
562 }
563
564 /**
565 * Add index to defender_scan_item.
566 *
567 * @param wpdb $wpdb WordPress Database object.
568 *
569 * @return void
570 * @since 2.4.7
571 */
572 private function add_index_to_defender_scan_item( $wpdb ) {
573 // Check index already exists or not.
574 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_scan_item WHERE Key_name = 'type';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
575 if ( is_array( $result ) ) {
576 return;
577 }
578
579 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_scan_item ADD INDEX `type` (`type`), ADD INDEX `status` (`status`);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
580 }
581
582 /**
583 * Add index to defender_lockout_log.
584 *
585 * @param wpdb $wpdb WordPress Database object.
586 *
587 * @return void
588 * @since 2.4.7
589 */
590 private function add_index_to_defender_lockout_log( $wpdb ) {
591 // Check index already exists or not.
592 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_lockout_log WHERE Key_name = 'ip';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
593 if ( is_array( $result ) ) {
594 return;
595 }
596
597 $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
598 }
599
600 /**
601 * Add index to defender_lockout.
602 *
603 * @param wpdb $wpdb WordPress Database object.
604 *
605 * @return void
606 * @since 2.4.7
607 */
608 private function add_index_to_defender_lockout( $wpdb ) {
609 // Check index already exists or not.
610 $result = $wpdb->get_row( "SHOW INDEX FROM {$wpdb->base_prefix}defender_lockout WHERE Key_name = 'ip';", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
611 if ( is_array( $result ) ) {
612 return;
613 }
614
615 $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
616 }
617
618 /**
619 * Upgrade to 2.4.10.
620 *
621 * @return void
622 * @since 2.4.10
623 */
624 private function upgrade_2_4_10(): void {
625 $service = wd_di()->get( Backup_Settings::class );
626 $configs = Config_Hub_Helper::get_configs( $service );
627 $deprecated_keys = array(
628 // Reason: updated or removed some tweak slugs.
629 'security_key',
630 'wp-rest-api',
631 // Reason: moved the security headers to a separate module.
632 'sh-referrer-policy',
633 'sh-strict-transport',
634 'sh-xframe',
635 'sh-content-security',
636 'sh-content-type-options',
637 'sh-feature-policy',
638 'sh-xss-protection',
639 );
640 foreach ( $configs as $key => $config ) {
641 $is_updated = false;
642 // Remove deprecated 'data' key inside Security tweaks.
643 if ( isset( $config['configs']['security_tweaks']['data'] ) ) {
644 unset( $configs[ $key ]['configs']['security_tweaks']['data'] );
645 $is_updated = true;
646 }
647 // Remove deprecated keys in 'issues'.
648 if ( isset( $config['configs']['security_tweaks']['issues'] ) ) {
649 foreach ( $config['configs']['security_tweaks']['issues'] as $iss_key => $issue ) {
650 if ( in_array( $issue, $deprecated_keys, true ) ) {
651 unset( $configs[ $key ]['configs']['security_tweaks']['issues'][ $iss_key ] );
652 $is_updated = true;
653 }
654 }
655 }
656 // In 'ignore'.
657 if ( isset( $config['configs']['security_tweaks']['ignore'] ) ) {
658 foreach ( $config['configs']['security_tweaks']['ignore'] as $ign_key => $issue ) {
659 if ( in_array( $issue, $deprecated_keys, true ) ) {
660 unset( $configs[ $key ]['configs']['security_tweaks']['ignore'][ $ign_key ] );
661 $is_updated = true;
662 }
663 }
664 }
665 // In 'fixed'.
666 if ( isset( $config['configs']['security_tweaks']['fixed'] ) ) {
667 foreach ( $config['configs']['security_tweaks']['fixed'] as $fix_key => $issue ) {
668 if ( in_array( $issue, $deprecated_keys, true ) ) {
669 unset( $configs[ $key ]['configs']['security_tweaks']['fixed'][ $fix_key ] );
670 $is_updated = true;
671 }
672 }
673 }
674
675 if ( $is_updated ) {
676 update_site_option( $key, $configs[ $key ] );
677 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
678 if ( isset( $config['is_active'] ) ) {
679 $model = new Model\Setting\Security_Tweaks();
680 $model->issues = $configs[ $key ]['configs']['security_tweaks']['issues'];
681 $model->ignore = $configs[ $key ]['configs']['security_tweaks']['ignore'];
682 $model->fixed = $configs[ $key ]['configs']['security_tweaks']['fixed'];
683 $model->save();
684 }
685 }
686 }
687 }
688
689 /**
690 * Upgrade to 2.5.0.
691 *
692 * @return void
693 * @since 2.5.0
694 */
695 private function upgrade_2_5_0(): void {
696 $model = wd_di()->get( Security_Headers::class );
697 // Directive ALLOW-FROM is deprecated. If header directive is ALLOW-FROM then set 'sameorigin'.
698 if ( isset( $model->sh_xframe_mode ) && 'allow-from' === $model->sh_xframe_mode ) {
699 $model->sh_xframe_mode = 'sameorigin';
700 $model->save();
701 }
702 /**
703 * Uncheck 'File change detection' option if there was checked only child 'Scan theme files' option and save
704 * settings. Also remove items for 'Scan theme files' without run Scan.
705 */
706 $scan_settings = new Scan_Settings();
707 if (
708 $scan_settings->integrity_check
709 && ! $scan_settings->check_core
710 && ! $scan_settings->check_plugins
711 ) {
712 $scan_settings->integrity_check = false;
713 $scan_settings->save();
714 }
715 }
716
717 /**
718 * Forces the addition of default lockout exclusions to the Notfound Lockout settings.
719 *
720 * @return void
721 */
722 private function force_nf_lockout_exclusions(): void {
723 $nf_settings = new Notfound_Lockout();
724 $allowlist = $nf_settings->get_lockout_list( 'allowlist' );
725 $default_allowlist = array( '/cdn-cgi/challenge-platform/' );
726 $is_save = false;
727 if ( array() !== $allowlist ) {
728 foreach ( $default_allowlist as $item ) {
729 if ( ! in_array( $item, $allowlist, true ) ) {
730 $allowlist[] = $item;
731 $is_save = true;
732 }
733 }
734 $nf_settings->whitelist = implode( "\n", $allowlist );
735 } else {
736 $nf_settings->whitelist = ".css\n.js\n.map\n/cdn-cgi/challenge-platform/";
737 $is_save = true;
738 }
739 // Save it.
740 if ( $is_save ) {
741 $nf_settings->save();
742 }
743 }
744
745 /**
746 * Upgrade to 2.5.6.
747 *
748 * @return void
749 * @since 2.5.6
750 */
751 private function upgrade_2_5_6(): void {
752 $adapted_component = wd_di()->get( Legacy_Versions::class );
753 $issue_list = $adapted_component->get_scan_issue_data();
754 $ignored_list = $adapted_component->get_scan_ignored_data();
755 if (
756 ( is_array( $issue_list ) && array() !== $issue_list )
757 || ( is_array( $ignored_list ) && array() !== $ignored_list ) ) {
758 $adapted_component->migrate_scan_data( $issue_list, $ignored_list );
759 $adapted_component->remove_old_scan_data( $issue_list, $ignored_list );
760 $adapted_component->change_onboarding_status();
761 }
762
763 // Trigger recurring cron schedule for autogenerate security salt.
764 $security_tweaks = wd_di()->get( Security_Tweaks::class );
765 $security_tweaks->get_security_key()->cron_schedule();
766 // Add some lockout extension to old installations forced.
767 $this->force_nf_lockout_exclusions();
768 }
769
770 /**
771 * Updates the body of the scan error email template.
772 *
773 * @param mixed $model The model containing the email template configuration.
774 *
775 * @return void
776 */
777 private function update_scan_error_send_body( $model ): void {
778 $body = isset( $model->configs['template']['error']['body'] ) ? trim( $model->configs['template']['error']['body'] ) : '';
779
780 if ( '' !== $body ) {
781 $needle = '{follow this link} and check the logs to see what casued the failure';
782 if ( false !== stripos( $body, $needle ) ) {
783 $model->configs['template']['error']['body'] = str_replace(
784 $needle,
785 'visit your site and run a manual scan',
786 $body
787 );
788 $model->save();
789 }
790 }
791 }
792
793 /**
794 * Upgrade to 2.6.1.
795 *
796 * @return void
797 * @since 2.6.1
798 */
799 private function upgrade_2_6_1(): void {
800 // Update the title of the basic config.
801 $config_component = wd_di()->get( Backup_Settings::class );
802 $configs = $config_component->get_configs();
803 if ( array() !== $configs ) {
804 foreach ( $configs as $k => $config ) {
805 if ( 0 === strcmp( $config['name'], esc_html__( 'Basic config', 'defender-security' ) ) ) {
806 $config['name'] = esc_html__( 'Basic Config', 'defender-security' );
807
808 update_site_option( $k, $config );
809 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
810 break;
811 }
812 }
813 }
814 // Update scan emails for 'When failed to scan' option.
815 $malware_notification = wd_di()->get( Malware_Notification::class );
816 $this->update_scan_error_send_body( $malware_notification );
817 $malware_report = wd_di()->get( Malware_Report::class );
818 $this->update_scan_error_send_body( $malware_report );
819 }
820
821 /**
822 * Update 2FA email template.
823 *
824 * @return void
825 */
826 private function update_2fa_send_body(): void {
827 $model = wd_di()->get( Two_Fa_Settings::class );
828 if ( isset( $model->email_body ) ) {
829 $model->email_body = 'Hi {{display_name}},
830
831 Your temporary password is {{passcode}}. To finish logging in, copy and paste the temporary password into the Password field on the login screen.';
832 $model->save();
833 }
834 }
835
836 /**
837 * Update Malware Scan email template.
838 *
839 * @return void
840 * @since 2.7.0
841 */
842 private function update_malware_scan_send_body(): void {
843 $models = array(
844 wd_di()->get( Malware_Notification::class ),
845 wd_di()->get( Malware_Report::class ),
846 );
847
848 foreach ( $models as $model ) {
849 $is_updated = false;
850 $body = isset( $model->configs['template']['error']['body'] ) ? trim( $model->configs['template']['error']['body'] ) : '';
851 if ( '' !== $body ) {
852 $subject = $this->replace_scan_email_content_error( $body );
853
854 if ( $body !== $subject ) {
855 $is_updated = true;
856
857 $model->configs['template']['error']['body'] = $subject;
858 }
859 }
860
861 $body = isset( $model->configs['template']['found']['body'] ) ? trim( $model->configs['template']['found']['body'] ) : '';
862 if ( '' !== $body ) {
863 $subject = $this->replace_scan_email_content_issue_found( $body );
864
865 if ( $body !== $subject ) {
866 $is_updated = true;
867
868 $model->configs['template']['found']['body'] = $subject;
869 }
870 }
871
872 $body = isset( $model->configs['template']['not_found']['body'] ) ? trim( $model->configs['template']['not_found']['body'] ) : '';
873 if ( '' !== $body ) {
874 $subject = $this->replace_scan_email_content_issue_not_found( $body );
875
876 if ( $body !== $subject ) {
877 $is_updated = true;
878
879 $model->configs['template']['not_found']['body'] = $subject;
880 }
881 }
882
883 if ( true === $is_updated ) {
884 $model->save();
885 }
886 }
887 }
888
889 /**
890 * Update Malware Scan email template common parts.
891 *
892 * @param string $subject Email subject.
893 *
894 * @return string
895 * @since 2.7.0
896 */
897 private function update_malware_scan_send_body_common( $subject ): string {
898 $subject = preg_replace( '/(\R+)WP Defender here, reporting back from the front\./i', '', $subject );
899
900 return preg_replace( '/(\R+)Stay Safe,(\R+)WP Defender(\R+)WPMU DEV Superhero(\s*)$/i', '', $subject );
901 }
902
903 /**
904 * Upgrade to 2.7.0.
905 *
906 * @return void
907 * @since 2.7.0
908 */
909 private function upgrade_2_7_0(): void {
910 $malware_report = wd_di()->get( Malware_Report::class );
911 $scan_settings = wd_di()->get( Scan_Settings::class );
912 // Migrate data from Malware_Report to Scan settings.
913 $scan_settings->scheduled_scanning = Notification::STATUS_ACTIVE === $malware_report->status;
914 $scan_settings->frequency = $malware_report->frequency;
915 $scan_settings->day = $malware_report->day;
916 $scan_settings->day_n = (int) $malware_report->day_n;
917 $scan_settings->time = $malware_report->time;
918 $scan_settings->save();
919
920 $this->update_2fa_send_body();
921 $this->update_malware_scan_send_body();
922 }
923
924 /**
925 * Update in_house recipients empty role.
926 *
927 * @return void
928 * @since 2.8.0
929 */
930 private function update_in_house_recipients_empty_role(): void {
931 $models = array(
932 wd_di()->get( Audit_Report::class ),
933 wd_di()->get( Firewall_Notification::class ),
934 wd_di()->get( Firewall_Report::class ),
935 wd_di()->get( Malware_Notification::class ),
936 wd_di()->get( Malware_Report::class ),
937 wd_di()->get( Tweak_Reminder::class ),
938 );
939
940 foreach ( $models as $model ) {
941 if ( is_array( $model->in_house_recipients ) && array() !== $model->in_house_recipients ) {
942 $is_updated = false;
943
944 foreach ( $model->in_house_recipients as &$recipient ) {
945 if ( ! isset( $recipient['role'] ) || ! is_string( $recipient['role'] ) || '' === trim( $recipient['role'] ) ) {
946 $is_updated = true;
947 $recipient['role'] = $this->get_current_user_role( $recipient['id'] );
948 }
949 }
950
951 if ( true === $is_updated ) {
952 $model->save();
953 }
954 }
955 }
956 }
957
958 /**
959 * Delete temp geolite file from /wp-admin directory.
960 *
961 * @return void
962 * @since 2.8.0
963 */
964 private function delete_tmp_geolite_file(): void {
965 $pattern = ABSPATH . 'wp-admin/geolite2-country*.tar.gz';
966 array_map( 'wp_delete_file', glob( $pattern ) );
967 }
968
969 /**
970 * Migrate old keys to checked options.
971 *
972 * @return void
973 * @since 2.8.0
974 */
975 private function update_2fa_methods(): void {
976 $settings = wd_di()->get( Two_Fa_Settings::class );
977 if ( $settings->enabled ) {
978 $service = wd_di()->get( Two_Fa_Component::class );
979 $query = new WP_User_Query(
980 array(
981 'blog_id' => 0,
982 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
983 array(
984 'key' => Totp::TOTP_AUTH_KEY,
985 'value' => true,
986 ),
987 ),
988 'fields' => 'ID',
989 )
990 );
991 if ( $query->get_total() > 0 ) {
992 // The data is independent of the user's data.
993 $providers = $service->get_providers();
994 $totp_slug = Totp::$slug;
995 // Add TOTP slug.
996 $enabled_providers = array( $totp_slug );
997 // If 'Enable lost phone' option is checked then we add the slug of the Email provider too.
998 if ( true === $settings->lost_phone ) {
999 $enabled_providers[] = Fallback_Email::$slug;
1000 }
1001 // Add slugs to the list of the enabled providers.
1002 $enabled_providers = array_intersect( $enabled_providers, array_keys( $providers ) );
1003 foreach ( $query->get_results() as $user_id ) {
1004 // If 2FA module is configured for the current user.
1005 if ( $service->is_enabled_otp_for_user( $user_id ) ) {
1006 update_user_meta( $user_id, Two_Fa_Component::ENABLED_PROVIDERS_USER_KEY, $enabled_providers );
1007 // Set TOTP as the default auth provider.
1008 update_user_meta( $user_id, Two_Fa_Component::DEFAULT_PROVIDER_USER_KEY, $totp_slug );
1009 }
1010 }
1011 }
1012 }
1013 }
1014
1015 /**
1016 * Upgrade to 2.8.0.
1017 *
1018 * @return void
1019 * @since 2.8.0
1020 */
1021 private function upgrade_2_8_0(): void {
1022 $this->update_in_house_recipients_empty_role();
1023 $this->delete_tmp_geolite_file();
1024 $this->update_2fa_methods();
1025 }
1026
1027 /**
1028 * Add column country_iso_code and index to table.
1029 *
1030 * @return void
1031 * @since 2.8.1
1032 */
1033 private function add_country_iso_code_column(): void {
1034 if ( ! function_exists( 'maybe_add_column' ) ) {
1035 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1036 }
1037
1038 global $wpdb;
1039
1040 $table_name = $wpdb->base_prefix . 'defender_lockout_log';
1041 $column_name = 'country_iso_code';
1042 $create_ddl = "ALTER TABLE {$table_name} ADD {$column_name} CHAR(2) DEFAULT NULL";
1043
1044 maybe_add_column( $table_name, $column_name, $create_ddl );
1045
1046 $prev_val = $wpdb->hide_errors();
1047 $wpdb->query( "CREATE INDEX `country_iso_code` ON {$wpdb->base_prefix}defender_lockout_log (`country_iso_code`)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1048 $wpdb->show_errors( $prev_val );
1049 }
1050
1051 /**
1052 * Upgrade to 2.8.3.
1053 *
1054 * @return void
1055 * @since 2.8.3
1056 */
1057 private function upgrade_2_8_3(): void {
1058 $this->add_country_iso_code_column();
1059 }
1060
1061 /**
1062 * Upgrade to 3.2.0.
1063 *
1064 * @return void
1065 * @since 3.2.0
1066 */
1067 private function upgrade_3_2_0(): void {
1068 $model = wd_di()->get( Two_Fa_Settings::class );
1069
1070 if ( is_string( $model->app_title ) && '' !== $model->app_title ) {
1071 $model->app_title = wp_specialchars_decode( $model->app_title, ENT_QUOTES );
1072 }
1073
1074 $model->custom_graphic_type = Two_Fa_Settings::CUSTOM_GRAPHIC_TYPE_UPLOAD;
1075 $model->save();
1076 }
1077
1078 /**
1079 * Upgrade to 3.3.0.
1080 *
1081 * @return void
1082 * @since 3.3.0
1083 */
1084 private function upgrade_3_3_0(): void {
1085 $this->update_webauthn_user_handle();
1086 $this->add_ua_lockout_to_firewall_notification();
1087 }
1088
1089 /**
1090 * Update webauthn 'userHandle' to make it independent of salt keys.
1091 *
1092 * @return void
1093 * @since 3.3.0
1094 */
1095 private function update_webauthn_user_handle(): void {
1096 global $wpdb;
1097 $service = wd_di()->get( Webauthn::class );
1098
1099 if ( is_multisite() ) {
1100 $offset = 0;
1101 $limit = 100;
1102 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1103 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1104 ARRAY_A
1105 );
1106 while ( is_array( $blogs ) && array() !== $blogs ) {
1107 foreach ( $blogs as $blog ) {
1108 switch_to_blog( $blog['blog_id'] );
1109
1110 $this->update_webauthn_user_handle_core( $service );
1111
1112 restore_current_blog();
1113 }
1114 $offset += $limit;
1115 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1116 $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} LIMIT %d, %d", $offset, $limit ),
1117 ARRAY_A
1118 );
1119 }
1120 } else {
1121 $this->update_webauthn_user_handle_core( $service );
1122 }
1123 }
1124
1125 /**
1126 * Core method for updating webauthn 'userHandle' to make it independent of salt keys.
1127 *
1128 * @param object $service Webauthn service instance.
1129 *
1130 * @return void
1131 * @since 3.3.0
1132 */
1133 private function update_webauthn_user_handle_core( object $service ) {
1134 $data = get_option( $this->option_prefix . $service::CREDENTIAL_OPTION_KEY );
1135 $data = is_string( $data ) ? json_decode( $data, true ) : array();
1136 delete_option( $this->option_prefix . $service::CREDENTIAL_OPTION_KEY );
1137 if ( ! is_array( $data ) || 0 === count( $data ) ) {
1138 return;
1139 }
1140
1141 $users = get_users( array( 'fields' => array( 'ID', 'user_login', 'display_name' ) ) );
1142
1143 foreach ( $users as $user ) {
1144 if ( ! is_array( $data ) || array() === $data ) {
1145 break;
1146 }
1147
1148 $user_credentials = array();
1149 foreach ( $data as $key => $item ) {
1150 $old_hash = hash( 'sha256', $user->user_login . '-' . $user->display_name . '-' . AUTH_SALT );
1151 // This is not obfuscation. Just encode the hashed value into a base64 string.
1152 $old_base64_hash = base64_encode( $old_hash ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1153 $old_base64_hash = preg_replace( '/\=+$/', '', $old_base64_hash );
1154
1155 if (
1156 isset( $item['credential_source']['userHandle'] ) &&
1157 $item['credential_source']['userHandle'] === $old_base64_hash
1158 ) {
1159 $new_hash = $this->get_user_hash( $user->user_login );
1160 // This is not obfuscation. Just encode the hashed value into a base64 string.
1161 $new_base64_hash = base64_encode( $new_hash ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1162 $new_base64_hash = preg_replace( '/\=+$/', '', $new_base64_hash );
1163 $item['user'] = $new_hash;
1164 $item['credential_source']['userHandle'] = $new_base64_hash;
1165 $user_credentials[ $key ] = $item;
1166 unset( $data[ $key ] );
1167 }
1168 }
1169
1170 if ( array() !== $user_credentials ) {
1171 $service->setCredentials( (int) $user->ID, $user_credentials );
1172 }
1173 }
1174 }
1175
1176 /**
1177 * Add the ability to send notifications about UA-lockout.
1178 *
1179 * @return void
1180 */
1181 private function add_ua_lockout_to_firewall_notification(): void {
1182 $model = wd_di()->get( Firewall_Notification::class );
1183 if ( ! isset( $model->configs['ua_lockout'] ) ) {
1184 $model->configs['ua_lockout'] = false;
1185 $model->save();
1186 }
1187 }
1188
1189 /**
1190 * Upgrade to 3.3.1: extra security steps.
1191 *
1192 * @return void
1193 */
1194 private function upgrade_3_3_1(): void {
1195 // Update tokens.
1196 $query = new WP_User_Query(
1197 array(
1198 'blog_id' => 0,
1199 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1200 array(
1201 'key' => Two_Fa_Component::TOKEN_USER_KEY,
1202 'compare' => 'EXISTS',
1203 ),
1204 ),
1205 'fields' => 'ID',
1206 )
1207 );
1208 if ( $query->get_total() > 0 ) {
1209 // Hashed tokens.
1210 foreach ( $query->get_results() as $user_id ) {
1211 $token = bin2hex( Crypt::random_bytes( 32 ) );
1212 update_user_meta( $user_id, Two_Fa_Component::TOKEN_USER_KEY, wp_hash( $user_id . $token ) );
1213 }
1214 }
1215 // Update fallback codes.
1216 $query = new WP_User_Query(
1217 array(
1218 'blog_id' => 0,
1219 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1220 array(
1221 'key' => Fallback_Email::FALLBACK_BACKUP_CODE_KEY,
1222 'compare' => 'EXISTS',
1223 ),
1224 ),
1225 'fields' => 'ID',
1226 )
1227 );
1228 if ( $query->get_total() > 0 ) {
1229 // Hashed codes.
1230 foreach ( $query->get_results() as $user_id ) {
1231 $backup_code = get_user_meta( $user_id, Fallback_Email::FALLBACK_BACKUP_CODE_KEY, true );
1232 if ( isset( $backup_code['code'], $backup_code['time'] ) ) {
1233 update_user_meta(
1234 $user_id,
1235 Fallback_Email::FALLBACK_BACKUP_CODE_KEY,
1236 array(
1237 'code' => wp_hash( $backup_code['code'] ),
1238 'time' => $backup_code['time'],
1239 )
1240 );
1241 }
1242 }
1243 }
1244 }
1245
1246 /**
1247 * Upgrade to 3.3.3: update 2FA flow for secret keys.
1248 *
1249 * @return void
1250 * @throws SodiumException If the encryption fails.
1251 * @since 3.3.3
1252 */
1253 private function upgrade_3_3_3(): void {
1254 // Create a file with a random key if it doesn't exist.
1255 if ( ( new Crypt() )->create_key_file() ) {
1256 // Get user data.
1257 $query = new WP_User_Query(
1258 array(
1259 'blog_id' => 0,
1260 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
1261 array(
1262 'key' => Totp::TOTP_SODIUM_SECRET_KEY,
1263 'compare' => 'EXISTS',
1264 ),
1265 ),
1266 'fields' => 'ID',
1267 )
1268 );
1269 if ( $query->get_total() > 0 ) {
1270 $service = wd_di()->get( Two_Fa_Component::class );
1271 foreach ( $query->get_results() as $user_id ) {
1272 // If we have the old states (cleartext, pub key) then re-encrypt via Sodium and save it.
1273 if ( ! $service->maybe_update( $user_id ) ) {
1274 // Otherwise just encrypt it via Sodium.
1275 $plaintext = defender_generate_random_string( Totp::TOTP_LENGTH, TOTP::TOTP_CHARACTERS );
1276 $new_key = Crypt::get_encrypted_data( $plaintext );
1277 if ( ! is_wp_error( $new_key ) ) {
1278 // Remove an old key.
1279 delete_user_meta( $user_id, Totp::TOTP_SECRET_KEY );
1280 // Update a new one.
1281 update_user_meta( $user_id, Totp::TOTP_SODIUM_SECRET_KEY, $new_key );
1282 }
1283 }
1284 }
1285 }
1286 }
1287 }
1288
1289 /**
1290 * Upgrade to 3.5.0: Delete config transient.
1291 *
1292 * @return void
1293 */
1294 private function upgrade_3_5_0(): void {
1295 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1296 }
1297
1298 /**
1299 * Upgrade to 3.8.0: Remove permanently banned ip record from `defender_lockout` table.
1300 *
1301 * @return void
1302 * @since 3.8.0
1303 */
1304 private function upgrade_3_8_0(): void {
1305 global $wpdb;
1306
1307 $blacklist_settings = new Model_Blacklist_Lockout();
1308 $blacklist = $blacklist_settings->get_list();
1309
1310 $offset = 0;
1311 $length = 100;
1312 $table = $wpdb->base_prefix . 'defender_lockout';
1313 // Variable within condition is for comparison.
1314 while ( $blacklist_chunk = array_slice( $blacklist, $offset, $length ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
1315 $sql = "DELETE FROM {$table} WHERE status = 'blocked' AND ip IN (" . implode( ', ', array_fill( 0, count( $blacklist_chunk ), '%s' ) ) . ')';
1316
1317 $query = call_user_func_array(
1318 array( $wpdb, 'prepare' ),
1319 array_merge( array( $sql ), $blacklist_chunk )
1320 );
1321 // SQL is prepared here. So we don't need to prepare it again.
1322 $wpdb->query( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
1323
1324 $offset += $length;
1325 }
1326
1327 $this->update_malware_notification_config();
1328 $this->update_malware_notification_setting();
1329 }
1330
1331 /**
1332 * Update malware notification config.
1333 *
1334 * @return void
1335 * @since 3.8.0
1336 */
1337 private function update_malware_notification_config(): void {
1338 $service = wd_di()->get( Backup_Settings::class );
1339 $configs = Config_Hub_Helper::get_configs( $service );
1340
1341 foreach ( $configs as $key => $config ) {
1342 $is_updated = false;
1343
1344 if ( isset( $config['configs']['scan']['email_subject_issue_found'] ) ) {
1345 $subject = $this->replace_scan_email_subject_issue_found( $config['configs']['scan']['email_subject_issue_found'] );
1346 $configs[ $key ]['configs']['scan']['email_subject_issue_found'] = $subject;
1347 $is_updated = true;
1348 }
1349 if ( isset( $config['configs']['scan']['email_content_issue_found'] ) ) {
1350 $content = $this->replace_scan_email_content_issue_found( $config['configs']['scan']['email_content_issue_found'] );
1351 $content = $this->replace_scan_email_content_issue_found_new( $content );
1352 $configs[ $key ]['configs']['scan']['email_content_issue_found'] = $content;
1353 $is_updated = true;
1354 }
1355
1356 if ( isset( $config['configs']['scan']['email_content_issue_not_found'] ) ) {
1357 $content = $this->replace_scan_email_content_issue_not_found( $config['configs']['scan']['email_content_issue_not_found'] );
1358 $configs[ $key ]['configs']['scan']['email_content_issue_not_found'] = $content;
1359 $is_updated = true;
1360 }
1361
1362 if ( isset( $config['configs']['scan']['email_content_error'] ) ) {
1363 $content = $this->replace_scan_email_content_error( $config['configs']['scan']['email_content_error'] );
1364 $configs[ $key ]['configs']['scan']['email_content_error'] = $content;
1365 $is_updated = true;
1366 }
1367
1368 if ( true === $is_updated ) {
1369 update_site_option( $key, $configs[ $key ] );
1370 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
1371 }
1372 }
1373
1374 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1375 }
1376
1377 /**
1378 * Update malware notification setting.
1379 *
1380 * @return void
1381 * @since 3.8.0
1382 */
1383 private function update_malware_notification_setting(): void {
1384 $models = array(
1385 wd_di()->get( Malware_Notification::class ),
1386 );
1387
1388 foreach ( $models as $model ) {
1389 $is_updated = false;
1390
1391 if ( isset( $model->configs['template']['found']['subject'] ) ) {
1392 $subject = $this->replace_scan_email_subject_issue_found( $model->configs['template']['found']['subject'] );
1393 $model->configs['template']['found']['subject'] = $subject;
1394 $is_updated = true;
1395 }
1396 if ( isset( $model->configs['template']['found']['body'] ) ) {
1397 $content = $this->replace_scan_email_content_issue_found( $model->configs['template']['found']['body'] );
1398 $content = $this->replace_scan_email_content_issue_found_new( $content );
1399 $model->configs['template']['found']['body'] = $content;
1400 $is_updated = true;
1401 }
1402
1403 if ( isset( $model->configs['template']['not_found']['body'] ) ) {
1404 $content = $this->replace_scan_email_content_issue_not_found( $model->configs['template']['not_found']['body'] );
1405 $model->configs['template']['not_found']['body'] = $content;
1406 $is_updated = true;
1407 }
1408
1409 if ( isset( $model->configs['template']['error']['body'] ) ) {
1410 $content = $this->replace_scan_email_content_error( $model->configs['template']['error']['body'] );
1411 $model->configs['template']['error']['body'] = $content;
1412 $is_updated = true;
1413 }
1414
1415 if ( true === $is_updated ) {
1416 $model->save();
1417 }
1418 }
1419 }
1420
1421 /**
1422 * Replace scan issue found email subject.
1423 *
1424 * @param string $subject Email subject.
1425 *
1426 * @return string
1427 * @since 3.8.0
1428 */
1429 private function replace_scan_email_subject_issue_found( string $subject ): string {
1430 $subject = str_replace(
1431 esc_html__( 'Scan of {SITE_URL} complete.', 'defender-security' ),
1432 esc_html__( 'Malware Scan of {SITE_URL} is complete.', 'defender-security' ),
1433 $subject
1434 );
1435
1436 return str_replace(
1437 esc_html__( '{ISSUES_COUNT} issues found.', 'defender-security' ),
1438 esc_html__( '{ISSUES_COUNT} issue(s) found.', 'defender-security' ),
1439 $subject
1440 );
1441 }
1442
1443 /**
1444 * Replace scan issue found email content.
1445 *
1446 * @param string $content Email content.
1447 *
1448 * @return string
1449 * @since 3.8.0
1450 */
1451 private function replace_scan_email_content_issue_found( string $content ): string {
1452 $content = $this->update_malware_scan_send_body_common( $content );
1453 $content = preg_replace(
1454 "/I(’|'|\\\')ve finished scanning \{SITE_URL\} for vulnerabilities and I found \{ISSUES_COUNT\} issues that you should take a closer look at!/i",
1455 '{ISSUES_COUNT} vulnerabilities were identified for {SITE_URL} during a Malware Scan. See details for each issue below.',
1456 $content
1457 );
1458
1459 return preg_replace( '/(\R+){ISSUES_LIST}/i', "\n\n{ISSUES_LIST}", $content );
1460 }
1461
1462 /**
1463 * Replace scan issue found email with new content.
1464 *
1465 * @param string $content Email content.
1466 *
1467 * @return string
1468 * @since 3.8.0
1469 */
1470 private function replace_scan_email_content_issue_found_new( string $content ): string {
1471 $content = str_replace(
1472 esc_html__( '{ISSUES_COUNT} vulnerabilities were identified for {SITE_URL} during a Malware Scan.', 'defender-security' ),
1473 esc_html__( 'Malware Scan identified {ISSUES_COUNT} issue(s) on {SITE_URL}.', 'defender-security' ),
1474 $content
1475 );
1476
1477 return str_replace(
1478 esc_html__( 'See details for each issue below.', 'defender-security' ),
1479 esc_html__( 'The identified issue(s) is/are listed below.', 'defender-security' ),
1480 $content
1481 );
1482 }
1483
1484 /**
1485 * Replace scan issue not found email content.
1486 *
1487 * @param string $content Email content.
1488 *
1489 * @return string
1490 * @since 3.8.0
1491 */
1492 private function replace_scan_email_content_issue_not_found( string $content ): string {
1493 $content = $this->update_malware_scan_send_body_common( $content );
1494 $content = preg_replace(
1495 "/I(’|'|\\\')ve finished scanning \{SITE_URL\} for vulnerabilities and I found nothing\. Well done for running such a tight ship!/i",
1496 'No vulnerabilities have been found for {SITE_URL}.',
1497 $content
1498 );
1499
1500 return preg_replace(
1501 "/(\R+)Keep up the good work! With regular security scans and a well-hardened installation you\'ll be just fine\./i",
1502 '',
1503 $content
1504 );
1505 }
1506
1507 /**
1508 * Replace scan error email content.
1509 *
1510 * @param string $content Email content.
1511 *
1512 * @return string
1513 * @since 3.8.0
1514 */
1515 private function replace_scan_email_content_error( string $content ): string {
1516 $content = $this->update_malware_scan_send_body_common( $content );
1517
1518 return str_replace( 'I couldn', 'We couldn', $content );
1519 }
1520
1521 /**
1522 * Upgrade to 3.8.2: Check XSS in existing configs.
1523 *
1524 * @return void
1525 */
1526 private function upgrade_3_8_2(): void {
1527 $service = wd_di()->get( Backup_Settings::class );
1528 $configs = Config_Hub_Helper::get_configs( $service );
1529
1530 foreach ( $configs as $key => $config ) {
1531 $is_updated = false;
1532
1533 if ( isset( $config['name'] ) ) {
1534 $prev_name = $config['name'];
1535 // Sanitize data.
1536 $new_name = sanitize_text_field( $config['name'] );
1537 if ( $prev_name !== $new_name ) {
1538 $is_updated = true;
1539 }
1540 $prev_desc = $config['description'];
1541 $new_desc = ! is_string( $config['description'] ) || '' === trim( $config['description'] ) ? '' : sanitize_textarea_field( $config['description'] );
1542 if ( $prev_desc !== $new_desc ) {
1543 $is_updated = true;
1544 }
1545 }
1546
1547 if ( true === $is_updated ) {
1548 update_site_option( $key, $configs[ $key ] );
1549 Config_Hub_Helper::update_on_hub( $configs[ $key ] );
1550 }
1551 }
1552
1553 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1554 }
1555
1556 /**
1557 * Upgrade to 3.9.0.
1558 *
1559 * @return void
1560 */
1561 private function upgrade_3_9_0(): void {
1562 global $wpdb;
1563
1564 $prev_val = $wpdb->hide_errors();
1565 // Changes for Lockout table.
1566 $wpdb->query( "ALTER TABLE{$wpdb->base_prefix}defender_lockout MODIFY COLUMN ip VARCHAR(45)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1567 // Changes for Lockout log table.
1568 $wpdb->query( "ALTER TABLE{$wpdb->base_prefix}defender_lockout_log MODIFY COLUMN ip VARCHAR(45)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1569 $wpdb->show_errors( $prev_val );
1570 }
1571
1572 /**
1573 * Upgrade to 3.11.0.
1574 *
1575 * @return void
1576 */
1577 private function upgrade_3_11_0(): void {
1578 // Move Global IP settings.
1579 $option = get_site_option( wd_di()->get( Model_Blacklist_Lockout::class )->get_table() );
1580 if ( is_string( $option ) && '' !== trim( $option ) ) {
1581 $old_settings = json_decode( $option, true );
1582 if ( is_array( $old_settings ) && isset( $old_settings['global_ip_list'] ) ) {
1583 $model_global_ip = wd_di()->get( Global_Ip_Lockout::class );
1584 $model_global_ip->enabled = (bool) $old_settings['global_ip_list'];
1585 $model_global_ip->save();
1586 }
1587 }
1588 }
1589
1590 /**
1591 * Upgrade to 3.12.0.
1592 *
1593 * @return void
1594 */
1595 private function upgrade_3_12_0(): void {
1596 delete_site_transient( Config_Hub_Helper::CONFIGS_TRANSIENT_KEY );
1597
1598 $service = wd_di()->get( Backup_Settings::class );
1599 $configs = Config_Hub_Helper::get_configs( $service );
1600
1601 foreach ( $configs as $key => &$config ) {
1602 if ( ! is_array( $config ) ) {
1603 continue;
1604 }
1605
1606 $config['is_removable'] = $config['is_removable'] ?? true;
1607
1608 // Update config data.
1609 update_site_option( $key, $config );
1610 }
1611
1612 set_site_transient(
1613 Config_Hub_Helper::CONFIGS_TRANSIENT_KEY,
1614 $configs,
1615 Config_Hub_Helper::CONFIGS_TRANSIENT_TIME
1616 );
1617 update_site_option( Config_Hub_Helper::CONFIGS_TRANSIENT_TIME_KEY, time() );
1618
1619 // Clear Global IP schedule hook to update event frequency.
1620 wp_clear_scheduled_hook( 'wpdef_fetch_global_ip_list' );
1621 }
1622
1623 /**
1624 * Upgrade to 4.0.0: Creates quarantine table.
1625 *
1626 * @return void
1627 */
1628 private function upgrade_4_0_0(): void {
1629 $bootstrap = wd_di()->get( Bootstrap::class );
1630 $bootstrap->create_table_quarantine();
1631 }
1632
1633 /**
1634 * Upgrade to 4.1.0.
1635 *
1636 * @return void
1637 */
1638 private function upgrade_4_1_0() {
1639 // Remove Maxmind DB directory from multisite subsites.
1640 if ( is_multisite() ) {
1641 global $wpdb, $wp_filesystem;
1642
1643 if ( is_null( $wp_filesystem ) ) {
1644 if ( ! function_exists( 'WP_Filesystem' ) ) {
1645 require_once ABSPATH . 'wp-admin/includes/file.php';
1646 }
1647 WP_Filesystem();
1648 }
1649 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
1650 return;
1651 }
1652
1653 $main_site_id = get_main_site_id();
1654 $offset = 0;
1655 $limit = 100;
1656 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1657 $wpdb->prepare(
1658 "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id != %d LIMIT %d, %d",
1659 $main_site_id,
1660 $offset,
1661 $limit
1662 ),
1663 ARRAY_A
1664 );
1665 while ( is_array( $blogs ) && array() !== $blogs ) {
1666 foreach ( $blogs as $blog ) {
1667 switch_to_blog( $blog['blog_id'] );
1668
1669 $maxmind_dir = $this->get_tmp_path() . DIRECTORY_SEPARATOR . MaxMind_Geolocation::DB_DIRECTORY;
1670 $wp_filesystem->delete( $maxmind_dir, true );
1671
1672 restore_current_blog();
1673 }
1674 $offset += $limit;
1675 $blogs = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1676 $wpdb->prepare(
1677 "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id != %d LIMIT %d, %d",
1678 $main_site_id,
1679 $offset,
1680 $limit
1681 ),
1682 ARRAY_A
1683 );
1684 }
1685 }
1686 }
1687
1688 /**
1689 * Upgrade to 4.2.0.
1690 *
1691 * @return void
1692 */
1693 private function upgrade_4_2_0(): void {
1694 // Add the Tracking modal.
1695 update_site_option( Data_Tracking::TRACKING_SLUG, true );
1696 }
1697
1698 /**
1699 * Upgrade to 4.5.1.
1700 *
1701 * @return void
1702 */
1703 private function upgrade_4_5_1(): void {
1704 $service = wd_di()->get( Firewall::class );
1705 $service->auto_switch_ip_detection_option();
1706 $service->maybe_show_misconfigured_ip_detection_option_notice();
1707 }
1708
1709 /**
1710 * Upgrade to 4.6.0.
1711 *
1712 * @return void
1713 */
1714 private function upgrade_4_6_0(): void {
1715 // Create Unlockout table.
1716 $bootstrap = wd_di()->get( Bootstrap::class );
1717 $bootstrap->create_table_unlockout();
1718 }
1719
1720 /**
1721 * Upgrade to 4.7.2.
1722 *
1723 * @return void
1724 */
1725 private function upgrade_4_7_2(): void {
1726 $model = wd_di()->get( Two_Fa_Settings::class );
1727
1728 if ( isset( $model->email_body ) ) {
1729 $model->email_body = 'Hi {{display_name}},
1730
1731 Your temporary password is {{passcode}}
1732 To complete your login, copy and paste the temporary password into the Password field on the login screen.';
1733 $model->save();
1734 }
1735 }
1736
1737 /**
1738 * Upgrade to 4.8.2.
1739 *
1740 * @return void
1741 */
1742 private function upgrade_4_8_2(): void {
1743 $xff = defender_get_data_from_request( 'HTTP_X_FORWARDED_FOR', 's' );
1744 if (
1745 ! ( is_string( $xff ) && 0 < strlen( $xff ) ) &&
1746 Firewall::is_switched_ip_detection_notice( Firewall::IP_DETECTION_XFF_SHOW_SLUG )
1747 ) {
1748 delete_site_option( Firewall::IP_DETECTION_XFF_SHOW_SLUG );
1749 }
1750 }
1751
1752 /**
1753 * Upgrade to 4.9.0.
1754 *
1755 * @return void
1756 */
1757 private function upgrade_4_9_0(): void {
1758 $model = wd_di()->get( Model_Firewall::class );
1759
1760 $model->ip_detection_type = 'manual';
1761 $model->save();
1762
1763 // Add tracking.
1764 $firewall_analytics = wd_di()->get( Firewall_Analytics::class );
1765 $detection_method = Firewall_Analytics::get_detection_method_label(
1766 $model->ip_detection_type,
1767 $model->http_ip_header
1768 );
1769
1770 $firewall_analytics->track_feature(
1771 Firewall_Analytics::EVENT_IP_DETECTION,
1772 array( Firewall_Analytics::PROP_IP_DETECTION => $detection_method )
1773 );
1774 }
1775
1776 /**
1777 * Upgrade.
1778 *
1779 * @return void
1780 */
1781 private function upgrade_5_0_0(): void {
1782 update_site_option( \WP_Defender\Component\IP\Antibot_Global_Firewall::NOTICE_SLUG, true );
1783 }
1784
1785 /**
1786 * Upgrade to 5.0.2: Clear the blocklist count. Also set the whitelist server public IP.
1787 *
1788 * @return void
1789 */
1790 private function upgrade_5_0_2(): void {
1791 delete_site_transient( 'wpdef_antibot_global_firewall_db_blocklist_count' );
1792 wd_di()->get( Firewall::class )->set_whitelist_server_public_ip();
1793 }
1794
1795 /**
1796 * Upgrade to 5.1.1: Migrate IP detection option to Automatic from Manual > All headers.
1797 *
1798 * @return void
1799 */
1800 private function upgrade_5_1_1(): void {
1801 $model = wd_di()->get( Model_Firewall::class );
1802 if ( 'manual' === $model->ip_detection_type && '' === $model->http_ip_header ) {
1803 $model->ip_detection_type = 'automatic';
1804 $model->http_ip_header = 'REMOTE_ADDR';
1805 $model->save();
1806 wd_di()->get( \WP_Defender\Component\Smart_Ip_Detection::class )->smart_ip_detection_ping();
1807 // Add tracking.
1808 $firewall_analytics = wd_di()->get( Firewall_Analytics::class );
1809 $detection_method = Firewall_Analytics::get_detection_method_label( 'automatic', '' );
1810
1811 $firewall_analytics->track_feature(
1812 Firewall_Analytics::EVENT_IP_DETECTION,
1813 array( Firewall_Analytics::PROP_IP_DETECTION => $detection_method )
1814 );
1815 }
1816 }
1817 /**
1818 * Update UA blocklist.
1819 *
1820 * @return void
1821 */
1822 private function update_ua_blocklist(): void {
1823 $settings = wd_di()->get( User_Agent_Lockout::class );
1824 $blacklist = $settings->get_lockout_list( 'blocklist', false );
1825 if ( array() === $blacklist ) {
1826 return;
1827 }
1828 $blacklist = array_filter(
1829 $blacklist,
1830 function ( $agent ) {
1831 return false === stripos( $agent, 'ahrefsbot' ) && false === stripos( $agent, 'semrushbot' );
1832 }
1833 );
1834 $settings->blacklist = implode( "\n", $blacklist ); // Convert back to string.
1835 $settings->save();
1836 }
1837
1838 /**
1839 * Upgrade to 5.2.0.
1840 *
1841 * @return void
1842 */
1843 private function upgrade_5_2_0(): void {
1844 $this->update_ua_blocklist();
1845 // Add the "What's new" modal.
1846 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1847 }
1848
1849 /**
1850 * Upgrade to 5.3.0.
1851 *
1852 * @return void
1853 */
1854 private function upgrade_5_3_0(): void {
1855 // Add the "What's new" modal.
1856 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1857 // Add composite index to the defender_lockout table.
1858 global $wpdb;
1859 // Check if the index already exists.
1860 $wpdb->query( "SHOW INDEX FROM {$wpdb->base_prefix}defender_lockout_log WHERE Key_name = 'idx_ip_date'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1861 if ( 0 === $wpdb->num_rows ) {
1862 // If the index does not exist, create it.
1863 $prev_val = $wpdb->hide_errors();
1864 $wpdb->query( "ALTER TABLE {$wpdb->base_prefix}defender_lockout_log ADD INDEX idx_ip_date (ip, date DESC);" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1865 $wpdb->show_errors( $prev_val );
1866 }
1867 }
1868
1869 /**
1870 * Upgrade to 5.3.1.
1871 *
1872 * @return void
1873 */
1874 private function upgrade_5_3_1(): void {
1875 delete_site_transient( \WP_Defender\Component\IP\Antibot_Global_Firewall::BLOCKLIST_STATS_KEY );
1876 }
1877
1878 /**
1879 * Improve UA Blocklist.
1880 *
1881 * @return void
1882 */
1883 private function improve_ua_blocklist(): void {
1884 $settings = wd_di()->get( User_Agent_Lockout::class );
1885 $blocklist_custom = $settings->get_lockout_list( 'blocklist' );
1886 if ( array() === $blocklist_custom ) {
1887 return;
1888 }
1889 // Get 'Blocklist Presets', check and remove duplicates on 'Custom User Agents'.
1890 $blocklist_presets = User_Agent_Service::get_nested_keys_of_blocklist_presets();
1891 $common_result = array_intersect( $blocklist_custom, $blocklist_presets );
1892 if ( array() !== $common_result ) {
1893 $blocklist_custom = User_Agent_Service::check_and_remove_duplicates(
1894 $blocklist_custom,
1895 $common_result
1896 );
1897 // Convert back to string.
1898 $settings->blacklist = implode( PHP_EOL, $blocklist_custom );
1899 // Enable option with nested suboptions.
1900 $settings->blocklist_presets = true;
1901 $settings->blocklist_preset_values = $common_result;
1902 }
1903 // The same, but for 'Script Presets'.
1904 $script_presets = array_keys( User_Agent_Service::get_script_presets() );
1905 $common_result = array_intersect( $blocklist_custom, $script_presets );
1906 if ( array() !== $common_result ) {
1907 $blocklist_custom = User_Agent_Service::check_and_remove_duplicates(
1908 $blocklist_custom,
1909 $common_result
1910 );
1911 // Convert back to string.
1912 $settings->blacklist = implode( PHP_EOL, $blocklist_custom );
1913 // Enable option with nested suboptions.
1914 $settings->script_presets = true;
1915 $settings->script_preset_values = $common_result;
1916 }
1917 $settings->save();
1918 }
1919
1920 /**
1921 * Upgrade to 5.4.0.
1922 *
1923 * @return void
1924 */
1925 private function upgrade_5_4_0(): void {
1926 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1927
1928 $this->improve_ua_blocklist();
1929 }
1930
1931 /**
1932 * Upgrade to 5.5.0.
1933 *
1934 * @return void
1935 */
1936 private function upgrade_5_5_0(): void {
1937 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1938 }
1939
1940 /**
1941 * Change lockout log mentions from fake_bot to malicious_bot. Also move BotTrap settings.
1942 *
1943 * @return void
1944 */
1945 private function change_to_malicious_bot(): void {
1946 global $wpdb;
1947
1948 $table_name = $wpdb->base_prefix . 'defender_lockout_log';
1949
1950 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1951 $wpdb->prepare(
1952 "UPDATE $table_name SET type = %s, log = %s WHERE type = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1953 'malicious_bot',
1954 'Lockout occurred: Bot ignored robots.txt rules.',
1955 'bot_trap'
1956 )
1957 );
1958 // Clear schedule as the name has changed to 'wpdef_rotate_malicious_bot_secret_hash'.
1959 wp_clear_scheduled_hook( 'wpdef_rotate_bot_trap_secret_hash' );
1960 // Move the BotTrap settings to new Malicious Bot settings.
1961 $settings = wd_di()->get( User_Agent_Lockout::class );
1962 if ( isset( $settings->bot_trap_enabled ) ) {
1963 $settings->malicious_bot_enabled = $settings->bot_trap_enabled;
1964 $settings->malicious_bot_lockout_type = $settings->bot_trap_lockout_type;
1965 $settings->malicious_bot_lockout_duration = $settings->bot_trap_lockout_duration;
1966 $settings->malicious_bot_lockout_duration_unit = $settings->bot_trap_lockout_duration_unit;
1967 $settings->save();
1968 }
1969 }
1970
1971 /**
1972 * Upgrade to 5.6.0.
1973 *
1974 * @return void
1975 */
1976 private function upgrade_5_6_0(): void {
1977 $this->change_to_malicious_bot();
1978 // Add the "What's new" modal.
1979 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1980 }
1981
1982 /**
1983 * Upgrade to 5.7.0.
1984 *
1985 * @return void
1986 */
1987 private function upgrade_5_7_0(): void {
1988 // Remove old objects from ncm options.
1989 wd_di()->get( \WP_Defender\Component\Network_Cron_Manager::class )->remove_data();
1990 // Add the "What's new" modal.
1991 update_site_option( Feature_Modal::FEATURE_SLUG, true );
1992 // Add Cloudflare challenge-platform to 404 whitelist.
1993 $this->force_nf_lockout_exclusions();
1994 // Remove the prev Breadcrumbs.
1995 wd_di()->get( \WP_Defender\Component\Breadcrumbs::class )->delete_previous_meta();
1996 }
1997
1998 /**
1999 * Migrates the uninstall settings from v5.11 to v6.0.
2000 *
2001 * During the upgrade:
2002 * - If at least one of the three options is set to Preserve/Keep,
2003 * all three options are set to Preserve/Keep.
2004 */
2005 private function combine_all_preserve_settings_to_one() {
2006 $main_settings = wd_di()->get( \WP_Defender\Model\Setting\Main_Setting::class );
2007
2008 if (
2009 'preserve' === $main_settings->uninstall_settings ||
2010 'keep' === $main_settings->uninstall_data ||
2011 'keep' === $main_settings->uninstall_quarantine
2012 ) {
2013 $main_settings->uninstall_settings = 'preserve';
2014 $main_settings->uninstall_data = 'keep';
2015 $main_settings->uninstall_quarantine = 'keep';
2016 $main_settings->save();
2017 }
2018 }
2019
2020 /**
2021 * Since with version 6.0.0, two nested options:
2022 * - Scan core files
2023 * - Scan plugin files,
2024 * are hidden, and automatically take the value of the parent "File change detection" option.
2025 */
2026 private function combine_all_file_change_detection_settings_to_one() {
2027 $scan_settings = wd_di()->get( Scan_Settings::class );
2028 if ( $scan_settings->integrity_check ) {
2029 $scan_settings->check_core = true;
2030 $scan_settings->check_plugins = true;
2031 } else {
2032 $scan_settings->check_core = false;
2033 $scan_settings->check_plugins = false;
2034 }
2035 $scan_settings->save();
2036 }
2037
2038 /**
2039 * Upgrade to 6.0.0: Ensure quarantine table exists for Free HC users and other changes.
2040 * The quarantine feature is now available in the free version.
2041 *
2042 * @return void
2043 */
2044 private function upgrade_6_0_0(): void {
2045 $bootstrap = wd_di()->get( Bootstrap::class );
2046 $bootstrap->create_table_quarantine();
2047 $this->combine_all_preserve_settings_to_one();
2048 $this->combine_all_file_change_detection_settings_to_one();
2049 $this->update_issues_report_email_template();
2050 $this->migrate_tweak_reminder_schedule();
2051 $this->migrate_two_fa_force_auth_roles();
2052 // Add the "What's new" modal.
2053 update_site_option( Feature_Modal::FEATURE_SLUG, true );
2054 }
2055
2056 /**
2057 * Clear force_auth_roles when user_roles is empty.
2058 *
2059 * @return void
2060 */
2061 private function migrate_two_fa_force_auth_roles(): void {
2062 $model = wd_di()->get( Two_Fa_Settings::class );
2063 if ( count( $model->user_roles ) === 0 ) {
2064 $model->force_auth = false;
2065 $model->force_auth_roles = array();
2066 $model->save();
2067 }
2068 }
2069
2070 /**
2071 * Migrate the Hardening report (Tweak Reminder) schedule from 5.11 to 6.0.0.
2072 *
2073 * In 5.11, this report was saved with type 'notification' and its frequency was kept in
2074 * `configs['reminder']`. In 6.0.0, it's saved with type 'report' and uses new, dedicated
2075 * fields instead: `frequency`, `day`, `day_n`, `time`, `est_timestamp`.
2076 *
2077 * Without this migration, a site updated from 5.11 keeps the old type 'notification', so the
2078 * new scheduling code can't calculate a valid next-send date and the report email gets sent
2079 * on every cron run instead of on the configured schedule.
2080 *
2081 * @return void
2082 */
2083 private function migrate_tweak_reminder_schedule(): void {
2084 $model = wd_di()->get( Tweak_Reminder::class );
2085 if ( 'notification' !== $model->type ) {
2086 return;
2087 }
2088
2089 $model->type = 'report';
2090 if ( isset( $model->configs['reminder'] ) && in_array( $model->configs['reminder'], array( 'daily', 'weekly', 'monthly' ), true ) ) {
2091 $model->frequency = $model->configs['reminder'];
2092 }
2093 if ( null === $model->day || '' === $model->day ) {
2094 $model->day = 'sunday';
2095 }
2096 if ( null === $model->time || '' === $model->time ) {
2097 $model->time = '4:00';
2098 }
2099 $model->save();
2100 }
2101
2102 /**
2103 * Update Issues Report email subject and body to match the new copy.
2104 *
2105 * @return void
2106 */
2107 private function update_issues_report_email_template(): void {
2108 $models = array(
2109 wd_di()->get( Malware_Notification::class ),
2110 wd_di()->get( Malware_Report::class ),
2111 );
2112
2113 $default_subject = esc_html__( 'Issues Report for {SITE_URL}: {ISSUES_COUNT} issue(s) found.', 'defender-security' );
2114 $default_body = esc_html__(
2115 'Hi {USER_NAME},
2116
2117 A scan of {SITE_URL} identified {ISSUES_COUNT} issue(s). The issue(s) found is/are listed below.
2118
2119 {ISSUES_LIST}',
2120 'defender-security'
2121 );
2122
2123 foreach ( $models as $model ) {
2124 if ( ! isset( $model->configs['template']['found'] ) ) {
2125 continue;
2126 }
2127 $model->configs['template']['found']['subject'] = $default_subject;
2128 $model->configs['template']['found']['body'] = $default_body;
2129 $model->save();
2130 }
2131 }
2132 }
2133