'atk-incidents-overview-' . get_current_blog_id() . '-' . get_current_user_id(), 'show' => __( 'Show overview', 'activity-track' ), 'hide' => __( 'Hide overview', 'activity-track' ), ] ); } /** Red count on the sidebar item for open High priority incidents only. */ public function menu_bubble() { global $menu; if ( ! is_array( $menu ) || ! current_user_can( 'manage_options' ) || ! ATKAI_Incidents::ready() ) return; $high = ATKAI_Incidents::counts()['high']; if ( ! $high ) return; foreach ( $menu as $index => $item ) { if ( 'activity-track-ai' !== ( $item[2] ?? '' ) ) continue; /* translators: %s: number of open high priority incidents. */ $label = sprintf( _n( '%s open high priority incident', '%s open high priority incidents', $high, 'activity-track' ), number_format_i18n( $high ) ); $menu[ $index ][0] .= ' ' . esc_html( $label ) . ''; break; } } public static function url( $args = [] ) { return add_query_arg( array_merge( [ 'page' => 'activity-track-ai', 'tab' => 'incidents' ], $args ), admin_url( 'admin.php' ) ); } private function authorize( $action = '' ) { if ( ! current_user_can( 'manage_options' ) ) wp_die( esc_html__( 'You cannot access incidents.', 'activity-track' ), '', [ 'response' => 403 ] ); if ( $action ) { if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) wp_die( esc_html__( 'Use the form to make changes.', 'activity-track' ), '', [ 'response' => 405 ] ); check_admin_referer( $action ); if ( ! ATKAI_Incidents::ready() ) wp_die( esc_html__( 'Incident storage is unavailable. Please try again after the database upgrade.', 'activity-track' ) ); } } private function input( $key, $source ) { return isset( $source[ $key ] ) && is_string( $source[ $key ] ) ? sanitize_text_field( wp_unslash( $source[ $key ] ) ) : ''; } private function redirect( $args = [] ) { wp_safe_redirect( self::url( $args ) ); exit; } /** Persist settings; when monitoring turns on, start from the newest event so old records never raise live alerts. */ private function store_settings( $settings ) { global $wpdb; $old = ATKAI_Incidents::settings(); if ( $settings['enabled'] && ! $old['enabled'] ) { $cursor = $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(event_id) FROM %i', ATKAI_Security_Events::table() ) ); if ( $wpdb->last_error ) wp_die( esc_html__( 'Monitoring could not be enabled. Please retry.', 'activity-track' ) ); update_option( 'atk_incidents_cursor', absint( $cursor ), false ); update_option( 'atk_incidents_started', time(), false ); delete_option( 'atk_incidents_error' ); } update_option( 'atk_incidents_settings', $settings, false ); $audit = (array) get_option( 'atk_incidents_settings_audit', [] ); $audit[] = [ 'at' => time(), 'actor' => get_current_user_id(), 'enabled' => $settings['enabled'], 'alerts' => $settings['alerts'], 'login_rule' => $settings['login_rule'], 'grant_rule' => $settings['grant_rule'] ]; update_option( 'atk_incidents_settings_audit', array_slice( $audit, -20 ), false ); if ( ! $settings['enabled'] ) ATKAI_Incidents::deactivate(); elseif ( ! wp_next_scheduled( ATKAI_Incidents::HOOK ) ) wp_schedule_event( time() + 60, 'atk_five_minutes', ATKAI_Incidents::HOOK ); delete_transient( 'atk_incidents_counts' ); } public function save_settings() { $this->authorize( 'atk_incidents_settings' ); $recipient = sanitize_email( $this->input( 'recipient', $_POST ) ); if ( ! is_email( $recipient ) ) wp_die( esc_html__( 'Enter a valid alert email address.', 'activity-track' ) ); $flag = function( $key ) { return '1' === $this->input( $key, $_POST ) ? 1 : 0; }; $old = ATKAI_Incidents::settings(); $pro = ATKAI_Incidents::pro(); $this->store_settings( [ 'enabled' => $flag( 'enabled' ), 'recipient' => $recipient, 'alerts' => $flag( 'alerts' ), 'review_alerts' => $flag( 'review_alerts' ), 'login_rule' => $flag( 'login_rule' ), 'grant_rule' => $flag( 'grant_rule' ), 'scan_rule' => $flag( 'scan_rule' ), 'exposure_rule' => $flag( 'exposure_rule' ), // Pro checks keep their saved value when Pro is not active, so a downgrade never silently changes them. 'enum_rule' => $pro ? $flag( 'enum_rule' ) : (int) $old['enum_rule'], 'scan_login_rule' => $pro ? $flag( 'scan_login_rule' ) : (int) $old['scan_login_rule'] ] ); $this->redirect( [ 'view' => 'settings', 'saved' => 1 ] ); } /** One-click start with the current (or default) checks and the site admin email as recipient. */ public function enable() { $this->authorize( 'atk_incidents_enable' ); $settings = ATKAI_Incidents::settings(); $settings['enabled'] = 1; if ( ! is_email( $settings['recipient'] ) ) $settings['recipient'] = get_option( 'admin_email' ); if ( ! $settings['login_rule'] && ! $settings['grant_rule'] ) { $settings['login_rule'] = 1; $settings['grant_rule'] = 1; } $this->store_settings( $settings ); $this->redirect( [ 'enabled' => 1 ] ); } /** Runs the bounded background check immediately, for sites where WP-Cron is slow or disabled. */ public function check_now() { $this->authorize( 'atk_incidents_check' ); if ( ! ATKAI_Incidents::enabled() ) wp_die( esc_html__( 'Turn on monitoring before running a check.', 'activity-track' ) ); update_option( 'atk_incidents_exposure_force', 1, false ); // Check now also re-runs the daily exposure self-check. do_action( ATKAI_Incidents::HOOK ); delete_transient( 'atk_incidents_counts' ); $this->redirect( [ 'checked' => 1 ] ); } public function resolve() { $id = absint( $this->input( 'incident', $_POST ) ); $this->authorize( 'atk_incident_resolve_' . $id ); $resolution = $this->input( 'resolution', $_POST ); if ( ! in_array( $resolution, [ 'addressed', 'expected', 'false_alarm' ], true ) ) wp_die( esc_html__( 'Choose a resolution.', 'activity-track' ) ); global $wpdb; $result = $wpdb->update( ATKAI_Incidents::table(), [ 'status' => 'resolved', 'resolution' => $resolution, 'note' => wp_check_invalid_utf8( substr( $this->input( 'note', $_POST ), 0, 1000 ), true ), 'resolved_by' => get_current_user_id(), 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + ATKAI_Incidents::retention_days() * DAY_IN_SECONDS ) ], [ 'incident_id' => $id, 'status' => 'open' ] ); if ( false === $result || 0 === $result ) wp_die( esc_html__( 'This incident could not be resolved, or was already resolved. Reload and try again.', 'activity-track' ) ); delete_transient( 'atk_incidents_counts' ); $this->redirect( [ 'incident' => $id, 'resolved' => 1 ] ); } public function bulk_resolve() { $this->authorize( 'atk_incidents_bulk_resolve' ); $resolution = $this->input( 'resolution', $_POST ); if ( ! in_array( $resolution, [ 'addressed', 'expected', 'false_alarm' ], true ) ) wp_die( esc_html__( 'Choose a resolution.', 'activity-track' ) ); $selected = isset( $_POST['incidents'] ) && is_array( $_POST['incidents'] ) ? $_POST['incidents'] : []; if ( ! $selected || count( $selected ) > 20 ) wp_die( esc_html__( 'Select between 1 and 20 open incidents from the current page.', 'activity-track' ) ); $ids = []; foreach ( $selected as $id ) { if ( ! is_scalar( $id ) || ! ctype_digit( (string) $id ) || ! absint( $id ) ) wp_die( esc_html__( 'Invalid incident selection.', 'activity-track' ) ); $ids[] = absint( $id ); } $ids = array_values( array_unique( $ids ) ); global $wpdb; $changed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET status = %s, resolution = %s, resolved_by = %d, expires_at = %s WHERE status = %s AND incident_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( [ ATKAI_Incidents::table(), 'resolved', $resolution, get_current_user_id(), gmdate( 'Y-m-d H:i:s', time() + ATKAI_Incidents::retention_days() * DAY_IN_SECONDS ), 'open' ], $ids ) ) ); if ( false === $changed ) wp_die( esc_html__( 'Incidents could not be resolved. Please retry.', 'activity-track' ) ); delete_transient( 'atk_incidents_counts' ); $args = [ 'bulk_resolved' => $changed ]; foreach ( [ 'status', 'priority', 'days', 's', 'paged' ] as $key ) $args[ $key ] = $this->input( $key, $_POST ); $this->redirect( $args ); } public function clear() { $this->authorize( 'atk_incidents_clear' ); if ( '1' !== $this->input( 'confirm', $_POST ) ) wp_die( esc_html__( 'Confirm that you want to delete resolved incidents.', 'activity-track' ) ); global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT incident_id FROM %i WHERE status = 'resolved' ORDER BY incident_id ASC LIMIT 100", ATKAI_Incidents::table() ) ); if ( $wpdb->last_error ) wp_die( esc_html__( 'Incident storage is unavailable.', 'activity-track' ) ); foreach ( $ids as $id ) if ( ! ATKAI_Incidents::remove( $id ) ) wp_die( esc_html__( 'Some incidents could not be deleted. Please retry.', 'activity-track' ) ); delete_transient( 'atk_incidents_counts' ); $this->redirect( [ 'view' => 'settings', 'cleared' => count( $ids ) ] ); } /* ---------- Helpers ---------- */ private function account( $id, $avatar = false ) { if ( ! $id ) return '' . esc_html__( 'Not recorded', 'activity-track' ) . ''; $user = get_userdata( $id ); if ( ! $user ) return esc_html( sprintf( /* translators: %d: deleted WordPress user ID. */ __( 'User Id %d (deleted)', 'activity-track' ), $id ) ); $name = '' . esc_html( $user->display_name ) . ''; if ( ! $avatar ) return $name; return '' . get_avatar( $id, 28 ) . '' . $name . '' . esc_html( sprintf( /* translators: %d: WordPress user ID. */ __( 'User Id %d', 'activity-track' ), $id ) ) . ''; } /** Anonymous 404 visitor: no account and no stored IP, only the client label recorded with the request. */ private function visitor( $incident ) { $device = ''; foreach ( json_decode( $incident->snapshot, true ) ?: [] as $step ) { if ( ! empty( $step['device'] ) ) { $device = $step['device']; break; } } return ATKAI_Incidents::visitor_badge( $device, 0, __( 'Anonymous visitor', 'activity-track' ) ); } /** Exposure findings come from the site's own daily self-check, not from a visitor or account. */ private function site_badge() { return '' . esc_html__( 'Your site', 'activity-track' ) . '' . esc_html__( 'Daily self-check', 'activity-track' ) . ''; } private function go_pro_url() { return add_query_arg( [ 'page' => 'activity-track-ai', 'tab' => 'activation' ], admin_url( 'admin.php' ) ); } /** One quiet line for Free users (or Pro without a license); nothing at all when Pro is active. */ private function pro_note( $text ) { if ( ATKAI_Incidents::pro() ) return ''; $link = defined( 'ATKAI_PRO_PLUGIN_ACTIVE' ) ? '' . esc_html__( 'Activate license', 'activity-track' ) . '' : '' . esc_html__( 'Learn more', 'activity-track' ) . ''; return '' . esc_html__( 'PRO', 'activity-track' ) . ' ' . esc_html( $text ) . ' ' . $link . ''; } private function scan_checks_on() { return ! empty( ATKAI_Incidents::settings()['scan_rule'] ) && class_exists( 'ATKAI_404_Events' ) && ATKAI_404_Events::enabled(); } private function date( $date ) { return get_date_from_gmt( $date, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); } private function ago( $date ) { /* translators: %s: human-readable time difference, e.g. "5 minutes". */ return sprintf( __( '%s ago', 'activity-track' ), human_time_diff( strtotime( $date . ' UTC' ), time() ) ); } private function badge( $incident ) { $high = 2 === (int) $incident->severity; return '' . esc_html( $high ? __( 'High priority', 'activity-track' ) : __( 'Needs review', 'activity-track' ) ) . ''; } private function step_label( $step ) { return ATKAI_Incidents::step_label( $step ); } /** Collapses consecutive identical steps ("5 failed sign-ins") so the story reads in a few lines. */ private function grouped_steps( $steps ) { $groups = []; foreach ( $steps as $step ) { $last = count( $groups ) - 1; $key = ( $step['src'] ?? 'sec' ) . ':' . (int) $step['id']; if ( $last >= 0 && $groups[ $last ]['type'] === $step['type'] && ( $groups[ $last ]['src'] ?? 'sec' ) === ( $step['src'] ?? 'sec' ) && ( $groups[ $last ]['change'] ?? '' ) === ( $step['change'] ?? '' ) && $groups[ $last ]['subject'] === $step['subject'] && $groups[ $last ]['actor'] === $step['actor'] ) { $groups[ $last ]['count']++; $groups[ $last ]['last_at'] = $step['at']; $groups[ $last ]['ids'][] = $key; if ( ! empty( $step['path'] ) && count( $groups[ $last ]['paths'] ) < 4 && ! in_array( $step['path'], $groups[ $last ]['paths'], true ) ) $groups[ $last ]['paths'][] = $step['path']; continue; } $groups[] = $step + [ 'count' => 1, 'last_at' => $step['at'], 'ids' => [ $key ], 'paths' => ! empty( $step['path'] ) ? [ $step['path'] ] : [] ]; } return $groups; } private function group_label( $group ) { if ( $group['count'] < 2 ) return $this->step_label( $group ); $plural = [ 'login_failed' => /* translators: %d: number of recorded events of this type. */ _n( '%d failed sign-in', '%d failed sign-ins', $group['count'], 'activity-track' ), 'login' => /* translators: %d: number of recorded events of this type. */ _n( '%d successful sign-in', '%d successful sign-ins', $group['count'], 'activity-track' ), 'admin_granted' => /* translators: %d: number of recorded events of this type. */ _n( '%d administrator role grant', '%d administrator role grants', $group['count'], 'activity-track' ), '404_sensitive' => /* translators: %d: number of recorded events of this type. */ _n( '%d sensitive file requested', '%d sensitive files requested', $group['count'], 'activity-track' ), '404_probe' => /* translators: %d: number of recorded events of this type. */ _n( '%d script file probed', '%d script files probed', $group['count'], 'activity-track' ), '404_enum' => /* translators: %d: number of recorded events of this type. */ _n( '%d plugin or theme file probed', '%d plugin or theme files probed', $group['count'], 'activity-track' ) ]; return isset( $plural[ $group['type'] ] ) ? sprintf( $plural[ $group['type'] ], $group['count'] ) : $this->step_label( $group ) . ' × ' . $group['count']; } private function alert_state( $incident ) { if ( ! $incident->notify_level ) return __( 'Not emailed (needs review only)', 'activity-track' ); if ( $incident->notified_level >= $incident->notify_level ) return __( 'Sent to WordPress mail', 'activity-track' ); if ( $incident->notify_attempts >= 5 ) return __( 'Failed after 5 attempts', 'activity-track' ); return ATKAI_Incidents::enabled() && ATKAI_Incidents::settings()['alerts'] ? __( 'Pending, retried automatically', 'activity-track' ) : __( 'Paused while alerts are off', 'activity-track' ); } private function state() { $settings = ATKAI_Incidents::settings(); $last = (int) get_option( 'atk_incidents_last_run', 0 ); if ( ! $settings['enabled'] ) return [ 'off', __( 'Monitoring off', 'activity-track' ), $last ]; $delayed = ( $last && time() - $last > 900 ) || ( ! $last && time() - (int) get_option( 'atk_incidents_started', time() ) > 900 ) || get_option( 'atk_incidents_backlog' ) || get_option( 'atk_incidents_error' ); if ( $delayed ) return [ 'delayed', __( 'Checks delayed', 'activity-track' ), $last ]; return [ 'active', $last ? __( 'Monitoring active', 'activity-track' ) : __( 'Starting', 'activity-track' ), $last ]; } private function last_check_text( $last ) { /* translators: %s: human-readable time difference. */ return $last ? sprintf( __( 'Last check %s ago', 'activity-track' ), human_time_diff( $last, time() ) ) : __( 'No check has run yet', 'activity-track' ); } private function post_button( $action, $label, $class = 'button', $fields = [] ) { echo '
'; } /* ---------- Screens ---------- */ public function render() { $this->authorize(); echo '' . esc_html__( 'Incident storage is unavailable. Monitoring will start after the database upgrade succeeds.', 'activity-track' ) . '
' . wp_kses_post( $note ) . '
'; // Escaped in pro_note(). ?>last_check_text( $last ) . '. ' . __( 'Checks run in the background about every 5 minutes.', 'activity-track' ) ); ?>
' . esc_html( $text ) . '
' : '' ) . '' . esc_html__( 'Incidents could not be loaded. Please retry.', 'activity-track' ) . '
' . esc_html__( 'Turn it on to get an alert when an account signs in after repeated failed attempts, when someone is given administrator access, or when a visitor scans your site for sensitive files. No IP addresses, passwords or typed usernames are stored.', 'activity-track' ) . '
family ? __( 'Found by the daily self-check, which requests these files from your own site. Nothing was changed or deleted.', 'activity-track' ) : ( $scan ? __( 'Grouped by a daily anonymous visitor fingerprint, not by IP. Nothing was blocked.', 'activity-track' ) : __( 'Linked by account, not by device or IP. No automatic action was taken.', 'activity-track' ) )); ?>
' . esc_html( $path ) . ''; }, $group['paths'] ) ) ) . ( $group['count'] > count( $group['paths'] ) ? ' …' : '' ) . '';
?>