'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 '
'; foreach ( $fields as $name => $value ) echo ''; wp_nonce_field( $action ); echo '
'; } /* ---------- Screens ---------- */ public function render() { $this->authorize(); echo '
'; if ( ! ATKAI_Incidents::ready() ) { echo '

' . esc_html__( 'Incident storage is unavailable. Monitoring will start after the database upgrade succeeds.', 'activity-track' ) . '

'; return; } $view = $this->input( 'view', $_GET ); $id = absint( $this->input( 'incident', $_GET ) ); $screen = 'settings' === $view ? 'settings' : 'list'; $this->header( $screen ); $this->notices( $screen ); if ( 'settings' === $screen ) { $this->settings_view(); } else { $this->listing(); // Like WooCommerce Activity "Details": the incident opens in a dialog over the list. if ( $id ) $this->detail( $id ); } echo ''; } private function header( $screen ) { [ $state, $label ] = $this->state(); ?>

post_button( 'atk_incidents_check', __( 'Check now', 'activity-track' ) ); ?>
overview(); ?>
state(); $checks = array_filter( [ $settings['login_rule'] ? __( 'Repeated failed sign-ins followed by a success', 'activity-track' ) : '', $settings['grant_rule'] ? __( 'administrator role grants', 'activity-track' ) : '', $settings['login_rule'] ? __( 'sensitive changes soon after a flagged sign-in', 'activity-track' ) : '', ! empty( $settings['exposure_rule'] ) ? __( 'sensitive files publicly accessible on your site', 'activity-track' ) : '', $this->scan_checks_on() ? __( 'signed-in accounts probing for hidden files', 'activity-track' ) : '', $this->scan_checks_on() && ATKAI_Incidents::pro() && $settings['enum_rule'] ? __( 'plugin discovery scans', 'activity-track' ) : '', $this->scan_checks_on() && ATKAI_Incidents::pro() && $settings['scan_login_rule'] ? __( 'scans followed by sign-in attempts', 'activity-track' ) : '' ] ); ?>

scan_checks_on() ? __( 'Sign-ins, administrator access and 404 scans', 'activity-track' ) : __( 'Sign-ins and administrator access', 'activity-track' ) ) ); ?>

pro_note( __( 'Plugin discovery scans, scan-to-sign-in correlation, AI explanations and 90-day history.', 'activity-track' ) ); if ( $note ) echo '

' . wp_kses_post( $note ) . '

'; // Escaped in pro_note(). ?>

last_check_text( $last ) . '. ' . __( 'Checks run in the background about every 5 minutes.', 'activity-track' ) ); ?>

