$event The stored row as the action carries it. */ public static function on_event_recorded( array $event ): void { if ( ! self::is_enabled() ) { return; } $alert = self::classify( $event ); if ( null === $alert ) { return; } if ( ! self::rate_limit_allows( $alert['type'] ) ) { return; } self::send( $alert ); } /** * The five decided alerts, as (type, subject, body) or null. Pure * apart from the failed-login counter, which is this class's own * state and unit-tested through the same seams. * * @param array $event * @return array{type:string,subject:string,body:string}|null */ public static function classify( array $event ): ?array { $event_type = isset( $event['event_type'] ) ? (string) $event['event_type'] : ''; $summary = isset( $event['summary'] ) ? (string) $event['summary'] : ''; if ( in_array( $event_type, self::SIMPLE_ALERT_TYPES, true ) ) { return self::alert( $event_type, $summary, $event ); } if ( 'user_created' === $event_type ) { $user_id = isset( $event['user_id'] ) ? (int) $event['user_id'] : 0; $user = $user_id > 0 ? get_userdata( $user_id ) : false; if ( $user && array_key_exists( 'administrator', (array) $user->caps ) ) { return self::alert( 'administrator_created', $summary, $event ); } return null; } if ( 'user_role_changed' === $event_type ) { $details = self::details_of( $event ); $new_role = isset( $details['new_role'] ) ? (string) $details['new_role'] : ''; if ( 'administrator' === $new_role ) { return self::alert( 'administrator_role_granted', $summary, $event ); } return null; } if ( 'login_failed' === $event_type ) { return self::classify_login_burst( $event ); } return null; } /** * The burst counter: failures are counted per source IP inside a * rolling window kept in a transient (the list of timestamps, pruned * on every append). The alert fires on the exact failure that crosses * the threshold -- and only on it, until the window has emptied. * * @param array $event * @return array{type:string,subject:string,body:string}|null */ private static function classify_login_burst( array $event ): ?array { $ip = isset( $event['ip_address'] ) && is_string( $event['ip_address'] ) && '' !== $event['ip_address'] ? $event['ip_address'] : 'unknown'; $key = self::BURST_TRANSIENT_PREFIX . md5( $ip ); $now = time(); $window = (array) get_transient( $key ); $window = array_values( array_filter( $window, static function ( $timestamp ) use ( $now ): bool { return is_int( $timestamp ) && $timestamp > $now - self::BURST_WINDOW_SECONDS; } ) ); $window[] = $now; set_transient( $key, $window, self::BURST_WINDOW_SECONDS ); if ( count( $window ) !== self::BURST_THRESHOLD ) { return null; } $username = ''; $details = self::details_of( $event ); if ( isset( $details['attempted_login'] ) ) { $username = (string) $details['attempted_login']; } return array( 'type' => 'login_burst', /* translators: %d: number of failed attempts in the window. */ 'subject' => sprintf( __( '[%s] Burst of failed logins', '404ops-activity-log' ), get_bloginfo( 'name' ) ), 'body' => sprintf( /* translators: 1: site name, 2: attempt count, 3: source IP, 4: minutes of the window, 5: last attempted username. */ __( "A burst of failed logins was just recorded:\n\nSite: %1\$s\nAttempts: %2\$d in the last %3\$d minutes\nSource IP: %4\$s\nLast attempted username: %5\$s\n\nThe audit log's Log tab has every attempt.", '404ops-activity-log' ), get_bloginfo( 'name' ), self::BURST_THRESHOLD, (int) ( self::BURST_WINDOW_SECONDS / MINUTE_IN_SECONDS ), $ip, '' !== $username ? $username : __( '(unknown)', '404ops-activity-log' ) ), ); } /** * @param string $type Internal alert type (rate-limit key). * @param string $summary The audit log's own one-line summary. * @param array $event * @return array{type:string,subject:string,body:string} */ private static function alert( string $type, string $summary, array $event ): array { $when = isset( $event['timestamp'] ) && is_string( $event['timestamp'] ) ? $event['timestamp'] : ''; return array( 'type' => $type, 'subject' => sprintf( '[%s] %s', get_bloginfo( 'name' ), $summary ), 'body' => sprintf( /* translators: 1: the event's one-line summary, 2: site name, 3: the admin screen URL. */ __( "A high-risk event was just recorded:\n\n%1\$s\n\nSite: %2\$s\nSee the full log: %3\$s\n\nYou are receiving this because email alerts are enabled in 404ops Activity Log's settings.", '404ops-activity-log' ), $summary, get_bloginfo( 'name' ), admin_url( 'admin.php?page=404ops-activity-log' ) ) . ( '' !== $when ? "\n\nRecorded at: " . $when : '' ), ); } private static function rate_limit_allows( string $type ): bool { $key = self::RATE_TRANSIENT_PREFIX . $type; if ( false !== get_transient( $key ) ) { return false; } set_transient( $key, 1, self::RATE_LIMIT_SECONDS ); return true; } /** * @param array{type:string,subject:string,body:string} $alert */ private static function send( array $alert ): bool { $to = get_option( 'admin_email' ); return is_string( $to ) && '' !== $to ? (bool) wp_mail( $to, $alert['subject'], $alert['body'] ) : false; } /** * An event's details as an array. * * EventLogger hands the recorded ROW to on_event_recorded(), where * `details` is the stored JSON string -- not an array. Reading it only as * an array meant the "role raised to administrator" alert never fired on * a real site and the failed-login mail always said "(unknown)"; the * tests passed arrays and never saw the real shape. Accept both. * * @param array $event The recorded event. * @return array */ private static function details_of( array $event ): array { $details = $event['details'] ?? null; if ( is_string( $details ) && '' !== $details ) { $details = json_decode( $details, true ); } return is_array( $details ) ? $details : array(); } }