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-audit-logging.php

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

696 lines 19.0 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 Generator;
13 use WP_Defender\Event;
14 use WP_Defender\Traits\User;
15 use Calotes\Component\Request;
16 use Calotes\Component\Response;
17 use Calotes\Helper\Array_Cache;
18 use WP_Defender\Traits\Formats;
19 use WP_Defender\Component\Audit;
20 use WP_Defender\Model\Audit_Log;
21 use WP_Defender\Behavior\WPMUDEV;
22 use WP_Defender\Component\Network_Cron_Manager;
23 use WP_Defender\Model\Notification\Audit_Report;
24 use WP_Defender\Component\Export\Csv;
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 $params = defender_get_data_from_request( null, 'g' );
204 $user_id = '';
205 $result = $this->fetch_logs( $params, 1, $user_id, 1000 );
206
207 if ( is_wp_error( $result ) ) {
208 wp_send_json_error(
209 array(
210 'message' => $result->get_error_message(),
211 )
212 );
213 }
214
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 try {
224 Csv::to_browser( 'audit-logs', $this->get_export_rows( $params, $result ), $headers );
225 } catch ( \RuntimeException $e ) {
226 $this->log( $e->getMessage(), 'audit-logging' );
227 }
228 }
229
230 /**
231 * Generates audit log export rows in database-backed pages.
232 *
233 * @param array $params Request filters.
234 * @param array $logs First page of audit logs.
235 *
236 * @return Generator
237 * @throws Exception If there is an error while fetching logs.
238 */
239 private function get_export_rows( array $params, array $logs ): Generator {
240 $page = 1;
241 $per_page = 1000;
242 $user_id = '';
243
244 do {
245 if ( 1 < $page ) {
246 $logs = $this->fetch_logs( $params, $page, $user_id, $per_page );
247 if ( is_wp_error( $logs ) ) {
248 throw new Exception( esc_html( $logs->get_error_message() ) );
249 }
250 }
251
252 foreach ( $this->format_log_rows( $logs ) as $row ) {
253 yield array(
254 $row['msg'],
255 is_array( $row['timestamp'] )
256 ? $this->format_date_time( $row['timestamp'][0] )
257 : $this->format_date_time( $row['timestamp'] ),
258 $row['context'],
259 $row['action_type'],
260 $row['ip'],
261 $row['user'],
262 );
263 }
264
265 $has_more = count( $logs ) === $per_page;
266 ++$page;
267 } while ( $has_more );
268 }
269
270 /**
271 * We'll pass all the event logs into the db handler, so it writes down to db.
272 * Do it in shutdown runtime, so no delay time.
273 *
274 * @return void
275 */
276 public function cache_audit_logs(): void {
277 $audit = new Audit();
278 $audit->log_audit_events();
279 }
280
281 /**
282 * Pull the logs from db cached:
283 * - date_from: the start of the date we will run the query, as mysql time format,
284 * - date_to: similar to the above,
285 * others will refer to Audit.
286 *
287 * @param Request $request The request object containing filter parameters.
288 *
289 * @return Response
290 * @throws Exception If there is an error during log retrieval.
291 * @defender_route
292 */
293 public function pull_logs( Request $request ): Response {
294 $data = $request->get_data(
295 array(
296 'date_from' => array(
297 'type' => 'string',
298 'sanitize' => 'sanitize_text_field',
299 ),
300 'date_to' => array(
301 'type' => 'string',
302 'sanitize' => 'sanitize_text_field',
303 ),
304 'username' => array(
305 'type' => 'string',
306 'sanitize' => 'sanitize_text_field',
307 ),
308 'events' => array(
309 'type' => 'array',
310 'sanitize' => 'sanitize_text_field',
311 ),
312 'ip_address' => array(
313 'type' => 'string',
314 'sanitize' => 'sanitize_text_field',
315 ),
316 'paged' => array(
317 'type' => 'int',
318 'sanitize' => 'sanitize_text_field',
319 ),
320 'per_page' => array(
321 'type' => 'int',
322 'sanitize' => 'sanitize_text_field',
323 ),
324 )
325 );
326 if ( ! isset( $data['date_from'] ) || ! isset( $data['date_to'] ) ) {
327 return new Response( false, array( 'message' => esc_html__( 'Invalid data.', 'defender-security' ) ) );
328 }
329 // Validate date strings before passing to fetch_logs.
330 $date_from_check = $this->date_string_to_timestamp( $data['date_from'] );
331 $date_to_check = $this->date_string_to_timestamp( $data['date_to'], true );
332 if ( $date_from_check <= 0 || $date_to_check <= 0 ) {
333 return new Response( false, array( 'message' => esc_html__( 'Invalid data.', 'defender-security' ) ) );
334 }
335
336 $paged = $data['paged'] ?? 1;
337 $per_page = min( 100, max( 1, (int) ( $data['per_page'] ?? self::DEFAULT_PER_PAGE ) ) );
338 $result = $this->fetch_logs( $data, $paged, $user_id, $per_page );
339
340 if ( is_wp_error( $result ) ) {
341 return new Response( false, array( 'message' => $result->get_error_message() ) );
342 }
343 $logs = is_array( $result ) && array() !== $result ? $this->format_log_rows( $result ) : array();
344
345 // @since 3.0.0 If no logs then $count = 0.
346 // Skip the COUNT query when the result is a partial page — the fetch already told us the total is count($logs).
347 $events = $data['events'] ?? array();
348 $ip_address = $data['ip_address'] ?? '';
349 if ( array() === $logs ) {
350 $count = 0;
351 } elseif ( count( $logs ) < $per_page ) {
352 $count = ( $paged - 1 ) * $per_page + count( $logs );
353 } else {
354 $count = Audit_Log::count( $date_from_check, $date_to_check, $events, $user_id, $ip_address );
355 }
356
357 // Get the count for the submitted data.
358 return new Response(
359 true,
360 array(
361 'logs' => $logs,
362 'total_items' => $count,
363 'total_pages' => ceil( $count / $per_page ),
364 'per_page' => $per_page,
365 )
366 );
367 }
368
369 /**
370 * Generates a human-readable frequency text for audit reports.
371 *
372 * @param Audit_Report $audit_report The audit report object.
373 *
374 * @return string Returns the formatted frequency description.
375 */
376 public function get_frequency_text( Audit_Report $audit_report ): string {
377 $text = '';
378 switch ( $audit_report->frequency ) {
379 case 'daily':
380 $text = ucfirst( $audit_report->day ) . 's at ' . $audit_report->time;
381 break;
382 case 'weekly':
383 case 'monthly':
384 $text = ucfirst( $audit_report->frequency ) . ' on ' . ucfirst( $audit_report->day ) . 's at ' . $audit_report->time;
385 break;
386 default:
387 break;
388 }
389
390 return $text;
391 }
392
393 /**
394 * Enqueues scripts and styles for this page.
395 */
396 public function enqueue_assets(): void {
397 if ( ! $this->is_page_active() ) {
398 return;
399 }
400
401 $handle = 'defender-ui-audit-logging';
402 wp_enqueue_script(
403 $handle,
404 WP_DEFENDER_BASE_URL . 'assets/js/audit-logging-ui.js',
405 array( 'def-vue', 'def-manifest', 'def-core-ui', 'defender', 'wp-i18n' ),
406 DEFENDER_VERSION,
407 true
408 );
409 wp_set_script_translations( $handle, 'wpdef' );
410
411 wp_localize_script(
412 $handle,
413 'defenderUIData',
414 array_merge(
415 $this->get_shared_data(),
416 $this->data_frontend()
417 )
418 );
419
420 wp_enqueue_style(
421 $handle,
422 WP_DEFENDER_BASE_URL . 'assets/css/core-ui.css',
423 array(),
424 DEFENDER_VERSION
425 );
426
427 $this->enqueue_main_assets();
428 }
429
430 /**
431 * Render the root element for frontend.
432 *
433 * @return void
434 */
435 public function main_view(): void {
436 $this->render( 'main' );
437 }
438
439 /**
440 * Provides a summary of audit logs.
441 *
442 * @return void
443 * @throws Exception If there is an error during summary generation.
444 * @defender_route
445 */
446 public function summary(): void {
447 $response = $this->is_active() ? $this->summary_data() : array();
448 wp_send_json_success( $response );
449 }
450
451 /**
452 * Returns an array with summary data for audit logging.
453 *
454 * @param bool $for_hub Default 'false' because it's displayed on site summary sections.
455 *
456 * @return array
457 * @throws Exception Emits Exception in case of an error.
458 */
459 public function summary_data( bool $for_hub = false ): array {
460 $timezone = wp_timezone();
461
462 // Monthly count.
463 $date_from = ( new DateTime( '-30 days', $timezone ) )
464 ->setTime( 0, 0, 0 )
465 ->getTimestamp();
466 $date_to = ( new DateTime( 'now', $timezone ) )
467 ->setTime( 23, 59, 59 )
468 ->getTimestamp();
469 $month_count = Audit_Log::count( $date_from, $date_to );
470
471 // Weekly count sets in the returned array.
472 // Daily count. Sync data to the Hub without timezone.
473 $date_from = $for_hub ? new DateTime( 'now' ) : new DateTime( 'now', wp_timezone() );
474 $date_from = $date_from->modify( '-24 hours' )->setTime( 0, 0, 0 )->getTimestamp();
475 $day_count = Audit_Log::count( $date_from, $date_to );
476
477 // Get the last item.
478 $last = Audit_Log::get_last();
479 if ( is_object( $last ) ) {
480 $last = $for_hub
481 ? $this->persistent_hub_datetime_format( $last->timestamp )
482 : $this->format_date_time( $last->timestamp );
483 } else {
484 $last = 'n/a';
485 }
486
487 return array(
488 'monthCount' => $month_count,
489 'weekCount' => Audit::get_audit_events_per_week(),
490 'dayCount' => $day_count,
491 'lastEvent' => $last,
492 'report' => wd_di()->get( Audit_Report::class )->to_string(),
493 );
494 }
495
496 /**
497 * Save settings.
498 *
499 * @param Request $request The request object containing new settings data.
500 *
501 * @return Response
502 * @defender_route
503 */
504 public function save_settings( Request $request ): Response {
505 $data = $request->get_data_by_model( $this->model );
506
507 $this->model->import( $data );
508 if ( $this->model->validate() ) {
509 $this->model->save();
510 }
511
512 Config_Hub_Helper::set_clear_active_flag();
513
514 return new Response(
515 true,
516 array_merge(
517 $this->data_frontend(),
518 array(
519 'message' => esc_html__( 'Your settings have been updated.', 'defender-security' ),
520 'auto_close' => true,
521 'model' => $this->model->export(),
522 )
523 )
524 );
525 }
526
527 /**
528 * Provides the audit settings data used outside the Audit Logging page.
529 *
530 * @return array
531 */
532 public function settings_data_frontend(): array {
533 return array_merge(
534 array(
535 'model' => $this->model->export(),
536 ),
537 $this->dump_routes_and_nonces()
538 );
539 }
540
541 /**
542 * Converts the current state of the object to an array.
543 *
544 * @return array Returns an associative array of object properties.
545 */
546 public function to_array(): array {
547 return array_merge(
548 array(
549 'enabled' => $this->is_active(),
550 'report' => true,
551 ),
552 $this->dump_routes_and_nonces()
553 );
554 }
555
556 /**
557 * Removes settings for all submodules.
558 */
559 public function remove_settings(): void {
560 ( new Model_Audit_Logging() )->delete();
561 }
562
563 /**
564 * Delete all the data & the cache.
565 */
566 public function remove_data(): void {
567 Audit_Log::truncate();
568 // Remove cached data.
569 Array_Cache::remove( 'sockets', 'audit' );
570 Array_Cache::remove( 'logs', 'audit' );
571 Array_Cache::remove( 'menu_updated', 'audit' );
572 Array_Cache::remove( 'post_updated', 'audit' );
573 delete_site_option( 'wd_audit_fetch_checkpoint' );
574 }
575
576 /**
577 * Provides data for the frontend.
578 *
579 * @return array An array of data for the frontend.
580 * @throws Exception If there is an error.
581 */
582 public function data_frontend(): array {
583 $logs = array();
584 $count = 0;
585 $per_page = self::DEFAULT_PER_PAGE;
586 $total_page = 1;
587 if ( $this->is_active() ) {
588 $timezone = wp_timezone();
589 $date_from = ( new DateTime( '-7 days', $timezone ) )->setTime( 0, 0, 0 );
590 $date_to = ( new DateTime( 'now', $timezone ) )->setTime( 23, 59, 59 );
591 $ip_address = sanitize_text_field( defender_get_data_from_request( 'ip', 'g' ) );
592 $result = $this->service->fetch(
593 $date_from->getTimestamp(),
594 $date_to->getTimestamp(),
595 array(),
596 '',
597 $ip_address,
598 1,
599 $per_page
600 );
601 if ( ! is_wp_error( $result ) ) {
602 $logs = $this->format_log_rows( $result );
603 // Skip COUNT query when the first page is a partial result — it's cheaper than a second DB round-trip.
604 if ( count( $logs ) < $per_page ) {
605 $count = count( $logs );
606 } else {
607 $count = Audit_Log::count( $date_from->getTimestamp(), $date_to->getTimestamp(), array(), '', $ip_address );
608 }
609 $total_page = ceil( $count / $per_page );
610 }
611 }
612
613 return array(
614 'auditLogging' => array_merge(
615 array(
616 'model' => $this->model->export(),
617 'logs' => $logs,
618 'events_type' => Audit_Log::allowed_events(),
619 'summary' => array(
620 'count_7_days' => $count,
621 'report' => wd_di()->get( Audit_Report::class )->to_string(),
622 ),
623 'paging' => array(
624 'paged' => 1,
625 'total_pages' => $total_page,
626 'count' => $count,
627 ),
628 ),
629 $this->dump_routes_and_nonces()
630 ),
631 'antibot' => wd_di()->get( Antibot_Global_Firewall::class )->data_frontend(),
632 );
633 }
634
635 /**
636 * Imports data into the model.
637 *
638 * @param array $data Data to be imported into the model.
639 *
640 * @throws Exception If table is not defined.
641 */
642 public function import_data( array $data ) {
643 $model = $this->model;
644 if ( array() === $data ) {
645 $model->enabled = false;
646 $model->storage_days = '6 months';
647 $model->save();
648 } else {
649 $model->import( $data );
650 if ( $model->validate() ) {
651 $model->save();
652 }
653 }
654 }
655
656 /**
657 * Exports strings.
658 *
659 * @return array An array of strings.
660 */
661 public function export_strings(): array {
662 $strings = $this->is_active()
663 ? array( esc_html__( 'Active', 'defender-security' ) )
664 : array( esc_html__( 'Inactive', 'defender-security' ) );
665
666 $strings[] = sprintf(
667 /* translators: %s: Html for Pro-tag. */
668 esc_html__( 'Email report inactive %s', 'defender-security' ),
669 '<span class="sui-tag sui-tag-pro">Pro</span>'
670 );
671
672 return $strings;
673 }
674
675 /**
676 * Generates configuration strings based on the provided configuration.
677 *
678 * @param array $config Configuration data.
679 *
680 * @return array Returns an array of configuration strings.
681 */
682 public function config_strings( array $config ): array {
683 $strings = $config['enabled']
684 ? array( esc_html__( 'Active', 'defender-security' ) )
685 : array( esc_html__( 'Inactive', 'defender-security' ) );
686
687 $strings[] = sprintf(
688 /* translators: %s: Html for Pro-tag. */
689 esc_html__( 'Email report inactive %s', 'defender-security' ),
690 '<span class="sui-tag sui-tag-pro">Pro</span>'
691 );
692
693 return $strings;
694 }
695 }
696