state(); $notice = function( $type, $title, $text = '' ) { echo '
' . esc_html( $title ) . '' . ( $text ? '

' . esc_html( $text ) . '

' : '' ) . '
'; }; if ( isset( $_GET['saved'] ) ) $notice( 'success', __( 'Monitoring settings saved.', 'activity-track' ) ); if ( isset( $_GET['enabled'] ) ) $notice( 'success', __( 'Monitoring is on.', 'activity-track' ), __( 'Only activity from now on is checked. Earlier records are not turned into incidents.', 'activity-track' ) ); if ( isset( $_GET['checked'] ) ) $notice( 'success', __( 'Check complete.', 'activity-track' ) ); if ( isset( $_GET['resolved'] ) ) $notice( 'success', __( 'Incident resolved.', 'activity-track' ) ); if ( isset( $_GET['bulk_resolved'] ) ) { $changed = absint( $_GET['bulk_resolved'] ); $notice( $changed ? 'success' : 'info', $changed ? sprintf( /* translators: %s: number of incidents resolved together. */ _n( '%s incident resolved.', '%s incidents resolved.', $changed, 'activity-track' ), number_format_i18n( $changed ) ) : __( 'No open incidents were changed. The selected incidents may already be resolved.', 'activity-track' ) ); } if ( isset( $_GET['cleared'] ) ) $notice( 'success', sprintf( /* translators: %s: number of deleted incidents. */ _n( '%s resolved incident deleted.', '%s resolved incidents deleted.', absint( $_GET['cleared'] ), 'activity-track' ), number_format_i18n( absint( $_GET['cleared'] ) ) ) ); if ( 'delayed' === $state ) { $backlog = get_option( 'atk_incidents_backlog' ); $notice( 'warning', __( 'Checks are behind', 'activity-track' ), __( 'WordPress cron may not be running, or the last check could not finish. Use Check now, then review cron or database access. An empty list does not mean recent activity was checked.', 'activity-track' ) . ( $backlog ? ' ' . sprintf( /* translators: %s: date and time of the oldest unchecked record. */ __( 'Oldest unchecked record: %s.', 'activity-track' ), $this->date( $backlog ) ) : '' ) ); } if ( 'settings' !== $screen && gmdate( 'Ymd' ) === get_transient( 'atk_incidents_404_capped' ) ) { $notice( 'info', __( 'Busy scanning day', 'activity-track' ), sprintf( /* translators: %d: daily limit for new scan incidents. */ __( '%d scan incidents were opened today. Further low-priority scans are still in 404 Errors but are not added here until tomorrow. High priority findings are always added.', 'activity-track' ), ATKAI_Incidents::SCAN_DAILY_LIMIT ) ); } if ( 'settings' !== $screen && get_option( 'atk_incidents_404_error' ) ) { $notice( 'warning', __( '404 checks could not finish', 'activity-track' ), __( 'Sign-in checks are not affected. The 404 checks retry on the next background run.', 'activity-track' ) ); } if ( 'settings' !== $screen && ( get_transient( 'atk_incidents_capture_gap' ) || get_option( 'atk_incidents_storage_limited' ) ) ) { $notice( 'warning', __( 'Some events were skipped', 'activity-track' ), __( 'A capture or storage limit was reached. Incidents cover the records that were kept; skipped events cannot be recovered.', 'activity-track' ) ); } } private function listing() { global $wpdb; $settings = ATKAI_Incidents::settings(); $status = $this->input( 'status', $_GET ); if ( ! in_array( $status, [ 'open', 'resolved', 'all' ], true ) ) $status = 'open'; $priority = $this->input( 'priority', $_GET ); if ( ! in_array( $priority, [ '1', '2' ], true ) ) $priority = ''; $days = absint( $this->input( 'days', $_GET ) ); if ( ! in_array( $days, [ 7, 30 ], true ) ) $days = 0; $search = substr( $this->input( 's', $_GET ), 0, 100 ); $filtered = 'open' !== $status || $priority || $days || $search; $where = '1=1'; if ( 'all' !== $status ) $where .= $wpdb->prepare( ' AND status = %s', $status ); if ( $priority ) $where .= $wpdb->prepare( ' AND severity = %d', $priority ); if ( $days ) $where .= $wpdb->prepare( ' AND last_at >= %s', gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ); if ( $search ) { $user = get_user_by( 'login', $search ) ?: ( is_email( $search ) ? get_user_by( 'email', $search ) : false ); $number = ctype_digit( $search ) ? absint( $search ) : 0; $account = $user ? $user->ID : $number; $where .= $account || $number ? $wpdb->prepare( ' AND (user_id = %d OR actor_id = %d OR incident_id = %d)', $account, $account, $number ?: -1 ) : ' AND 1=0'; } $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE $where", ATKAI_Incidents::table() ) ); $pages = max( 1, (int) ceil( $total / 20 ) ); $page = min( $pages, max( 1, absint( $this->input( 'paged', $_GET ) ) ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where ORDER BY status ASC, severity DESC, last_at DESC, incident_id DESC LIMIT 20 OFFSET %d", ATKAI_Incidents::table(), ( $page - 1 ) * 20 ) ); if ( $wpdb->last_error ) { echo '

' . esc_html__( 'Incidents could not be loaded. Please retry.', 'activity-track' ) . '

'; return; } // Nothing stored yet and monitoring is off: one clear call to action instead of an empty table. if ( ! $settings['enabled'] && ! $total && ! $filtered ) { echo '

' . esc_html__( 'Incident monitoring is off', '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' ) . '

'; $this->post_button( 'atk_incidents_enable', __( 'Turn on monitoring', 'activity-track' ), 'button button-primary' ); echo '' . esc_html__( 'Review settings first', 'activity-track' ) . '
'; return; } ?>
$value ) : ?>
grouped_steps( json_decode( $incident->snapshot, true ) ?: [] ); $story = array_map( [ $this, 'group_label' ], array_slice( $steps, -3 ) ); $link = self::url( [ 'incident' => $incident->incident_id ] ); ?>
state(); echo $filtered ? esc_html__( 'No incidents match these filters.', 'activity-track' ) : ' ' . esc_html( __( 'No open incidents.', 'activity-track' ) . ' ' . $this->last_check_text( $last ) . '.' ); ?>
status ); ?>> badge( $incident ) ); ?> 3 ? '… → ' : '' ) . implode( ' → ', $story ) ); ?>
#incident_id ); ?>
ago( $incident->last_at ) ); ?>date( $incident->last_at ) ); ?> status ? __( 'Open', 'activity-track' ) : __( 'Resolved', 'activity-track' ) ); ?>event_count, 'activity-track' ), $incident->event_count ) ); ?>
add_query_arg( 'paged', '%#%', self::url( array_filter( compact( 'status', 'priority', 'days' ) + [ 's' => $search ] ) ) ), 'format' => '', 'current' => $page, 'total' => $pages, 'prev_text' => '‹', 'next_text' => '›' ] ) ); ?>
' . esc_html__( 'Account unavailable', 'activity-track' ) . '' : esc_html__( 'Not recorded', 'activity-track' ); $role = isset( $user->roles[0], $wp_roles->role_names[ $user->roles[0] ] ) ? translate_user_role( $wp_roles->role_names[ $user->roles[0] ] ) : __( 'No role', 'activity-track' ); return '' . get_avatar( $id, 30 ) . '' . esc_html( $user->display_name ) . '' . esc_html( sprintf( /* translators: %d: WordPress user ID. */ __( 'User ID %d', 'activity-track' ), $id ) ) . ' · ' . esc_html( $role ) . ''; } private function detail( $id ) { global $wpdb; $incident = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE incident_id = %d', ATKAI_Incidents::table(), $id ) ); $title = $incident ? ATKAI_Incidents::title( $incident ) : __( 'Incident unavailable', 'activity-track' ); echo ''; echo '

