PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.1.0
Defender Security – Malware Scanner, Login Security & Firewall v6.1.0
6.3.0 6.2.3 6.2.4 6.2.0 6.2.1 6.2.2 6.1.0 5.3.1 5.4.0 5.4.1 5.5.0 5.5.1 5.6.0 5.6.1 5.6.2 5.7.0 5.7.1 5.7.2 5.8.0 5.8.1 5.9.0 6.0.0 6.0.1 3.0.1 3.1.0 All 141 releases
defender-security / src / component / class-cli.php

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

1,338 lines 40.7 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 Cli class which is used to handle WP-CLI commands for the WP Defender plugin.
4 * It provides methods to manage scans, audits, firewall settings, and more through the command line.
5 *
6 * @package WP_Defender\Component
7 */
8
9 namespace WP_Defender\Component;
10
11 use WP_CLI;
12 use Countable;
13 use Exception;
14 use Throwable;
15 use Faker\Factory;
16 use WP_Filesystem_Base;
17 use WP_CLI\ExitException;
18 use WP_Defender\Traits\IO;
19 use WP_Defender\Traits\Theme;
20 use WP_Defender\Traits\Plugin;
21 use WP_Defender\Traits\Formats;
22 use WP_Defender\Model\Audit_Log;
23 use WP_Defender\Model\Scan_Item;
24 use WP_Defender\Model\Lockout_Ip;
25 use WP_Defender\Model\Lockout_Log;
26 use WP_Defender\Controller\Dashboard;
27 use WP_Defender\Controller\Two_Factor;
28 use WP_Defender\Controller\Login_Access;
29 use WP_Defender\Controller\Main_Setting;
30 use WP_Defender\Controller\Audit_Logging;
31 use WP_Defender\Model\Scan as Model_Scan;
32 use WP_Defender\Controller\Security_Tweaks;
33 use WP_Defender\Model\Setting\Login_Lockout;
34 use WP_Defender\Behavior\Scan\Core_Integrity;
35 use WP_Defender\Controller\Blocklist_Monitor;
36 use WP_Defender\Model\Setting\Password_Reset;
37 use WP_Defender\Model\Setting\Notfound_Lockout;
38 use WP_Defender\Model\Setting\Security_Headers;
39 use WP_Defender\Component\Scan as Scan_Component;
40 use WP_Defender\Component\Logger\Rotation_Logger;
41 use WP_Defender\Model\Setting\User_Agent_Lockout;
42 use function WP_CLI\Utils\format_items;
43
44 if ( ! defined( 'WPINC' ) ) {
45 die;
46 }
47
48 /**
49 * Class Cli
50 */
51 class Cli {
52
53 use Formats {
54 calculate_date_interval as protected;
55 format_bytes_into_readable as protected;
56 format_date_time as protected;
57 get_date as protected;
58 get_days_of_week as protected;
59 get_times as protected;
60 get_timezone_string as protected;
61 local_to_utc as protected;
62 moment_datetime_format_from as protected;
63 persistent_hub_datetime_format as protected;
64 time_since as protected;
65 get_local_human_date as protected;
66 release_cron_lock as protected;
67 create_lock as protected;
68 has_lock as protected;
69 remove_lock as protected;
70 check_plugin_on_wp_org as protected;
71 check_by_readme_file as protected;
72 acquire_cron_lock as protected;
73 compare_hashes as protected;
74 delete_dir as protected;
75 detect_line_ending as protected;
76 get_abs_plugin_path_by_slug as protected;
77 get_active_plugin_names as protected;
78 get_path_of_themes_dir as protected;
79 get_log_path as protected;
80 get_plugin_details_by as protected;
81 get_plugin_directory_name as protected;
82 get_plugin_headers as protected;
83 get_plugin_relative_path as protected;
84 get_plugin_slugs as protected;
85 get_plugins as protected;
86 get_plugin_slug_by as protected;
87 get_theme as protected;
88 get_theme_slugs as protected;
89 get_themes as protected;
90 get_time_diff as protected;
91 handle_wp_org_response_by as protected;
92 is_active_plugin as protected;
93 is_active_theme as protected;
94 is_likely_wporg_slug as protected;
95 ping_wp_org_by_plugin_slug as protected;
96 }
97 use IO;
98 use Theme;
99 use Plugin;
100
101 /**
102 * Run scans and manage scan results via WP-CLI.
103 *
104 * ## OPTIONS
105 *
106 * <command>
107 * : Action to perform.
108 * ---
109 * options:
110 * - run
111 * - ignore
112 * - unignore
113 * - resolve
114 * - delete
115 * - clear_logs
116 * ---
117 *
118 * [--type=<type>]
119 * : Filter by issue type. Omit to target all types.
120 * ---
121 * options:
122 * - detailed
123 * - core_integrity
124 * - plugin_integrity
125 * - vulnerability
126 * - suspicious_code
127 * - plugin_outdated
128 * - plugin_closed
129 * ---
130 *
131 * ## EXAMPLES
132 *
133 * # Run a full scan.
134 * $ wp defender scan run
135 * Success: All done!
136 *
137 * # Run a detailed scan with table output.
138 * $ wp defender scan run --type=detailed
139 *
140 * # Ignore all active core integrity issues.
141 * $ wp defender scan ignore --type=core_integrity
142 *
143 * # Resolve all active vulnerability issues.
144 * $ wp defender scan resolve --type=vulnerability
145 *
146 * # Delete all suspicious code files.
147 * $ wp defender scan delete --type=suspicious_code
148 *
149 * # Clear completed scan logs.
150 * $ wp defender scan clear_logs
151 *
152 * @param mixed $args Command arguments.
153 * @param mixed $options Command options.
154 *
155 * @throws ExitException If an invalid command is provided.
156 */
157 public function scan( $args, $options ) {
158 if ( ! is_array( $args ) || array() === $args ) {
159 WP_CLI::error( 'Invalid command' );
160
161 return;
162 }
163 [$command] = $args;
164 switch ( $command ) {
165 case 'run':
166 $this->scan_all( $options );
167 break;
168 case 'clear_logs':
169 $this->scan_clear_logs();
170 break;
171 default:
172 $commands = array( 'ignore', 'unignore', 'resolve', 'delete' );
173 if ( in_array( $command, $commands, true ) ) {
174 WP_CLI::confirm( 'This can cause your site get fatal error and can\'t restore back unless you have a backup, are you sure to continue?', $options );
175 $this->scan_task( $command, $options );
176 } else {
177 WP_CLI::error( sprintf( 'Unknown command %s', $command ) );
178 }
179 break;
180 }
181 }
182
183 /**
184 * Starts a full scan based on the provided options.
185 *
186 * @param array $options Command options.
187 */
188 private function scan_all( $options ) {
189 $type = $options['type'] ?? null;
190 $is_detailed = false;
191 switch ( $type ) {
192 case null:
193 // All items.
194 $type = null;
195 break;
196 case 'detailed':
197 $is_detailed = true;
198 break;
199 default:
200 WP_CLI::error( sprintf( 'Unknown scan type %s', $type ) );
201 break;
202 }
203 WP_CLI::log( 'Check if there is a scan ongoing...' );
204 $scan = Model_Scan::get_active();
205 if ( ! is_object( $scan ) ) {
206 WP_CLI::log( 'No active scan, creating...' );
207 // Match the web-triggered flow: clear stale idle scans first so they don't skew "last scan" lookups.
208 wd_di()->get( Model_Scan::class )->delete_idle();
209 delete_site_option( Core_Integrity::ISSUE_CHECKSUMS );
210 $scan = Model_Scan::create();
211 if ( is_wp_error( $scan ) ) {
212 WP_CLI::error( $scan->get_error_message() );
213 }
214 wd_di()->get( Scan_Component::class )->gather_actioned_plugin_details();
215 } else {
216 WP_CLI::log( 'Continue from last scan' );
217 }
218 // Start detailed scan.
219 if ( $is_detailed ) {
220 $start = microtime( true );
221 }
222 $handler = wd_di()->get( Scan_Component::class );
223 while ( $handler->process() === false ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedWhile
224 }
225 $scan = Model_Scan::get_last();
226 if ( ! is_object( $scan ) || is_wp_error( $scan ) ) {
227 return;
228 }
229 $results = $scan->to_array();
230 if ( is_array( $results ) && isset( $results['issues_items'] ) && array() !== $results['issues_items'] ) {
231 $count = is_array( $results['issues_items'] ) || $results['issues_items'] instanceof Countable ? count( $results['issues_items'] ) : 0;
232 // Finish detailed scan.
233 if ( $is_detailed ) {
234 format_items( 'table', $results['issues_items'], array( 'type', 'short_desc', 'full_path' ) );
235 WP_CLI::log( sprintf( 'Saved %d items.', $count ) );
236 $finish = microtime( true ) - $start;
237 WP_CLI::log( 'Scan takes ' . round( $finish, 2 ) . 's to process.' );
238 } else {
239 WP_CLI::log( sprintf( 'Found %d issues.', $count ) );
240 }
241 }
242 WP_CLI::success( 'All done!' );
243 }
244
245 /**
246 * Clear completed action scheduler logs.
247 */
248 private function scan_clear_logs() {
249 $scan_component = wd_di()->get( Scan_Component::class );
250 $result = $scan_component::clear_logs();
251 $message = $result['success'] ?? $result['error'] ?? 'Malware scan logs are cleared';
252
253 WP_CLI::log( $message );
254 }
255
256 /**
257 * Executes tasks based on the type of scan.
258 *
259 * @param string $command The task to perform.
260 * @param mixed $options Command options.
261 */
262 private function scan_task( $command, $options ) {
263 $option_type = is_array( $options ) ? ( $options['type'] ?? null ) : null;
264 $type = is_string( $option_type ) && '' !== $option_type ? strtolower( $option_type ) : null;
265 if ( defender_is_wp_org_version() && in_array(
266 $type,
267 array(
268 Scan_Item::TYPE_VULNERABILITY, // TYPE_SUSPICIOUS const is not suitable for use.
269 'suspicious_code',
270
271 ),
272 true
273 ) ) {
274 WP_CLI::warning( 'A WPMU DEV subscription is required to use this command.' );
275 return;
276 }
277
278 switch ( $type ) {
279 case null:
280 // All items.
281 $type = null;
282 break;
283 case 'core_integrity':
284 $type = Scan_Item::TYPE_INTEGRITY;
285 break;
286 case 'plugin_integrity':
287 $type = Scan_Item::TYPE_PLUGIN_CHECK;
288 break;
289 case 'vulnerability':
290 $type = Scan_Item::TYPE_VULNERABILITY;
291 break;
292 case 'suspicious_code':
293 $type = Scan_Item::TYPE_SUSPICIOUS;
294 break;
295 case 'plugin_outdated':
296 $type = Scan_Item::TYPE_PLUGIN_OUTDATED;
297 break;
298 case 'plugin_closed':
299 $type = Scan_Item::TYPE_PLUGIN_CLOSED;
300 break;
301 default:
302 WP_CLI::error( sprintf( 'Unknown scan type %s', $type ) );
303 break;
304 }
305 $active = Model_Scan::get_active();
306 if ( is_object( $active ) ) {
307 WP_CLI::error( 'A scan is running, you need to wait till it complete to continue' );
308 }
309 $model = Model_Scan::get_last();
310 if ( ! is_object( $model ) ) {
311 return;
312 }
313 switch ( $command ) {
314 case 'ignore':
315 $issues = $model->get_issues( $type, Scan_Item::STATUS_ACTIVE );
316 foreach ( $issues as $issue ) {
317 $issue_data = $this->split_scan_issue_into_file_and_dir( $type, $issue->raw_data );
318 if ( $model->ignore_issue( $issue->id ) ) {
319 WP_CLI::log( sprintf( 'Ignoring %s: %s', $issue_data['type'], $issue_data['path'] ) );
320 }
321 }
322 WP_CLI::log( sprintf( 'Ignored %s items', count( $issues ) ) );
323 break;
324 case 'unignore':
325 $issues = $model->get_issues( $type, Scan_Item::STATUS_IGNORE );
326 foreach ( $issues as $issue ) {
327 $issue_data = $this->split_scan_issue_into_file_and_dir( $type, $issue->raw_data );
328 if ( $model->unignore_issue( $issue->id ) ) {
329 WP_CLI::log( sprintf( 'Unignoring %s: %s', $issue_data['type'], $issue_data['path'] ) );
330 }
331 }
332 WP_CLI::log( sprintf( 'Unignored %s items', count( $issues ) ) );
333 break;
334 case 'resolve':
335 $items = $model->get_issues( $type, Scan_Item::STATUS_ACTIVE );
336 $resolved = array();
337 foreach ( $items as $item ) {
338 if ( in_array( $item->type, array( Scan_Item::TYPE_INTEGRITY, Scan_Item::TYPE_PLUGIN_CHECK ), true ) ) {
339 WP_CLI::log( sprintf( 'Reverting %s to original', $item->raw_data['file'] ) );
340 $ret = $item->resolve();
341 if ( ! is_wp_error( $ret ) ) {
342 $resolved[] = $item;
343 } else {
344 WP_CLI::error( $ret->get_error_message() );
345 }
346 } elseif ( Scan_Item::TYPE_SUSPICIOUS === $item->type ) {
347 // If this is content, we will try to delete them.
348 $whitelist = array(// wordfence waf.
349 ABSPATH . '/wordfence-waf.php', // Any files inside plugins, if removed, can cause fatal error.
350 WP_CONTENT_DIR . '/plugins/', // Any files inside themes.
351 $this->get_path_of_themes_dir(),
352 );
353 $path = $item->raw_data['file'];
354 $can_delete = true;
355 foreach ( $whitelist as $value ) {
356 $current = $value;
357 if ( str_contains( $path, $value ) ) {
358 // Ignore this.
359 $can_delete = false;
360 break;
361 }
362 }
363 if ( false === $can_delete ) {
364 WP_CLI::log( sprintf( 'Ignore file %s as it is in %s', $path, $current ) );
365 } elseif ( ! is_dir( $path ) && wp_delete_file( $path ) ) {
366 WP_CLI::log( sprintf( 'Delete file %s', $path ) );
367 $model->remove_issue( $item->id );
368 $resolved[] = $item;
369 } else {
370 WP_CLI::error( sprintf( "Can't delete file %s", $path ) );
371 }
372 } elseif ( Scan_Item::TYPE_VULNERABILITY === $item->type ) {
373 $ret = $item->resolve();
374 if ( is_wp_error( $ret ) ) {
375 WP_CLI::error( $ret->get_error_message() );
376 } elseif ( is_array( $ret ) && isset( $ret['type_notice'] ) && 'error' === $ret['type_notice'] ) {
377 WP_CLI::error( $ret['message'] ?? esc_html__( 'Unable to resolve vulnerability.', 'defender-security' ) );
378 } else {
379 $model->remove_issue( $item->id );
380 $resolved[] = $item;
381 }
382 }
383 }
384 WP_CLI::log( sprintf( 'Resolved %s items', count( $resolved ) ) );
385 break;
386 case 'delete':
387 $items = $model->get_issues( $type, Scan_Item::STATUS_ACTIVE );
388 $deleted = array();
389 foreach ( $items as $item ) {
390 $issue_data = $this->split_scan_issue_into_file_and_dir( $type, $item->raw_data );
391 $path = $issue_data['path'];
392 $issue_type = $issue_data['type'];
393 if ( ! file_exists( $path ) ) {
394 continue;
395 }
396 // Work with plugin dir or single file, e.g. for Vulnerability, Outdated or Closed plugin types.
397 if ( 'folder' === $issue_type ) {
398 if ( $this->is_active_plugin( $path ) ) {
399 WP_CLI::warning( sprintf( 'This plugin %s cannot be removed because it is active.', $path ) );
400 continue;
401 }
402
403 if ( is_dir( $path ) ) {
404 if ( $this->delete_dir( $path ) ) {
405 WP_CLI::log( sprintf( 'Delete %s: %s', $issue_type, $path ) );
406 $model->remove_issue( $item->id );
407 $deleted[] = $item;
408 }
409 } elseif ( wp_delete_file( $path ) ) {
410 WP_CLI::log( sprintf( 'Delete %s: %s', $issue_type, $path ) );
411 $model->remove_issue( $item->id );
412 $deleted[] = $item;
413 } else {
414
415 WP_CLI::error( sprintf( "Can't delete %s: %s", $issue_type, $path ) );
416 }
417 } elseif ( 'file' === $issue_type ) {
418 // Work with core_integrity, plugin_integrity or suspicious_code types.
419 if ( wp_delete_file( $path ) ) {
420 WP_CLI::log( sprintf( 'Delete %s: %s', $issue_type, $path ) );
421 $model->remove_issue( $item->id );
422 $deleted[] = $item;
423 } else {
424 WP_CLI::warning( sprintf( "Can't delete %s: %s", $issue_type, $path ) );
425 }
426 }
427 }
428 WP_CLI::log( sprintf( 'Deleted %s items', count( $deleted ) ) );
429 break;
430 default:
431 break;
432 }
433 }
434
435 /**
436 * Split scan issue into file and dir.
437 *
438 * @param string|null $type Scan type.
439 * @param array $raw_data Array of raw scan data.
440 *
441 * @return array
442 */
443 private function split_scan_issue_into_file_and_dir( $type, $raw_data ): array {
444 // General case without type-param.
445 if ( null === $type ) {
446 if ( isset( $raw_data['file'] ) ) {
447 return array(
448 'type' => 'file',
449 'path' => $raw_data['file'],
450 );
451 } elseif ( isset( $raw_data['base_slug'] ) ) {
452 return array(
453 'type' => 'folder',
454 'path' => $this->get_abs_plugin_path_by_slug( $raw_data['base_slug'] ),
455 );
456 } elseif ( isset( $raw_data['slug'] ) ) {
457 return array(
458 'type' => 'folder',
459 'path' => $this->get_abs_plugin_path_by_slug( $raw_data['slug'] ),
460 );
461 }
462 }
463
464 if ( in_array( $type, array( Scan_Item::TYPE_PLUGIN_OUTDATED, Scan_Item::TYPE_PLUGIN_CLOSED ), true ) ) {
465 return array(
466 'type' => 'folder',
467 'path' => $this->get_abs_plugin_path_by_slug( $raw_data['slug'] ),
468 );
469 } elseif ( Scan_Item::TYPE_VULNERABILITY === $type ) {
470 return array(
471 'type' => 'folder',
472 'path' => $this->get_abs_plugin_path_by_slug( $raw_data['base_slug'] ),
473 );
474 } else {
475 return array(
476 'type' => 'file',
477 'path' => $raw_data['file'],
478 );
479 }
480 }
481
482 /**
483 * Generate dummy data, use in unit tests.
484 * DO NOT USE IN PRODUCTION.
485 *
486 * @param mixed $args Command arguments.
487 */
488 public function seed( $args ) {
489 global $wp_filesystem;
490 // Initialize the WP filesystem, no more using 'file-put-contents' function.
491 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
492 require_once ABSPATH . '/wp-admin/includes/file.php';
493 WP_Filesystem();
494 }
495 if ( ! is_array( $args ) || array() === $args ) {
496 WP_CLI::error( 'Invalid command' );
497
498 return;
499 }
500 [$command] = $args;
501 switch ( $command ) {
502 case 'scan:core':
503 $file_path = ABSPATH . 'wp-load.php';
504 $content = '//this make different';
505 if ( $wp_filesystem->exists( $file_path ) ) {
506 $content = $wp_filesystem->get_contents( $file_path ) . $content;
507 }
508 $wp_filesystem->put_contents( $file_path, $content );
509 break;
510 case 'ip:logs':
511 // We will generate randomly 10k logs in 3 months.
512 $types = array( Lockout_Log::AUTH_FAIL, Lockout_Log::AUTH_LOCK, Lockout_Log::ERROR_404, Lockout_Log::LOCKOUT_404, Lockout_Log::LOCKOUT_UA );
513 $is_lock = array( Lockout_Log::AUTH_LOCK, Lockout_Log::LOCKOUT_404, Lockout_Log::LOCKOUT_UA );
514 $faker = Factory::create();
515 WP_CLI::log( $faker->ipv4 );
516 $range = array(
517 'today midnight' => array( 'now', 100 ),
518 '-6 days' => array( 'yesterday', 50 ),
519 '-30 days' => array( '-7 days', 70 ),
520 );
521 $counter = array(
522 'last_24_hours' => 0,
523 'last_30_days' => 0,
524 'login_lockout' => 0,
525 '404_lockout' => 0,
526 'ua_lockout' => 0,
527 );
528 $last_lockout = 0;
529 foreach ( $range as $date => $to ) {
530 [$to, $count] = $to;
531 for ( $i = 0; $i < $count; $i++ ) {
532 $model = new Lockout_Log();
533 $model->ip = $faker->ipv4;
534 $model->type = $types[ array_rand( $types ) ];
535 $model->log = $faker->sentence( 20 );
536 $model->date = $faker->dateTimeBetween( $date, $to )->getTimestamp();
537 $model->blog_id = 1;
538 $model->tried = $faker->userName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
539 $model->country_iso_code = $faker->countryCode; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
540 $model->save();
541 if ( ( $model->date > $last_lockout ) ) {
542 $last_lockout = $model->date;
543 }
544 if ( in_array( $model->type, $is_lock, true ) ) {
545 $counter['last_30_days'] += 1;
546 if ( $model->date > strtotime( 'yesterday midnight' ) ) {
547 $counter['last_24_hours'] += 1;
548 }
549 if ( $model->date > strtotime( '-6 days', strtotime( 'today midnight' ) ) ) {
550 if ( Lockout_Log::AUTH_LOCK === $model->type ) {
551 $counter['login_lockout'] += 1;
552 } elseif ( Lockout_Log::LOCKOUT_404 === $model->type ) {
553 $counter['404_lockout'] += 1;
554 } else {
555 $counter['ua_lockout'] += 1;
556 }
557 }
558 }
559 }
560 }
561 $counter['last_lockout'] = $this->format_date_time( $last_lockout );
562 echo wp_json_encode( $counter );
563 break;
564 default:
565 WP_CLI::error( 'Invalid command' );
566 break;
567 }
568 }
569
570 /**
571 * Clean up dummy data.
572 * DO NOT USE IN PRODUCTION.
573 *
574 * @param mixed $args Command arguments.
575 */
576 public function unseed( $args ) {
577 global $wp_filesystem;
578 // Initialize the WP filesystem, no more using 'file-put-contents' function.
579 if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
580 require_once ABSPATH . '/wp-admin/includes/file.php';
581 WP_Filesystem();
582 }
583 if ( ! is_array( $args ) || array() === $args ) {
584 WP_CLI::error( 'Invalid command' );
585
586 return;
587 }
588 [$command] = $args;
589 switch ( $command ) {
590 case 'scan:core':
591 $content = file_get_contents( ABSPATH . 'wp-load.php' );
592 $wp_filesystem->put_contents( ABSPATH . 'wp-load.php', str_replace( '//this make different', '', $content ) );
593 break;
594 case 'scan:suspicious':
595 wp_delete_file( WP_CONTENT_DIR . '/false-positive.php' );
596 break;
597 default:
598 break;
599 }
600 }
601
602 /**
603 * Manage audit logs via WP-CLI. Requires a WPMU DEV subscription.
604 *
605 * ## OPTIONS
606 *
607 * <command>
608 * : Action to perform.
609 * ---
610 * options:
611 * - reset
612 * ---
613 *
614 * ## EXAMPLES
615 *
616 * # Delete all audit log entries from the database.
617 * $ wp defender audit reset
618 * All clear
619 *
620 * @param mixed $args Command arguments.
621 */
622 public function audit( $args ) {
623 if ( defender_is_wp_org_version() ) {
624 WP_CLI::warning( 'A WPMU DEV subscription is required to use this command.' );
625 return;
626 }
627 }
628
629 /**
630 * Manage security headers via WP-CLI.
631 *
632 * ## OPTIONS
633 *
634 * <command>
635 * : Action to perform.
636 * ---
637 * options:
638 * - check
639 * - activate
640 * - deactivate
641 * ---
642 *
643 * ## EXAMPLES
644 *
645 * # Check the current status of all security headers.
646 * $ wp defender security_headers check
647 * Success: Checking is ready.
648 *
649 * # Activate all security headers.
650 * $ wp defender security_headers activate
651 * Activating is ready.
652 *
653 * # Deactivate all security headers.
654 * $ wp defender security_headers deactivate
655 * Deactivating is ready.
656 *
657 * @param mixed $args Command arguments.
658 *
659 * @throws ExitException|Exception If an invalid command is provided.
660 */
661 public function security_headers( $args ) {
662 if ( ! is_array( $args ) || array() === $args ) {
663 WP_CLI::error( 'Invalid command.' );
664
665 return;
666 }
667 $model = new Security_Headers();
668 [$command] = $args;
669 switch ( $command ) {
670 case 'check':
671 $i = 1;
672 foreach ( $model->get_headers() as $header ) {
673 $state = true === $header->check() ? 'enabled' : 'disabled';
674 WP_CLI::log( sprintf( '#%s - %s is %s', $i, $header->get_title(), $state ) );
675 ++$i;
676 }
677 WP_CLI::success( 'Checking is ready.' );
678 break;
679 case 'activate':
680 foreach ( $model->get_headers() as $rule_slug => $header ) {
681 $this->set_security_header_state( $model, $rule_slug, true );
682 }
683 $model->save();
684 WP_CLI::log( 'Activating is ready.' );
685 break;
686 case 'deactivate':
687 foreach ( $model->get_headers() as $rule_slug => $header ) {
688 $this->set_security_header_state( $model, $rule_slug, false );
689 }
690 $model->save();
691 WP_CLI::log( 'Deactivating is ready.' );
692 break;
693 default:
694 WP_CLI::error( sprintf( 'Unknown command %s', $command ) );
695 break;
696 }
697 }
698
699 /**
700 * Set a security header setting without using dynamic model properties.
701 *
702 * @param Security_Headers $model The security headers settings model.
703 * @param string $rule_slug The header rule slug.
704 * @param bool $enabled Whether the rule is enabled.
705 */
706 private function set_security_header_state( Security_Headers $model, string $rule_slug, bool $enabled ): void {
707 switch ( $rule_slug ) {
708 case 'sh_xframe':
709 $model->sh_xframe = $enabled;
710 break;
711 case 'sh_xss_protection':
712 $model->sh_xss_protection = $enabled;
713 break;
714 case 'sh_content_type_options':
715 $model->sh_content_type_options = $enabled;
716 break;
717 case 'sh_strict_transport':
718 $model->sh_strict_transport = $enabled;
719 break;
720 case 'sh_referrer_policy':
721 $model->sh_referrer_policy = $enabled;
722 break;
723 case 'sh_feature_policy':
724 $model->sh_feature_policy = $enabled;
725 break;
726 }
727 }
728
729 /**
730 * Manage plugin settings via WP-CLI.
731 *
732 * ## OPTIONS
733 *
734 * <command>
735 * : Action to perform.
736 * ---
737 * options:
738 * - reset
739 * ---
740 *
741 * ## EXAMPLES
742 *
743 * # Reset all plugin settings to defaults.
744 * $ wp defender settings reset
745 * All cleared!
746 *
747 * @param mixed $args Command arguments.
748 * @param mixed $options Command options.
749 */
750 public function settings( $args, $options ) {
751 if ( ! is_array( $args ) || array() === $args ) {
752 WP_CLI::error( 'Invalid command, add necessary arguments. See below...', false );
753 WP_CLI::runcommand( 'defender settings --help', [ 'launch' => false, 'exit_error' => false ] );
754
755 return;
756 }
757
758 [$command] = $args;
759 switch ( $command ) {
760 case 'reset':
761 WP_CLI::confirm( 'This will completely reset the plugin settings, are you sure to continue?', $options );
762 // Analog Settings > Reset Settings.
763 wd_di()->get( Login_Access::class )->remove_settings();
764 wd_di()->get( Dashboard::class )->remove_settings();
765 wd_di()->get( Security_Tweaks::class )->remove_settings();
766 wd_di()->get( \WP_Defender\Controller\Scan::class )->remove_settings();
767 // Parent and submodules.
768 wd_di()->get( \WP_Defender\Controller\Firewall::class )->remove_settings();
769
770 wd_di()->get( \WP_Defender\Controller\Mask_Login::class )->remove_settings();
771 wd_di()->get( \WP_Defender\Controller\Notification::class )->remove_settings();
772 wd_di()->get( Two_Factor::class )->remove_settings();
773 wd_di()->get( Main_Setting::class )->remove_settings();
774 WP_CLI::log( 'All cleared!' );
775 break;
776 default:
777 WP_CLI::error( sprintf( 'Unknown command %s, use correct arguments. See below...', $command ), false );
778 WP_CLI::runcommand( 'defender settings --help', [ 'launch' => false, 'exit_error' => false ] );
779 break;
780 }
781 }
782
783 /**
784 * Manage firewall submodules, data, and lockouts via WP-CLI.
785 *
786 * ## OPTIONS
787 *
788 * <command>
789 * : Action to perform.
790 * ---
791 * options:
792 * - clear
793 * - unblock
794 * - list
795 * - activate
796 * - deactivate
797 * ---
798 *
799 * <type>
800 * : The firewall data type to target (e.g. ip, user_agent, files, maxmind, submodule).
801 *
802 * [<field>]
803 * : The specific field or submodule to target. Defaults to 'all' for the list command.
804 *
805 * [--ips=<ips>]
806 * : Comma-separated list of IP addresses to unblock. Required for the unblock command.
807 *
808 * ## EXAMPLES
809 *
810 * # Clear the IP allowlist.
811 * $ wp defender firewall clear ip allowlist
812 * Firewall allowlist ip is cleared.
813 *
814 * # Unblock specific IPs from lockout.
815 * $ wp defender firewall unblock ip lockout --ips=127.0.0.1,236.211.38.221
816 * Firewall lockout ip unblocked
817 *
818 * # List all user agent entries.
819 * $ wp defender firewall list user_agent all
820 *
821 * # Activate login protection submodule.
822 * $ wp defender firewall activate submodule login_protection
823 * Success: Firewall "Login Protection" has been activated.
824 *
825 * # Deactivate 404 detection submodule.
826 * $ wp defender firewall deactivate submodule 404_detection
827 * Success: Firewall "404 Detection" has been deactivated.
828 *
829 * @param mixed $args Command arguments.
830 * @param mixed $options Command options.
831 */
832 public function firewall( $args, $options ) {
833 $arg_count = is_array( $args ) || $args instanceof Countable ? count( $args ) : 0;
834 if ( $arg_count < 2 ) {
835 WP_CLI::error( 'Invalid command, add necessary arguments. See below...', false );
836 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
837
838 return;
839 }
840
841 $command = $args[0];
842 $type = $args[1];
843 // Field is optional for the 'list' command — defaults to 'all'.
844 $field = $args[2] ?? ( 'list' === $command ? 'all' : '' );
845
846 if ( ! is_string( $type ) || '' === $type ) {
847 WP_CLI::error( 'Invalid option.', false );
848 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
849
850 return;
851 }
852 switch ( $command ) {
853 case 'clear':
854 $this->clear_firewall( $type, $field );
855 break;
856 case 'unblock':
857 $this->unblock_firewall( $type, $field, $options );
858 break;
859 case 'list':
860 $this->list_firewall( $type, $field );
861 break;
862 case 'activate':
863 $this->toggle_firewall_submodule( $type, $field, 'activate' );
864 break;
865 case 'deactivate':
866 $this->toggle_firewall_submodule( $type, $field, 'deactivate' );
867 break;
868 default:
869 WP_CLI::error( sprintf( 'Unknown command %s', $command ) );
870 break;
871 }
872 }
873
874 /**
875 * Clears the firewall data based on the specified type and field.
876 *
877 * @param string $type The type of data to clear.
878 * @param string $field The specific field to clear.
879 */
880 private function clear_firewall( $type, $field ) {
881 $type_default = array( 'ip', 'files', 'user_agent', 'maxmind' );
882 $field_default = array( 'blocklist', 'allowlist', 'country_allowlist', 'country_blocklist', 'license_key' );
883
884 if ( ! in_array( $type, $type_default, true ) ) {
885 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $type ), false );
886 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
887
888 return;
889 }
890
891 if ( ! in_array( $field, $field_default, true ) ) {
892 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $field ), false );
893 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
894
895 return;
896 }
897
898 // Rename the field's name to original model field name.
899 $original_field = $this->rename_field( $field );
900 if ( 'ip' === $type ) {
901 // Get the model instance.
902 $model = wd_di()->get( \WP_Defender\Model\Setting\Blacklist_Lockout::class );
903 $data = $model->export();
904 // Rename the field to match with the appropriate model field name.
905 $mod_field = $this->is_country( $original_field ) ? $original_field : 'ip_' . $original_field;
906 // Reset to default data with correct data type.
907 $default_data = $this->is_country( $original_field ) ? array() : '';
908 // Empty the $field option of field data.
909 $data[ $mod_field ] = $default_data;
910 $model->import( $data );
911 $model->save();
912 } elseif ( 'files' === $type ) {
913 // Get the model instance.
914 $model = wd_di()->get( Notfound_Lockout::class );
915 $data = $model->export();
916 // Empty the $field option of field data.
917 $data[ $original_field ] = '';
918 $model->import( $data );
919 $model->save();
920 } elseif ( 'user_agent' === $type ) {
921 $model = wd_di()->get( User_Agent_Lockout::class );
922 $data = $model->export();
923 $data[ $original_field ] = '';
924 $model->import( $data );
925 $model->save();
926 } elseif ( 'maxmind' === $type ) {
927 try {
928 $model = wd_di()->get( \WP_Defender\Model\Setting\Blacklist_Lockout::class );
929 if ( ! is_null( $model->geodb_path ) && is_file( $model->geodb_path ) ) {
930 wp_delete_file( $model->geodb_path );
931 }
932 $model->maxmind_license_key = '';
933 $model->geodb_path = '';
934 $model->save();
935 } catch ( Throwable $th ) {
936 WP_CLI::log( $th->getMessage() );
937 }
938 }
939
940 WP_CLI::log( sprintf( 'Firewall %s %s is cleared.', str_replace( '_', ' ', $field ), $type ) );
941 }
942
943 /**
944 * Rename a field to its original model field name.
945 *
946 * @param string $field The field name to rename.
947 *
948 * @return string The renamed field name.
949 */
950 private function rename_field( $field ) {
951 if ( '' !== $field ) {
952 return str_replace( array( 'allow', 'block' ), array( 'white', 'black' ), $field );
953 }
954
955 return '';
956 }
957
958 /**
959 * Check if the specified field is related to country settings.
960 *
961 * @param string $field The field to check.
962 *
963 * @return bool True if the field is related to country settings, false otherwise.
964 */
965 private function is_country( $field ) {
966 return ( 'country_whitelist' === $field || 'country_blacklist' === $field );
967 }
968
969 /**
970 * Unblocks the specified IPs from the firewall.
971 *
972 * @param string $type The type of data to unblock.
973 * @param string $field The specific field to unblock.
974 * @param array $options Command options including IPs to unblock.
975 */
976 private function unblock_firewall( $type, $field, $options ) {
977 $type_default = array( 'ip' );
978 $field_default = array( 'lockout' );
979
980 if ( ! in_array( $type, $type_default, true ) ) {
981 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $type ), false );
982 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
983
984 return;
985 }
986
987 if ( ! in_array( $field, $field_default, true ) ) {
988 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $field ), false );
989 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
990
991 return;
992 }
993
994 if ( array_key_exists( 'ips', $options ) ) {
995 $ips = array_map( 'trim', explode( ',', $options['ips'] ) );
996 $models = Lockout_Ip::get_bulk( Lockout_Ip::STATUS_BLOCKED, $ips );
997
998 foreach ( $models as $model ) {
999 $model->status = Lockout_Ip::STATUS_NORMAL;
1000 $model->save();
1001 }
1002 } else {
1003 WP_CLI::error( 'Option \'ips\' is not provided. See below...', false );
1004 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
1005
1006 return;
1007 }
1008
1009 WP_CLI::log( sprintf( 'Firewall %s %s unblocked', str_replace( '_', ' ', $field ), $type ) );
1010 }
1011
1012 /**
1013 * Lists details for the firewall based on the specified type and field.
1014 * Example: wp defender firewall list user_agent all
1015 *
1016 * @param string $type The type of data to list.
1017 * @param string $field The specific field to list.
1018 *
1019 * @since v2.6.4. Add the details for User Agent Banning.
1020 */
1021 private function list_firewall( $type, $field ) {
1022 $type_default = array( 'user_agent' );
1023 $field_default = array( 'all', 'allowlist', 'blocklist' );
1024 if ( ! in_array( $type, $type_default, true ) ) {
1025 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $type ), false );
1026 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
1027
1028 return;
1029 }
1030 if ( ! in_array( $field, $field_default, true ) ) {
1031 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $field ), false );
1032 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
1033
1034 return;
1035 }
1036 $model = wd_di()->get( User_Agent_Lockout::class );
1037 $data = $model->export();
1038 if ( 'all' === $field && isset( $data['whitelist'] ) && '' !== $data['whitelist'] && isset( $data['blacklist'] ) && '' !== $data['blacklist'] ) {
1039 WP_CLI::log( 'ALLOWLIST:' );
1040 WP_CLI::log( $data['whitelist'] );
1041 WP_CLI::log( 'BLOCKLIST:' );
1042 WP_CLI::log( $data['blacklist'] );
1043 } elseif ( 'allowlist' === $field && isset( $data['whitelist'] ) && '' !== $data['whitelist'] ) {
1044 WP_CLI::log( $data['whitelist'] );
1045 } elseif ( 'blocklist' === $field && isset( $data['blacklist'] ) && '' !== $data['blacklist'] ) {
1046 WP_CLI::log( $data['blacklist'] );
1047 } else {
1048 WP_CLI::log( 'No data.' );
1049 }
1050 }
1051
1052 /**
1053 * Change status of Firewall submodules: login_protection, 404_detection or user_agent.
1054 * Example: wp defender firewall activate submodule user_agent
1055 * Example: wp defender firewall deactivate submodule login_protection
1056 *
1057 * @param string $key_word The keyword to identify the action.
1058 * @param string $submodule The submodule to toggle.
1059 * @param string $action The action to perform (activate or deactivate).
1060 */
1061 private function toggle_firewall_submodule( $key_word, $submodule, $action ) {
1062 if ( 'submodule' !== $key_word ) {
1063 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $key_word ), false );
1064 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
1065
1066 return;
1067 }
1068 if ( ! in_array( $submodule, array( 'login_protection', '404_detection', 'user_agent' ), true ) ) {
1069 WP_CLI::error( sprintf( 'Invalid option %s. See below...', $submodule ), false );
1070 WP_CLI::runcommand( 'defender firewall --help', [ 'launch' => false, 'exit_error' => false ] );
1071
1072 return;
1073 }
1074 // Get submodule slug.
1075 if ( 'login_protection' === $submodule ) {
1076 $model = wd_di()->get( Login_Lockout::class );
1077 $submodule = Login_Lockout::get_module_name();
1078 } elseif ( '404_detection' === $submodule ) {
1079 $model = wd_di()->get( Notfound_Lockout::class );
1080 $submodule = Notfound_Lockout::get_module_name();
1081 } else {
1082 $model = wd_di()->get( User_Agent_Lockout::class );
1083 $submodule = User_Agent_Lockout::get_module_name();
1084 }
1085 // Activate/deactivate submodule.
1086 if ( 'activate' === $action ) {
1087 $text = 'activated';
1088 // Check if the submodule is not yet activated.
1089 if ( true !== $model->enabled ) {
1090 $model->enabled = true;
1091 $model->save();
1092 }
1093 } else {
1094 $text = 'deactivated';
1095 // Check if the submodule is not yet deactivated.
1096 if ( false !== $model->enabled ) {
1097 $model->enabled = false;
1098 $model->save();
1099 }
1100 }
1101
1102 WP_CLI::success( sprintf( 'Firewall "%s" has been %s.', $submodule, $text ) );
1103 }
1104
1105 /**
1106 * Manage mask login settings via WP-CLI.
1107 *
1108 * ## OPTIONS
1109 *
1110 * <command>
1111 * : Action to perform.
1112 * ---
1113 * options:
1114 * - clear
1115 * ---
1116 *
1117 * ## EXAMPLES
1118 *
1119 * # Reset all mask login settings to defaults.
1120 * $ wp defender mask_login clear
1121 * Mask login settings cleared!
1122 *
1123 * @param mixed $args Command arguments.
1124 */
1125 public function mask_login( $args ) {
1126 if ( ( is_array( $args ) || $args instanceof Countable ? count( $args ) : 0 ) < 1 ) {
1127 WP_CLI::error( 'Invalid command, add necessary arguments. See below...', false );
1128 WP_CLI::runcommand( 'defender mask_login --help', [ 'launch' => false, 'exit_error' => false ] );
1129
1130 return;
1131 }
1132
1133 [$command] = $args;
1134 switch ( $command ) {
1135 case 'clear':
1136 wd_di()->get( \WP_Defender\Model\Setting\Mask_Login::class )->delete();
1137 WP_CLI::log( 'Mask login settings cleared!' );
1138 break;
1139 default:
1140 WP_CLI::error( sprintf( 'Unknown command %s', $command ) );
1141 break;
1142 }
1143 }
1144
1145 /**
1146 * Manage bulk password reset via WP-CLI.
1147 *
1148 * ## OPTIONS
1149 *
1150 * <command>
1151 * : Action to perform.
1152 * ---
1153 * options:
1154 * - force
1155 * - undo
1156 * ---
1157 *
1158 * ## EXAMPLES
1159 *
1160 * # Force all users to reset their password on next login.
1161 * $ wp defender password_reset force
1162 *
1163 * # Cancel a previously forced password reset.
1164 * $ wp defender password_reset undo
1165 * Passwords reset is no longer required.
1166 *
1167 * @param mixed $args Command arguments.
1168 */
1169 public function password_reset( $args ) {
1170 if ( ( is_array( $args ) || $args instanceof Countable ? count( $args ) : 0 ) < 1 ) {
1171 WP_CLI::error( 'Invalid command.' );
1172
1173 return;
1174 }
1175
1176 [$command] = $args;
1177 switch ( $command ) {
1178 case 'force':
1179 // Get the model instance.
1180 $model = wd_di()->get( Password_Reset::class );
1181 $model->expire_force = true;
1182 $model->force_time = time();
1183 $model->save();
1184 $message = sprintf( 'Passwords created before %s are required to be reset upon next login.', $this->format_date_time( $model->force_time ) );
1185 WP_CLI::log( $message );
1186 break;
1187 case 'undo':
1188 $model = wd_di()->get( Password_Reset::class );
1189 $model->expire_force = false;
1190 $model->save();
1191 WP_CLI::log( 'Passwords reset is no longer required.' );
1192 break;
1193 default:
1194 WP_CLI::error( sprintf( 'Unknown command %s', $command ) );
1195 break;
1196 }
1197 }
1198
1199 /**
1200 * Manage Defender's internal log files.
1201 *
1202 * ## OPTIONS
1203 *
1204 * <command>
1205 * : Action to perform.
1206 * ---
1207 * options:
1208 * - delete
1209 * ---
1210 *
1211 * ## EXAMPLES
1212 *
1213 * # Delete log files older than one week.
1214 * $ wp defender logs delete
1215 * Logs older than a week have been deleted.
1216 *
1217 * @param mixed $args Command arguments.
1218 */
1219 public function logs( $args ) {
1220 if ( ( is_array( $args ) || $args instanceof Countable ? count( $args ) : 0 ) < 1 ) {
1221 WP_CLI::error( 'Invalid command, add necessary arguments. See below...', false );
1222 WP_CLI::runcommand( 'defender logs --help', [ 'launch' => false, 'exit_error' => false ] );
1223
1224 return;
1225 }
1226
1227 [$command] = $args;
1228
1229 switch ( $command ) {
1230 case 'delete':
1231 $rotation_logger = wd_di()->get( Rotation_Logger::class );
1232 $rotation_logger->purge_old_log();
1233 WP_CLI::log( 'Logs older than a week have been deleted.' );
1234 break;
1235 default:
1236 WP_CLI::error( sprintf( 'Unknown command %s', $command ) );
1237 break;
1238 }
1239 }
1240
1241 /**
1242 * Manage CAPTCHA settings via WP-CLI.
1243 *
1244 * ## OPTIONS
1245 *
1246 * <command>
1247 * : Action to perform.
1248 * ---
1249 * options:
1250 * - activate
1251 * - deactivate
1252 * - clear
1253 * ---
1254 *
1255 * ## EXAMPLES
1256 *
1257 * # Enable CAPTCHA.
1258 * $ wp defender captcha activate
1259 * CAPTCHA is activated.
1260 *
1261 * # Disable CAPTCHA.
1262 * $ wp defender captcha deactivate
1263 * CAPTCHA is deactivated.
1264 *
1265 * # Reset all CAPTCHA settings to defaults.
1266 * $ wp defender captcha clear
1267 * CAPTCHA is cleared.
1268 *
1269 * @param mixed $args Command arguments.
1270 */
1271 public function captcha( $args ) {
1272 if ( ! is_array( $args ) || array() === $args ) {
1273 WP_CLI::error( 'Invalid command.' );
1274
1275 return;
1276 }
1277 $model = wd_di()->get( \WP_Defender\Model\Setting\Captcha::class );
1278 [$command] = $args;
1279 switch ( $command ) {
1280 case 'activate':
1281 if ( true !== $model->enabled ) {
1282 $model->enabled = true;
1283 $model->save();
1284 }
1285 WP_CLI::log( 'CAPTCHA is activated.' );
1286 break;
1287 case 'deactivate':
1288 if ( false !== $model->enabled ) {
1289 $model->enabled = false;
1290 $model->save();
1291 }
1292 WP_CLI::log( 'CAPTCHA is deactivated.' );
1293 break;
1294 case 'clear':
1295 $default_values = $model->get_default_values();
1296 $model->message = $default_values['message'];
1297 $model->language = 'automatic';
1298 $model->provider = 'recaptcha';
1299 $model->data_v2_checkbox = array(
1300 'key' => '',
1301 'secret' => '',
1302 'size' => 'normal',
1303 'style' => 'light',
1304 );
1305 $model->data_v2_invisible = array(
1306 'key' => '',
1307 'secret' => '',
1308 );
1309 $model->data_v3_recaptcha = array(
1310 'key' => '',
1311 'secret' => '',
1312 'threshold' => '0.5',
1313 );
1314 $model->data_turnstile = array(
1315 'key' => '',
1316 'secret' => '',
1317 'size' => 'normal',
1318 'style' => 'auto',
1319 'message' => $default_values['turnstile_message'],
1320 'language' => 'auto',
1321 );
1322 $model->locations = array();
1323 $model->detect_woo = false;
1324 $model->woo_checked_locations = array();
1325 $model->detect_buddypress = false;
1326 $model->buddypress_checked_locations = array();
1327 $model->disable_for_known_users = true;
1328 $model->save();
1329
1330 WP_CLI::log( 'CAPTCHA is cleared.' );
1331 break;
1332 default:
1333 WP_CLI::error( sprintf( 'Unknown command %s.', $command ) );
1334 break;
1335 }
1336 }
1337 }
1338