collector = $collector; add_action( 'admin_init', [ $this, 'install' ], 20 ); add_filter( 'cron_schedules', [ $this, 'schedules' ] ); add_action( self::HOOK, [ $this, 'run' ] ); add_action( 'atk_ai_logs_cleanup_daily_event', [ $this, 'cleanup' ] ); add_filter( 'wp_privacy_personal_data_exporters', [ $this, 'exporters' ] ); add_filter( 'wp_privacy_personal_data_erasers', [ $this, 'erasers' ] ); add_action( 'wp_login', [ $this, 'login' ], 10, 2 ); add_action( 'wp_login_failed', [ $this, 'failed_login' ] ); add_action( 'add_user_role', [ $this, 'grant' ], 10, 2 ); add_action( 'user_register', [ $this, 'registered' ] ); add_action( 'activated_plugin', [ $this, 'plugin_activated' ] ); add_action( 'updated_option', [ $this, 'option_changed' ], 10, 3 ); register_deactivation_hook( ATKAI_PLUGIN_FILE, [ __CLASS__, 'deactivate' ] ); } public static function table() { global $wpdb; return $wpdb->prefix . 'atk_incidents'; } public static function links() { global $wpdb; return $wpdb->prefix . 'atk_incident_events'; } public static function settings() { return array_merge( [ 'enabled' => 0, 'recipient' => '', 'alerts' => 1, 'review_alerts' => 0, 'login_rule' => 1, 'grant_rule' => 1, 'scan_rule' => 1, 'enum_rule' => 1, 'scan_login_rule' => 1, 'exposure_rule' => 1 ], (array) get_option( 'atk_incidents_settings', [] ) ); } public static function enabled() { return ! empty( self::settings()['enabled'] ); } /** Pro adds checks through this filter (license-aware); Free never assumes Pro. */ public static function pro() { return (bool) apply_filters( 'atkai_incidents_pro', false ); } public static function scans() { global $wpdb; return $wpdb->prefix . 'atk_incident_404'; } public static function ready_404() { return self::ready() && self::VERSION_404 === get_option( 'atk_incidents_404_db_version' ) && class_exists( 'ATKAI_404_Events' ) && ATKAI_404_Events::VERSION === get_option( 'atk_404_db_version' ); } /** Dashicon matching the recorded client: search engines, AI crawlers, scripts, scanners, phones, computers. */ public static function device_icon( $device, $user_id = 0 ) { $device = (string) $device; if ( $user_id ) return 'admin-users'; $groups = [ 'search' => [ 'Googlebot', 'Bingbot', 'Baiduspider' ], 'format-chat' => [ 'GPTBot', 'ClaudeBot' ], 'shield' => [ 'sqlmap', 'nikto', 'Suspected bot' ], 'editor-code' => [ 'python-requests', 'python-httpx', 'curl', 'wget', 'Go-http-client', 'Scrapy' ], 'admin-page' => [ 'HeadlessChrome' ], 'smartphone' => [ 'iOS', 'Android' ], 'desktop' => [ 'Windows', 'macOS', 'Linux', 'Chrome', 'Firefox', 'Safari', 'Edge' ], ]; foreach ( $groups as $icon => $needles ) { foreach ( $needles as $needle ) if ( false !== stripos( $device, $needle ) ) return $icon; } return 0 === stripos( $device, 'Bot' ) ? 'admin-site-alt3' : 'editor-help'; } /** Visitor badge (icon + client label) used by the 404 Errors table and Incidents. */ public static function visitor_badge( $device, $user_id = 0, $heading = '', $context = '' ) { $device = (string) $device ?: __( 'Unknown client', 'activity-track' ); $bot = 0 === stripos( $device, 'Bot' ) || 0 === stripos( $device, 'Suspected bot' ); $context = $context ?: ( $user_id ? sprintf( /* translators: %d: signed-in WordPress user ID. */ __( 'Signed-in · User ID %d', 'activity-track' ), $user_id ) : ( $bot ? __( 'Automated client', 'activity-track' ) : __( 'Anonymous visitor', 'activity-track' ) ) ); // With a heading (Incidents), the heading leads and the client label is the context line. [ $main, $sub ] = $heading ? [ $heading, $device ] : [ $device, $context ]; return '' . esc_html( $main ) . '' . esc_html( $sub ) . ''; } public static function is_404( $incident ) { return in_array( (string) ( $incident->family ?? '' ), self::FAMILIES_404, true ); } /** Same daily, salted visitor fingerprint the 404 log already stores; never an IP address. */ public static function visitor_hash() { return hash_hmac( 'sha256', gmdate( 'Ymd' ) . '|' . ( $_SERVER['REMOTE_ADDR'] ?? '' ) . '|' . ( $_SERVER['HTTP_USER_AGENT'] ?? '' ), wp_salt( 'auth' ) ); } /** Classifies a redacted 404 path: sensitive file, script probe, or plugin/theme folder (slug). */ public static function classify_404( $url ) { $path = (string) strtok( (string) $url, '?' ); $sensitive = (bool) preg_match( self::SENSITIVE_PATTERN, $path ); $probe = $sensitive || preg_match( self::PROBE_PATTERN, $path ) || ( class_exists( 'ATKAI_404_Events' ) && preg_match( '~' . ATKAI_404_Events::SCANNER_PATH_PATTERN . '~i', $path ) ); $slug = preg_match( self::ENUM_PATTERN, $path, $match ) ? strtolower( $match[1] ) : ''; return [ 'path' => substr( $path, 0, 200 ), 'sensitive' => $sensitive, 'probe' => (bool) $probe, 'slug' => $slug ]; } public static function ready() { return self::VERSION === get_option( 'atk_incidents_db_version' ) && ATKAI_Security_Events::VERSION === get_option( 'atk_security_db_version' ); } public static function deactivate() { wp_clear_scheduled_hook( self::HOOK ); } public function schedules( $schedules ) { $schedules['atk_five_minutes'] = [ 'interval' => 300, 'display' => __( 'Every five minutes', 'activity-track' ) ]; return $schedules; } public function install() { global $wpdb; if ( ! current_user_can( 'manage_options' ) ) return; if ( self::VERSION !== get_option( 'atk_incidents_db_version' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $links = self::links(); $collate = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table ( incident_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, fingerprint char(64) NOT NULL, family varchar(12) NOT NULL, user_id bigint(20) unsigned NOT NULL DEFAULT 0, actor_id bigint(20) unsigned NOT NULL DEFAULT 0, severity tinyint(1) unsigned NOT NULL DEFAULT 1, status varchar(12) NOT NULL DEFAULT 'open', first_at datetime NOT NULL, last_at datetime NOT NULL, snapshot longtext NOT NULL, event_count int(10) unsigned NOT NULL DEFAULT 0, resolution varchar(12) NOT NULL DEFAULT '', note text NOT NULL, resolved_by bigint(20) unsigned NOT NULL DEFAULT 0, expires_at datetime NULL, notify_level tinyint(1) unsigned NOT NULL DEFAULT 0, notified_level tinyint(1) unsigned NOT NULL DEFAULT 0, notify_attempts tinyint(1) unsigned NOT NULL DEFAULT 0, notify_after datetime NOT NULL, PRIMARY KEY (incident_id), UNIQUE KEY fingerprint (fingerprint), KEY status_priority (status,severity,last_at), KEY account_time (user_id,first_at), KEY actor_time (actor_id,last_at), KEY expires_at (expires_at) ) $collate;" ); dbDelta( "CREATE TABLE $links ( incident_id bigint(20) unsigned NOT NULL, source_id bigint(20) unsigned NOT NULL, subject_id bigint(20) unsigned NOT NULL DEFAULT 0, actor_id bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (incident_id,source_id), KEY source_id (source_id), KEY subject_id (subject_id), KEY actor_id (actor_id) ) $collate;" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $links ) ) ) === $links ) { add_option( 'atk_incidents_quota_login', '0:0', '', false ); add_option( 'atk_incidents_quota_grant', '0:0', '', false ); add_option( 'atk_incidents_quota_day', '0:0', '', false ); add_option( 'atk_incidents_quota_capacity_login', 'all:0', '', false ); add_option( 'atk_incidents_quota_capacity_grant', 'all:0', '', false ); update_option( 'atk_incidents_db_version', self::VERSION, false ); } } if ( self::ready() && self::VERSION_404 !== get_option( 'atk_incidents_404_db_version' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $scans = self::scans(); dbDelta( "CREATE TABLE $scans ( incident_id bigint(20) unsigned NOT NULL, log_id bigint(20) unsigned NOT NULL, PRIMARY KEY (incident_id,log_id), KEY log_id (log_id) ) " . $wpdb->get_charset_collate() . ';' ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $scans ) ) ) === $scans ) update_option( 'atk_incidents_404_db_version', self::VERSION_404, false ); } if ( self::ready() && ! get_option( 'atk_incidents_scan_cleanup_v1' ) ) { $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'resolved', resolution = 'auto', expires_at = %s WHERE status = 'open' AND severity = 1 AND user_id = 0 AND family IN ('404-scan','404-file','404-enum')", self::table(), gmdate( 'Y-m-d H:i:s', time() + self::retention_days() * DAY_IN_SECONDS ) ) ); if ( ! $wpdb->last_error ) { update_option( 'atk_incidents_scan_cleanup_v1', 1, false ); delete_transient( 'atk_incidents_counts' ); } } if ( self::enabled() && self::ready() && ! wp_next_scheduled( self::HOOK ) ) wp_schedule_event( time() + 60, 'atk_five_minutes', self::HOOK ); if ( ! self::enabled() ) self::deactivate(); } private function quota( $key, $bucket, $limit ) { global $wpdb; $result = $wpdb->query( $wpdb->prepare( "UPDATE %i SET option_value = IF(SUBSTRING_INDEX(option_value, ':', 1) = %s, CONCAT(%s, ':', CAST(SUBSTRING_INDEX(option_value, ':', -1) AS UNSIGNED) + 1), %s) WHERE option_name = %s AND (SUBSTRING_INDEX(option_value, ':', 1) <> %s OR CAST(SUBSTRING_INDEX(option_value, ':', -1) AS UNSIGNED) < %d)", $wpdb->options, $bucket, $bucket, $bucket . ':1', $key, $bucket, $limit ) ); return 1 === $result; } private function capture( $type, $subject, $actor, $context = [], $key = '' ) { if ( ! self::enabled() || ! self::ready() ) return; if ( array_intersect( [ (int) $subject, (int) $actor ], (array) get_option( 'atk_incidents_erasing', [] ) ) ) return; global $wpdb; $previous = $wpdb->suppress_errors( true ); try { $grant = 'admin_granted' === $type; if ( ( ! $grant && get_option( 'atk_incidents_storage_limited' ) ) || ! $this->quota( 'atk_incidents_quota_' . ( $grant ? 'grant' : 'login' ), gmdate( 'YmdHi' ), $grant ? 60 : 120 ) || ( ! $grant && ! $this->quota( 'atk_incidents_quota_day', gmdate( 'Ymd' ), 5000 ) ) ) { if ( ! get_transient( 'atk_incidents_capture_gap' ) ) set_transient( 'atk_incidents_capture_gap', gmdate( 'Y-m-d H:i:s' ), DAY_IN_SECONDS ); return; } if ( ! $this->quota( 'atk_incidents_quota_capacity_' . ( $grant ? 'grant' : 'login' ), 'all', $grant ? 5000 : 45000 ) ) { update_option( 'atk_incidents_storage_limited', 1, false ); if ( ! get_transient( 'atk_incidents_capture_gap' ) ) set_transient( 'atk_incidents_capture_gap', gmdate( 'Y-m-d H:i:s' ), DAY_IN_SECONDS ); return; } // Account correlation does not require storing client IPs or device fingerprints. $recorded = $this->collector->incident( $type, $subject, $actor, $context, $key ); $failed = false === $recorded || (bool) $wpdb->last_error; if ( ! $recorded ) $this->release_capacity( $grant ? 'grant' : 'login', 1 ); if ( $failed && ! get_transient( 'atk_incidents_capture_gap' ) ) set_transient( 'atk_incidents_capture_gap', gmdate( 'Y-m-d H:i:s' ), DAY_IN_SECONDS ); } catch ( Throwable $error ) { if ( ! get_transient( 'atk_incidents_capture_gap' ) ) set_transient( 'atk_incidents_capture_gap', gmdate( 'Y-m-d H:i:s' ), DAY_IN_SECONDS ); } finally { $wpdb->suppress_errors( $previous ); } } private function release_capacity( $pool, $count ) { global $wpdb; return $wpdb->query( $wpdb->prepare( "UPDATE %i SET option_value = CONCAT('all:', GREATEST(0, CAST(SUBSTRING_INDEX(option_value, ':', -1) AS UNSIGNED) - %d)) WHERE option_name = %s", $wpdb->options, $count, 'atk_incidents_quota_capacity_' . $pool ) ); } /** The daily visitor hash is kept only when the Pro scan-to-sign-in check needs it; Free stores no fingerprint. */ private function visitor_context() { $settings = self::settings(); return self::pro() && ! empty( $settings['scan_rule'] ) && ! empty( $settings['scan_login_rule'] ) ? [ 'visitor' => self::visitor_hash() ] : []; } public function login( $name, $user ) { if ( self::settings()['login_rule'] && ! empty( $user->ID ) ) $this->capture( 'login', $user->ID, $user->ID, $this->visitor_context() ); } public function failed_login( $name ) { if ( ! self::enabled() || ! self::ready() || ! self::settings()['login_rule'] || ! is_string( $name ) ) return; $user = get_user_by( 'login', $name ); if ( ! $user && is_email( $name ) ) $user = get_user_by( 'email', $name ); // Unknown attempted names are not needed for F1; never persist submitted identifiers. if ( $user ) $this->capture( 'login_failed', $user->ID, 0, $this->visitor_context() ); } public function registered( $id ) { if ( ! self::enabled() ) return; $user = get_userdata( $id ); if ( $user && in_array( 'administrator', (array) $user->roles, true ) ) $this->grant( $id, 'administrator' ); } public function grant( $id, $role ) { if ( ! self::enabled() || 'administrator' !== $role || ( ! self::settings()['grant_rule'] && ! self::settings()['login_rule'] ) ) return; global $wpdb; $capabilities = get_user_meta( $id, $wpdb->get_blog_prefix() . 'capabilities', true ); if ( ! is_array( $capabilities ) || empty( $capabilities['administrator'] ) ) return; // add_user_role fires after successful role persistence; user_register can observe the same grant. $this->capture( 'admin_granted', $id, get_current_user_id(), [ 'role' => 'administrator' ], 'incident_grant|' . $id . '|' . $this->request_key() ); } private function request_key() { static $key; if ( ! $key ) $key = wp_generate_uuid4(); return $key; } public function plugin_activated( $plugin ) { if ( self::settings()['login_rule'] ) $this->capture( 'sensitive_change', 0, get_current_user_id(), [ 'change' => 'plugin_activated' ] ); } public function option_changed( $name, $old, $new ) { if ( self::settings()['login_rule'] && $old !== $new && in_array( $name, [ 'users_can_register', 'default_role', 'siteurl', 'home', 'admin_email' ], true ) ) { $this->capture( 'sensitive_change', 0, get_current_user_id(), [ 'change' => $name ] ); } } private function results( $sql ) { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Private helper; every caller supplies a complete wpdb::prepare() result. $rows = $wpdb->get_results( $sql ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); return $rows ?: []; } private function write( $result ) { if ( false === $result ) throw new RuntimeException( 'incident_write' ); } private function lock( $suffix ) { global $wpdb; return 'atk_inc_' . md5( DB_NAME . $wpdb->prefix . $suffix ); } public function run() { if ( ! self::enabled() || ! self::ready() ) return; global $wpdb; $previous = $wpdb->suppress_errors( true ); $locked = false; $started = microtime( true ); try { $locked = '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $this->lock( 'engine' ) ) ); if ( ! $locked ) { update_option( 'atk_incidents_error', 'lock', false ); return; } $cursor = absint( get_option( 'atk_incidents_cursor', 0 ) ); $events = ATKAI_Security_Events::table(); // A bounded overlap recovers late-visible inserts and partial work, using stable event IDs. $previous_cursor = $cursor; $rows = $this->results( $wpdb->prepare( 'SELECT * FROM %i WHERE incident_only = 1 AND event_id > %d ORDER BY event_id ASC LIMIT 250', $events, $cursor ) ); foreach ( $rows as $row ) { if ( microtime( true ) - $started > 2 ) break; $this->evaluate( $row ); $cursor = max( $cursor, (int) $row->event_id ); } // Fresh work takes priority so repeated lookbacks cannot starve the checkpoint. if ( microtime( true ) - $started < 2 ) { $replay = $this->results( $wpdb->prepare( 'SELECT * FROM %i WHERE incident_only = 1 AND event_id > %d AND event_id <= %d ORDER BY event_id ASC LIMIT 50', $events, max( 0, $previous_cursor - 50 ), $previous_cursor ) ); foreach ( $replay as $row ) { if ( microtime( true ) - $started > 2 ) break; $this->evaluate( $row ); } } update_option( 'atk_incidents_cursor', $cursor, false ); update_option( 'atk_incidents_last_run', time(), false ); $pending = $wpdb->get_var( $wpdb->prepare( 'SELECT recorded_at FROM %i WHERE incident_only = 1 AND event_id > %d ORDER BY event_id ASC LIMIT 1', $events, $cursor ) ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); update_option( 'atk_incidents_backlog', $pending ?: '', false ); delete_option( 'atk_incidents_error' ); $this->trim_events( $cursor, $started + 2 ); $this->run_404( $started ); $this->run_exposure(); } catch ( Throwable $error ) { update_option( 'atk_incidents_error', 'storage', false ); } finally { if ( $locked ) $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->lock( 'engine' ) ) ); $wpdb->suppress_errors( $previous ); } if ( $locked && ! get_option( 'atk_incidents_error' ) ) $this->deliver(); } private function evaluate( $event ) { global $wpdb; $events = ATKAI_Security_Events::table(); $table = self::table(); if ( array_intersect( [ (int) $event->subject_id, (int) $event->actor_id ], (array) get_option( 'atk_incidents_erasing', [] ) ) ) return; $time = strtotime( $event->occurred_at . ' UTC' ); $oldest = gmdate( 'Y-m-d H:i:s', $time - 2 * HOUR_IN_SECONDS ); // Do not turn old delayed/replayed records into new live warnings after history was resolved/expired. if ( $time < (int) get_option( 'atk_incidents_started', time() ) || $time < time() - DAY_IN_SECONDS ) return; $closed = $this->results( $wpdb->prepare( "SELECT i.incident_id FROM %i l INNER JOIN %i i ON i.incident_id = l.incident_id WHERE l.source_id = %d AND i.status = 'resolved' LIMIT 1", self::links(), $table, $event->event_id ) ); if ( $closed ) return; if ( 'login' === $event->event_type || 'login_failed' === $event->event_type ) $this->link_scan_to_login( $event ); if ( self::settings()['login_rule'] && 'login' === $event->event_type && $event->subject_id ) { $failures = $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE incident_only = 1 AND subject_id = %d AND event_type = 'login_failed' AND occurred_at >= %s AND occurred_at <= %s AND (occurred_at < %s OR event_id < %d) ORDER BY occurred_at DESC, event_id DESC LIMIT 5", $events, $event->subject_id, gmdate( 'Y-m-d H:i:s', $time - 30 * MINUTE_IN_SECONDS ), $event->occurred_at, $event->occurred_at, $event->event_id ) ); if ( count( $failures ) < 5 || strtotime( $failures[0]->occurred_at . ' UTC' ) - strtotime( $failures[4]->occurred_at . ' UTC' ) > 15 * MINUTE_IN_SECONDS ) return; // The latest failed attempt must precede this successful sign-in (IDs break equal-time ties). if ( $failures[0]->occurred_at === $event->occurred_at && $failures[0]->event_id > $event->event_id ) return; $prior = $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE family = 'login' AND user_id = %d AND first_at >= %s AND first_at <= %s ORDER BY incident_id DESC LIMIT 1", $table, $event->subject_id, $oldest, $event->occurred_at ) ); if ( $prior && 'open' !== $prior[0]->status ) $prior = []; $this->save_finding( $prior[0] ?? null, 'login', $event, array_merge( array_reverse( $failures ), [ $event ] ), 1 ); $changes = $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE incident_only = 1 AND actor_id = %d AND event_type IN ('admin_granted','sensitive_change') AND occurred_at >= %s AND occurred_at <= %s ORDER BY event_id ASC LIMIT 10", $events, $event->subject_id, $event->occurred_at, gmdate( 'Y-m-d H:i:s', $time + HOUR_IN_SECONDS ) ) ); foreach ( $changes as $change ) $this->evaluate( $change ); } elseif ( 'admin_granted' === $event->event_type || 'sensitive_change' === $event->event_type ) { // Already absorbed into a sign-in chain (the chain's last_at may have moved past it): never report it twice. $chained = $this->results( $wpdb->prepare( "SELECT i.incident_id FROM %i l INNER JOIN %i i ON i.incident_id = l.incident_id WHERE l.source_id = %d AND i.family = 'login' LIMIT 1", self::links(), $table, $event->event_id ) ); if ( $chained ) return; $login = self::settings()['login_rule'] && $event->actor_id ? $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE family = 'login' AND user_id = %d AND first_at >= %s AND last_at <= %s ORDER BY incident_id DESC LIMIT 1", $table, $event->actor_id, $oldest, $event->occurred_at ) ) : []; if ( $login && 'open' === $login[0]->status ) { $snapshot = json_decode( $login[0]->snapshot, true ) ?: []; $successes = array_filter( $snapshot, static function( $step ) { return 'login' === $step['type']; } ); $success = $successes ? end( $successes ) : null; if ( $success && ( $event->occurred_at !== $success['at'] || (int) $event->event_id > $success['id'] ) && $time - strtotime( $success['at'] . ' UTC' ) <= HOUR_IN_SECONDS ) { $this->save_finding( $login[0], 'login', $event, [ $event ], 2 ); return; } } if ( self::settings()['grant_rule'] && 'admin_granted' === $event->event_type ) $this->save_finding( null, 'grant', $event, [ $event ], 1 ); } } /* ---------- Sensitive-file exposure (daily self-check of the site's own public URLs) ---------- */ /** Risky files that may sit in the web root. Only files that exist on disk are requested over HTTP. */ public static function exposure_candidates() { $files = [ '.env' => 'env', '.env.local' => 'env', '.env.production' => 'env', '.env.backup' => 'env', '.git/config' => 'git', '.git/HEAD' => 'githead', '.htpasswd' => 'htpasswd', 'wp-config.php.bak' => 'config', 'wp-config.php.old' => 'config', 'wp-config.php.save' => 'config', 'wp-config.php.orig' => 'config', 'wp-config.php.txt' => 'config', 'wp-config.php~' => 'config', 'wp-config.bak' => 'config', 'wp-config.old' => 'config', 'wp-config.txt' => 'config', 'phpinfo.php' => 'phpinfo', 'info.php' => 'phpinfo', 'php_info.php' => 'phpinfo', 'debug.log' => 'log', 'error_log' => 'log' ]; $root = wp_normalize_path( ABSPATH ); $logs = [ WP_CONTENT_DIR . '/debug.log' ]; if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) ) $logs[] = WP_DEBUG_LOG; foreach ( $logs as $log ) { $log = wp_normalize_path( $log ); if ( 0 === strpos( $log, $root ) ) $files[ substr( $log, strlen( $root ) ) ] = 'log'; } // Database dumps and site archives left in the web root. foreach ( array_merge( (array) glob( ABSPATH . '*.sql' ), (array) glob( ABSPATH . '*.sql.gz' ), (array) glob( ABSPATH . '*.zip' ), (array) glob( ABSPATH . '*.tar.gz' ), (array) glob( ABSPATH . '*.tgz' ) ) as $file ) { $name = basename( (string) $file ); if ( preg_match( '/\.sql(\.gz)?$/i', $name ) ) $files[ $name ] = 'sql'; elseif ( preg_match( '/(backup|dump|db|database|site|www|public_html|wordpress|wp|full|export)/i', $name ) ) $files[ $name ] = 'archive'; if ( count( $files ) > 60 ) break; } return (array) apply_filters( 'atkai_exposure_candidates', $files ); } /** True when the served content proves the file itself was returned (not a themed 200 page). */ public static function exposure_signature( $kind, $body ) { $body = (string) $body; switch ( $kind ) { case 'env': return (bool) preg_match( '/^\s*[A-Z][A-Z0-9_]*\s*=/m', $body ) && false === stripos( $body, ' time(), 'checked' => 0, 'found' => 0, 'failed' => 0 ]; try { $deadline = microtime( true ) + 15; foreach ( self::exposure_candidates() as $relative => $kind ) { $relative = ltrim( wp_normalize_path( (string) $relative ), '/' ); if ( '' === $relative || false !== strpos( $relative, '..' ) || ! is_file( ABSPATH . $relative ) ) continue; if ( microtime( true ) > $deadline ) break; ++$result['checked']; $response = wp_remote_get( home_url( '/' . str_replace( '%2F', '/', rawurlencode( $relative ) ) ), [ 'timeout' => 5, 'redirection' => 0, 'limit_response_size' => 8192, 'headers' => [ 'Range' => 'bytes=0-8191' ], 'user-agent' => 'ActivityTrack exposure check' ] ); if ( is_wp_error( $response ) ) { ++$result['failed']; continue; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( ! in_array( $code, [ 200, 206 ], true ) || ! self::exposure_signature( $kind, wp_remote_retrieve_body( $response ) ) ) continue; ++$result['found']; $this->save_exposure( '/' . $relative, $code, (int) filesize( ABSPATH . $relative ) ); } delete_option( 'atk_incidents_exposure_error' ); } catch ( Throwable $error ) { update_option( 'atk_incidents_exposure_error', 1, false ); } update_option( 'atk_incidents_exposure_result', $result, false ); } private function save_exposure( $path, $code, $size ) { global $wpdb; $table = self::table(); $now = gmdate( 'Y-m-d H:i:s' ); $step = [ 'id' => (int) sprintf( '%u', crc32( $path ) ), 'src' => 'exp', 'type' => 'exposed_file', 'at' => $now, 'subject' => 0, 'actor' => 0, 'change' => '', 'path' => $path, 'detail' => sprintf( 'HTTP %d · %s', $code, size_format( $size ) ) ]; foreach ( $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE family = 'exposure' AND status = 'open'", $table ) ) as $open ) { $steps = json_decode( $open->snapshot, true ) ?: []; if ( ( $steps[0]['path'] ?? '' ) !== $path ) continue; $steps[0]['detail'] = $step['detail']; $this->write( $wpdb->update( $table, [ 'last_at' => $now, 'snapshot' => wp_json_encode( $steps ) ], [ 'incident_id' => $open->incident_id ] ) ); return; } $settings = self::settings(); $this->write( $wpdb->insert( $table, [ 'fingerprint' => hash( 'sha256', get_current_blog_id() . '|exposure|' . $path . '|' . $now ), 'family' => 'exposure', 'user_id' => 0, 'actor_id' => 0, 'severity' => 2, 'first_at' => $now, 'last_at' => $now, 'snapshot' => wp_json_encode( [ $step ] ), 'event_count' => 1, 'note' => '', 'notify_level' => $settings['alerts'] ? 2 : 0, 'notify_after' => $now ] ) ); delete_transient( 'atk_incidents_counts' ); } /* ---------- 404 checks (separate cursor, tables and error state) ---------- */ /** Bounded pass over new 404 rows; only visitors with probe-like requests are evaluated. */ private function run_404( $started ) { $settings = self::settings(); if ( empty( $settings['scan_rule'] ) || ! self::ready_404() || ! ATKAI_404_Events::enabled() ) return; global $wpdb; try { $logs = ATKAI_404_Events::table(); $cursor = get_option( 'atk_incidents_cursor_404', false ); if ( false === $cursor || '' === $cursor ) { // Start with new 404s only; existing history never becomes live incidents. $max = $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(log_id) FROM %i', $logs ) ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_404_read' ); update_option( 'atk_incidents_cursor_404', absint( $max ), false ); return; } $cursor = absint( $cursor ); $enum = self::pro() && ! empty( $settings['enum_rule'] ); $rows = $this->results( $wpdb->prepare( 'SELECT log_id, target_url, visitor_hash FROM %i WHERE log_id > %d ORDER BY log_id ASC LIMIT 500', $logs, $cursor ) ); $visitors = []; foreach ( $rows as $row ) { $cursor = max( $cursor, (int) $row->log_id ); $kind = self::classify_404( $row->target_url ); if ( preg_match( '/^[a-f0-9]{64}$/', (string) $row->visitor_hash ) && ( $kind['probe'] || ( $enum && $kind['slug'] ) ) ) $visitors[ $row->visitor_hash ] = true; } // Scanners send many requests, so a visitor skipped by the time budget is re-evaluated on its next 404. foreach ( array_slice( array_keys( $visitors ), 0, 50 ) as $visitor ) { if ( microtime( true ) - $started > 4 ) break; $this->evaluate_404( $visitor ); } update_option( 'atk_incidents_cursor_404', $cursor, false ); delete_option( 'atk_incidents_404_error' ); } catch ( Throwable $error ) { update_option( 'atk_incidents_404_error', 1, false ); } } /** Most distinct probe paths (or plugin/theme folders) requested within any 10-minute window. */ private static function burst( $hits, $kind ) { $points = []; foreach ( $hits as $hit ) { $key = 'slug' === $kind ? $hit['kind']['slug'] : ( $hit['kind']['probe'] ? $hit['kind']['path'] : '' ); if ( '' !== $key ) $points[] = [ $hit['time'], $key ]; } usort( $points, static function( $a, $b ) { return $a[0] <=> $b[0]; } ); $best = 0; $start = 0; $counts = []; foreach ( $points as [ $time, $key ] ) { $counts[ $key ] = ( $counts[ $key ] ?? 0 ) + 1; while ( $time - $points[ $start ][0] > self::SCAN_WINDOW ) { $old = $points[ $start ][1]; if ( 0 === --$counts[ $old ] ) unset( $counts[ $old ] ); ++$start; } $best = max( $best, count( $counts ) ); } return $best; } /** Re-evaluates one visitor's recent 404s. The visitor hash rotates daily, so this is one incident per visitor per day. */ private function evaluate_404( $visitor ) { global $wpdb; $settings = self::settings(); $pro = self::pro(); $rows = $this->results( $wpdb->prepare( 'SELECT log_id, captured_at, target_url, device, user_id FROM %i WHERE visitor_hash = %s AND captured_at >= %s ORDER BY log_id ASC LIMIT 1000', ATKAI_404_Events::table(), $visitor, gmdate( 'Y-m-d H:i:s', time() - 26 * HOUR_IN_SECONDS ) ) ); $hits = []; $probes = []; $sensitive = []; $user = 0; foreach ( $rows as $row ) { $kind = self::classify_404( $row->target_url ); if ( ! $kind['probe'] && ! ( $pro && ! empty( $settings['enum_rule'] ) && $kind['slug'] ) ) continue; $hits[] = [ 'row' => $row, 'kind' => $kind, 'time' => strtotime( $row->captured_at . ' UTC' ) ]; if ( $kind['probe'] ) $probes[ $kind['path'] ] = true; if ( $kind['sensitive'] ) $sensitive[ $kind['path'] ] = true; if ( $row->user_id ) $user = (int) $row->user_id; } if ( ! $hits ) return; $matched = []; if ( $user && count( $probes ) >= 3 ) $matched['404-user'] = 2; if ( self::burst( $hits, 'probe' ) >= 10 ) $matched['404-scan'] = 1; if ( count( $sensitive ) >= 3 ) $matched['404-file'] = 1; if ( $pro && ! empty( $settings['enum_rule'] ) && self::burst( $hits, 'slug' ) >= 10 ) $matched['404-enum'] = 1; $logins = []; if ( $matched && $pro && ! empty( $settings['scan_login_rule'] ) ) { $logins = $this->results( $wpdb->prepare( "SELECT event_id, event_type, subject_id, actor_id, occurred_at FROM %i WHERE incident_only = 1 AND event_type IN ('login_failed','login') AND occurred_at >= %s AND context LIKE %s ORDER BY event_id ASC LIMIT 20", ATKAI_Security_Events::table(), gmdate( 'Y-m-d H:i:s', $hits[0]['time'] ), '%' . $wpdb->esc_like( '"visitor":"' . $visitor . '"' ) . '%' ) ); if ( $logins ) $matched['404-login'] = 2; } if ( ! $matched ) return; // Every public site is scanned daily; a scan that only got 404s found nothing. Count it for the // 404 Errors summary and open an incident only for High findings (signed-in probing, scan + sign-in). if ( max( $matched ) < 2 ) { self::note_scanner( $visitor ); return; } $table = self::table(); $family = current( array_intersect( self::FAMILIES_404, array_keys( $matched ) ) ); $severity = max( $matched ); $fingerprint = hash( 'sha256', get_current_blog_id() . '|404|' . $visitor ); $found = $this->results( $wpdb->prepare( 'SELECT * FROM %i WHERE fingerprint = %s', $table, $fingerprint ) ); $incident = $found[0] ?? null; if ( $incident && 'open' !== $incident->status ) return; // Resolved today; a new visitor hash starts tomorrow. $last_hit = end( $hits ); if ( ! $incident ) { // Busy sites are scanned constantly: cap new Needs-review scan incidents per day. High priority always gets through. $today = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE family LIKE %s AND first_at >= %s', $table, $wpdb->esc_like( '404-' ) . '%', gmdate( 'Y-m-d 00:00:00' ) ) ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); if ( $today >= self::SCAN_DAILY_LIMIT && $severity < 2 ) { set_transient( 'atk_incidents_404_capped', gmdate( 'Ymd' ), DAY_IN_SECONDS ); return; } $capacity = $wpdb->get_var( $wpdb->prepare( 'SELECT incident_id FROM %i ORDER BY incident_id ASC LIMIT 1 OFFSET %d', $table, self::INCIDENT_LIMIT - 1 ) ); if ( $wpdb->last_error || $capacity ) throw new RuntimeException( 'incident_capacity' ); $this->write( $wpdb->insert( $table, [ 'fingerprint' => $fingerprint, 'family' => $family, 'user_id' => $user, 'actor_id' => 0, 'severity' => $severity, 'first_at' => $hits[0]['row']->captured_at, 'last_at' => $last_hit['row']->captured_at, 'snapshot' => '[]', 'note' => '', 'notify_after' => gmdate( 'Y-m-d H:i:s' ) ] ) ); $found = $this->results( $wpdb->prepare( 'SELECT * FROM %i WHERE fingerprint = %s', $table, $fingerprint ) ); $incident = $found[0] ?? null; if ( ! $incident ) throw new RuntimeException( 'incident_write' ); } $steps = []; foreach ( json_decode( $incident->snapshot, true ) ?: [] as $step ) $steps[ ( $step['src'] ?? 'sec' ) . ':' . $step['id'] ] = $step; foreach ( $hits as $hit ) { $kind = $hit['kind']; $steps[ '404:' . $hit['row']->log_id ] = [ 'id' => (int) $hit['row']->log_id, 'src' => '404', 'type' => $kind['sensitive'] ? '404_sensitive' : ( $kind['probe'] ? '404_probe' : '404_enum' ), 'at' => $hit['row']->captured_at, 'subject' => (int) $hit['row']->user_id, 'actor' => 0, 'change' => '', 'path' => $kind['path'], 'device' => substr( (string) $hit['row']->device, 0, 60 ) ]; } $last_at = max( $incident->last_at, $last_hit['row']->captured_at ); foreach ( $logins as $login ) { $steps[ 'sec:' . $login->event_id ] = [ 'id' => (int) $login->event_id, 'src' => 'sec', 'type' => $login->event_type, 'at' => $login->occurred_at, 'subject' => (int) $login->subject_id, 'actor' => (int) $login->actor_id, 'change' => '' ]; $last_at = max( $last_at, $login->occurred_at ); // Linked like other account evidence, so privacy export/erasure and replay protection include it. $this->write( $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (incident_id, source_id, subject_id, actor_id) VALUES (%d, %d, %d, %d)', self::links(), $incident->incident_id, $login->event_id, $login->subject_id, $login->actor_id ) ) ); } uasort( $steps, static function( $a, $b ) { return [ $a['at'], $a['id'] ] <=> [ $b['at'], $b['id'] ]; } ); if ( count( $steps ) > 50 ) $steps = array_slice( $steps, 0, 25, true ) + array_slice( $steps, -25, null, true ); $linked = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE incident_id = %d', self::scans(), $incident->incident_id ) ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); $ids = array_slice( array_map( static function( $hit ) { return (int) $hit['row']->log_id; }, $hits ), 0, max( 0, 200 - $linked ) ); if ( $ids ) { $values = implode( ',', array_fill( 0, count( $ids ), '(%d,%d)' ) ); $args = [ self::scans() ]; foreach ( $ids as $id ) { $args[] = $incident->incident_id; $args[] = $id; } $this->write( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (incident_id, log_id) VALUES $values", $args ) ) ); } $severity = max( $severity, (int) $incident->severity ); $current = array_search( $incident->family, self::FAMILIES_404, true ); $notify = $settings['alerts'] && ( 2 === $severity || $settings['review_alerts'] ) ? $severity : 0; $update = [ 'severity' => $severity, 'family' => false !== $current && $current < array_search( $family, self::FAMILIES_404, true ) ? $incident->family : $family, 'user_id' => (int) $incident->user_id ?: $user, 'snapshot' => wp_json_encode( array_values( $steps ) ), 'event_count' => max( (int) $incident->event_count, count( $hits ) + count( $logins ) ), 'last_at' => $last_at, 'notify_level' => max( $notify, (int) $incident->notify_level ) ]; if ( $notify > (int) $incident->notify_level ) { $update['notify_attempts'] = 0; $update['notify_after'] = gmdate( 'Y-m-d H:i:s' ); } $this->write( $wpdb->update( $table, $update, [ 'incident_id' => $incident->incident_id ] ) ); delete_transient( 'atk_incidents_counts' ); } public static function note_scanner( $visitor ) { $day = gmdate( 'Ymd' ); $stats = (array) get_option( 'atk_incidents_scanners', [] ); if ( ( $stats['today'] ?? '' ) !== $day ) $stats['seen'] = []; $key = substr( (string) $visitor, 0, 16 ); if ( isset( $stats['seen'][ $key ] ) ) return; $stats['today'] = $day; if ( count( $stats['seen'] ?? [] ) < 2000 ) $stats['seen'][ $key ] = 1; $stats['days'][ $day ] = (int) ( $stats['days'][ $day ] ?? 0 ) + 1; krsort( $stats['days'] ); $stats['days'] = array_slice( $stats['days'], 0, 7, true ); update_option( 'atk_incidents_scanners', $stats, false ); } /** Scanners counted today and over the last 7 days (UTC). */ public static function scanner_summary() { $stats = (array) get_option( 'atk_incidents_scanners', [] ); $days = (array) ( $stats['days'] ?? [] ); $week = 0; foreach ( $days as $day => $count ) if ( (int) $day >= (int) gmdate( 'Ymd', time() - 6 * DAY_IN_SECONDS ) ) $week += (int) $count; return [ 'today' => (int) ( $days[ gmdate( 'Ymd' ) ] ?? 0 ), 'week' => $week ]; } /** Pro: a sign-in attempt from a visitor that scanned the site today opens a High priority incident. */ private function link_scan_to_login( $event ) { $settings = self::settings(); if ( ! self::pro() || empty( $settings['scan_rule'] ) || empty( $settings['scan_login_rule'] ) || ! self::ready_404() ) return; $context = json_decode( (string) ( $event->context ?? '' ), true ); $visitor = is_array( $context ) ? (string) ( $context['visitor'] ?? '' ) : ''; if ( ! preg_match( '/^[a-f0-9]{64}$/', $visitor ) ) return; global $wpdb; try { $this->evaluate_404( $visitor ); } catch ( Throwable $error ) { update_option( 'atk_incidents_404_error', 1, false ); } } private function save_finding( $incident, $family, $trigger, $evidence, $severity ) { global $wpdb; $table = self::table(); $links = self::links(); if ( ! $incident ) { $fingerprint = hash( 'sha256', get_current_blog_id() . '|' . $family . '|' . $trigger->event_id ); $found = $this->results( $wpdb->prepare( 'SELECT * FROM %i WHERE fingerprint = %s', $table, $fingerprint ) ); $incident = $found[0] ?? null; if ( ! $incident ) { $capacity = $wpdb->get_var( $wpdb->prepare( 'SELECT incident_id FROM %i ORDER BY incident_id ASC LIMIT 1 OFFSET %d', $table, self::INCIDENT_LIMIT - 1 ) ); if ( $wpdb->last_error || $capacity ) throw new RuntimeException( 'incident_capacity' ); $this->write( $wpdb->insert( $table, [ 'fingerprint' => $fingerprint, 'family' => $family, 'user_id' => $trigger->subject_id, 'actor_id' => $trigger->actor_id, 'severity' => $severity, 'first_at' => $evidence[0]->occurred_at, 'last_at' => $trigger->occurred_at, 'snapshot' => '[]', 'note' => '', 'notify_after' => gmdate( 'Y-m-d H:i:s' ) ] ) ); $found = $this->results( $wpdb->prepare( 'SELECT * FROM %i WHERE fingerprint = %s', $table, $fingerprint ) ); $incident = $found[0] ?? null; if ( ! $incident ) throw new RuntimeException( 'incident_write' ); } } if ( 'open' !== $incident->status ) return; $snapshot = json_decode( $incident->snapshot, true ) ?: []; $steps = []; foreach ( $snapshot as $step ) $steps[ $step['id'] ] = $step; $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE incident_id = %d', $links, $incident->incident_id ) ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); foreach ( $evidence as $event ) { $existing = $wpdb->get_var( $wpdb->prepare( 'SELECT source_id FROM %i WHERE incident_id = %d AND source_id = %d', $links, $incident->incident_id, $event->event_id ) ); if ( $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); if ( ! $existing ) { if ( $count >= 100 ) { // Keep the beginning and newest evidence; an old replay must not evict later facts. if ( $steps && (int) $event->event_id <= max( array_keys( $steps ) ) ) continue; $victim = $wpdb->get_var( $wpdb->prepare( 'SELECT source_id FROM %i WHERE incident_id = %d ORDER BY source_id ASC LIMIT 1 OFFSET 50', $links, $incident->incident_id ) ); if ( ! $victim || $wpdb->last_error ) throw new RuntimeException( 'incident_read' ); $this->write( $wpdb->delete( $links, [ 'incident_id' => $incident->incident_id, 'source_id' => $victim ] ) ); unset( $steps[ $victim ] ); --$count; } $this->write( $wpdb->insert( $links, [ 'incident_id' => $incident->incident_id, 'source_id' => $event->event_id, 'subject_id' => $event->subject_id, 'actor_id' => $event->actor_id ] ) ); ++$count; } $context = json_decode( $event->context, true ) ?: []; $change = $context['change'] ?? ''; if ( ! in_array( $change, [ 'plugin_activated', 'users_can_register', 'default_role', 'siteurl', 'home', 'admin_email' ], true ) ) $change = ''; $steps[ $event->event_id ] = [ 'id' => (int) $event->event_id, 'type' => $event->event_type, 'at' => $event->occurred_at, 'subject' => (int) $event->subject_id, 'actor' => (int) $event->actor_id, 'change' => $change ]; } uasort( $steps, static function( $a, $b ) { return [ $a['at'], $a['id'] ] <=> [ $b['at'], $b['id'] ]; } ); // Keep both the beginning and the latest facts, including the escalation, under a fixed bound. if ( count( $steps ) > 50 ) $steps = array_slice( $steps, 0, 25, true ) + array_slice( $steps, -25, null, true ); $severity = max( $severity, (int) $incident->severity ); $settings = self::settings(); $notify = $settings['alerts'] && ( 2 === $severity || 'grant' === $family || $settings['review_alerts'] ) ? $severity : 0; $update = [ 'severity' => $severity, 'snapshot' => wp_json_encode( array_values( $steps ) ), 'event_count' => $count, 'last_at' => max( $trigger->occurred_at, $incident->last_at ), 'notify_level' => max( $notify, (int) $incident->notify_level ) ]; if ( $notify > (int) $incident->notify_level ) { $update['notify_attempts'] = 0; $update['notify_after'] = gmdate( 'Y-m-d H:i:s' ); } $this->write( $wpdb->update( $table, $update, [ 'incident_id' => $incident->incident_id ] ) ); if ( 2 === $severity && 'admin_granted' === $trigger->event_type ) { $grant_key = hash( 'sha256', get_current_blog_id() . '|grant|' . $trigger->event_id ); $duplicates = $this->results( $wpdb->prepare( "SELECT incident_id FROM %i WHERE fingerprint = %s AND status = 'open' AND incident_id <> %d", $table, $grant_key, $incident->incident_id ) ); foreach ( $duplicates as $duplicate ) if ( ! self::remove( $duplicate->incident_id ) ) throw new RuntimeException( 'incident_write' ); } delete_transient( 'atk_incidents_counts' ); } public static function counts() { $counts = [ 'open' => 0, 'high' => 0, 'review' => 0, 'resolved' => 0 ]; if ( ! self::ready() || ! current_user_can( 'manage_options' ) ) return $counts; $cached = get_transient( 'atk_incidents_counts' ); if ( is_array( $cached ) ) return $cached; global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(status = 'open') AS open, SUM(status = 'open' AND severity = 2) AS high, SUM(status = 'open' AND severity = 1) AS review, SUM(status = 'resolved' AND last_at >= %s) AS resolved FROM %i", gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ), self::table() ), ARRAY_A ); if ( $wpdb->last_error ) return $counts; foreach ( $counts as $key => $value ) $counts[ $key ] = (int) ( $row[ $key ] ?? 0 ); set_transient( 'atk_incidents_counts', $counts, 60 ); return $counts; } /** Titles and explanations for 404 incident types, keyed by family. */ public static function scan_text( $family ) { $texts = [ '404-user' => [ __( 'Signed-in account probing for hidden files', 'activity-track' ), __( 'A signed-in account requested several files that do not exist, in places attackers commonly probe. This can mean the account is compromised or misused. Every request returned 404, so nothing was served.', 'activity-track' ) ], '404-login' => [ __( 'Scanner then tried to sign in', 'activity-track' ), __( 'A visitor that probed this site for vulnerable or hidden files also tried to sign in on the same day. Scanning followed by sign-in attempts is a common attack pattern. The match uses a daily anonymous visitor fingerprint, not a stored IP address.', 'activity-track' ) ], '404-scan' => [ __( 'Vulnerability scan', 'activity-track' ), __( 'One visitor requested 10 or more different script files that do not exist within 10 minutes. This is typical of automated vulnerability scanners. All requests returned 404, so nothing was found at those addresses.', 'activity-track' ) ], '404-enum' => [ __( 'Plugin and theme discovery scan', 'activity-track' ), __( 'One visitor requested files from 10 or more different plugin or theme folders within 10 minutes. Attackers do this to learn which plugins you run before trying known vulnerabilities.', 'activity-track' ) ], '404-file' => [ __( 'Probing for sensitive files', 'activity-track' ), __( 'One visitor requested several sensitive files, such as environment files, configuration backups or repository data. They returned 404, so they are not exposed at these addresses.', 'activity-track' ) ], ]; return $texts[ $family ] ?? $texts['404-scan']; } /** Shared timeline labels (admin dialog, AI payload). */ public static function step_label( $step ) { $labels = [ 'login_failed' => __( 'Failed sign-in', 'activity-track' ), 'login' => __( 'Successful sign-in', 'activity-track' ), 'admin_granted' => __( 'Administrator role granted', 'activity-track' ), 'sensitive_change' => __( 'Sensitive setting changed', 'activity-track' ), '404_sensitive' => __( 'Sensitive file requested', 'activity-track' ), '404_probe' => __( 'Script file probed', 'activity-track' ), '404_enum' => __( 'Plugin or theme folder probed', 'activity-track' ), 'exposed_file' => __( 'Publicly accessible file', 'activity-track' ) ]; $changes = [ 'plugin_activated' => __( 'Plugin activated', 'activity-track' ), 'users_can_register' => __( 'Registration setting changed', 'activity-track' ), 'default_role' => __( 'Default user role changed', 'activity-track' ), 'siteurl' => __( 'WordPress address changed', 'activity-track' ), 'home' => __( 'Site address changed', 'activity-track' ), 'admin_email' => __( 'Administrator email changed', 'activity-track' ) ]; return $changes[ $step['change'] ?? '' ] ?? ( $labels[ $step['type'] ?? '' ] ?? __( 'Recorded activity', 'activity-track' ) ); } /** Structured, privacy-reduced description of one incident (used by Pro AI explanations). */ public static function describe( $id ) { global $wpdb; if ( ! self::ready() ) return new WP_Error( 'unavailable', __( 'Incident storage is unavailable.', 'activity-track' ) ); $incident = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE incident_id = %d', self::table(), $id ) ); if ( ! $incident ) return new WP_Error( 'missing', __( 'This incident is no longer available. Refresh the list.', 'activity-track' ) ); $steps = []; foreach ( json_decode( $incident->snapshot, true ) ?: [] as $step ) { $steps[] = array_filter( [ 'time_utc' => $step['at'] ?? '', 'event' => self::step_label( $step ), 'path' => $step['path'] ?? '', 'client' => $step['device'] ?? '', 'account' => ! empty( $step['subject'] ) ? 'User ID ' . (int) $step['subject'] : '', 'acting_user' => ! empty( $step['actor'] ) ? 'User ID ' . (int) $step['actor'] : '' ] ); } return [ 'activity' => 'Security incident', 'type' => self::title( $incident ), 'description' => wp_json_encode( [ 'priority' => 2 === (int) $incident->severity ? 'High priority' : 'Needs review', 'status' => $incident->status, 'why_flagged' => self::reason( $incident ), 'first_seen_utc' => $incident->first_at, 'last_seen_utc' => $incident->last_at, 'linked_records' => (int) $incident->event_count, 'timeline' => array_slice( $steps, -30 ), 'limitations' => 'Detection is rule-based and monitoring only; nothing was blocked. 404 requests returned "not found". Request headers can be spoofed; query values are redacted; visitors are grouped by a daily anonymous fingerprint, not by stored IP addresses. Timeline may be truncated to the first and latest steps.', ] ), 'reference' => '', 'context' => [ 'log_id' => 'incident:' . (int) $incident->incident_id, 'actor' => $incident->user_id ? 'User ID ' . (int) $incident->user_id : ( self::is_404( $incident ) ? 'Anonymous visitor' : 'Not recorded' ), 'occurred_at' => get_date_from_gmt( $incident->last_at, 'Y-m-d H:i:s' ), 'timezone' => wp_timezone_string() ], ]; } public static function title( $incident ) { if ( 'exposure' === ( $incident->family ?? '' ) ) return __( 'Sensitive file publicly accessible', 'activity-track' ); if ( self::is_404( $incident ) ) return self::scan_text( $incident->family )[0]; if ( 2 === (int) $incident->severity ) return __( 'Sensitive change after a flagged sign-in', 'activity-track' ); return 'grant' === $incident->family ? __( 'Administrator access granted', 'activity-track' ) : __( 'Successful sign-in after repeated failed attempts', 'activity-track' ); } public static function reason( $incident ) { if ( 'exposure' === ( $incident->family ?? '' ) ) return __( 'Your site served a file that should never be public. Anyone can download it, and it may contain database passwords, API keys or other private data. This is exactly what the scanners in 404 Errors are looking for.', 'activity-track' ); if ( self::is_404( $incident ) ) return self::scan_text( $incident->family )[1]; if ( 2 === (int) $incident->severity ) return __( 'The same account performed a sensitive change within an hour of a sign-in flagged for repeated failures. This account-level link does not confirm the same person performed both actions.', 'activity-track' ); return 'grant' === $incident->family ? __( 'The administrator role was assigned. This can be expected maintenance; confirm the account and acting user.', 'activity-track' ) : __( 'At least five failed sign-ins targeted this account within fifteen minutes, followed by a successful sign-in within thirty minutes. This does not prove a password was guessed.', 'activity-track' ); } public function deliver() { if ( ! self::enabled() || ! self::settings()['alerts'] || ! is_email( self::settings()['recipient'] ) ) return; global $wpdb; $previous = $wpdb->suppress_errors( true ); $locked = false; try { $locked = '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $this->lock( 'mail' ) ) ); if ( ! $locked ) return; $rows = $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE status = 'open' AND notify_level > notified_level AND notify_attempts < 5 AND notify_after <= %s ORDER BY severity DESC, incident_id ASC LIMIT 3", self::table(), gmdate( 'Y-m-d H:i:s' ) ) ); $rate = (array) get_option( 'atk_incidents_mail_rate', [] ); if ( ( $rate['hour'] ?? '' ) !== gmdate( 'YmdH' ) ) $rate = [ 'hour' => gmdate( 'YmdH' ), 'count' => 0 ]; foreach ( $rows as $incident ) { if ( $rate['count'] >= 10 ) break; // Reserve the attempt before sending. An interrupted/ambiguous send may retry, never loop unboundedly. $attempt = (int) $incident->notify_attempts + 1; $this->write( $wpdb->update( self::table(), [ 'notify_attempts' => $attempt, 'notify_after' => gmdate( 'Y-m-d H:i:s', time() + 300 * ( 2 ** $attempt ) ) ], [ 'incident_id' => $incident->incident_id ] ) ); ++$rate['count']; update_option( 'atk_incidents_mail_rate', $rate, false ); $url = add_query_arg( [ 'page' => 'activity-track-ai', 'tab' => 'incidents', 'incident' => $incident->incident_id ], admin_url( 'admin.php' ) ); $message = self::reason( $incident ) . "\n\n" . ( self::is_404( $incident ) ? __( 'Review the requests and the account involved before making changes.', 'activity-track' ) : __( 'Review the account activity before making changes.', 'activity-track' ) ) . "\n" . $url; if ( wp_mail( self::settings()['recipient'], '[Activity Track] ' . self::title( $incident ), $message ) ) { $this->write( $wpdb->update( self::table(), [ 'notified_level' => $incident->notify_level ], [ 'incident_id' => $incident->incident_id ] ) ); } } } catch ( Throwable $error ) { update_option( 'atk_incidents_error', 'mail', false ); } finally { if ( $locked ) $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->lock( 'mail' ) ) ); $wpdb->suppress_errors( $previous ); } } private function trim_events( $cursor, $deadline = null ) { global $wpdb; $cutoff = time() - ( get_option( 'atk_incidents_storage_limited' ) ? 2 * HOUR_IN_SECONDS : 30 * DAY_IN_SECONDS ); $rows = $this->results( $wpdb->prepare( 'SELECT event_id, event_type FROM %i WHERE incident_only = 1 AND (occurred_at < %s OR (event_id <= %d AND occurred_at < %s)) ORDER BY event_id ASC LIMIT 500', ATKAI_Security_Events::table(), gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ), max( 0, $cursor - 50 ), gmdate( 'Y-m-d H:i:s', $cutoff ) ) ); $released = [ 'login' => 0, 'grant' => 0 ]; foreach ( $rows as $row ) { if ( $deadline && microtime( true ) >= $deadline ) break; $deleted = $wpdb->delete( ATKAI_Security_Events::table(), [ 'event_id' => $row->event_id, 'incident_only' => 1 ] ); $this->write( $deleted ); $released[ 'admin_granted' === $row->event_type ? 'grant' : 'login' ] += (int) $deleted; } foreach ( $released as $pool => $count ) { if ( $count ) $this->write( $this->release_capacity( $pool, $count ) ); } if ( array_sum( $released ) ) delete_option( 'atk_incidents_storage_limited' ); } public function cleanup() { if ( ! self::ready() ) return; global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT incident_id FROM %i WHERE status = 'resolved' AND expires_at < %s ORDER BY expires_at ASC LIMIT 100", self::table(), gmdate( 'Y-m-d H:i:s' ) ) ); foreach ( $ids as $id ) self::remove( $id ); // Scans with no new activity for 7 days resolve themselves; High priority always waits for a person. $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'resolved', resolution = 'auto', expires_at = %s WHERE status = 'open' AND severity = 1 AND family LIKE %s AND last_at < %s", self::table(), gmdate( 'Y-m-d H:i:s', time() + self::retention_days() * DAY_IN_SECONDS ), $wpdb->esc_like( '404-' ) . '%', gmdate( 'Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS ) ) ); try { $this->trim_events( absint( get_option( 'atk_incidents_cursor', 0 ) ) ); } catch ( Throwable $error ) { update_option( 'atk_incidents_error', 'storage', false ); } delete_transient( 'atk_incidents_counts' ); } /** Days a resolved incident is kept (Pro may extend). */ public static function retention_days() { return max( 1, (int) apply_filters( 'atkai_incident_retention_days', 30 ) ); } public static function remove( $id ) { global $wpdb; if ( false === $wpdb->delete( self::links(), [ 'incident_id' => $id ] ) ) return false; if ( self::VERSION_404 === get_option( 'atk_incidents_404_db_version' ) && false === $wpdb->delete( self::scans(), [ 'incident_id' => $id ] ) ) return false; return false !== $wpdb->delete( self::table(), [ 'incident_id' => $id ] ); } public function erasers( $erasers ) { $erasers['activity-track-incidents'] = [ 'eraser_friendly_name' => __( 'Activity Track incidents', 'activity-track' ), 'callback' => [ $this, 'erase' ] ]; return $erasers; } public function exporters( $exporters ) { $exporters['activity-track-incidents'] = [ 'exporter_friendly_name' => __( 'Activity Track incidents', 'activity-track' ), 'callback' => [ $this, 'export_personal_data' ] ]; return $exporters; } public function export_personal_data( $email, $page = 1 ) { $user = get_user_by( 'email', $email ); if ( ! $user || ! self::ready() ) return [ 'data' => [], 'done' => true ]; global $wpdb; $rows = $this->results( $wpdb->prepare( "SELECT * FROM %i WHERE resolved_by = %d OR (family LIKE %s AND user_id = %d) OR incident_id IN (SELECT incident_id FROM %i WHERE subject_id = %d OR actor_id = %d) ORDER BY incident_id ASC LIMIT 100 OFFSET %d", self::table(), $user->ID, $wpdb->esc_like( '404-' ) . '%', $user->ID, self::links(), $user->ID, $user->ID, ( max( 1, absint( $page ) ) - 1 ) * 100 ) ); $data = []; foreach ( $rows as $row ) $data[] = [ 'group_id' => 'activity-track-incidents', 'group_label' => __( 'Activity Track incidents', 'activity-track' ), 'item_id' => 'atk-incident-' . $row->incident_id, 'data' => [ [ 'name' => __( 'Incident', 'activity-track' ), 'value' => self::title( $row ) ], [ 'name' => __( 'Status', 'activity-track' ), 'value' => $row->status ], [ 'name' => __( 'Recorded facts', 'activity-track' ), 'value' => $row->snapshot ], [ 'name' => __( 'Resolution note', 'activity-track' ), 'value' => $row->note ], ], ]; $sources = $this->results( $wpdb->prepare( 'SELECT event_id, event_type, occurred_at, actor_id, subject_id FROM %i WHERE incident_only = 1 AND (subject_id = %d OR actor_id = %d) ORDER BY event_id ASC LIMIT 100 OFFSET %d', ATKAI_Security_Events::table(), $user->ID, $user->ID, ( max( 1, absint( $page ) ) - 1 ) * 100 ) ); foreach ( $sources as $source ) $data[] = [ 'group_id' => 'activity-track-incident-sources', 'group_label' => __( 'Activity Track incident source records', 'activity-track' ), 'item_id' => 'atk-source-' . $source->event_id, 'data' => [ [ 'name' => __( 'Recorded facts', 'activity-track' ), 'value' => wp_json_encode( $source ) ] ] ]; return [ 'data' => $data, 'done' => count( $rows ) < 100 && count( $sources ) < 100 ]; } public function erase( $email, $page = 1 ) { $result = [ 'items_removed' => false, 'items_retained' => false, 'messages' => [], 'done' => true ]; $user = get_user_by( 'email', $email ); if ( ! $user || ! self::ready() ) return $result; global $wpdb; $previous = $wpdb->suppress_errors( true ); $locked = false; try { $locked = '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $this->lock( 'engine' ) ) ); if ( ! $locked ) throw new RuntimeException( 'incident_lock' ); $erasing = array_unique( array_merge( (array) get_option( 'atk_incidents_erasing', [] ), [ (int) $user->ID ] ) ); update_option( 'atk_incidents_erasing', $erasing, false ); $ids = $this->results( $wpdb->prepare( "SELECT incident_id FROM %i WHERE resolved_by = %d OR (family LIKE %s AND user_id = %d) OR incident_id IN (SELECT incident_id FROM %i WHERE subject_id = %d OR actor_id = %d) LIMIT 100", self::table(), $user->ID, $wpdb->esc_like( '404-' ) . '%', $user->ID, self::links(), $user->ID, $user->ID ) ); foreach ( $ids as $row ) { if ( ! self::remove( $row->incident_id ) ) throw new RuntimeException( 'incident_write' ); $result['items_removed'] = true; } $sources = $this->results( $wpdb->prepare( 'SELECT event_id, event_type FROM %i WHERE incident_only = 1 AND (subject_id = %d OR actor_id = %d) ORDER BY event_id ASC LIMIT 100', ATKAI_Security_Events::table(), $user->ID, $user->ID ) ); foreach ( $sources as $source ) { $deleted = $wpdb->delete( ATKAI_Security_Events::table(), [ 'event_id' => $source->event_id, 'incident_only' => 1 ] ); $this->write( $deleted ); if ( $deleted ) { $this->write( $this->release_capacity( 'admin_granted' === $source->event_type ? 'grant' : 'login', 1 ) ); $result['items_removed'] = true; } } $audit = (array) get_option( 'atk_incidents_settings_audit', [] ); foreach ( $audit as &$entry ) if ( (int) $entry['actor'] === (int) $user->ID ) $entry['actor'] = 0; unset( $entry ); update_option( 'atk_incidents_settings_audit', $audit, false ); $result['done'] = count( $ids ) < 100 && count( $sources ) < 100; if ( $result['done'] ) update_option( 'atk_incidents_erasing', array_values( array_diff( $erasing, [ (int) $user->ID ] ) ), false ); delete_transient( 'atk_incidents_counts' ); } catch ( Throwable $error ) { $result['items_retained'] = true; $result['done'] = false; $result['messages'][] = __( 'Incident evidence could not be fully erased. Please retry when monitoring storage is available.', 'activity-track' ); } finally { if ( $locked ) $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->lock( 'engine' ) ) ); $wpdb->suppress_errors( $previous ); } return $result; } }