: ", then the level on its own line, * then the message. A message in any other shape still gets a title: its first line. * * @param string $body Message text as posted. * @return array{title:string,source:string,detail:string} */ public static function parse( string $body ): array { $lines = array(); foreach ( (array) preg_split( '/\R/u', $body ) as $line ) { $line = trim( (string) $line ); if ( '' !== $line ) { $lines[] = $line; } } $source = ''; if ( isset( $lines[1] ) && preg_match( '/^(CRITICAL|ERROR|WARNING|INFO|SUCCESS)$/', $lines[1] ) ) { $at = strrpos( $lines[0], ' : ' ); if ( false !== $at ) { $source = trim( substr( $lines[0], $at + 3 ) ); } $lines = array_slice( $lines, 2 ); } $title = (string) preg_replace( '/^CSDT:\s*/', '', (string) array_shift( $lines ) ); $title = trim( (string) preg_replace( '/\s+/', ' ', $title ) ); if ( strlen( $title ) > self::TITLE_MAX ) { $title = substr( $title, 0, self::TITLE_MAX ) . '...'; } return array( 'title' => $title, 'source' => $source, 'detail' => implode( ' ', $lines ), ); } /** * The level of a message, from the words the sender put in it. * * @param string $body Message text. * @param string $fallback Level the caller already worked out. * @return string critical|error|warning|info */ public static function level( string $body, string $fallback = 'info' ): string { foreach ( array( 'CRITICAL' => 'critical', 'ERROR' => 'error', 'WARNING' => 'warning', 'INFO' => 'info', 'SUCCESS' => 'info' ) as $word => $level ) { if ( preg_match( '/^' . $word . '$/m', $body ) ) { return $level; } } return in_array( $fallback, array( 'critical', 'error', 'warning', 'info' ), true ) ? $fallback : 'info'; } /** * Sends an alert to Telegram and lists it. The only way this plugin sends one. * * @param string $kind self::SECURITY or self::GENERAL. Required: see the file header. * @param string $text Message; its first line is the row's title. * @param string $source Sender label shown in Telegram. * @param string $level critical|error|high|warning|info. * @param bool $request_is_subject Whether the request in flight is this alert's subject. * Defaults true. Pass false when the alert reports an event the current request only * carried (an Edge Gateway batch), so the origin block does not name the courier. * @return bool What CSDT_Telegram::send() answered; false when there is no Telegram. */ public static function send( string $kind, string $text, string $source = '', string $level = 'info', bool $request_is_subject = true ): bool { $part = self::parse( $text ); self::note( $kind, $part['title'], ( '' !== $source ? 'From ' . $source . '. ' : '' ) . $part['detail'], self::level( $text, 'high' === $level ? 'error' : $level ) ); if ( ! class_exists( 'CSDT_Telegram' ) ) { return false; } $before = self::$declared; self::$declared = self::kind( $kind ); try { return (bool) CSDT_Telegram::send( $text, $source, $level, $request_is_subject ); } finally { self::$declared = $before; } } /** * A kind, or GENERAL for anything that is not one. Never promotes to SECURITY. * * @param mixed $kind Declared kind. * @return string */ public static function kind( $kind ): string { return self::SECURITY === $kind ? self::SECURITY : self::GENERAL; } /** * Whether a stored event belongs under Recent Security Events. * * @param mixed $event Stored event. * @return bool */ public static function is_security_event( $event ): bool { return is_array( $event ) && self::TYPE_GENERAL !== ( $event['type'] ?? '' ); } /** * Records one alert as a dashboard event. * * @param string $kind self::SECURITY or self::GENERAL. * @param string $title What the alert is, one line. * @param string $detail The rest of it. * @param string $level critical|error|warning|info. * @return bool Whether an event was written. False when it is a repeat. */ public static function note( string $kind, string $title, string $detail, string $level = 'warning' ): bool { $title = trim( (string) preg_replace( '/^CSDT:\s*/', '', $title ) ); if ( '' === $title || ! is_callable( array( 'CSDT_Login', 'record_security_event' ) ) ) { return false; } $type = self::SECURITY === self::kind( $kind ) ? self::TYPE_SECURITY : self::TYPE_GENERAL; $since = time() - self::SAME_ALERT_WINDOW; foreach ( self::events() as $ev ) { if ( in_array( $ev['type'] ?? '', array( self::TYPE_SECURITY, self::TYPE_GENERAL ), true ) && $title === ( $ev['title'] ?? '' ) && (int) ( $ev['time'] ?? 0 ) > $since ) { return false; } } $detail = trim( (string) preg_replace( '/\s+/', ' ', $detail ) ); if ( strlen( $detail ) > self::DETAIL_MAX ) { $detail = substr( $detail, 0, self::DETAIL_MAX ) . '...'; } CSDT_Login::record_security_event( $type, $title, $detail, $level ); return true; } /** * A Telegram message was posted: put it on the dashboard unless it is already there. * * Already there means an event written in the last two minutes whose title appears in * the message: the alert send() or send_threat_alert() just recorded, or the login event * whose notification this message is. What is left is a message from another plugin, * which declared no kind and is therefore GENERAL. * * @param mixed $body Message text. * @param mixed $level Level the alert log read from it. * @return void */ public static function on_captured( $body, $level = 'info' ): void { try { $body = (string) $body; $part = self::parse( $body ); if ( '' === $part['title'] ) { return; } $since = time() - self::ECHO_WINDOW; foreach ( self::events() as $ev ) { $seen = (string) ( $ev['title'] ?? '' ); if ( (int) ( $ev['time'] ?? 0 ) > $since && strlen( $seen ) >= 8 && false !== strpos( $body, $seen ) ) { return; } } $detail = ( '' !== $part['source'] ? 'From ' . $part['source'] . '. ' : '' ) . $part['detail']; self::note( self::$declared ?? self::GENERAL, $part['title'], $detail, self::level( $body, (string) $level ) ); } catch ( \Throwable $e ) { // A list on the dashboard must never be able to break the alert it lists. return; } } /** * Stored events, oldest first. * * @return array> */ private static function events(): array { $events = get_option( 'csdt_security_events', array() ); return is_array( $events ) ? $events : array(); } /** * Colours and mark for an alert row, by level. One place, so the dashboard widget and * the home screen cannot draw the same alert two ways. * * @param string $level Stored level; anything unknown is drawn as a warning. * @return array{icon:string,bg:string,border:string,tx:string} */ public static function row_style( string $level ): array { if ( in_array( $level, array( 'critical', 'error' ), true ) ) { return array( 'icon' => '🔔', 'bg' => '#fef2f2', 'border' => '#fca5a5', 'tx' => '#991b1b', ); } if ( 'info' === $level ) { return array( 'icon' => '🔔', 'bg' => '#f0f9ff', 'border' => '#bae6fd', 'tx' => '#075985', ); } return array( 'icon' => '🔔', 'bg' => '#fff7ed', 'border' => '#fdba74', 'tx' => '#9a3412', ); } /** * The SECURITY events a short list should show: newest first, with alerts guaranteed a place. * * A plain "newest N" let the firewall's probe blocks, which arrive by the dozen, push * every alert off the list within hours. * * @param array $events Stored events, oldest first. * @param int $limit Rows wanted. * @param int $since Oldest timestamp shown. * @return array> */ public static function shortlist( array $events, int $limit, int $since ): array { $alerts = array(); $others = array(); $titles = array(); foreach ( array_reverse( $events ) as $ev ) { if ( ! is_array( $ev ) || (int) ( $ev['time'] ?? 0 ) < $since ) { continue; } if ( ! self::is_security_event( $ev ) ) { continue; } if ( self::TYPE_SECURITY === ( $ev['type'] ?? '' ) ) { $t = (string) ( $ev['title'] ?? '' ); if ( ! isset( $titles[ $t ] ) ) { $titles[ $t ] = true; $alerts[] = $ev; } continue; } $others[] = $ev; } $keep = (int) ceil( $limit / 2 ); $alerts = array_slice( $alerts, 0, max( $keep, $limit - count( $others ) ) ); $out = array_merge( $alerts, array_slice( $others, 0, max( 0, $limit - count( $alerts ) ) ) ); usort( $out, static fn( $a, $b ) => (int) ( $b['time'] ?? 0 ) <=> (int) ( $a['time'] ?? 0 ) ); return $out; } /** * The GENERAL alerts a short list should show: newest first, one row per title. * * @param array $events Stored events, oldest first. * @param int $limit Rows wanted. * @param int $since Oldest timestamp shown. * @return array> */ public static function general( array $events, int $limit, int $since ): array { $out = array(); $titles = array(); foreach ( array_reverse( $events ) as $ev ) { if ( ! is_array( $ev ) || self::TYPE_GENERAL !== ( $ev['type'] ?? '' ) || (int) ( $ev['time'] ?? 0 ) < $since ) { continue; } $t = (string) ( $ev['title'] ?? '' ); if ( isset( $titles[ $t ] ) ) { continue; } $titles[ $t ] = true; $out[] = $ev; if ( count( $out ) >= $limit ) { break; } } return $out; } /** * Prints the "Other Alerts" list, or nothing when there are none. Used by the dashboard * widget and the home screen, so the two cannot disagree. * * @param int $limit Rows shown. * @param string $href Where a row leads: the alert log. * @param string $class Extra class on the wrapper, for the caller's margins. * @param bool $collapsible Start folded behind a Show / Hide button, as the dashboard * widget's Recent Security Events list does. * @return void */ public static function render_general( int $limit, string $href, string $class = '', bool $collapsible = false ): void { $rows = self::general( self::events(), $limit, time() - ( 4 * WEEK_IN_SECONDS ) ); if ( empty( $rows ) ) { return; } // "New since you last clicked Show", per admin user rather than per browser, the same // rule and the same handler as Recent Security Events, under its own marker so opening // one list does not silence the other. $latest = $collapsible ? (int) max( array_map( 'intval', array_column( $rows, 'time' ) ) ?: array( 0 ) ) : 0; $unseen = $latest > 0 && $latest > (int) get_user_meta( get_current_user_id(), self::SEEN_META, true ); $nonce = $collapsible ? wp_create_nonce( 'csdt_devtools_events_seen' ) : ''; ?>
🔔