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

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