' . esc_html__( 'Incident details', 'activity-track' ) . '

'; echo '
'; if ( ! $incident || $wpdb->last_error ) { echo '

' . esc_html__( 'This incident is unavailable or has been deleted.', 'activity-track' ) . '

'; return; } $steps = json_decode( $incident->snapshot, true ) ?: []; $users = [ (int) $incident->resolved_by, (int) $incident->user_id ]; foreach ( $steps as $step ) { $users[] = $step['subject']; $users[] = $step['actor']; } cache_users( array_unique( array_filter( $users ) ) ); $retained = array_map( static function( $source ) { return 'sec:' . (int) $source; }, $wpdb->get_col( $wpdb->prepare( 'SELECT source_id FROM %i l INNER JOIN %i e ON e.event_id = l.source_id WHERE l.incident_id = %d', ATKAI_Incidents::links(), ATKAI_Security_Events::table(), $id ) ) ); $scan = ATKAI_Incidents::is_404( $incident ); if ( $scan && ATKAI_Incidents::ready_404() ) { $retained = array_merge( $retained, array_map( static function( $log ) { return '404:' . (int) $log; }, $wpdb->get_col( $wpdb->prepare( 'SELECT l.log_id FROM %i l INNER JOIN %i r ON r.log_id = l.log_id WHERE l.incident_id = %d', ATKAI_Incidents::scans(), ATKAI_404_Events::table(), $id ) ) ) ); } $open = 'open' === $incident->status; $resolutions = [ 'addressed' => __( 'Addressed', 'activity-track' ), 'expected' => __( 'Expected activity', 'activity-track' ), 'false_alarm' => __( 'False alarm', 'activity-track' ) ]; ?>

# ·
badge( $incident ) ); ?>

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' ) )); ?>

family ) : $file = $steps[0] ?? []; ?>
user_id ? $this->user_card( (int) $incident->user_id ) : $this->visitor( $incident ) ); ?>
event_count ) ); ?>
user_card( (int) $incident->user_id ) ); ?>
actor_id && (int) $incident->actor_id !== (int) $incident->user_id ? $this->user_card( (int) $incident->actor_id ) : esc_html__( 'Same account', 'activity-track' ) ); ?>
date( $incident->first_at ) ); ?>
date( $incident->last_at ) ); ?>ago( $incident->last_at ) ); ?>
family ) : ?>
event_count ) ); ?>
alert_state( $incident ) ); ?>
    grouped_steps( $steps ) as $group ) : $hot = in_array( $group['type'], [ 'login', 'admin_granted', 'sensitive_change', '404_sensitive' ], true ); $expired = ! array_intersect( $group['ids'], $retained ); ?>
  1. group_label( $group ) ); ?> 1 ) echo ' · ' . esc_html( sprintf( /* translators: %s: time of the latest event in the group. */ __( 'until %s', 'activity-track' ), get_date_from_gmt( $group['last_at'], 'H:i' ) ) ) . ''; $parts = []; if ( $group['subject'] ) $parts[] = $this->account( $group['subject'] ); if ( $group['actor'] && $group['actor'] !== $group['subject'] ) $parts[] = esc_html__( 'by', 'activity-track' ) . ' ' . $this->account( $group['actor'] ); if ( $parts ) echo '' . wp_kses_post( implode( ' ', $parts ) ) . ( $expired ? ' · ' . esc_html__( 'saved copy', 'activity-track' ) : '' ) . ''; // Parts are escaped above. if ( $group['paths'] ) echo '' . wp_kses_post( implode( ' ', array_map( static function( $path ) { return '' . esc_html( $path ) . ''; }, $group['paths'] ) ) ) . ( $group['count'] > count( $group['paths'] ) ? ' …' : '' ) . ''; ?>
event_count ) : ?>event_count ) ); ?>
pro_note( __( 'Explain this incident with AI: what happened, how serious it is and what to do next.', 'activity-track' ) ); if ( $ai || $ai_note ) : ?>
[ 'class' => true, 'aria-live' => true, 'role' => true ], 'button' => [ 'type' => true, 'class' => true, 'data-incident-id' => true ], 'span' => [ 'class' => true, 'aria-hidden' => true ], 'svg' => [ 'class' => true, 'aria-hidden' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'xmlns' => true ], 'path' => [ 'd' => true, 'fill' => true, 'fill-rule' => true, 'clip-rule' => true ], 'p' => [ 'class' => true ], 'a' => [ 'href' => true, 'class' => true ], 'strong' => [] ] ) : wp_kses_post( $ai_note ); // pro_note() escapes its output. ?>
    family ) : ?>
  1. user_id || '404-login' === $incident->family ) : ?>
user_id ) : ?>
get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'resolved'", ATKAI_Incidents::table() ) ); ?>

←