PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.12
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.12
2.11.12 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 All 87 releases
vigilante / includes / class-activity-log.php

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

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