PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.4
Defender Security – Malware Scanner, Login Security & Firewall v6.2.4
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.4, at src/controller/class-firewall-logs.php

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