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

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

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