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

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