PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.1
Defender Security – Malware Scanner, Login Security & Firewall v6.2.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 / controller / class-firewall-logs.php

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

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