PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.4
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.4
2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-activity-log.php

class-activity-log.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.4, at includes/class-activity-log.php

1,057 lines 35.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Activity Log Class
4 *
5 * Handles security event logging with configurable retention.
6 * Master switch: modules.activity_log toggle on the dashboard.
7 * Per-type flags: log_logins, log_post_changes, etc. in activity_log settings.
8 *
9 * @package Vigilante
10 */
11
12 // Prevent direct access
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class Vigilante_Activity_Log
19 *
20 * Manages activity logging
21 */
22 class Vigilante_Activity_Log {
23
24 /**
25 * Settings instance
26 *
27 * @var Vigilante_Settings
28 */
29 private $settings;
30
31 /**
32 * Database instance
33 *
34 * @var Vigilante_Database
35 */
36 private $database;
37
38 /**
39 * Map event_type to its settings flag.
40 * Types not in this map (firewall, system, security, settings) always log.
41 *
42 * @var array
43 */
44 private static $type_flag_map = array(
45 'login' => 'log_logins',
46 'user' => 'log_user_changes',
47 'content' => 'log_post_changes',
48 'plugin' => 'log_plugin_changes',
49 'theme' => 'log_theme_changes',
50 'comment' => 'log_comments',
51 'media' => 'log_media',
52 'file' => 'log_file_changes',
53 );
54
55 /**
56 * Post IDs already logged in this request (deduplication for post_updated)
57 *
58 * @var array
59 */
60 private $logged_post_ids = array();
61
62 /**
63 * Constructor
64 *
65 * @param Vigilante_Settings $settings Settings instance.
66 * @param Vigilante_Database $database Database instance.
67 */
68 public function __construct( $settings, $database ) {
69 $this->settings = $settings;
70 $this->database = $database;
71
72 // Only register hooks if the module is enabled
73 if ( $this->settings->is_module_enabled( 'activity_log' ) ) {
74 $this->init_hooks();
75 }
76 }
77
78 /**
79 * Get current activity_log options (fresh from settings, not cached)
80 *
81 * @return array
82 */
83 private function get_current_options() {
84 return $this->settings->get_section( 'activity_log' );
85 }
86
87 /**
88 * Initialize logging hooks.
89 * These cover events from WordPress core actions.
90 * External modules (firewall, login-security, etc.) call log() directly
91 * and are filtered by the per-type map in log().
92 */
93 private function init_hooks() {
94 $options = $this->get_current_options();
95
96 // Post changes (status transitions + content edits)
97 if ( ! empty( $options['log_post_changes'] ) ) {
98 add_action( 'transition_post_status', array( $this, 'log_post_status_change' ), 10, 3 );
99 add_action( 'post_updated', array( $this, 'log_post_content_change' ), 10, 3 );
100 add_action( 'delete_post', array( $this, 'log_post_delete' ) );
101 }
102
103 // Plugin changes (activation, deactivation, install, update, delete)
104 if ( ! empty( $options['log_plugin_changes'] ) ) {
105 add_action( 'activated_plugin', array( $this, 'log_plugin_activated' ) );
106 add_action( 'deactivated_plugin', array( $this, 'log_plugin_deactivated' ) );
107 add_action( 'upgrader_process_complete', array( $this, 'log_upgrader_event' ), 10, 2 );
108 add_action( 'deleted_plugin', array( $this, 'log_plugin_deleted' ), 10, 2 );
109 }
110
111 // Theme changes (switch, install, update via upgrader)
112 if ( ! empty( $options['log_theme_changes'] ) ) {
113 add_action( 'switch_theme', array( $this, 'log_theme_switch' ), 10, 3 );
114 if ( empty( $options['log_plugin_changes'] ) ) {
115 // Only add upgrader hook if not already registered by plugin changes
116 add_action( 'upgrader_process_complete', array( $this, 'log_upgrader_event' ), 10, 2 );
117 }
118 }
119
120 // Option changes (blacklist approach)
121 if ( ! empty( $options['log_option_changes'] ) ) {
122 add_action( 'updated_option', array( $this, 'log_option_update' ), 10, 3 );
123 }
124
125 // Comment changes
126 if ( ! empty( $options['log_comments'] ) ) {
127 add_action( 'wp_insert_comment', array( $this, 'log_comment_insert' ), 10, 2 );
128 add_action( 'transition_comment_status', array( $this, 'log_comment_status_change' ), 10, 3 );
129 add_action( 'delete_comment', array( $this, 'log_comment_delete' ) );
130 }
131
132 // Media uploads and deletions
133 if ( ! empty( $options['log_media'] ) ) {
134 add_action( 'add_attachment', array( $this, 'log_media_upload' ) );
135 add_action( 'delete_attachment', array( $this, 'log_media_delete' ) );
136 }
137 }
138
139 /**
140 * Log an event.
141 *
142 * Gate checks (in order):
143 * 1. Module master switch (modules.activity_log)
144 * 2. Per-type flag via $type_flag_map (types not in the map always pass)
145 * 3. Sub-check: failed logins respect log_failed_logins
146 * 4. User/IP exclusions
147 *
148 * @param string $type Event type.
149 * @param string $action Event action.
150 * @param string $message Event message.
151 * @param array $data Additional data.
152 * @param string $severity Severity level: info, warning, critical.
153 * @return int|false Log ID or false.
154 */
155 public function log( $type, $action, $message, $data = array(), $severity = 'info' ) {
156 // Gate 1: Module master switch
157 if ( ! $this->settings->is_module_enabled( 'activity_log' ) ) {
158 return false;
159 }
160
161 // Gate 2: Per-type flag
162 $current_options = $this->get_current_options();
163
164 if ( isset( self::$type_flag_map[ $type ] ) ) {
165 $flag = self::$type_flag_map[ $type ];
166 if ( empty( $current_options[ $flag ] ) ) {
167 return false;
168 }
169 }
170
171 // Gate 3: Failed logins sub-check
172 if ( 'login' === $type && in_array( $action, array( 'failed', 'lockout' ), true ) ) {
173 if ( empty( $current_options['log_failed_logins'] ) ) {
174 return false;
175 }
176 }
177
178 // Gate 4: User/IP exclusions (fresh from settings)
179 $user_id = get_current_user_id();
180
181 $excluded_users = $current_options['excluded_users'] ?? array();
182 if ( in_array( $user_id, array_map( 'absint', $excluded_users ), true ) ) {
183 return false;
184 }
185
186 $ip = $this->database->get_client_ip();
187
188 $excluded_ips = $current_options['excluded_ips'] ?? array();
189 if ( in_array( $ip, $excluded_ips, true ) ) {
190 return false;
191 }
192
193 // Extract object info BEFORE storing remainder as extra_data (avoids duplication)
194 $object_type = '';
195 $object_id = 0;
196 $object_name = '';
197
198 if ( isset( $data['object_type'] ) ) {
199 $object_type = $data['object_type'];
200 unset( $data['object_type'] );
201 }
202 if ( isset( $data['object_id'] ) ) {
203 $object_id = $data['object_id'];
204 unset( $data['object_id'] );
205 }
206 if ( isset( $data['object_name'] ) ) {
207 $object_name = $data['object_name'];
208 unset( $data['object_name'] );
209 }
210
211 $log_data = array(
212 'event_type' => $type,
213 'event_action' => $action,
214 'event_message' => $message,
215 'user_id' => $user_id,
216 'ip_address' => $ip,
217 'severity' => $severity,
218 'object_type' => $object_type,
219 'object_id' => $object_id,
220 'object_name' => $object_name,
221 'extra_data' => $data,
222 );
223
224 $log_id = $this->database->insert_activity_log( $log_data );
225
226 if ( $log_id ) {
227 /**
228 * Fires after a security event passed every gate and was persisted.
229 *
230 * Lets the Audit Alerts engine react to events without coupling to
231 * each module: it only fires for events that were actually logged
232 * (module on, type flag on, not excluded).
233 *
234 * @param string $type Event type (login, user, plugin, firewall, ...).
235 * @param string $action Event action (failed, created, deactivated, ...).
236 * @param string $severity Severity level: info, warning, critical.
237 * @param array $context Event context: message, user_id, ip,
238 * object_type, object_id, object_name,
239 * extra_data, log_id.
240 */
241 do_action(
242 'vigilante_event_logged',
243 $type,
244 $action,
245 $severity,
246 array(
247 'message' => $message,
248 'user_id' => $user_id,
249 'ip' => $ip,
250 'object_type' => $object_type,
251 'object_id' => $object_id,
252 'object_name' => $object_name,
253 'extra_data' => $data,
254 'log_id' => $log_id,
255 )
256 );
257 }
258
259 return $log_id;
260 }
261
262 // =========================================================================
263 // POST / CONTENT EVENTS
264 // =========================================================================
265
266 /**
267 * Log post status change
268 *
269 * @param string $new_status New status.
270 * @param string $old_status Old status.
271 * @param WP_Post $post Post object.
272 */
273 public function log_post_status_change( $new_status, $old_status, $post ) {
274 if ( wp_is_post_autosave( $post ) || wp_is_post_revision( $post ) ) {
275 return;
276 }
277 if ( $new_status === $old_status ) {
278 return;
279 }
280
281 $skip_types = array( 'nav_menu_item', 'revision', 'attachment' );
282 if ( in_array( $post->post_type, $skip_types, true ) ) {
283 return;
284 }
285
286 // Mark to prevent duplicate from post_updated
287 $this->logged_post_ids[ $post->ID ] = true;
288
289 $action = 'updated';
290 $severity = 'info';
291
292 if ( 'auto-draft' === $old_status && 'draft' === $new_status ) {
293 $action = 'created';
294 } elseif ( 'publish' === $new_status ) {
295 $action = 'published';
296 } elseif ( 'trash' === $new_status ) {
297 $action = 'trashed';
298 $severity = 'warning';
299 }
300
301 $this->log(
302 'content',
303 $action,
304 sprintf(
305 /* translators: 1: Post type, 2: Post title, 3: Old status, 4: New status */
306 __( '%1$s "%2$s" status changed: %3$s -> %4$s', 'vigilante' ),
307 ucfirst( $post->post_type ),
308 $post->post_title,
309 $old_status,
310 $new_status
311 ),
312 array(
313 'object_type' => $post->post_type,
314 'object_id' => $post->ID,
315 'object_name' => $post->post_title,
316 'old_status' => $old_status,
317 'new_status' => $new_status,
318 ),
319 $severity
320 );
321 }
322
323 /**
324 * Log post content change (edits without status change).
325 * Skipped if transition_post_status already logged this post in this request.
326 *
327 * @param int $post_id Post ID.
328 * @param WP_Post $post_after Post object after update.
329 * @param WP_Post $post_before Post object before update.
330 */
331 public function log_post_content_change( $post_id, $post_after, $post_before ) {
332 if ( isset( $this->logged_post_ids[ $post_id ] ) ) {
333 return;
334 }
335 if ( wp_is_post_autosave( $post_after ) || wp_is_post_revision( $post_after ) ) {
336 return;
337 }
338
339 $skip_types = array( 'nav_menu_item', 'revision', 'attachment', 'customize_changeset' );
340 if ( in_array( $post_after->post_type, $skip_types, true ) ) {
341 return;
342 }
343 if ( 'auto-draft' === $post_after->post_status ) {
344 return;
345 }
346
347 // Only log if title, content, or excerpt actually changed
348 $changed = (
349 $post_before->post_title !== $post_after->post_title ||
350 $post_before->post_content !== $post_after->post_content ||
351 $post_before->post_excerpt !== $post_after->post_excerpt
352 );
353 if ( ! $changed ) {
354 return;
355 }
356
357 $this->log(
358 'content',
359 'edited',
360 sprintf(
361 /* translators: 1: Post type, 2: Post title */
362 __( '%1$s "%2$s" content edited', 'vigilante' ),
363 ucfirst( $post_after->post_type ),
364 $post_after->post_title
365 ),
366 array(
367 'object_type' => $post_after->post_type,
368 'object_id' => $post_id,
369 'object_name' => $post_after->post_title,
370 ),
371 'info'
372 );
373 }
374
375 /**
376 * Log post deletion
377 *
378 * @param int $post_id Post ID.
379 */
380 public function log_post_delete( $post_id ) {
381 $post = get_post( $post_id );
382 if ( ! $post || wp_is_post_revision( $post ) ) {
383 return;
384 }
385
386 $skip_types = array( 'nav_menu_item', 'revision' );
387 if ( in_array( $post->post_type, $skip_types, true ) ) {
388 return;
389 }
390
391 $this->log(
392 'content',
393 'deleted',
394 sprintf(
395 /* translators: 1: Post type, 2: Post title */
396 __( '%1$s "%2$s" permanently deleted', 'vigilante' ),
397 ucfirst( $post->post_type ),
398 $post->post_title
399 ),
400 array(
401 'object_type' => $post->post_type,
402 'object_id' => $post->ID,
403 'object_name' => $post->post_title,
404 ),
405 'warning'
406 );
407 }
408
409 // =========================================================================
410 // PLUGIN EVENTS
411 // =========================================================================
412
413 /**
414 * Log plugin activation
415 *
416 * @param string $plugin Plugin path.
417 */
418 public function log_plugin_activated( $plugin ) {
419 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
420
421 $this->log(
422 'plugin',
423 'activated',
424 sprintf(
425 /* translators: %s: Plugin name */
426 __( 'Plugin activated: %s', 'vigilante' ),
427 $plugin_data['Name']
428 ),
429 array(
430 'object_type' => 'plugin',
431 'object_name' => $plugin_data['Name'],
432 'plugin_path' => $plugin,
433 'version' => $plugin_data['Version'],
434 ),
435 'info'
436 );
437 }
438
439 /**
440 * Log plugin deactivation
441 *
442 * @param string $plugin Plugin path.
443 */
444 public function log_plugin_deactivated( $plugin ) {
445 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
446
447 $this->log(
448 'plugin',
449 'deactivated',
450 sprintf(
451 /* translators: %s: Plugin name */
452 __( 'Plugin deactivated: %s', 'vigilante' ),
453 $plugin_data['Name']
454 ),
455 array(
456 'object_type' => 'plugin',
457 'object_name' => $plugin_data['Name'],
458 'plugin_path' => $plugin,
459 ),
460 'warning'
461 );
462 }
463
464 /**
465 * Log plugin/theme install or update via upgrader
466 *
467 * @param WP_Upgrader $upgrader Upgrader instance.
468 * @param array $options Update options.
469 */
470 public function log_upgrader_event( $upgrader, $options ) {
471 $action_type = $options['action'] ?? '';
472 $item_type = $options['type'] ?? '';
473
474 // Plugin update/install
475 if ( 'plugin' === $item_type ) {
476 if ( 'update' === $action_type && isset( $options['plugins'] ) ) {
477 foreach ( $options['plugins'] as $plugin ) {
478 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
479 $this->log(
480 'plugin',
481 'updated',
482 sprintf(
483 /* translators: 1: Plugin name, 2: Version */
484 __( 'Plugin updated: %1$s to version %2$s', 'vigilante' ),
485 $plugin_data['Name'],
486 $plugin_data['Version']
487 ),
488 array(
489 'object_type' => 'plugin',
490 'object_name' => $plugin_data['Name'],
491 'version' => $plugin_data['Version'],
492 ),
493 'info'
494 );
495 }
496 } elseif ( 'install' === $action_type ) {
497 $result = $upgrader->result ?? array();
498 $name = __( 'Unknown plugin', 'vigilante' );
499 if ( ! empty( $result['destination_name'] ) ) {
500 $plugin_dir = WP_PLUGIN_DIR . '/' . $result['destination_name'];
501 if ( is_dir( $plugin_dir ) ) {
502 $plugins = get_plugins( '/' . $result['destination_name'] );
503 if ( ! empty( $plugins ) ) {
504 $first = reset( $plugins );
505 $name = $first['Name'] ?? $result['destination_name'];
506 }
507 }
508 }
509 $this->log(
510 'plugin',
511 'installed',
512 sprintf(
513 /* translators: %s: Plugin name */
514 __( 'Plugin installed: %s', 'vigilante' ),
515 $name
516 ),
517 array(
518 'object_type' => 'plugin',
519 'object_name' => $name,
520 ),
521 'info'
522 );
523 }
524 }
525
526 // Theme update/install
527 if ( 'theme' === $item_type ) {
528 if ( 'update' === $action_type && isset( $options['themes'] ) ) {
529 foreach ( $options['themes'] as $theme_slug ) {
530 $theme = wp_get_theme( $theme_slug );
531 $this->log(
532 'theme',
533 'updated',
534 sprintf(
535 /* translators: 1: Theme name, 2: Version */
536 __( 'Theme updated: %1$s to version %2$s', 'vigilante' ),
537 $theme->get( 'Name' ),
538 $theme->get( 'Version' )
539 ),
540 array(
541 'object_type' => 'theme',
542 'object_name' => $theme->get( 'Name' ),
543 'version' => $theme->get( 'Version' ),
544 ),
545 'info'
546 );
547 }
548 } elseif ( 'install' === $action_type ) {
549 $result = $upgrader->result ?? array();
550 $slug = ! empty( $result['destination_name'] ) ? $result['destination_name'] : '';
551 $name = $slug;
552 if ( $slug ) {
553 $theme = wp_get_theme( $slug );
554 if ( $theme->exists() ) {
555 $name = $theme->get( 'Name' );
556 }
557 }
558 if ( empty( $name ) ) {
559 $name = __( 'Unknown theme', 'vigilante' );
560 }
561 $this->log(
562 'theme',
563 'installed',
564 sprintf(
565 /* translators: %s: Theme name */
566 __( 'Theme installed: %s', 'vigilante' ),
567 $name
568 ),
569 array(
570 'object_type' => 'theme',
571 'object_name' => $name,
572 ),
573 'info'
574 );
575 }
576 }
577 }
578
579 /**
580 * Log plugin deletion
581 *
582 * @param string $plugin Plugin path.
583 * @param bool $deleted Whether deletion was successful.
584 */
585 public function log_plugin_deleted( $plugin, $deleted ) {
586 if ( ! $deleted ) {
587 return;
588 }
589
590 $this->log(
591 'plugin',
592 'deleted',
593 sprintf(
594 /* translators: %s: Plugin path */
595 __( 'Plugin deleted: %s', 'vigilante' ),
596 $plugin
597 ),
598 array(
599 'object_type' => 'plugin',
600 'plugin_path' => $plugin,
601 ),
602 'warning'
603 );
604 }
605
606 // =========================================================================
607 // THEME EVENTS
608 // =========================================================================
609
610 /**
611 * Log theme switch
612 *
613 * @param string $new_name New theme name.
614 * @param WP_Theme $new_theme New theme object.
615 * @param WP_Theme $old_theme Old theme object.
616 */
617 public function log_theme_switch( $new_name, $new_theme, $old_theme ) {
618 $this->log(
619 'theme',
620 'switched',
621 sprintf(
622 /* translators: 1: Old theme name, 2: New theme name */
623 __( 'Theme switched from %1$s to %2$s', 'vigilante' ),
624 $old_theme->get( 'Name' ),
625 $new_name
626 ),
627 array(
628 'object_type' => 'theme',
629 'object_name' => $new_name,
630 'old_theme' => $old_theme->get( 'Name' ),
631 'new_theme' => $new_name,
632 ),
633 'warning'
634 );
635 }
636
637 // =========================================================================
638 // OPTION / SETTINGS EVENTS
639 // =========================================================================
640
641 /**
642 * WordPress core options relevant for security auditing.
643 * Only these (plus user-configured extras) are tracked.
644 *
645 * @var array
646 */
647 private static $core_tracked_options = array(
648 // Site identity and URLs (compromise indicators)
649 'siteurl',
650 'home',
651 'blogname',
652 'blogdescription',
653 'admin_email',
654 // User management (security-critical)
655 'users_can_register',
656 'default_role',
657 // Active components
658 'active_plugins',
659 'template',
660 'stylesheet',
661 // Visibility and access
662 'blog_public',
663 'permalink_structure',
664 // Comments policy
665 'default_comment_status',
666 'comment_moderation',
667 'comment_registration',
668 'require_name_email',
669 'close_comments_for_old_posts',
670 'default_pingback_flag',
671 'default_ping_status',
672 // Homepage and reading
673 'show_on_front',
674 'page_on_front',
675 'page_for_posts',
676 'posts_per_page',
677 // Privacy and locale
678 'wp_page_for_privacy_policy',
679 'timezone_string',
680 'WPLANG',
681 // Mail configuration
682 'mailserver_url',
683 'mailserver_login',
684 );
685
686 /**
687 * Log option update.
688 * Tracks curated WordPress core options + user-configured extras.
689 * Vigilante internal options are always skipped (logged via apply_section_changes).
690 *
691 * @param string $option Option name.
692 * @param mixed $old_value Old value.
693 * @param mixed $new_value New value.
694 */
695 public function log_option_update( $option, $old_value, $new_value ) {
696 // Always skip Vigilante internal options (already logged via apply_section_changes)
697 if ( strpos( $option, 'vigilante_' ) !== false ) {
698 return;
699 }
700
701 // Skip if values are identical
702 if ( $old_value === $new_value ) {
703 return;
704 }
705
706 // Check curated whitelist
707 $tracked = in_array( $option, self::$core_tracked_options, true );
708
709 // Check user-configured extras
710 if ( ! $tracked ) {
711 $current_options = $this->get_current_options();
712 $user_tracked = $current_options['tracked_options'] ?? array();
713 foreach ( $user_tracked as $pattern ) {
714 $pattern = trim( $pattern );
715 if ( empty( $pattern ) ) {
716 continue;
717 }
718 // Exact match or prefix match (e.g. 'woocommerce_' tracks all WooCommerce options)
719 if ( $option === $pattern || ( substr( $pattern, -1 ) === '_' && strpos( $option, $pattern ) === 0 ) ) {
720 $tracked = true;
721 break;
722 }
723 }
724 }
725
726 if ( ! $tracked ) {
727 return;
728 }
729
730 $this->log(
731 'settings',
732 'option_updated',
733 sprintf(
734 /* translators: %s: Option name */
735 __( 'Option updated: %s', 'vigilante' ),
736 $option
737 ),
738 array(
739 'option_name' => $option,
740 ),
741 'info'
742 );
743 }
744
745 // =========================================================================
746 // COMMENT EVENTS
747 // =========================================================================
748
749 /**
750 * Log comment insert
751 *
752 * @param int $comment_id Comment ID.
753 * @param WP_Comment $comment Comment object.
754 */
755 public function log_comment_insert( $comment_id, $comment ) {
756 $this->log(
757 'comment',
758 'created',
759 sprintf(
760 /* translators: 1: Comment author, 2: Post ID */
761 __( 'New comment by %1$s on post #%2$d', 'vigilante' ),
762 $comment->comment_author,
763 $comment->comment_post_ID
764 ),
765 array(
766 'object_type' => 'comment',
767 'object_id' => $comment_id,
768 'comment_author' => $comment->comment_author,
769 'post_id' => $comment->comment_post_ID,
770 ),
771 'info'
772 );
773 }
774
775 /**
776 * Log comment status change (approve, unapprove, spam, trash)
777 *
778 * @param string $new_status New comment status.
779 * @param string $old_status Old comment status.
780 * @param WP_Comment $comment Comment object.
781 */
782 public function log_comment_status_change( $new_status, $old_status, $comment ) {
783 // Skip if status didn't actually change
784 if ( $new_status === $old_status ) {
785 return;
786 }
787
788 $status_labels = array(
789 'approved' => __( 'approved', 'vigilante' ),
790 'unapproved' => __( 'held for moderation', 'vigilante' ),
791 'hold' => __( 'held for moderation', 'vigilante' ),
792 'spam' => __( 'marked as spam', 'vigilante' ),
793 'trash' => __( 'trashed', 'vigilante' ),
794 );
795
796 $action = sanitize_key( $new_status );
797 $label = isset( $status_labels[ $new_status ] ) ? $status_labels[ $new_status ] : $new_status;
798 $severity = in_array( $new_status, array( 'spam', 'trash' ), true ) ? 'warning' : 'info';
799
800 $this->log(
801 'comment',
802 $action,
803 sprintf(
804 /* translators: 1: Comment author, 2: Comment ID, 3: Status label */
805 __( 'Comment by %1$s (ID: %2$d) %3$s', 'vigilante' ),
806 $comment->comment_author,
807 $comment->comment_ID,
808 $label
809 ),
810 array(
811 'object_type' => 'comment',
812 'object_id' => $comment->comment_ID,
813 'old_status' => $old_status,
814 'new_status' => $new_status,
815 'post_id' => $comment->comment_post_ID,
816 ),
817 $severity
818 );
819 }
820
821 /**
822 * Log comment deleted
823 *
824 * @param int $comment_id Comment ID.
825 */
826 public function log_comment_delete( $comment_id ) {
827 $this->log(
828 'comment',
829 'deleted',
830 sprintf(
831 /* translators: %d: Comment ID */
832 __( 'Comment permanently deleted (ID: %d)', 'vigilante' ),
833 $comment_id
834 ),
835 array(
836 'object_type' => 'comment',
837 'object_id' => $comment_id,
838 ),
839 'warning'
840 );
841 }
842
843 // =========================================================================
844 // MEDIA EVENTS
845 // =========================================================================
846
847 /**
848 * Log media upload
849 *
850 * @param int $attachment_id Attachment ID.
851 */
852 public function log_media_upload( $attachment_id ) {
853 $attachment = get_post( $attachment_id );
854
855 $this->log(
856 'media',
857 'uploaded',
858 sprintf(
859 /* translators: %s: File name */
860 __( 'Media uploaded: %s', 'vigilante' ),
861 $attachment->post_title
862 ),
863 array(
864 'object_type' => 'attachment',
865 'object_id' => $attachment_id,
866 'object_name' => $attachment->post_title,
867 'mime_type' => $attachment->post_mime_type,
868 ),
869 'info'
870 );
871 }
872
873 /**
874 * Log media deletion
875 *
876 * @param int $attachment_id Attachment ID.
877 */
878 public function log_media_delete( $attachment_id ) {
879 $attachment = get_post( $attachment_id );
880
881 if ( $attachment ) {
882 $this->log(
883 'media',
884 'deleted',
885 sprintf(
886 /* translators: %s: File name */
887 __( 'Media deleted: %s', 'vigilante' ),
888 $attachment->post_title
889 ),
890 array(
891 'object_type' => 'attachment',
892 'object_id' => $attachment_id,
893 'object_name' => $attachment->post_title,
894 ),
895 'warning'
896 );
897 }
898 }
899
900 // =========================================================================
901 // QUERY, CLEANUP & EXPORT
902 // =========================================================================
903
904 /**
905 * Get logs with pagination
906 *
907 * @param array $args Query arguments.
908 * @return array
909 */
910 public function get_logs( $args = array() ) {
911 return $this->database->get_activity_logs( $args );
912 }
913
914 /**
915 * Get total logs count
916 *
917 * @param array $args Query arguments.
918 * @return int
919 */
920 public function get_logs_count( $args = array() ) {
921 return $this->database->get_activity_logs_count( $args );
922 }
923
924 /**
925 * The address a logged event was recorded for, when the event carries one
926 *
927 * Every blocking module stores the request it turned away under
928 * 'request_uri' in the entry's extra data, but nothing ever showed it. The
929 * address is what tells a firewall hit on a legitimate page apart from a
930 * scanner probe, and a remote manager being refused apart from an intruder,
931 * so an owner looking at a surprising entry had no way to tell which one
932 * they were reading.
933 *
934 * That first sentence was not true of the firewall, which is the module
935 * that logs the most: it stored the address under 'uri', so the column
936 * this method feeds was empty for every one of its blocks, and diagnosing
937 * one meant reading the table by hand. Fixed in the firewall in 2.11.1;
938 * 'uri' is read here as well so the entries already on disk show it too.
939 *
940 * @since 2.10.2
941 *
942 * @param string|array|null $extra_data The entry's extra data, as stored.
943 * @return string The recorded address, or '' when the entry carries none.
944 */
945 public static function extract_request_uri( $extra_data ) {
946 if ( is_string( $extra_data ) ) {
947 $extra_data = json_decode( $extra_data, true );
948 }
949
950 if ( ! is_array( $extra_data ) ) {
951 return '';
952 }
953
954 $key = isset( $extra_data['request_uri'] ) ? 'request_uri' : 'uri';
955
956 if ( ! isset( $extra_data[ $key ] ) ) {
957 return '';
958 }
959
960 // Nothing writes anything but a string here, but the value comes back
961 // from a longtext column that any past version could have filled, and
962 // casting an array would emit a notice and print the word "Array".
963 if ( ! is_scalar( $extra_data[ $key ] ) ) {
964 return '';
965 }
966
967 return (string) $extra_data[ $key ];
968 }
969
970 /**
971 * Cleanup old logs based on retention settings (uses fresh options)
972 */
973 public function cleanup_old_logs() {
974 $options = $this->get_current_options();
975 $retention_days = absint( $options['retention_days'] ?? 30 );
976 $max_entries = absint( $options['max_entries'] ?? 10000 );
977
978 // Delete by age
979 $this->database->cleanup_old_activity_logs( $retention_days );
980
981 // Delete by count if needed
982 $count = $this->database->get_activity_logs_count();
983 if ( $count > $max_entries ) {
984 $to_delete = $count - $max_entries;
985 global $wpdb;
986 $table = $this->database->get_activity_log_table();
987
988 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
989 $wpdb->query(
990 $wpdb->prepare(
991 'DELETE FROM %i ORDER BY created_at ASC LIMIT %d',
992 $table,
993 $to_delete
994 )
995 );
996 }
997 }
998
999 /**
1000 * Clear all logs
1001 *
1002 * @return bool
1003 */
1004 public function clear_all_logs() {
1005 return $this->database->truncate_activity_log();
1006 }
1007
1008 /**
1009 * Export logs to array
1010 *
1011 * @param array $args Query arguments.
1012 * @return array
1013 */
1014 public function export_logs( $args = array() ) {
1015 $args['per_page'] = 9999;
1016 return $this->get_logs( $args );
1017 }
1018
1019 // =========================================================================
1020 // STATIC HELPERS
1021 // =========================================================================
1022
1023 /**
1024 * Get available event types
1025 *
1026 * @return array
1027 */
1028 public static function get_event_types() {
1029 return array(
1030 'login' => __( 'Login Events', 'vigilante' ),
1031 'user' => __( 'User Events', 'vigilante' ),
1032 'content' => __( 'Content Events', 'vigilante' ),
1033 'plugin' => __( 'Plugin Events', 'vigilante' ),
1034 'theme' => __( 'Theme Events', 'vigilante' ),
1035 'settings' => __( 'Settings Events', 'vigilante' ),
1036 'comment' => __( 'Comment Events', 'vigilante' ),
1037 'media' => __( 'Media Events', 'vigilante' ),
1038 'firewall' => __( 'Firewall Events', 'vigilante' ),
1039 'file' => __( 'File Events', 'vigilante' ),
1040 'security' => __( 'Security Events', 'vigilante' ),
1041 'system' => __( 'System Events', 'vigilante' ),
1042 );
1043 }
1044
1045 /**
1046 * Get severity levels
1047 *
1048 * @return array
1049 */
1050 public static function get_severity_levels() {
1051 return array(
1052 'info' => __( 'Info', 'vigilante' ),
1053 'warning' => __( 'Warning', 'vigilante' ),
1054 'critical' => __( 'Critical', 'vigilante' ),
1055 );
1056 }
1057 }