N events from same user/action inside 60s). * 3. Off-hours admin activity (admin events outside the user's typical * active window over the past 30 days). * 4. Role escalation to administrator. * 5. Failed-login brute force from a single IP. * * @package ActivityPilot */ if ( ! defined( 'ABSPATH' ) ) { exit; } final class APWPM_Insights { /** * Evaluate freshly inserted event for anomaly signals. * Called from APWPM_Logger right after insertion. */ public static function evaluate_row( $log_id, array $row ) { $settings = (array) get_option( 'apwpm_settings', array() ); if ( empty( $settings['enable_insights'] ) ) { return; } $reasons = array(); if ( self::is_role_escalation( $row ) ) { $reasons[] = __( 'Role escalation to administrator.', 'activitypilot' ); } if ( self::is_failed_login_burst( $row ) ) { $reasons[] = __( 'Repeated failed logins from this IP.', 'activitypilot' ); } if ( self::is_burst( $row ) ) { $reasons[] = __( 'Burst of identical events from this user.', 'activitypilot' ); } if ( self::is_off_hours( $row ) ) { $reasons[] = __( 'Activity outside user\'s typical hours.', 'activitypilot' ); } if ( ! empty( $reasons ) ) { self::mark_anomaly( $log_id, $reasons ); } } /** * Periodic batch scan called by cron — uses z-score on hourly buckets. */ public static function batch_scan() { $settings = (array) get_option( 'apwpm_settings', array() ); if ( empty( $settings['enable_insights'] ) ) { return; } // Get hourly counts for the last 24h per action_type. $current = APWPM_DB::get_hourly_action_counts(); if ( ! is_array( $current ) || empty( $current ) ) { return; } foreach ( $current as $bucket ) { $series = APWPM_DB::get_action_hourly_series( $bucket['action_type'] ); if ( count( $series ) < 24 ) { continue; // not enough history } $z = self::z_score( (float) $bucket['c'], $series ); if ( $z >= 3.0 ) { APWPM_Logger::log( array( 'action_type' => 'insight_spike', 'object_type' => 'insight', 'object_title' => $bucket['action_type'], 'description' => sprintf( /* translators: 1: action type, 2: count, 3: z-score */ __( 'Unusual surge in "%1$s" — %2$d events in the last hour (z=%3$.1f).', 'activitypilot' ), $bucket['action_type'], (int) $bucket['c'], $z ), 'severity' => 'warning', 'context' => array( 'z_score' => $z, 'mean' => self::mean( $series ), 'stddev' => self::stddev( $series ), 'current' => (int) $bucket['c'], ), ) ); } } } /* ---------------------------------------------------------------- */ /* Heuristic detectors */ /* ---------------------------------------------------------------- */ protected static function is_role_escalation( $row ) { if ( 'user_role_changed' !== $row['action_type'] ) { return false; } $ctx = self::decode( $row['context'] ); return isset( $ctx['new_role'] ) && 'administrator' === $ctx['new_role']; } protected static function is_failed_login_burst( $row ) { if ( 'user_login_failed' !== $row['action_type'] || empty( $row['ip_address'] ) ) { return false; } $count = APWPM_DB::count_failed_logins_by_ip( $row['ip_address'], 5 ); return $count >= 5; } protected static function is_burst( $row ) { if ( empty( $row['user_id'] ) || empty( $row['action_type'] ) ) { return false; } $count = APWPM_DB::count_user_actions( (int) $row['user_id'], (string) $row['action_type'], 1 ); return $count >= 10; } protected static function is_off_hours( $row ) { if ( empty( $row['user_id'] ) ) { return false; } // Only flag for privileged users. $privileged = array( 'administrator', 'editor' ); if ( empty( $row['user_role'] ) || ! in_array( $row['user_role'], $privileged, true ) ) { return false; } $hours = APWPM_DB::get_user_activity_hours( (int) $row['user_id'] ); if ( ! is_array( $hours ) || count( $hours ) < 30 ) { return false; // not enough history } $current_hour = (int) gmdate( 'G', strtotime( $row['created_at'] ) ); $histogram = array_fill( 0, 24, 0 ); foreach ( $hours as $h ) { $histogram[ (int) $h ]++; } $total = array_sum( $histogram ); // Off-hours = current hour represents < 1% of historical activity. return $total > 0 && ( $histogram[ $current_hour ] / $total ) < 0.01; } /* ---------------------------------------------------------------- */ protected static function mark_anomaly( $log_id, array $reasons ) { APWPM_DB::mark_log_anomaly( (int) $log_id ); APWPM_Logger::log( array( 'action_type' => 'insight_anomaly', 'object_type' => 'insight', 'object_id' => (int) $log_id, 'object_title' => __( 'Anomalous activity detected', 'activitypilot' ), 'description' => implode( ' ', $reasons ), 'severity' => 'critical', 'context' => array( 'source_log_id' => (int) $log_id, 'reasons' => $reasons, ), ) ); } protected static function decode( $json ) { if ( empty( $json ) ) { return array(); } $d = json_decode( $json, true ); return is_array( $d ) ? $d : array(); } protected static function mean( array $values ) { $n = count( $values ); return $n > 0 ? array_sum( $values ) / $n : 0.0; } protected static function stddev( array $values ) { $n = count( $values ); if ( $n < 2 ) { return 0.0; } $mean = self::mean( $values ); $sum = 0.0; foreach ( $values as $v ) { $sum += pow( ( (float) $v ) - $mean, 2 ); } return sqrt( $sum / ( $n - 1 ) ); } protected static function z_score( $value, array $baseline ) { $sd = self::stddev( $baseline ); if ( $sd <= 0.0001 ) { return 0.0; } return ( $value - self::mean( $baseline ) ) / $sd; } /** * Returns the most recent anomalies for the dashboard widget. */ public static function recent_anomalies( $limit = 5 ) { return APWPM_DB::get_recent_anomalies( $limit ); } }