PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.3.0
Defender Security – Malware Scanner, Login Security & Firewall v6.3.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
← All changes | src/model/class-lockout-log.php +194 -108 5.6.0 → 6.3.0 View file →
@@ -9,10 +9,12 @@
9 9
10 10 use WP_Defender\DB;
11 11 use Calotes\Base\Model;
12 12 use WP_Defender\Traits\Formats;
13 +use WP_Defender\Traits\Country;
13 14 use WP_Defender\Component\User_Agent;
14 15 use WP_Defender\Component\Table_Lockout;
16 +use WP_Defender\Model\Setting\Blacklist_Lockout;
15 17 use WP_Defender\Model\Setting\User_Agent_Lockout;
16 18
17 19 /**
18 20 * Model for the lockout log table.
@@ -19,25 +21,27 @@
19 21 */
20 22 class Lockout_Log extends DB {
21 23
22 24 use Formats;
25 + use Country;
23 26
24 - public const AUTH_FAIL = 'auth_fail';
25 - public const AUTH_LOCK = 'auth_lock';
27 + // The Firewall log hierarchy. Parent items: 'login', '404', 'bots', 'unlock' and 'custom'.
28 + // Group of 'login' cases.
29 + public const AUTH_FAIL = 'auth_fail', AUTH_LOCK = 'auth_lock';
30 + // Group of '404' cases.
31 + public const ERROR_404 = '404_error', LOCKOUT_404 = '404_lockout', ERROR_404_IGNORE = '404_error_ignore';
32 + // New log cases since v5.7.0.
33 + public const ERROR_404_XSS = 'xss_attempt', LOCKOUT_404_XSS = 'xss_lockout';
34 + public const ERROR_404_NON_EXISTENT_PLUGIN = 'plugin_attempt', LOCKOUT_404_NON_EXISTENT_PLUGIN = 'plugin_lockout';
35 + public const ERROR_404_NON_EXISTENT_THEME = 'theme_attempt', LOCKOUT_404_NON_EXISTENT_THEME = 'theme_lockout';
36 + // Group of 'bots' cases.
37 + // 'ua_lockout' includes 'bad_user_agent' due to attempted login with banned user agent && 'bad_post' due to empty User-Agent and Referer headers).
38 + public const LOCKOUT_MALICIOUS_BOT = 'malicious_bot', LOCKOUT_FAKE_BOT = 'fake_bot', LOCKOUT_UA = 'ua_lockout';
39 + // Group of 'unlock' cases.
26 40 public const IP_UNLOCK = 'ip_unlock';
27 -
28 - public const ERROR_404 = '404_error';
29 - public const LOCKOUT_404 = '404_lockout';
30 - public const ERROR_404_IGNORE = '404_error_ignore';
31 - public const LOCKOUT_MALICIOUS_BOT = 'malicious_bot';
32 - public const LOCKOUT_FAKE_BOT = 'fake_bot';
33 -
34 - public const LOCKOUT_UA = 'ua_lockout';
35 - // Different IP Lockout types.
41 + // Group of 'custom' cases.
36 42 public const LOCKOUT_IP_CUSTOM = 'custom_lockout';
37 43
38 - public const INFINITE_SCROLL_SIZE = 50;
39 -
40 44 /**
41 45 * Table name.
42 46 *
43 47 * @var string
@@ -108,8 +112,34 @@
108 112 */
109 113 public $country_iso_code;
110 114
111 115 /**
116 + * Factory method to instantiate and populate a Lockout_Log model with request data.
117 + *
118 + * @param string $ip The IP address.
119 + * @param string|null $tried The attempted URI, username, or user agent.
120 + *
121 + * @return self
122 + */
123 + public static function create( string $ip, ?string $tried = null ): self {
124 + $model = new self();
125 + $model->ip = $ip;
126 + $user_agent = defender_get_data_from_request( 'HTTP_USER_AGENT', 's' );
127 + $model->user_agent = '' !== $user_agent ? User_Agent::fast_cleaning( $user_agent ) : '';
128 + $model->date = time();
129 + $model->tried = $tried ?? $model->user_agent;
130 + $model->blog_id = get_current_blog_id();
131 +
132 + $ip_to_country = $model->ip_to_country( $ip );
133 +
134 + if ( isset( $ip_to_country['iso'] ) ) {
135 + $model->country_iso_code = $ip_to_country['iso'];
136 + }
137 +
138 + return $model;
139 + }
140 +
141 + /**
112 142 * Query the logs based on the provided filters and pagination settings.
113 143 *
114 144 * @param array $filters An array of filters to apply to the query. Default is an empty array.
115 145 * The following filters are supported:
@@ -146,8 +176,11 @@
146 176
147 177 if ( isset( $filters['ip'] ) && '' !== $filters['ip'] ) {
148 178 $orm->where( 'ip', 'like', '%' . $filters['ip'] . '%' );
149 179 }
180 + if ( isset( $filters['user_agent'] ) && '' !== $filters['user_agent'] ) {
181 + $orm->where( 'user_agent', 'like', '%' . $filters['user_agent'] . '%' );
182 + }
150 183 if ( isset( $filters['type'] ) && '' !== $filters['type'] ) {
151 184 $orm->where( 'type', $filters['type'] );
152 185 }
153 186
@@ -200,14 +233,14 @@
200 233 if ( is_string( $ip ) && '' !== trim( $ip ) ) {
201 234 $orm->where( 'ip', 'like', "%$ip%" );
202 235 }
203 236
237 + if ( isset( $filters['user_agent'] ) && '' !== trim( $filters['user_agent'] ) ) {
238 + $orm->where( 'user_agent', 'like', '%' . $filters['user_agent'] . '%' );
239 + }
240 +
204 241 if ( isset( $filters['ban_status'] ) && '' !== trim( $filters['ban_status'] ) ) {
205 - $ban_status_where = self::ban_status_where( $filters['ban_status'] );
206 -
207 - if ( 3 === count( $ban_status_where ) ) {
208 - $orm->where( ...$ban_status_where );
209 - }
242 + self::apply_ban_status_filter( $orm, array_merge( $filters, array( 'type' => $type ) ) );
210 243 }
211 244
212 245 return $orm->count();
213 246 }
@@ -212,8 +245,31 @@
212 245 return $orm->count();
213 246 }
214 247
215 248 /**
249 + * Get 404 lockout types.
250 + */
251 + public static function get_404_lockout_types(): array {
252 + return array(
253 + self::LOCKOUT_404,
254 + self::LOCKOUT_404_XSS,
255 + self::LOCKOUT_404_NON_EXISTENT_PLUGIN,
256 + self::LOCKOUT_404_NON_EXISTENT_THEME,
257 + );
258 + }
259 +
260 + /**
261 + * Get all lockout types.
262 + */
263 + public static function get_all_lockout_types(): array {
264 + return array_merge(
265 + self::get_404_lockout_types(),
266 + array( self::AUTH_LOCK ),
267 + self::get_ua_lockout_types()
268 + );
269 + }
270 +
271 + /**
216 272 * Count login lockout in the last 7 days.
217 273 *
218 274 * @return string|null
219 275 */
@@ -232,13 +288,13 @@
232 288 public static function count_404_lockout_last_7_days(): ?string {
233 289 $start = strtotime( '-7 days' );
234 290 $end = time();
235 291
236 - return self::count( $start, $end, self::LOCKOUT_404 );
292 + return self::count( $start, $end, self::get_404_lockout_types() );
237 293 }
238 294
239 295 /**
240 - * Count UA lockout in the last 7 days.
296 + * Count all UA lockouts in the last 7 days.
241 297 *
242 298 * @return string|null
243 299 */
244 300 public static function count_ua_lockout_last_7_days(): ?string {
@@ -244,16 +300,28 @@
244 300 public static function count_ua_lockout_last_7_days(): ?string {
245 301 $start = strtotime( '-7 days' );
246 302 $end = time();
247 303
248 - return self::count( $start, $end, self::LOCKOUT_UA );
304 + return self::count( $start, $end, self::get_ua_lockout_types() );
249 305 }
250 306
251 307 /**
252 - * A shortcut for quickly count lockout in last 24 hours.
308 + * Count all UA lockouts in the last 24 hours.
253 309 *
254 310 * @return string|null
255 311 */
312 + public static function count_ua_lockouts_in_24_hours(): ?string {
313 + $start = strtotime( '-24 hours' );
314 + $end = time();
315 +
316 + return self::count( $start, $end, self::get_ua_lockout_types() );
317 + }
318 +
319 + /**
320 + * A shortcut for quickly count ALL lockouts in last 24 hours.
321 + *
322 + * @return string|null
323 + */
256 324 public static function count_lockout_in_24_hours(): ?string {
257 325 $start = strtotime( '-24 hours' );
258 326 $end = time();
259 327
@@ -259,13 +327,9 @@
259 327
260 328 return self::count(
261 329 $start,
262 330 $end,
263 - array(
264 - self::AUTH_LOCK,
265 - self::LOCKOUT_404,
266 - self::LOCKOUT_UA,
267 - )
331 + self::get_all_lockout_types()
268 332 );
269 333 }
270 334
271 335 /**
@@ -279,13 +343,9 @@
279 343
280 344 return self::count(
281 345 $start,
282 346 $end,
283 - array(
284 - self::AUTH_LOCK,
285 - self::LOCKOUT_404,
286 - self::LOCKOUT_UA,
287 - )
347 + self::get_all_lockout_types()
288 348 );
289 349 }
290 350
291 351 /**
@@ -299,14 +359,10 @@
299 359
300 360 return self::count(
301 361 $start,
302 362 $end,
303 - array(
304 - self::AUTH_LOCK,
305 - self::LOCKOUT_404,
306 - self::LOCKOUT_UA,
307 - // LOCKOUT_IP_CUSTOM is not taken into account.
308 - )
363 + // LOCKOUT_IP_CUSTOM is not taken into account.
364 + self::get_all_lockout_types()
309 365 );
310 366 }
311 367
312 368 /**
@@ -349,8 +405,37 @@
349 405 ->truncate();
350 406 }
351 407
352 408 /**
409 + * Get log types that are attempts/errors (not lockout events).
410 + *
411 + * @return array
412 + */
413 + public static function get_attempt_types(): array {
414 + return array(
415 + self::AUTH_FAIL,
416 + self::ERROR_404,
417 + self::ERROR_404_IGNORE,
418 + self::ERROR_404_XSS,
419 + self::ERROR_404_NON_EXISTENT_PLUGIN,
420 + self::ERROR_404_NON_EXISTENT_THEME,
421 + );
422 + }
423 +
424 + /**
425 + * Delete only lockout-type log records, preserving attempt/error entries.
426 + *
427 + * @return int|false Number of rows deleted or false on failure.
428 + */
429 + public static function delete_lockout_records() {
430 + $orm = self::get_orm();
431 +
432 + return $orm->get_repository( self::class )
433 + ->where( 'type', 'not in', self::get_attempt_types() )
434 + ->delete_all();
435 + }
436 +
437 + /**
353 438 * Remove logs based on timestamp and limit.
354 439 *
355 440 * @param int $timestamp The timestamp to filter logs by.
356 441 * @param int $limit The maximum number of logs to delete.
@@ -377,8 +462,11 @@
377 462 $today_midnight = strtotime( '-24 hours', $current_time );
378 463 $first_this_week = strtotime( '-7 days', $current_time );
379 464 $first_this_month = strtotime( '-30 days', $current_time );
380 465
466 + $ua_types_sql = "'" . implode( "', '", self::get_ua_lockout_types() ) . "'";
467 + $types_404_sql = "'" . implode( "', '", self::get_404_lockout_types() ) . "'";
468 +
381 469 // Prepare columns.
382 470 $select = array(
383 471 'MAX(date) as lockout_last',
384 472 'COUNT(*) as lockout_this_month',
@@ -383,19 +471,19 @@
383 471 'MAX(date) as lockout_last',
384 472 'COUNT(*) as lockout_this_month',
385 473 // 24 hours
386 474 "COUNT(IF(date > {$today_midnight}, 1, NULL)) as lockout_today",
387 - "COUNT(IF(date > {$today_midnight} AND type = '" . self::LOCKOUT_404 . "', 1, NULL)) as lockout_404_today",
475 + "COUNT(IF(date > {$today_midnight} AND type IN ({$types_404_sql}), 1, NULL)) as lockout_404_today",
388 476 "COUNT(IF(date > {$today_midnight} AND type = '" . self::AUTH_LOCK . "', 1, NULL)) as lockout_login_today",
389 - "COUNT(IF(date > {$today_midnight} AND type = '" . self::LOCKOUT_UA . "', 1, NULL)) as lockout_ua_today",
477 + "COUNT(IF(date > {$today_midnight} AND type IN ({$ua_types_sql}), 1, NULL)) as lockout_ua_today",
390 478 // 7 days
391 - "COUNT(IF(date > {$first_this_week} AND type = '" . self::LOCKOUT_404 . "', 1, NULL)) as lockout_404_this_week",
479 + "COUNT(IF(date > {$first_this_week} AND type IN ({$types_404_sql}), 1, NULL)) as lockout_404_this_week",
392 480 "COUNT(IF(date > {$first_this_week} AND type = '" . self::AUTH_LOCK . "', 1, NULL)) as lockout_login_this_week",
393 - "COUNT(IF(date > {$first_this_week} AND type = '" . self::LOCKOUT_UA . "', 1, NULL)) as lockout_ua_this_week",
481 + "COUNT(IF(date > {$first_this_week} AND type IN ({$ua_types_sql}), 1, NULL)) as lockout_ua_this_week",
394 482 // 30 days
395 - "COUNT(IF(date > {$first_this_month} AND type = '" . self::LOCKOUT_404 . "', 1, NULL)) as lockout_404_this_month",
483 + "COUNT(IF(date > {$first_this_month} AND type IN ({$types_404_sql}), 1, NULL)) as lockout_404_this_month",
396 484 "COUNT(IF(date > {$first_this_month} AND type = '" . self::AUTH_LOCK . "', 1, NULL)) as lockout_login_this_month",
397 - "COUNT(IF(date > {$first_this_month} AND type = '" . self::LOCKOUT_UA . "', 1, NULL)) as lockout_ua_this_month",
485 + "COUNT(IF(date > {$first_this_month} AND type IN ({$ua_types_sql}), 1, NULL)) as lockout_ua_this_month",
398 486 );
399 487 $select = implode( ',', $select );
400 488
401 489 $orm = self::get_orm();
@@ -401,9 +489,9 @@
401 489 $orm = self::get_orm();
402 490 $result = $orm->get_repository( self::class )
403 491 ->select( $select )
404 492 // LOCKOUT_IP_CUSTOM is not taken into account.
405 - ->where( 'type', 'in', array( self::LOCKOUT_404, self::AUTH_LOCK, self::LOCKOUT_UA ) )
493 + ->where( 'type', 'in', self::get_all_lockout_types() )
406 494 ->where( 'date', '>=', strtotime( '-30 days', $current_time ) )
407 495 ->get_results();
408 496
409 497 return $result[0] ?? array();
@@ -418,10 +506,16 @@
418 506 */
419 507 protected static function get_log_tag( $type ): string {
420 508 switch ( $type ) {
421 509 case self::LOCKOUT_404:
510 + case self::LOCKOUT_404_XSS:
511 + case self::LOCKOUT_404_NON_EXISTENT_THEME:
512 + case self::LOCKOUT_404_NON_EXISTENT_PLUGIN:
422 513 case self::ERROR_404:
423 514 case self::ERROR_404_IGNORE:
515 + case self::ERROR_404_XSS:
516 + case self::ERROR_404_NON_EXISTENT_THEME:
517 + case self::ERROR_404_NON_EXISTENT_PLUGIN:
424 518 $tag = '404';
425 519 break;
426 520 case self::AUTH_FAIL:
427 521 case self::AUTH_LOCK:
@@ -454,8 +548,11 @@
454 548 protected static function get_log_container_class( $type ): string {
455 549 switch ( $type ) {
456 550 case self::AUTH_LOCK:
457 551 case self::LOCKOUT_404:
552 + case self::LOCKOUT_404_XSS:
553 + case self::LOCKOUT_404_NON_EXISTENT_THEME:
554 + case self::LOCKOUT_404_NON_EXISTENT_PLUGIN:
458 555 case self::LOCKOUT_UA:
459 556 case self::LOCKOUT_MALICIOUS_BOT:
460 557 case self::LOCKOUT_FAKE_BOT:
461 558 $class = 'sui-error';
@@ -462,8 +559,11 @@
462 559 break;
463 560 case self::AUTH_FAIL:
464 561 case self::ERROR_404:
465 562 case self::ERROR_404_IGNORE:
563 + case self::ERROR_404_XSS:
564 + case self::ERROR_404_NON_EXISTENT_THEME:
565 + case self::ERROR_404_NON_EXISTENT_PLUGIN:
466 566 default:
467 567 $class = 'sui-warning';
468 568 break;
469 569 }
@@ -516,8 +616,27 @@
516 616 ->first();
517 617 }
518 618
519 619 /**
620 + * Get logs by an array of IDs.
621 + *
622 + * @param int[] $ids Array of log IDs.
623 + *
624 + * @return array
625 + */
626 + public static function find_by_ids( array $ids ): array {
627 + if ( array() === $ids ) {
628 + return array();
629 + }
630 +
631 + $orm = self::get_orm();
632 +
633 + return $orm->get_repository( self::class )
634 + ->where( 'id', 'in', $ids )
635 + ->get();
636 + }
637 +
638 + /**
520 639 * Delete current log.
521 640 */
522 641 public function delete() {
523 642 $orm = self::get_orm();
@@ -535,26 +654,40 @@
535 654 * @param array $filters The filters array.
536 655 */
537 656 private static function apply_ban_status_filter( $orm, $filters ) {
538 657 $ban_status = $filters['ban_status'];
539 - $type = $filters['type'] ?? 'all';
658 + $type = $filters['type'] ?? '';
659 + $ua_types = self::get_ua_lockout_types();
540 660
541 - // Define lockout type categories.
542 - $ua_types = array( self::get_ua_lockout_types() );
661 + $where = self::ban_status_where( $ban_status );
543 662
544 - if ( 'all' === $type || '' === $type ) {
545 - // For 'all' type, only show UA types with ban_status filtering.
546 - $ban_status_where = self::ban_status_where( $ban_status );
547 - if ( 3 === count( $ban_status_where ) ) {
663 + if ( 3 === count( $where ) ) {
664 + if ( in_array( $type, $ua_types, true ) ) {
665 + $orm->where( ...$where );
666 + } elseif ( '' === $type ) {
548 667 $orm->where( 'type', 'in', $ua_types );
549 - $orm->where( ...$ban_status_where );
668 + $orm->where( ...$where );
550 669 }
551 - } elseif ( in_array( $type, $ua_types, true ) ) {
552 - // For UA-specific types, apply UA filtering.
553 - $ban_status_where = self::ban_status_where( $ban_status );
554 - if ( 3 === count( $ban_status_where ) ) {
555 - $orm->where( ...$ban_status_where );
670 + return;
671 + }
672 +
673 + $table_lockout = wd_di()->get( Table_Lockout::class );
674 + $bl_model = wd_di()->get( Blacklist_Lockout::class );
675 +
676 + if ( $table_lockout::STATUS_BAN === $ban_status ) {
677 + $list = $bl_model->get_list( 'blocklist' );
678 + // Empty blocklist means no IPs are banned — force zero results.
679 + $orm->where( 'ip', 'in', array() === $list ? array( '' ) : $list );
680 + } elseif ( $table_lockout::STATUS_NOT_BAN === $ban_status ) {
681 + $list = $bl_model->get_list( 'blocklist' );
682 + // Empty blocklist means all IPs are "not banned" — no filter needed.
683 + if ( array() !== $list ) {
684 + $orm->where( 'ip', 'not in', $list );
556 685 }
686 + } elseif ( $table_lockout::STATUS_ALLOWLIST === $ban_status ) {
687 + $list = $bl_model->get_list( 'allowlist' );
688 + // Empty allowlist means no IPs are allowlisted — force zero results.
689 + $orm->where( 'ip', 'in', array() === $list ? array( '' ) : $list );
557 690 }
558 691 }
559 692
560 693 /**
@@ -602,21 +735,15 @@
602 735 * @return array
603 736 * @since 3.11.0
604 737 */
605 738 public static function format_logs( array $logs ): array {
606 - $data = array();
607 - $ua_model = wd_di()->get( User_Agent_Lockout::class );
739 + $data = array();
608 740 foreach ( $logs as $item ) {
609 - $ip_model = Lockout_Ip::get( $item->ip );
610 -
611 741 // Escape object properties received from end user.
612 742 $item->log = sanitize_textarea_field( $item->log );
613 743 $item->tried = sanitize_textarea_field( $item->tried );
744 + $log = $item->export();
614 745
615 - $arr_ip_statuses = $ip_model->get_access_status();
616 -
617 - $log = $item->export();
618 -
619 746 // Escape array keys received from end user.
620 747 $log['log'] = sanitize_textarea_field( $log['log'] );
621 748 $log['tried'] = sanitize_textarea_field( $log['tried'] );
622 749
@@ -623,51 +750,10 @@
623 750 $log['date'] = $item->format_date_time( $item->date );
624 751 $log['format_date'] = $item->get_date( $item->date );
625 752 $log['tag'] = self::get_log_tag( $item->type );
626 753 $log['container_class'] = self::get_log_container_class( $item->type );
627 - if ( self::LOCKOUT_UA === $item->type ) {
628 - if ( User_Agent::REASON_BAD_POST === $item->tried ) {
629 - $log['description'] = esc_html__(
630 - 'Lockout occurred due to attempted access with empty User-Agent and Referer headers. By default, IP addresses that send POST requests with empty User-Agent and Referer headers will be automatically banned. You can disable this option in the User Agent Banning settings, or you can unban the locked out IP address below.',
631 - 'defender-security'
632 - );
633 - $log['type_label'] = esc_html__( 'Type', 'defender-security' );
634 - $log['type_value'] = esc_html__( 'Empty Headers', 'defender-security' );
635 - $arr_statuses = $arr_ip_statuses;
636 - } else {
637 - $log['description'] = sprintf(
638 - /* translators: 1. Log. 2. User agent. */
639 - esc_html__(
640 - '%1$s: %2$s. This user agent is considered bad bots and may harm your site.',
641 - 'defender-security'
642 - ),
643 - sanitize_textarea_field( $item->log ),
644 - '<strong>' . sanitize_textarea_field( $item->user_agent ) . '</strong>'
645 - );
646 - $log['type_label'] = esc_html__( 'User Agent name', 'defender-security' );
647 - $log['type_value'] = sanitize_textarea_field( $item->user_agent );
648 - $log['access_status_ip'] = $arr_ip_statuses;
649 - $arr_statuses = $ua_model->get_access_status( $item->user_agent );
650 - }
651 - } else {
754 + if ( self::LOCKOUT_UA !== $item->type ) {
652 755 $log['description'] = sanitize_textarea_field( $item->log );
653 - $log['type_label'] = esc_html__( 'Type', 'defender-security' );
654 - $log['type_value'] = str_replace( '_', ' ', $item->type );
655 - $arr_statuses = $arr_ip_statuses;
656 -
657 - if ( 'malicious_bot' === $item->type ) {
658 - $log['access_status_ua'] = $ua_model->get_access_status( $item->user_agent );
659 - }
660 - }
661 - // There may be several statuses.
662 - $log['access_status'] = $arr_statuses;
663 -
664 - // For UA lockout types, show UA status; for others show IP status.
665 - if ( in_array( $item->type, array( self::get_ua_lockout_types() ), true ) ) {
666 - $ua_statuses = $ua_model->get_access_status( $item->user_agent );
667 - $log['access_status_text'] = $ip_model->get_access_status_text( $ua_statuses[0] ?? 'na' );
668 - } else {
669 - $log['access_status_text'] = $ip_model->get_access_status_text( $arr_statuses[0] );
670 756 }
671 757 $data[] = $log;
672 758 }
673 759