PluginProbe
Defender Security – Malware Scanner, Login Security & Firewall / 6.2.2
Defender Security – Malware Scanner, Login Security & Firewall v6.2.2
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-audit-logging.php

class-audit-logging.php in Defender Security – Malware Scanner, Login Security & Firewall 6.2.2, at src/controller/class-audit-logging.php

677 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Handle Audit Logging module.
4 *
5 * @package WP_Defender\Controller
6 */
7
8 namespace WP_Defender\Controller;
9
10 use DateTime;
11 use Exception;
12 use DateInterval;
13 use WP_Defender\Event;
14 use Calotes\Helper\HTTP;
15 use WP_Defender\Traits\User;
16 use Calotes\Component\Request;
17 use Calotes\Component\Response;
18 use Calotes\Helper\Array_Cache;
19 use WP_Defender\Traits\Formats;
20 use WP_Defender\Component\Audit;
21 use WP_Defender\Model\Audit_Log;
22 use WP_Defender\Behavior\WPMUDEV;
23 use WP_Defender\Component\Network_Cron_Manager;
24 use WP_Defender\Model\Notification\Audit_Report;
25 use WP_Defender\Component\Config\Config_Hub_Helper;
26 use WP_Defender\Model\Setting\Audit_Logging as Model_Audit_Logging;
27
28 /**
29 * Handle Audit Logging module.
30 */
31 class Audit_Logging extends Event {
32
33 public const DEFAULT_PER_PAGE = 10;
34
35 use User;
36 use Formats;
37
38 /**
39 * The slug identifier for this controller.
40 *
41 * @var string
42 */
43 public $slug = 'wdf-logging';
44
45 /**
46 * The model for handling the data.
47 *
48 * @var Model_Audit_Logging
49 */
50 public $model;
51
52 /**
53 * Service for handling logic.
54 *
55 * @var Audit|null
56 */
57 public ?Audit $service;
58
59 /**
60 * Indicates whether the current installation is a pro version.
61 *
62 * @var bool
63 */
64 private $is_pro;
65
66 /**
67 * Initializes the model and service, registers routes, and sets up scheduled events if the model is active.
68 */
69 public function __construct() {
70 $this->register_page(
71 esc_html__( 'Audit Log', 'defender-security' ),
72 $this->slug,
73 array( $this, 'main_view' ),
74 $this->parent_slug
75 );
76 add_action( 'defender_enqueue_assets', array( $this, 'enqueue_assets' ) );
77 $this->model = wd_di()->get( Model_Audit_Logging::class );
78 $this->service = new Audit();
79 $this->register_routes();
80 $this->is_pro = wd_di()->get( WPMUDEV::class )->is_pro();
81 if ( $this->is_active() ) {
82 $this->service->enqueue_event_listener();
83 add_action( 'shutdown', array( $this, 'cache_audit_logs' ) );
84
85 /**
86 * Network Cron Manager
87 *
88 * @var Network_Cron_Manager $network_cron_manager
89 */
90 $network_cron_manager = wd_di()->get( Network_Cron_Manager::class );
91 $network_cron_manager->register_callback(
92 'audit_clean_up_logs',
93 array( $this->service, 'audit_clean_up_logs' ),
94 HOUR_IN_SECONDS
95 );
96 }
97 }
98
99 /**
100 * Is there permission to Pro feature?
101 *
102 * @return bool
103 */
104 private function is_active(): bool {
105 return $this->model->is_active();
106 }
107
108 /**
109 * Parses a raw request params array, resolves filters, and fetches audit logs.
110 *
111 * Accepted keys (all optional except date range):
112 * date_from — date string or empty (defaults to -7 days midnight).
113 * date_to — date string or empty (defaults to today 23:59:59).
114 * events — array of event type strings (also accepts key 'event_type' for CSV route).
115 * username — login name (also accepts key 'term' for CSV route).
116 * ip_address — IP filter string.
117 * paged — page number; pass false for unbounded export.
118 *
119 * Returns the resolved user_id via the $user_id out-param so callers can use it for COUNT queries.
120 *
121 * @param array $params Raw request data.
122 * @param mixed $paged Page number or false for all results.
123 * @param int|string $user_id Populated with the resolved user ID (out-param).
124 * @param int $per_page Number of logs per page.
125 *
126 * @return array|\WP_Error
127 */
128 private function fetch_logs( array $params, $paged, &$user_id = '', $per_page = self::DEFAULT_PER_PAGE ) {
129 $date_from_str = $params['date_from'] ?? '';
130 $date_to_str = $params['date_to'] ?? '';
131 $date_from = $date_from_str ? $this->date_string_to_timestamp( $date_from_str ) : strtotime( '-7 days midnight' );
132 $date_to = $date_to_str ? $this->date_string_to_timestamp( $date_to_str, true ) : strtotime( 'today 23:59:59' );
133 $events = $params['events'] ?? ( isset( $params['event_type'] ) && is_array( $params['event_type'] ) ? $params['event_type'] : array() );
134 $username = $params['username'] ?? ( $params['term'] ?? '' );
135 $ip_address = $params['ip_address'] ?? '';
136 $user_id = '';
137
138 if ( '' !== $username ) {
139 $user = get_user_by( 'login', $username );
140 if ( ! is_object( $user ) ) {
141 // Try matching by display_name or email before giving up.
142 $users = get_users(
143 array(
144 'search' => '*' . $username . '*',
145 'search_columns' => array( 'display_name' ),
146 'number' => 1,
147 )
148 );
149 if ( array() === $users ) {
150 return array();
151 }
152 $user_id = $users[0]->ID;
153 } else {
154 $user_id = $user->ID;
155 }
156 }
157
158 return $this->service->fetch( $date_from, $date_to, $events, $user_id, $ip_address, $paged, $per_page );
159 }
160
161 /**
162 * Prefetches users from a result set then formats each log into an array.
163 *
164 * @param array $result Array of Audit_Log objects.
165 *
166 * @return array
167 */
168 private function format_log_rows( array $result ): array {
169 $user_ids = array_filter(
170 array_unique( array_column( $result, 'user_id' ) ),
171 static fn ( $user_id ): bool => (int) $user_id > 0
172 );
173 if ( array() !== $user_ids ) {
174 get_users( array( 'include' => $user_ids ) );
175 }
176
177 $logs = array();
178 foreach ( $result as $item ) {
179 $item_user_id = isset( $item->user_id ) ? $item->user_id : 0;
180 $item_user_id = ! is_int( $item_user_id ) ? (int) $item_user_id : $item_user_id;
181 $logs[] = array_merge(
182 $item->export(),
183 array(
184 'user' => $this->get_user_display( $item->user_id ),
185 'user_url' => $item_user_id > 0 ? get_edit_user_link( $item_user_id ) : '',
186 'log_date' => $this->get_date( $item->timestamp ),
187 'format_date' => $this->format_date_time( $item->timestamp ),
188 )
189 );
190 }
191
192 return $logs;
193 }
194
195 /**
196 * Exports audit logs as a CSV file.
197 *
198 * @return void
199 * @throws Exception If there is an error during export.
200 * @defender_route
201 */
202 public function export_as_csv(): void {
203 $result = $this->fetch_logs( defender_get_data_from_request( null, 'g' ), false );
204
205 if ( is_wp_error( $result ) ) {
206 wp_send_json_error(
207 array(
208 'message' => $result->get_error_message(),
209 )
210 );
211 }
212
213 // WP_Filesystem class doesn't directly provide a function for opening a stream to php://memory with the 'w' mode.
214 $fp = fopen( 'php://memory', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
215 $headers = array(
216 esc_html__( 'Summary', 'defender-security' ),
217 esc_html__( 'Date / Time', 'defender-security' ),
218 esc_html__( 'Context', 'defender-security' ),
219 esc_html__( 'Type', 'defender-security' ),
220 esc_html__( 'IP address', 'defender-security' ),
221 esc_html__( 'User', 'defender-security' ),
222 );
223 fputcsv( $fp, $headers, ',', '"', '\\' );
224 foreach ( $this->format_log_rows( $result ) as $row ) {
225 $vars = array(
226 $row['msg'],
227 is_array( $row['timestamp'] )
228 ? $this->format_date_time( $row['timestamp'][0] )
229 : $this->format_date_time( $row['timestamp'] ),
230 $row['context'],
231 $row['action_type'],
232 $row['ip'],
233 $row['user'],
234 );
235 fputcsv( $fp, $vars, ',', '"', '\\' );
236 }
237 $filename = 'wdf-audit-logs-export-' . wp_date( 'ymdHis' ) . '.csv';
238 fseek( $fp, 0 );
239 header( 'Content-Type: text/csv' );
240 header( 'Content-Disposition: attachment; filename="' . $filename . '";' );
241 // Make php send the generated csv lines to the browser.
242 fpassthru( $fp );
243 exit();
244 }
245
246 /**
247 * We'll pass all the event logs into the db handler, so it writes down to db.
248 * Do it in shutdown runtime, so no delay time.
249 *
250 * @return void
251 */
252 public function cache_audit_logs(): void {
253 $audit = new Audit();
254 $audit->log_audit_events();
255 }
256
257 /**
258 * Pull the logs from db cached:
259 * - date_from: the start of the date we will run the query, as mysql time format,
260 * - date_to: similar to the above,
261 * others will refer to Audit.
262 *
263 * @param Request $request The request object containing filter parameters.
264 *
265 * @return Response
266 * @throws Exception If there is an error during log retrieval.
267 * @defender_route
268 */
269 public function pull_logs( Request $request ): Response {
270 $data = $request->get_data(
271 array(
272 'date_from' => array(
273 'type' => 'string',
274 'sanitize' => 'sanitize_text_field',
275 ),
276 'date_to' => array(
277 'type' => 'string',
278 'sanitize' => 'sanitize_text_field',
279 ),
280 'username' => array(
281 'type' => 'string',
282 'sanitize' => 'sanitize_text_field',
283 ),
284 'events' => array(
285 'type' => 'array',
286 'sanitize' => 'sanitize_text_field',
287 ),
288 'ip_address' => array(
289 'type' => 'string',
290 'sanitize' => 'sanitize_text_field',
291 ),
292 'paged' => array(
293 'type' => 'int',
294 'sanitize' => 'sanitize_text_field',
295 ),
296 'per_page' => array(
297 'type' => 'int',
298 'sanitize' => 'sanitize_text_field',
299 ),
300 )
301 );
302 if ( ! isset( $data['date_from'] ) || ! isset( $data['date_to'] ) ) {
303 return new Response( false, array( 'message' => esc_html__( 'Invalid data.', 'defender-security' ) ) );
304 }
305 // Validate date strings before passing to fetch_logs.
306 $date_from_check = $this->date_string_to_timestamp( $data['date_from'] );
307 $date_to_check = $this->date_string_to_timestamp( $data['date_to'], true );
308 if ( $date_from_check <= 0 || $date_to_check <= 0 ) {
309 return new Response( false, array( 'message' => esc_html__( 'Invalid data.', 'defender-security' ) ) );
310 }
311
312 $paged = $data['paged'] ?? 1;
313 $per_page = min( 100, max( 1, (int) ( $data['per_page'] ?? self::DEFAULT_PER_PAGE ) ) );
314 $result = $this->fetch_logs( $data, $paged, $user_id, $per_page );
315
316 if ( is_wp_error( $result ) ) {
317 return new Response( false, array( 'message' => $result->get_error_message() ) );
318 }
319 $logs = is_array( $result ) && array() !== $result ? $this->format_log_rows( $result ) : array();
320
321 // @since 3.0.0 If no logs then $count = 0.
322 // Skip the COUNT query when the result is a partial page — the fetch already told us the total is count($logs).
323 $events = $data['events'] ?? array();
324 $ip_address = $data['ip_address'] ?? '';
325 if ( array() === $logs ) {
326 $count = 0;
327 } elseif ( count( $logs ) < $per_page ) {
328 $count = ( $paged - 1 ) * $per_page + count( $logs );
329 } else {
330 $count = Audit_Log::count( $date_from_check, $date_to_check, $events, $user_id, $ip_address );
331 }
332
333 // Get the count for the submitted data.
334 return new Response(
335 true,
336 array(
337 'logs' => $logs,
338 'total_items' => $count,
339 'total_pages' => ceil( $count / $per_page ),
340 'per_page' => $per_page,
341 )
342 );
343 }
344
345 /**
346 * Generates a human-readable frequency text for audit reports.
347 *
348 * @param Audit_Report $audit_report The audit report object.
349 *
350 * @return string Returns the formatted frequency description.
351 */
352 public function get_frequency_text( Audit_Report $audit_report ): string {
353 $text = '';
354 switch ( $audit_report->frequency ) {
355 case 'daily':
356 $text = ucfirst( $audit_report->day ) . 's at ' . $audit_report->time;
357 break;
358 case 'weekly':
359 case 'monthly':
360 $text = ucfirst( $audit_report->frequency ) . ' on ' . ucfirst( $audit_report->day ) . 's at ' . $audit_report->time;
361 break;
362 default:
363 break;
364 }
365
366 return $text;
367 }
368
369 /**
370 * Enqueues scripts and styles for this page.
371 */
372 public function enqueue_assets(): void {
373 if ( ! $this->is_page_active() ) {
374 return;
375 }
376
377 $handle = 'defender-ui-audit-logging';
378 wp_enqueue_script(
379 $handle,
380 WP_DEFENDER_BASE_URL . 'assets/js/audit-logging-ui.js',
381 array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
382 DEFENDER_VERSION,
383 true
384 );
385 wp_set_script_translations( $handle, 'wpdef' );
386
387 wp_localize_script(
388 $handle,
389 'defenderUIData',
390 array_merge(
391 $this->get_shared_data(),
392 $this->data_frontend()
393 )
394 );
395
396 wp_enqueue_style(
397 $handle,
398 WP_DEFENDER_BASE_URL . 'assets/css/showcase.css',
399 array(),
400 DEFENDER_VERSION
401 );
402
403 $this->enqueue_main_assets();
404 }
405
406 /**
407 * Render the root element for frontend.
408 *
409 * @return void
410 */
411 public function main_view(): void {
412 $this->render( 'main' );
413 }
414
415 /**
416 * Provides a summary of audit logs.
417 *
418 * @return void
419 * @throws Exception If there is an error during summary generation.
420 * @defender_route
421 */
422 public function summary(): void {
423 $response = $this->is_active() ? $this->summary_data() : array();
424 wp_send_json_success( $response );
425 }
426
427 /**
428 * Returns an array with summary data for audit logging.
429 *
430 * @param bool $for_hub Default 'false' because it's displayed on site summary sections.
431 *
432 * @return array
433 * @throws Exception Emits Exception in case of an error.
434 */
435 public function summary_data( bool $for_hub = false ): array {
436 $timezone = wp_timezone();
437
438 // Monthly count.
439 $date_from = ( new DateTime( '-30 days', $timezone ) )
440 ->setTime( 0, 0, 0 )
441 ->getTimestamp();
442 $date_to = ( new DateTime( 'now', $timezone ) )
443 ->setTime( 23, 59, 59 )
444 ->getTimestamp();
445 $month_count = Audit_Log::count( $date_from, $date_to );
446
447 // Weekly count.
448 $date_from = ( new DateTime( '-7 days', $timezone ) )
449 ->setTime( 0, 0, 0 )
450 ->getTimestamp();
451 $week_count = Audit_Log::count( $date_from, $date_to );
452
453 // Daily count. Sync data to the Hub without timezone.
454 $date_from = $for_hub ? new DateTime( 'now' ) : new DateTime( 'now', wp_timezone() );
455 $date_from = $date_from->modify( '-24 hours' )->setTime( 0, 0, 0 )->getTimestamp();
456 $day_count = Audit_Log::count( $date_from, $date_to );
457
458 // Get the last item.
459 $last = Audit_Log::get_last();
460 if ( is_object( $last ) ) {
461 $last = $for_hub
462 ? $this->persistent_hub_datetime_format( $last->timestamp )
463 : $this->format_date_time( $last->timestamp );
464 } else {
465 $last = 'n/a';
466 }
467
468 return array(
469 'monthCount' => $month_count,
470 'weekCount' => $week_count,
471 'dayCount' => $day_count,
472 'lastEvent' => $last,
473 'report' => wd_di()->get( Audit_Report::class )->to_string(),
474 );
475 }
476
477 /**
478 * Save settings.
479 *
480 * @param Request $request The request object containing new settings data.
481 *
482 * @return Response
483 * @defender_route
484 */
485 public function save_settings( Request $request ): Response {
486 $data = $request->get_data_by_model( $this->model );
487
488 $this->model->import( $data );
489 if ( $this->model->validate() ) {
490 $this->model->save();
491 }
492
493 Config_Hub_Helper::set_clear_active_flag();
494
495 return new Response(
496 true,
497 array_merge(
498 $this->data_frontend(),
499 array(
500 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
501 'auto_close' => true,
502 'model' => $this->model->export(),
503 )
504 )
505 );
506 }
507
508 /**
509 * Provides the audit settings data used outside the Audit Logging page.
510 *
511 * @return array
512 */
513 public function settings_data_frontend(): array {
514 return array_merge(
515 array(
516 'model' => $this->model->export(),
517 ),
518 $this->dump_routes_and_nonces()
519 );
520 }
521
522 /**
523 * Converts the current state of the object to an array.
524 *
525 * @return array Returns an associative array of object properties.
526 */
527 public function to_array(): array {
528 return array_merge(
529 array(
530 'enabled' => $this->is_active(),
531 'report' => true,
532 ),
533 $this->dump_routes_and_nonces()
534 );
535 }
536
537 /**
538 * Removes settings for all submodules.
539 */
540 public function remove_settings(): void {
541 ( new Model_Audit_Logging() )->delete();
542 }
543
544 /**
545 * Delete all the data & the cache.
546 */
547 public function remove_data(): void {
548 Audit_Log::truncate();
549 // Remove cached data.
550 Array_Cache::remove( 'sockets', 'audit' );
551 Array_Cache::remove( 'logs', 'audit' );
552 Array_Cache::remove( 'menu_updated', 'audit' );
553 Array_Cache::remove( 'post_updated', 'audit' );
554 delete_site_option( 'wd_audit_fetch_checkpoint' );
555 }
556
557 /**
558 * Provides data for the frontend.
559 *
560 * @return array An array of data for the frontend.
561 * @throws Exception If there is an error.
562 */
563 public function data_frontend(): array {
564 $logs = array();
565 $count = 0;
566 $per_page = self::DEFAULT_PER_PAGE;
567 $total_page = 1;
568 if ( $this->is_active() ) {
569 $timezone = wp_timezone();
570 $date_from = ( new DateTime( '-7 days', $timezone ) )->setTime( 0, 0, 0 );
571 $date_to = ( new DateTime( 'now', $timezone ) )->setTime( 23, 59, 59 );
572 $ip_address = sanitize_text_field( defender_get_data_from_request( 'ip', 'g' ) );
573 $result = $this->service->fetch(
574 $date_from->getTimestamp(),
575 $date_to->getTimestamp(),
576 array(),
577 '',
578 $ip_address,
579 1,
580 $per_page
581 );
582 if ( ! is_wp_error( $result ) ) {
583 $logs = $this->format_log_rows( $result );
584 // Skip COUNT query when the first page is a partial result — it's cheaper than a second DB round-trip.
585 if ( count( $logs ) < $per_page ) {
586 $count = count( $logs );
587 } else {
588 $count = Audit_Log::count( $date_from->getTimestamp(), $date_to->getTimestamp(), array(), '', $ip_address );
589 }
590 $total_page = ceil( $count / $per_page );
591 }
592 }
593
594 return array(
595 'auditLogging' => array_merge(
596 array(
597 'model' => $this->model->export(),
598 'logs' => $logs,
599 'events_type' => Audit_Log::allowed_events(),
600 'summary' => array(
601 'count_7_days' => $count,
602 'report' => wd_di()->get( Audit_Report::class )->to_string(),
603 ),
604 'paging' => array(
605 'paged' => 1,
606 'total_pages' => $total_page,
607 'count' => $count,
608 ),
609 ),
610 $this->dump_routes_and_nonces()
611 ),
612 'antibot' => wd_di()->get( Antibot_Global_Firewall::class )->data_frontend(),
613 );
614 }
615
616 /**
617 * Imports data into the model.
618 *
619 * @param array $data Data to be imported into the model.
620 *
621 * @throws Exception If table is not defined.
622 */
623 public function import_data( array $data ) {
624 $model = $this->model;
625 if ( array() === $data ) {
626 $model->enabled = false;
627 $model->storage_days = '6 months';
628 $model->save();
629 } else {
630 $model->import( $data );
631 if ( $model->validate() ) {
632 $model->save();
633 }
634 }
635 }
636
637 /**
638 * Exports strings.
639 *
640 * @return array An array of strings.
641 */
642 public function export_strings(): array {
643 $strings = $this->is_active()
644 ? array( esc_html__( 'Active', 'defender-security' ) )
645 : array( esc_html__( 'Inactive', 'defender-security' ) );
646
647 $strings[] = sprintf(
648 /* translators: %s: Html for Pro-tag. */
649 esc_html__( 'Email report inactive %s', 'defender-security' ),
650 '<span class="sui-tag sui-tag-pro">Pro</span>'
651 );
652
653 return $strings;
654 }
655
656 /**
657 * Generates configuration strings based on the provided configuration.
658 *
659 * @param array $config Configuration data.
660 *
661 * @return array Returns an array of configuration strings.
662 */
663 public function config_strings( array $config ): array {
664 $strings = $config['enabled']
665 ? array( esc_html__( 'Active', 'defender-security' ) )
666 : array( esc_html__( 'Inactive', 'defender-security' ) );
667
668 $strings[] = sprintf(
669 /* translators: %s: Html for Pro-tag. */
670 esc_html__( 'Email report inactive %s', 'defender-security' ),
671 '<span class="sui-tag sui-tag-pro">Pro</span>'
672 );
673
674 return $strings;
675 }
676 }
677