PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 5.4.0
Defender Security – Malware Scanner, Login Security & Firewall v5.4.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 / model / class-lockout-log.php

class-lockout-log.php in Defender Security – Malware Scanner, Login Security & Firewall 5.4.0, at src/model/class-lockout-log.php

669 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles interactions with the database table for lockout logs.
4 *
5 * @package WP_Defender\Model
6 */
7
8 namespace WP_Defender\Model;
9
10 use WP_Defender\DB;
11 use Calotes\Base\Model;
12 use WP_Defender\Traits\Formats;
13 use WP_Defender\Component\User_Agent;
14 use WP_Defender\Component\Table_Lockout;
15 use WP_Defender\Model\Setting\User_Agent_Lockout;
16
17 /**
18 * Model for the lockout log table.
19 */
20 class Lockout_Log extends DB {
21
22 use Formats;
23
24 public const AUTH_FAIL = 'auth_fail';
25 public const AUTH_LOCK = 'auth_lock';
26 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_BOT_TRAP = 'bot_trap';
32
33 public const LOCKOUT_UA = 'ua_lockout';
34 // Different IP Lockout types.
35 public const LOCKOUT_IP_CUSTOM = 'custom_lockout';
36
37 public const INFINITE_SCROLL_SIZE = 50;
38
39 /**
40 * Table name.
41 *
42 * @var string
43 */
44 protected $table = 'defender_lockout_log';
45
46 /**
47 * Primary key column.
48 *
49 * @var int
50 * @defender_property
51 */
52 public $id;
53 /**
54 * Table column for log.
55 *
56 * @var string
57 * @defender_property
58 */
59 public $log;
60 /**
61 * Table column for IP address.
62 *
63 * @var string
64 * @defender_property
65 */
66 public $ip;
67 /**
68 * Table column for date.
69 *
70 * @var int
71 * @defender_property
72 */
73 public $date;
74 /**
75 * Table column for user agent.
76 *
77 * @var string
78 * @defender_property
79 */
80 public $user_agent;
81 /**
82 * Table column for type.
83 *
84 * @var string
85 * @defender_property
86 */
87 public $type;
88 /**
89 * Table column for blog id.
90 *
91 * @var int
92 * @defender_property
93 */
94 public $blog_id;
95 /**
96 * Table column for tried.
97 *
98 * @var string
99 * @defender_property
100 */
101 public $tried;
102 /**
103 * Table column for country iso code.
104 *
105 * @var string
106 * @defender_property
107 */
108 public $country_iso_code;
109
110 /**
111 * Query the logs based on the provided filters and pagination settings.
112 *
113 * @param array $filters An array of filters to apply to the query. Default is an empty array.
114 * The following filters are supported:
115 * - from: The start date for the date range filter.
116 * - to: The end date for the date range filter.
117 * - ip: The IP address to filter by.
118 * - type: The type of log to filter by.
119 * - ban_status: The ban status to filter by.
120 * @param int $paged The page number of the results to retrieve. Default is 1.
121 * @param string $order_by The field to order the results by. Default is 'id'.
122 * @param string $order The order direction of the results. Default is 'desc'.
123 * @param int $page_size The number of results to retrieve per page. Default is 50.
124 * Set to -1 to retrieve all results.
125 *
126 * @return array An array of Lockout_Log objects representing the queried logs.
127 */
128 public static function query_logs(
129 $filters = array(),
130 $paged = 1,
131 $order_by = 'id',
132 $order = 'desc',
133 $page_size = 50
134 ): array {
135 $orm = self::get_orm();
136 $orm->get_repository( self::class )
137 ->where(
138 'date',
139 'between',
140 array(
141 $filters['from'],
142 $filters['to'],
143 )
144 );
145
146 if ( isset( $filters['ip'] ) && ! empty( $filters['ip'] ) ) {
147 $orm->where( 'ip', 'like', '%' . $filters['ip'] . '%' );
148 }
149 if ( isset( $filters['type'] ) && ! empty( $filters['type'] ) ) {
150 $orm->where( 'type', $filters['type'] );
151 }
152
153 if ( ! empty( $filters['ban_status'] ) ) {
154 $ban_status_where = self::ban_status_where( $filters['ban_status'] );
155
156 if ( 3 === count( $ban_status_where ) ) {
157 $orm->where( ...$ban_status_where );
158 }
159 }
160
161 if ( ! empty( $order_by ) && ! empty( $order ) ) {
162 $orm->order_by( $order_by, $order );
163 }
164 if ( false !== $page_size && -1 !== (int) $page_size ) {
165 $offset = ( $paged - 1 ) * $page_size;
166 $orm->limit( "$offset,$page_size" );
167 }
168
169 return $orm->get();
170 }
171
172 /**
173 * Count the number of records in the database table based on the provided filters.
174 *
175 * @param mixed $date_from The start date for the date range filter.
176 * @param mixed $date_to The end date for the date range filter.
177 * @param mixed $type The type of log to filter by.
178 * @param string $ip The IP address to filter by. Default is an empty string.
179 * @param array $filters An array of additional filters to apply to the query. Default is an empty array.
180 * The following filters are supported:
181 * - ban_status: The ban status to filter by.
182 *
183 * @return int|null The number of records matching the provided filters, or null if an error occurred.
184 */
185 public static function count( $date_from, $date_to, $type, $ip = '', $filters = array() ): ?string {
186 $orm = self::get_orm();
187 $orm->get_repository( self::class )
188 ->where(
189 'date',
190 'between',
191 array(
192 $date_from,
193 $date_to,
194 )
195 );
196
197 if ( ! empty( $type ) ) {
198 if ( is_array( $type ) ) {
199 $orm->where( 'type', 'in', $type );
200 } else {
201 $orm->where( 'type', $type );
202 }
203 }
204
205 if ( ! empty( $ip ) ) {
206 $orm->where( 'ip', 'like', "%$ip%" );
207 }
208
209 if ( ! empty( $filters['ban_status'] ) ) {
210 $ban_status_where = self::ban_status_where( $filters['ban_status'] );
211
212 if ( 3 === count( $ban_status_where ) ) {
213 $orm->where( ...$ban_status_where );
214 }
215 }
216
217 return $orm->count();
218 }
219
220 /**
221 * Count login lockout in the last 7 days.
222 *
223 * @return string|null
224 */
225 public static function count_login_lockout_last_7_days(): ?string {
226 $start = strtotime( '-7 days' );
227 $end = time();
228
229 return self::count( $start, $end, self::AUTH_LOCK );
230 }
231
232 /**
233 * Count 404 lockout in the last 7 days.
234 *
235 * @return string|null
236 */
237 public static function count_404_lockout_last_7_days(): ?string {
238 $start = strtotime( '-7 days' );
239 $end = time();
240
241 return self::count( $start, $end, self::LOCKOUT_404 );
242 }
243
244 /**
245 * Count UA lockout in the last 7 days.
246 *
247 * @return string|null
248 */
249 public static function count_ua_lockout_last_7_days(): ?string {
250 $start = strtotime( '-7 days' );
251 $end = time();
252
253 return self::count( $start, $end, self::LOCKOUT_UA );
254 }
255
256 /**
257 * A shortcut for quickly count lockout in last 24 hours.
258 *
259 * @return string|null
260 */
261 public static function count_lockout_in_24_hours(): ?string {
262 $start = strtotime( '-24 hours' );
263 $end = time();
264
265 return self::count(
266 $start,
267 $end,
268 array(
269 self::AUTH_LOCK,
270 self::LOCKOUT_404,
271 self::LOCKOUT_UA,
272 )
273 );
274 }
275
276 /**
277 * A shortcut for quickly count lockout in last 7 days.
278 *
279 * @return string|null
280 */
281 public static function count_lockout_in_7_days(): ?string {
282 $start = strtotime( '-7 days' );
283 $end = time();
284
285 return self::count(
286 $start,
287 $end,
288 array(
289 self::AUTH_LOCK,
290 self::LOCKOUT_404,
291 self::LOCKOUT_UA,
292 )
293 );
294 }
295
296 /**
297 * A shortcut for count lockout in 30 days.
298 *
299 * @return string|null
300 */
301 public static function count_lockout_in_30_days(): ?string {
302 $start = strtotime( '-30 days' );
303 $end = time();
304
305 return self::count(
306 $start,
307 $end,
308 array(
309 self::AUTH_LOCK,
310 self::LOCKOUT_404,
311 self::LOCKOUT_UA,
312 // LOCKOUT_IP_CUSTOM is not taken into account.
313 )
314 );
315 }
316
317 /**
318 * Retrieves the date of the last lockout that occurred within the last 30 days.
319 *
320 * @param bool $for_hub (optional) Whether to format the date for the persistent hub. Default is false.
321 *
322 * @return string The formatted date of the last lockout, or 'n/a' if no lockouts were found.
323 */
324 public static function get_last_lockout_date( $for_hub = false ) {
325 $data = self::query_logs(
326 array(
327 'from' => strtotime( '-30 days' ),
328 'to' => time(),
329 ),
330 1,
331 'id',
332 'desc',
333 1
334 );
335 $last = array_shift( $data );
336 if ( ! is_object( $last ) ) {
337 return 'n/a';
338 }
339
340 return $for_hub
341 ? $last->persistent_hub_datetime_format( $last->date )
342 : $last->format_date_time( $last->date );
343 }
344
345 /**
346 * Remove all data.
347 *
348 * @return bool|int
349 */
350 public static function truncate() {
351 $orm = self::get_orm();
352
353 return $orm->get_repository( self::class )
354 ->truncate();
355 }
356
357 /**
358 * Remove logs based on timestamp and limit.
359 *
360 * @param int $timestamp The timestamp to filter logs by.
361 * @param int $limit The maximum number of logs to delete.
362 *
363 * @return void
364 */
365 public static function remove_logs( $timestamp, $limit ) {
366 $orm = self::get_orm();
367 $orm->get_repository( self::class )
368 ->where( 'date', '<=', $timestamp )
369 ->order_by( 'id' )
370 ->limit( $limit )
371 ->delete_by_limit();
372 }
373
374 /**
375 * Get log summary.
376 *
377 * @return array
378 */
379 public static function get_summary(): array {
380 // Time.
381 $current_time = time();
382 $today_midnight = strtotime( '-24 hours', $current_time );
383 $first_this_week = strtotime( '-7 days', $current_time );
384 $first_this_month = strtotime( '-30 days', $current_time );
385
386 // Prepare columns.
387 $select = array(
388 'MAX(date) as lockout_last',
389 'COUNT(*) as lockout_this_month',
390 // 24 hours
391 "COUNT(IF(date > {$today_midnight}, 1, NULL)) as lockout_today",
392 "COUNT(IF(date > {$today_midnight} AND type = '" . self::LOCKOUT_404 . "', 1, NULL)) as lockout_404_today",
393 "COUNT(IF(date > {$today_midnight} AND type = '" . self::AUTH_LOCK . "', 1, NULL)) as lockout_login_today",
394 "COUNT(IF(date > {$today_midnight} AND type = '" . self::LOCKOUT_UA . "', 1, NULL)) as lockout_ua_today",
395 // 7 days
396 "COUNT(IF(date > {$first_this_week} AND type = '" . self::LOCKOUT_404 . "', 1, NULL)) as lockout_404_this_week",
397 "COUNT(IF(date > {$first_this_week} AND type = '" . self::AUTH_LOCK . "', 1, NULL)) as lockout_login_this_week",
398 "COUNT(IF(date > {$first_this_week} AND type = '" . self::LOCKOUT_UA . "', 1, NULL)) as lockout_ua_this_week",
399 // 30 days
400 "COUNT(IF(date > {$first_this_month} AND type = '" . self::LOCKOUT_404 . "', 1, NULL)) as lockout_404_this_month",
401 "COUNT(IF(date > {$first_this_month} AND type = '" . self::AUTH_LOCK . "', 1, NULL)) as lockout_login_this_month",
402 "COUNT(IF(date > {$first_this_month} AND type = '" . self::LOCKOUT_UA . "', 1, NULL)) as lockout_ua_this_month",
403 );
404 $select = implode( ',', $select );
405
406 $orm = self::get_orm();
407 $result = $orm->get_repository( self::class )
408 ->select( $select )
409 // LOCKOUT_IP_CUSTOM is not taken into account.
410 ->where( 'type', 'in', array( self::LOCKOUT_404, self::AUTH_LOCK, self::LOCKOUT_UA ) )
411 ->where( 'date', '>=', strtotime( '-30 days', $current_time ) )
412 ->get_results();
413
414 return $result[0] ?? array();
415 }
416
417 /**
418 * Returns the log tag based on the given type.
419 *
420 * @param int $type The type of the log.
421 *
422 * @return string The log tag.
423 */
424 protected static function get_log_tag( $type ): string {
425 switch ( $type ) {
426 case self::LOCKOUT_404:
427 case self::ERROR_404:
428 case self::ERROR_404_IGNORE:
429 $tag = '404';
430 break;
431 case self::AUTH_FAIL:
432 case self::AUTH_LOCK:
433 $tag = 'login';
434 break;
435 case self::LOCKOUT_IP_CUSTOM:
436 $tag = 'Custom';
437 break;
438 case self::IP_UNLOCK:
439 $tag = 'Unlock';
440 break;
441 case self::LOCKOUT_UA:
442 default:
443 $tag = 'bots';
444 break;
445 }
446
447 return $tag;
448 }
449
450 /**
451 * Returns the CSS class for the log container based on the given type.
452 *
453 * @param string $type The type of the log.
454 *
455 * @return string The CSS class for the log container.
456 */
457 protected static function get_log_container_class( $type ): string {
458 switch ( $type ) {
459 case self::AUTH_LOCK:
460 case self::LOCKOUT_404:
461 case self::LOCKOUT_UA:
462 case self::LOCKOUT_BOT_TRAP:
463 $class = 'sui-error';
464 break;
465 case self::AUTH_FAIL:
466 case self::ERROR_404:
467 case self::ERROR_404_IGNORE:
468 default:
469 $class = 'sui-warning';
470 break;
471 }
472
473 return $class;
474 }
475
476 /**
477 * Retrieves logs from the database and formats them for display on the frontend.
478 *
479 * @param array $filters An array of filters to apply to the query. Default is an empty array.
480 * The following filters are supported:
481 * - from: The start date for the date range filter.
482 * - to: The end date for the date range filter.
483 * - ip: The IP address to filter by.
484 * - type: The type of log to filter by.
485 * - ban_status: The ban status to filter by.
486 * @param int $paged The page number of the results to retrieve. Default is 1.
487 * @param string $order_by The field to order the results by. Default is 'id'.
488 * @param string $order The order direction of the results. Default is 'desc'.
489 * @param int $page_size The number of results to retrieve per page. Default is 50.
490 * Set to -1 to retrieve all results.
491 *
492 * @return array An array of formatted log entries.
493 */
494 public static function get_logs_and_format(
495 $filters = array(),
496 $paged = 1,
497 $order_by = 'id',
498 $order = 'desc',
499 $page_size = 50
500 ): array {
501 $logs = self::query_logs( $filters, $paged, $order_by, $order, $page_size );
502
503 return self::format_logs( $logs );
504 }
505
506 /**
507 * Get the first log by ID.
508 *
509 * @param int $id The ID of the log.
510 *
511 * @return null|Model
512 */
513 public static function find_by_id( $id ): ?Model {
514 $orm = self::get_orm();
515
516 return $orm->get_repository( self::class )
517 ->where( 'id', $id )
518 ->first();
519 }
520
521 /**
522 * Delete current log.
523 */
524 public function delete() {
525 $orm = self::get_orm();
526 $orm->get_repository( self::class )->delete(
527 array(
528 'id' => $this->id,
529 )
530 );
531 }
532
533 /**
534 * Prepare user-agent where condition based on the ban status variant.
535 *
536 * @param string $ban_status_type Ban status type.
537 *
538 * @return array Where condition arguments or empty array.
539 */
540 private static function ban_status_where( $ban_status_type ): array {
541 $table_lockout = wd_di()->get( Table_Lockout::class );
542
543 if ( $table_lockout::STATUS_NOT_BAN === $ban_status_type ) {
544 $ua_model = wd_di()->get( User_Agent_Lockout::class );
545
546 $blocklist = $ua_model->get_lockout_list( 'blocklist' );
547 $allowlist = $ua_model->get_lockout_list( 'allowlist' );
548
549 $all = array_merge( $blocklist, $allowlist );
550
551 return array( 'user_agent', 'not regexp', implode( '|', $all ) );
552 } elseif ( $table_lockout::STATUS_BAN === $ban_status_type ) {
553 $ua_model = wd_di()->get( User_Agent_Lockout::class );
554
555 $blocklist = $ua_model->get_lockout_list( 'blocklist' );
556
557 return array( 'user_agent', 'regexp', implode( '|', $blocklist ) );
558 } elseif ( $table_lockout::STATUS_ALLOWLIST === $ban_status_type ) {
559 $ua_model = wd_di()->get( User_Agent_Lockout::class );
560
561 $allowlist = $ua_model->get_lockout_list( 'allowlist' );
562
563 return array( 'user_agent', 'regexp', implode( '|', $allowlist ) );
564 }
565
566 return array();
567 }
568
569 /**
570 * Format logs for ready to use on frontend.
571 *
572 * @param array $logs An array of log entries.
573 *
574 * @return array
575 * @since 3.11.0
576 */
577 public static function format_logs( array $logs ): array {
578 $data = array();
579 $ua_model = wd_di()->get( User_Agent_Lockout::class );
580 foreach ( $logs as $item ) {
581 $ip_model = Lockout_Ip::get( $item->ip );
582
583 // Escape object properties received from end user.
584 $item->log = sanitize_textarea_field( $item->log );
585 $item->tried = sanitize_textarea_field( $item->tried );
586
587 $arr_ip_statuses = $ip_model->get_access_status();
588
589 $log = $item->export();
590
591 // Escape array keys received from end user.
592 $log['log'] = sanitize_textarea_field( $log['log'] );
593 $log['tried'] = sanitize_textarea_field( $log['tried'] );
594
595 $log['date'] = $item->format_date_time( $item->date );
596 $log['format_date'] = $item->get_date( $item->date );
597 $log['tag'] = self::get_log_tag( $item->type );
598 $log['container_class'] = self::get_log_container_class( $item->type );
599 if ( self::LOCKOUT_UA === $item->type ) {
600 if ( User_Agent::REASON_BAD_POST === $item->tried ) {
601 $log['description'] = esc_html__(
602 '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.',
603 'defender-security'
604 );
605 $log['type_label'] = esc_html__( 'Type', 'defender-security' );
606 $log['type_value'] = esc_html__( 'Empty Headers', 'defender-security' );
607 $arr_statuses = $arr_ip_statuses;
608 } else {
609 $log['description'] = sprintf(
610 /* translators: 1. Log. 2. User agent. */
611 esc_html__(
612 '%1$s: %2$s. This user agent is considered bad bots and may harm your site.',
613 'defender-security'
614 ),
615 sanitize_textarea_field( $item->log ),
616 '<strong>' . sanitize_textarea_field( $item->user_agent ) . '</strong>'
617 );
618 $log['type_label'] = esc_html__( 'User Agent name', 'defender-security' );
619 $log['type_value'] = sanitize_textarea_field( $item->user_agent );
620 $log['access_status_ip'] = $arr_ip_statuses;
621 $arr_statuses = $ua_model->get_access_status( $item->user_agent );
622 }
623 } else {
624 $log['description'] = sanitize_textarea_field( $item->log );
625 $log['type_label'] = esc_html__( 'Type', 'defender-security' );
626 $log['type_value'] = str_replace( '_', ' ', $item->type );
627 $arr_statuses = $arr_ip_statuses;
628
629 if ( 'bot_trap' === $item->type ) {
630 $log['access_status_ua'] = $ua_model->get_access_status( $item->user_agent );
631 }
632 }
633 // There may be several statuses.
634 $log['access_status'] = $arr_statuses;
635 $log['access_status_text'] = $ip_model->get_access_status_text( $arr_statuses[0] );
636 $data[] = $log;
637 }
638
639 return $data;
640 }
641
642 /**
643 * Determine if the IP should be added to the database based on the timeframe.
644 *
645 * @return bool True if the IP should be added to the database, false otherwise.
646 */
647 public function has_recent_ip_log(): bool {
648 // Ensure IP is set before proceeding.
649 if ( empty( $this->ip ) ) {
650 return false;
651 }
652
653 $orm = self::get_orm();
654 // Query the latest log for the current IP.
655 $latest_log = $orm->get_repository( self::class )
656 ->select( 'date' )
657 ->where( 'ip', $this->ip )
658 ->order_by( 'date', 'desc' )
659 ->first();
660
661 if ( $latest_log ) {
662 // Return true if the log is within the 5-minute timeframe.
663 return ( time() - $latest_log->date ) <= 300;
664 }
665
666 return false;
667 }
668 }
669