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
defender-security / src / controller / class-firewall-logs.php

class-firewall-logs.php in Defender Security – Malware Scanner, Login Security & Firewall 6.3.0, at src/controller/class-firewall-logs.php

695 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handles firewall logs and interactions with Block list API service.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use DateTime;
11 use Exception;
12 use Generator;
13 use Valitron\Validator;
14 use Calotes\Helper\HTTP;
15 use WP_Defender\Controller;
16 use Calotes\Component\Request;
17 use Calotes\Component\Response;
18 use WP_Defender\Traits\Formats;
19 use WP_Defender\Traits\Country;
20 use WP_Defender\Behavior\WPMUDEV;
21 use WP_Defender\Model\Lockout_Log;
22 use WP_Defender\Component\User_Agent;
23 use WP_Defender\Component\Table_Lockout;
24 use WP_Defender\Component\Network_Cron_Manager;
25 use WP_Defender\Integrations\Antibot_Global_Firewall_Client;
26 use WP_Defender\Component\Export\Csv;
27 use WP_Defender\Model\Setting\Blacklist_Lockout;
28 use WP_Defender\Model\Setting\User_Agent_Lockout;
29 use WP_Defender\Component\Firewall_Logs as Firewall_Logs_Component;
30
31 /**
32 * Responsible for managing firewall logs, including bulk actions, exporting logs to CSV,
33 * toggling IP addresses and user agents, querying logs, and sending logs to the Block list API.
34 */
35 class Firewall_Logs extends Controller {
36
37 use Formats;
38 use Country;
39
40 /**
41 * The slug identifier for this controller.
42 *
43 * @var string
44 */
45 protected $slug = 'wdf-ip-lockout';
46
47 /**
48 * The WPMUDEV instance used for interacting with WPMUDEV services.
49 *
50 * @var WPMUDEV
51 */
52 private $wpmudev;
53
54 /**
55 * The client for interacting with the AntiBot Global Firewall API.
56 *
57 * @var Antibot_Global_Firewall_Client
58 */
59 private $antibot_client;
60
61 /**
62 * The transient key used to store the list of IP addresses from the
63 * Akismet service that are blocked in the firewall.
64 *
65 * @var string
66 */
67 const AKISMET_BLOCKED_IPS = 'defender_akismet_blocked_ips';
68
69 /**
70 * Default number of items per page.
71 */
72 public const DEFAULT_PER_PAGE = 10;
73
74 /**
75 * Constructor for the class.
76 *
77 * @param Antibot_Global_Firewall_Client $antibot_client The client for interacting with the Block list API service.
78 */
79 public function __construct( Antibot_Global_Firewall_Client $antibot_client ) {
80 $this->register_routes();
81 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
82
83 $this->wpmudev = wd_di()->get( WPMUDEV::class );
84
85 $this->antibot_client = $antibot_client;
86
87 /**
88 * Send Firewall logs to AntiBot Global Firewall API.
89 *
90 * @var Network_Cron_Manager $network_cron_manager
91 */
92 $network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
93 $network_cron_manager->register_callback(
94 'wpdef_firewall_send_compact_logs_to_api',
95 array( $this, 'send_compact_logs_to_api' ),
96 12 * HOUR_IN_SECONDS,
97 time() + 15
98 );
99 if ( class_exists( 'Akismet' ) ) {
100 add_filter( 'http_response', array( $this, 'akismet_http_response' ), 10, 3 );
101 }
102 }
103
104 /**
105 * Bulk action handler for lockout logs.
106 *
107 * @param Request $request The request object containing the data.
108 *
109 * @return Response The response object with the result of the bulk action.
110 * @defender_route
111 */
112 public function bulk( Request $request ): Response {
113 $data = $request->get_data(
114 array(
115 'action' => array(
116 'type' => 'string',
117 'sanitize' => 'sanitize_text_field',
118 ),
119 'ips' => array(
120 'type' => 'array',
121 ),
122 )
123 );
124
125 $ips = implode( PHP_EOL, array_filter( array_map( 'sanitize_text_field', (array) $data['ips'] ), 'boolval' ) );
126 $bl = wd_di()->get( Blacklist_Lockout::class );
127
128 switch ( $data['action'] ) {
129 case 'ban':
130 $bl->ip_blacklist = $ips;
131 break;
132 case 'allowlist':
133 $bl->ip_whitelist = $ips;
134 break;
135 default:
136 break;
137 }
138
139 $bl->save();
140
141 return new Response(
142 true,
143 array(
144 'banning' => $bl->export(),
145 )
146 );
147 }
148
149 /**
150 * Export all logs matching the current filter to CSV.
151 *
152 * @return void
153 * @defender_route
154 */
155 public function export_as_csv(): void {
156 $timezone = wp_timezone();
157 $date_from_str = sanitize_text_field( (string) HTTP::get( 'date_from', '' ) );
158 $date_to_str = sanitize_text_field( (string) HTTP::get( 'date_to', '' ) );
159 $date_from = $date_from_str
160 ? $this->date_string_to_timestamp( $date_from_str )
161 : ( new DateTime( '-30 days', $timezone ) )->setTime( 0, 0, 0 )->getTimestamp();
162 $date_to = $date_to_str
163 ? $this->date_string_to_timestamp( $date_to_str, true )
164 : ( new DateTime( 'now', $timezone ) )->setTime( 23, 59, 59 )->getTimestamp();
165 $ip = sanitize_text_field( (string) HTTP::get( 'ip', '' ) );
166 $user_agent = sanitize_text_field( (string) HTTP::get( 'user_agent', '' ) );
167 $type = sanitize_text_field( (string) HTTP::get( 'type', '' ) );
168 $ban_status = sanitize_text_field( (string) HTTP::get( 'ban_status', '' ) );
169 $sort = sanitize_text_field( (string) HTTP::get( 'sort', Table_Lockout::SORT_DESC ) );
170 $sort_params = wd_di()->get( Table_Lockout::class )->resolve_sort( $sort );
171
172 $filters = array(
173 'from' => $date_from,
174 'to' => $date_to,
175 'ip' => $ip,
176 'user_agent' => $user_agent,
177 'type' => 'all' === $type ? '' : $type,
178 'ban_status' => 'all' === $ban_status ? '' : $ban_status,
179 );
180
181 $headers = array(
182 esc_html__( 'Log', 'defender-security' ),
183 esc_html__( 'Date / Time', 'defender-security' ),
184 esc_html__( 'Type', 'defender-security' ),
185 esc_html__( 'IP address', 'defender-security' ),
186 esc_html__( 'IP Status', 'defender-security' ),
187 esc_html__( 'User Agent Name', 'defender-security' ),
188 esc_html__( 'User Agent Status', 'defender-security' ),
189 );
190
191 try {
192 Csv::to_browser( 'lockout-logs', $this->get_export_rows( $filters, $sort_params ), $headers );
193 } catch ( \RuntimeException $e ) {
194 $this->log( $e->getMessage(), 'firewall-logs' );
195 }
196 }
197
198 /**
199 * Generates firewall log export rows in database-backed pages.
200 *
201 * @param array $filters Log filters.
202 * @param array $sort_params Sort parameters.
203 *
204 * @return Generator
205 */
206 private function get_export_rows( array $filters, array $sort_params ): Generator {
207 $page = 1;
208 $per_page = 1000;
209 $tl_component = new Table_Lockout();
210 $ua_component = wd_di()->get( User_Agent::class );
211
212 do {
213 $logs = Lockout_Log::query_logs(
214 $filters,
215 $page,
216 $sort_params['order_by'],
217 $sort_params['order'],
218 $per_page
219 );
220
221 foreach ( $logs as $log ) {
222 yield array(
223 $log->log,
224 $this->format_date_time( $log->date ),
225 $tl_component->get_type( $log->type ),
226 $log->ip,
227 $tl_component->get_ip_status_text( $log->ip ),
228 $log->user_agent,
229 $ua_component->get_status_text( $log->type, $log->tried ),
230 );
231 }
232
233 $has_more = count( $logs ) === $per_page;
234 ++$page;
235 } while ( $has_more );
236 }
237
238 /**
239 * Toggles an IP address to or from a specified list.
240 *
241 * @param Request $request The HTTP request object.
242 *
243 * @return Response The HTTP response object.
244 * @defender_route
245 */
246 public function toggle_ip_to_list( Request $request ): Response {
247 $data = $request->get_data(
248 array(
249 'list' => array(
250 'type' => 'string',
251 'sanitize' => 'sanitize_textarea_field',
252 ),
253 'type' => array(
254 'type' => 'string',
255 'sanitize' => 'sanitize_text_field',
256 ),
257 )
258 );
259
260 $collection = $data['list'];
261 $list_type = $data['type'];
262
263 $model = wd_di()->get( Blacklist_Lockout::class );
264 if ( 'blacklist' === $list_type ) {
265 $model->ip_blacklist = $collection;
266 } elseif ( 'whitelist' === $list_type ) {
267 $model->ip_whitelist = $collection;
268 }
269 $model->save();
270
271 return new Response(
272 true,
273 array(
274 'banning' => $model->export(),
275 )
276 );
277 }
278
279 /**
280 * Toggles a user agent to/from a specified list based on the given request data.
281 *
282 * @param Request $request The request object containing the data for toggling the user agent.
283 *
284 * @return Response The response object indicating the success or failure of the toggle operation.
285 * @defender_route
286 */
287 public function toggle_ua_to_list( Request $request ): Response {
288 $data = $request->get_data(
289 array(
290 'list' => array(
291 'type' => 'string',
292 'sanitize' => 'sanitize_textarea_field',
293 ),
294 'type' => array(
295 'type' => 'string',
296 'sanitize' => 'sanitize_text_field',
297 ),
298 )
299 );
300
301 $collection = $data['list'];
302 $list_type = $data['type'];
303
304 $model = wd_di()->get( User_Agent_Lockout::class );
305 if ( 'blacklist' === $list_type ) {
306 $model->blacklist = $collection;
307 } elseif ( 'whitelist' === $list_type ) {
308 $model->whitelist = $collection;
309 }
310 $model->save();
311
312 return new Response(
313 true,
314 array(
315 'uaLockout' => $model->export(),
316 )
317 );
318 }
319
320 /**
321 * Query the logs and display on frontend.
322 *
323 * @param Request $request The request object containing filter parameters.
324 *
325 * @return Response
326 * @defender_route
327 * @throws Exception If an argument is not of the expected type.
328 */
329 public function query_logs( Request $request ): Response {
330 $data = $request->get_data(
331 array(
332 'date_from' => array(
333 'type' => 'string',
334 'sanitize' => 'sanitize_text_field',
335 ),
336 'date_to' => array(
337 'type' => 'string',
338 'sanitize' => 'sanitize_text_field',
339 ),
340 'ip' => array(
341 'type' => 'string',
342 'sanitize' => 'sanitize_text_field',
343 ),
344 'user_agent' => array(
345 'type' => 'string',
346 'sanitize' => 'sanitize_text_field',
347 ),
348 'type' => array(
349 'type' => 'string',
350 'sanitize' => 'sanitize_text_field',
351 ),
352 'paged' => array(
353 'type' => 'int',
354 'sanitize' => 'sanitize_text_field',
355 ),
356 'sort' => array(
357 'type' => 'string',
358 'sanitize' => 'sanitize_text_field',
359 ),
360 'ban_status' => array(
361 'type' => 'string',
362 'sanitize' => 'sanitize_text_field',
363 ),
364 'per_page' => array(
365 'type' => 'int',
366 'sanitize' => 'sanitize_text_field',
367 ),
368 )
369 );
370 // Validate.
371 $v = new Validator( $data, array() );
372 $v->rule( 'required', array( 'date_from', 'date_to' ) );
373 if ( ! $v->validate() ) {
374 return new Response( false, array( 'message' => esc_html__( 'Start and end date are required.', 'defender-security' ) ) );
375 }
376
377 $date_from = $this->date_string_to_timestamp( $data['date_from'] );
378 $date_to = $this->date_string_to_timestamp( $data['date_to'], true );
379 if ( $date_from <= 0 || $date_to <= 0 ) {
380 return new Response( false, array( 'message' => esc_html__( 'Wrong start and end date.', 'defender-security' ) ) );
381 }
382
383 $sort = $data['sort'] ?? Table_Lockout::SORT_DESC;
384 $sort_params = wd_di()->get( Table_Lockout::class )->resolve_sort( $sort );
385
386 $result = $this->retrieve_logs(
387 array(
388 'from' => $date_from,
389 'to' => $date_to,
390 'ip' => $data['ip'],
391 'user_agent' => $data['user_agent'] ?? '',
392 // If this is all, then we set to null to exclude it from the filter.
393 'type' => 'all' === $data['type'] ? '' : $data['type'],
394 'ban_status' => 'all' === $data['ban_status'] ? '' : $data['ban_status'],
395 ),
396 $data['paged'],
397 $sort_params['order'],
398 $sort_params['order_by'],
399 $data['per_page'] ?? 0
400 );
401
402 return new Response( true, $result );
403 }
404
405 /**
406 * Enqueues scripts and styles for this page.
407 * Only enqueues assets if the page is active.
408 */
409 public function enqueue_assets() {
410 if ( ! $this->is_page_active() ) {
411 return;
412 }
413 }
414
415 /**
416 * Provides data for the frontend.
417 *
418 * @return array An array of data for the frontend.
419 */
420 public function data_frontend(): array {
421 $type = defender_get_data_from_request( 'type', 'g' );
422 $ip = defender_get_data_from_request( 'ip', 'g' );
423 $user_agent = defender_get_data_from_request( 'user_agent', 'g' );
424 $timezone = wp_timezone();
425
426 $init_filters = array(
427 'from' => ( new DateTime( '-30 days', $timezone ) )->setTime( 0, 0, 0 )->getTimestamp(),
428 'to' => ( new DateTime( 'now', $timezone ) )->setTime( 23, 59, 59 )->getTimestamp(),
429 'type' => $type,
430 'ip' => $ip,
431 'user_agent' => $user_agent,
432 'ban_status' => '',
433 );
434 $misc = wd_di()->get( Table_Lockout::class )->get_filters();
435 $misc['countries'] = $this->countries_list();
436 $def_filters = array(
437 'misc' => $misc,
438 'default_filter' => $init_filters,
439 'per_page' => self::DEFAULT_PER_PAGE,
440 );
441
442 return array_merge(
443 $this->retrieve_logs( $init_filters ),
444 $def_filters,
445 $this->dump_routes_and_nonces()
446 );
447 }
448
449 /**
450 * Retrieves logs based on the given filters, paging, order, and order by.
451 *
452 * @param array $filters An array containing the following keys:
453 * - 'from': The start date of the logs.
454 * - 'to': The end date of the logs.
455 * - 'type': The type of logs.
456 * - 'ip': The IP address of the logs.
457 * - 'ban_status': The ban status of the logs.
458 * @param int $paged The page number of the logs to retrieve. Default is 1.
459 * @param string $order The order of the logs. Default is 'desc'.
460 * @param string $order_by The field to order the logs by. Default is 'id'.
461 * @param int $per_page Number of logs per page. 0 falls back to the default of 10.
462 *
463 * @return array An array containing the following keys:
464 * - 'count': The total count of logs.
465 * - 'logs': The retrieved logs.
466 * - 'per_page': The number of logs per page.
467 * - 'total_pages': The total number of pages.
468 */
469 private function retrieve_logs( $filters, $paged = 1, $order = 'desc', $order_by = 'id', $per_page = 0 ): array {
470 $per_page = (int) $per_page;
471 if ( 0 === $per_page ) {
472 $per_page = self::DEFAULT_PER_PAGE;
473 }
474 $conditions = array( 'ban_status' => $filters['ban_status'] );
475
476 $count = Lockout_Log::count( $filters['from'], $filters['to'], $filters['type'], $filters['ip'], $conditions );
477 $logs = Lockout_Log::get_logs_and_format( $filters, $paged, $order_by, $order, $per_page );
478 return array(
479 'logs' => $logs,
480 'per_page' => $per_page,
481 'total_pages' => ceil( $count / $per_page ),
482 );
483 }
484
485 /**
486 * Converts the current object state to an array.
487 *
488 * @return array The array representation of the object.
489 */
490 public function to_array(): array {
491 return array();
492 }
493
494 /**
495 * Imports data into the model.
496 *
497 * @param array $data Data to be imported into the model.
498 */
499 public function import_data( array $data ) {
500 }
501
502 /**
503 * Removes settings for all submodules.
504 */
505 public function remove_settings() {
506 }
507
508 /**
509 * Delete all the data & the cache.
510 */
511 public function remove_data() {
512 delete_site_transient( self::AKISMET_BLOCKED_IPS );
513 }
514
515 /**
516 * Exports strings.
517 *
518 * @return array An array of strings.
519 */
520 public function export_strings(): array {
521 return array();
522 }
523
524 /**
525 * Exports strings.
526 *
527 * @param array $logs Prepared logs.
528 * @param bool $is_staging Send logs to staging.
529 */
530 private function maybe_send_reports( array $logs, bool $is_staging = false ): void {
531 $offset = 0;
532 $length = 1000;
533 $logs_chunk = array_slice( $logs, $offset, $length );
534 while ( array() !== $logs_chunk ) {
535 $data = array(
536 'logs' => $logs_chunk,
537 );
538
539 $antibot_client = $this->antibot_client;
540 if ( $is_staging ) {
541 $antibot_client = new Antibot_Global_Firewall_Client( 'https://staging-api.blocklist-service.com' );
542 }
543 $response = $antibot_client->send_reports( $data );
544
545 if ( is_wp_error( $response ) ) {
546 $this->log(
547 sprintf( 'AntiBot Global Firewall Error: %s', $response->get_error_message() ),
548 Firewall::FIREWALL_LOG
549 );
550 } elseif ( isset( $response['status'] ) && 'error' === $response['status'] ) {
551 $this->log(
552 sprintf( 'AntiBot Global Firewall Error: %s', $response['message'] ),
553 Firewall::FIREWALL_LOG
554 );
555 }
556
557 $offset += $length;
558 $logs_chunk = array_slice( $logs, $offset, $length );
559 }
560
561 $this->log( 'AntiBot Global Firewall: Process for sending logs completed.', Firewall::FIREWALL_LOG );
562 }
563
564 /**
565 * Send last 12 hours logs to AntiBot Global Firewall API.
566 * If running for first time then grab 7 days of logs.
567 * If last run difference is greater than 12 hours then grab 12+ hours of log but at most grab 7 days of logs.
568 *
569 * @return void
570 */
571 public function send_compact_logs_to_api(): void {
572 $site_id = get_current_blog_id();
573 $event_name = 'wpdef_firewall_send_compact_logs_to_api';
574 $this->log( "Cron job {$event_name} triggered at site {$site_id}", Firewall::FIREWALL_LOG );
575
576 /**
577 * Enable/disable sending Firewall logs to API.
578 *
579 * @param bool $status Status for sending logs. Send logs to API if true.
580 *
581 * @since 4.5.0
582 */
583 $send_logs = apply_filters( 'wpdef_firewall_send_logs_to_api', true );
584 $send_logs = is_bool( $send_logs ) ? $send_logs : (bool) $send_logs;
585
586 if (
587 ! $send_logs ||
588 ! $this->wpmudev->is_dash_activated() ||
589 ! $this->wpmudev->is_site_connected_to_hub()
590 ) {
591 return;
592 }
593
594 // Acquire lock before executing.
595 if ( ! $this->acquire_cron_lock( $event_name, 'twicedaily' ) ) {
596 $this->log( "{$event_name} is skipped running from site {$site_id}", Firewall::FIREWALL_LOG );
597 return;
598 }
599 // Log the site ID where the event is triggered.
600 $this->log( "{$event_name} is processing from site {$site_id}", Firewall::FIREWALL_LOG );
601 $from = time() - ( 7 * DAY_IN_SECONDS );
602
603 $last_run_time = get_site_option( 'wpdef_ip_blocklist_sync_last_run_time', 0 );
604 if ( 0 < $last_run_time ) {
605 $time_difference = time() - $last_run_time;
606
607 if ( $time_difference < 7 * DAY_IN_SECONDS ) { // 7 days in seconds
608 $from = $last_run_time;
609 }
610 }
611 update_site_option( 'wpdef_ip_blocklist_sync_last_run_time', time() );
612
613 $service = wd_di()->get( Firewall_Logs_Component::class );
614
615 $logs = $service->get_compact_logs( $from );
616 if ( array() !== $logs ) {
617 $this->maybe_send_reports( $logs );
618 }
619
620 $logs = $service->get_akismet_auto_spam_comment_logs();
621 if ( array() !== $logs ) {
622 $this->maybe_send_reports( $logs );
623 }
624
625 $logs = $service->get_404_intelligence_logs( $from );
626 if ( array() !== $logs ) {
627 $this->maybe_send_reports( $logs, true );
628 }
629
630 // Release lock after execution.
631 $this->release_cron_lock( $event_name );
632 }
633
634 /**
635 * Filters a successful HTTP API response before returning it.
636 *
637 * @param array $response HTTP response.
638 * @param array $parsed_args HTTP request arguments.
639 * @param string $url The request URL.
640 *
641 * @return array HTTP response.
642 */
643 public function akismet_http_response( $response, $parsed_args, $url ) {
644 // If the URL is not the Akismet comment-check endpoint, return the response as is.
645 if ( 'https://rest.akismet.com/1.1/comment-check' !== $url ) {
646 return $response;
647 }
648
649 // Retrieve response body safely.
650 $body = wp_remote_retrieve_body( $response );
651 // If the body is empty or does not equal 'true' (indicating spam), return the response as is.
652 if ( ! is_string( $body ) || in_array( trim( $body ), array( '', 'true' ), true ) ) {
653 return $response;
654 }
655
656 // Ensure the request body contains data; otherwise, return the response.
657 $body_arg = $parsed_args['body'] ?? '';
658 if (
659 ( is_string( $body_arg ) && '' === trim( $body_arg ) )
660 || ( is_array( $body_arg ) && array() === $body_arg )
661 || ( is_object( $body_arg ) && 0 === count( get_object_vars( $body_arg ) ) )
662 ) {
663 return $response;
664 }
665
666 $request_data = wp_parse_args( $body_arg );
667 // If the comment author's IP is not present in the request data, return the response.
668 $author_ip = $request_data['comment_author_IP'] ?? '';
669 if ( '' === $author_ip ) {
670 return $response;
671 }
672
673 // Validate the user IP address from the request data.
674 $user_ip = filter_var( $author_ip, FILTER_VALIDATE_IP );
675 if ( false === $user_ip ) {
676 return $response;
677 }
678
679 // Retrieve the current list of blocked IPs from the site transient.
680 $option = get_site_transient( self::AKISMET_BLOCKED_IPS );
681 // Ensure the retrieved data is an array; if not, initialize it as an empty array.
682 if ( ! is_array( $option ) ) {
683 $option = array();
684 }
685
686 // Increment the count of how many times this IP has been associated with spam.
687 $option[ $user_ip ] = isset( $option[ $user_ip ] ) ? (int) $option[ $user_ip ] + 1 : 1;
688 // Update the site transient with the new list of blocked IPs.
689 set_site_transient( self::AKISMET_BLOCKED_IPS, $option );
690
691 // Return the original HTTP response.
692 return $response;
693 }
694 }
695