key => label. */ public static function events(): array { return array( 'critical' => __( 'Critical alerts', 'cloudscale-cyber-devtools' ), 'autoblock' => __( 'Auto-blocks', 'cloudscale-cyber-devtools' ), 'new_admin' => __( 'New administrator', 'cloudscale-cyber-devtools' ), 'vuln' => __( 'Vulnerability found', 'cloudscale-cyber-devtools' ), 'weekly' => __( 'Weekly report', 'cloudscale-cyber-devtools' ), 'test' => __( 'Test', 'cloudscale-cyber-devtools' ), ); } /** Registers the listener on the alert choke point. */ public static function init(): void { add_action( 'csdt_alert_captured', array( __CLASS__, 'on_alert' ), 10, 2 ); } // ── Settings ───────────────────────────────────────────────────────── /** The stored URL, or '' when none. */ public static function url(): string { return trim( (string) CSDT_Options::get( self::OPTION_URL ) ); } /** Whether a URL is set. Off by default: nothing is sent until one is saved. */ public static function is_configured(): bool { return '' !== self::url(); } /** The stored format, validated. */ public static function format(): string { return 'json' === (string) CSDT_Options::get( self::OPTION_FORMAT ) ? 'json' : 'slack'; } /** * The subscribed events. * * @return array */ public static function enabled_events(): array { return self::parse_events( (string) CSDT_Options::get( self::OPTION_EVENTS ) ); } /** * Parses a stored or posted event list, keeping only known keys ('test' is never stored). * * @param string $raw Comma-separated keys. * @return array */ public static function parse_events( string $raw ): array { $known = array_keys( self::events() ); $out = array(); foreach ( explode( ',', $raw ) as $key ) { $key = strtolower( trim( $key ) ); if ( '' !== $key && 'test' !== $key && in_array( $key, $known, true ) && ! in_array( $key, $out, true ) ) { $out[] = $key; } } return $out; } /** * Why a URL cannot be used, or '' when it can. * * The cheap, deterministic checks run first, so the obvious refusals never cost a DNS * lookup and are testable without a network: https only, no credentials in the URL, no * names that only mean something inside a network, no literal private or reserved * addresses. Then WordPress's own wp_http_validate_url(), which resolves the host and * refuses a private address. Then, where it is loaded, the plugin's existing SSRF guard, * which checks EVERY address the host resolves to, IPv6 included. The send itself goes * through wp_safe_remote_post(), which repeats WordPress's check at request time. * * @param string $url Candidate URL. * @return string Empty when acceptable. */ public static function validate_url( string $url ): string { $url = trim( $url ); if ( '' === $url ) { return __( 'Enter a URL.', 'cloudscale-cyber-devtools' ); } if ( strlen( $url ) > 2048 ) { return __( 'That URL is too long.', 'cloudscale-cyber-devtools' ); } if ( preg_match( '/[\s\x00-\x1f\x7f]/', $url ) ) { return __( 'The URL contains spaces or control characters.', 'cloudscale-cyber-devtools' ); } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) ) { return __( 'The URL must start with https://', 'cloudscale-cyber-devtools' ); } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return __( 'Put credentials in the shared secret, not in the URL.', 'cloudscale-cyber-devtools' ); } $host = strtolower( trim( (string) ( $parts['host'] ?? '' ), '[]' ) ); if ( '' === $host ) { return __( 'The URL has no host name.', 'cloudscale-cyber-devtools' ); } if ( ! self::host_is_public( $host ) ) { return __( 'That host is internal to a network. The webhook must be a public HTTPS address.', 'cloudscale-cyber-devtools' ); } if ( function_exists( 'wp_http_validate_url' ) && ! wp_http_validate_url( $url ) ) { return __( 'WordPress refused that URL: it does not resolve, or it resolves to a private address.', 'cloudscale-cyber-devtools' ); } if ( is_callable( array( 'CSDT_Thumbnails', 'is_safe_external_url' ) ) && ! CSDT_Thumbnails::is_safe_external_url( $url ) ) { return __( 'That host resolves to a private or reserved address.', 'cloudscale-cyber-devtools' ); } return ''; } /** * Whether a host name, before resolution, could be a public internet host. * * @param string $host Lower-case host, brackets stripped. * @return bool */ public static function host_is_public( string $host ): bool { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return false !== filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } // A single label ("intranet") resolves only through a local search domain. if ( false === strpos( $host, '.' ) ) { return false; } // Numeric forms PHP's IP filter does not read as addresses but resolvers do: 0x7f.1, 2130706433. if ( preg_match( '/^[0-9.]+$/', $host ) || preg_match( '/^0x/i', $host ) ) { return false; } foreach ( array( 'localhost', 'local', 'internal', 'intranet', 'lan', 'home.arpa', 'corp', 'localdomain' ) as $suffix ) { if ( $host === $suffix || substr( $host, -( strlen( $suffix ) + 1 ) ) === '.' . $suffix ) { return false; } } return true; } // ── Payload ────────────────────────────────────────────────────────── /** * The exact body to post. Encoded once; the signature is computed over this string. * * @param string $format 'slack' or 'json'. * @param string $event Event key. * @param string $title One-line title. * @param string $text Plain-text body. * @param string $level info|warning|critical. * @param string $site Site URL. * @param int $ts Unix time. * @return string JSON. */ public static function build_body( string $format, string $event, string $title, string $text, string $level, string $site, int $ts ): string { if ( 'json' === $format ) { $payload = array( 'event' => $event, 'level' => $level, 'site' => $site, 'title' => $title, 'text' => $text, 'sent_at' => gmdate( 'c', $ts ), ); } else { // Slack's incoming-webhook shape, which Mattermost, Discord's /slack endpoint and // Teams' Slack-compatible connectors also accept. Plain text, so no markup in an // alert can be interpreted by the receiver as a mention or a link. $payload = array( 'text' => ( '' !== $title ? $title . "\n\n" : '' ) . $text ); } return (string) wp_json_encode( $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * Signature header value for a body. * * @param string $body The exact bytes that will be posted. * @param string $secret Shared secret. * @return string "sha256=", or '' when there is no secret. */ public static function signature( string $body, string $secret ): string { if ( '' === $secret ) { return ''; } return 'sha256=' . hash_hmac( 'sha256', $body, $secret ); } /** * Request arguments for a body. The body in the result is the same string that was signed. * * @param string $body JSON body. * @param string $secret Shared secret, may be ''. * @param string $event Event key. * @param bool $blocking Whether the caller waits for the answer. * @return array */ public static function request_args( string $body, string $secret, string $event, bool $blocking ): array { $headers = array( 'Content-Type' => 'application/json; charset=utf-8', self::EVENT_HEADER => $event, ); $sig = self::signature( $body, $secret ); if ( '' !== $sig ) { $headers[ self::SIGNATURE_HEADER ] = $sig; } return array( 'user-agent' => 'CloudScale-Cyber-DevTools/' . ( defined( 'CSDT_DevTools::VERSION' ) ? CSDT_DevTools::VERSION : '0' ), 'timeout' => $blocking ? self::TIMEOUT_REPORTED : self::TIMEOUT_BACKGROUND, 'blocking' => $blocking, 'redirection' => 0, 'headers' => $headers, 'body' => $body, ); } // ── Classification of captured alerts ──────────────────────────────── /** * Which subscribable events an alert text belongs to. * * Read from the text CSDT_Telegram composes: its header names the source ("CSDT * Auto-Block", "Vulnerable component") and its second line is the level word. * * @param string $body Alert text as sent. * @param string $level Level as CSDT_Alert_Log read it. * @return array */ public static function classify( string $body, string $level ): array { $out = array(); if ( false !== stripos( $body, 'Auto-Block' ) ) { $out[] = 'autoblock'; } if ( false !== stripos( $body, 'New admin' ) ) { $out[] = 'new_admin'; } if ( false !== stripos( $body, 'Vulnerable component' ) || false !== stripos( $body, 'software vulnerability' ) || false !== stripos( $body, 'vulnerability finding' ) ) { $out[] = 'vuln'; } if ( 'critical' === $level ) { $out[] = 'critical'; } return $out; } /** * The event a captured alert is delivered as, given the subscription, or '' to skip it. * * @param array $matched Events the alert belongs to. * @param array $enabled Subscribed events. * @return string */ public static function pick_event( array $matched, array $enabled ): string { foreach ( $matched as $event ) { if ( in_array( $event, $enabled, true ) ) { return $event; } } return ''; } /** * The first meaningful line of an alert, without the severity marker. * * @param string $body Alert text. * @return string */ public static function title_from( string $body ): string { foreach ( preg_split( '/\R/u', $body ) as $line ) { $line = trim( (string) preg_replace( '/^[^\p{L}\p{N}]+/u', '', (string) $line ) ); if ( '' !== $line ) { return function_exists( 'mb_substr' ) ? mb_substr( $line, 0, 150 ) : substr( $line, 0, 150 ); } } return ''; } /** * Listener for csdt_alert_captured. Never throws; never blocks the request. * * @param string $body Alert text. * @param string $level Level. */ public static function on_alert( $body, $level = 'info' ): void { try { if ( self::$sending || ! is_string( $body ) || '' === trim( $body ) || ! self::is_configured() ) { return; } $event = self::pick_event( self::classify( $body, (string) $level ), self::enabled_events() ); if ( '' === $event ) { return; } self::send( $event, self::title_from( $body ), $body, (string) $level, false ); } catch ( \Throwable $e ) { return; } } // ── Delivery ───────────────────────────────────────────────────────── /** * Posts one event. Refused on a muted copy, like every other alert channel. * * A background send (blocking false) cannot see the HTTP status, only a transport refusal, * so only that is recorded. A reported send records any non-2xx answer too. * * @param string $event Event key. * @param string $title Title. * @param string $text Body text. * @param string $level Level. * @param bool $blocking Wait for and report the answer. * @return array{ok:bool,code:int,error:string} */ public static function send( string $event, string $title, string $text, string $level, bool $blocking ): array { if ( ! self::is_configured() ) { return array( 'ok' => false, 'code' => 0, 'error' => __( 'No webhook URL is saved.', 'cloudscale-cyber-devtools' ) ); } if ( class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_muted() ) { return array( 'ok' => false, 'code' => 0, 'error' => __( 'Alerts are muted on this copy of the site.', 'cloudscale-cyber-devtools' ) ); } $body = self::build_body( self::format(), $event, $title, $text, $level, home_url(), time() ); $args = self::request_args( $body, (string) CSDT_Options::get( self::OPTION_SECRET ), $event, $blocking ); self::$sending = true; try { $resp = wp_safe_remote_post( self::url(), $args ); } finally { self::$sending = false; } $error = ''; $code = 0; if ( is_wp_error( $resp ) ) { $error = CSDT_Error_Text::in_seconds( $resp->get_error_message() ); } elseif ( $blocking ) { $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code < 200 || $code > 299 ) { /* translators: %d: HTTP status code. */ $error = sprintf( __( 'The endpoint answered HTTP %d.', 'cloudscale-cyber-devtools' ), $code ); } } if ( '' !== $error && is_callable( array( 'CSDT_Alert_Log', 'record_channel_failure' ) ) ) { CSDT_Alert_Log::record_channel_failure( 'webhook', $title, $error, $code, $level ); } return array( 'ok' => '' === $error, 'code' => $code, 'error' => $error ); } // ── Admin ──────────────────────────────────────────────────────────── /** Saves the settings. */ public static function ajax_save(): void { check_ajax_referer( CSDT_DevTools::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $typed = isset( $_POST['url'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['url'] ) ) ) : ''; $url = isset( $_POST['url'] ) ? trim( esc_url_raw( wp_unslash( $_POST['url'] ) ) ) : ''; // esc_url_raw() returns '' for a scheme it does not allow (javascript:, data:). Saving // that '' would quietly turn the webhook off, so a typed value that sanitised to // nothing is refused instead. if ( '' !== $typed && '' === $url ) { wp_send_json_error( __( 'The URL must start with https://', 'cloudscale-cyber-devtools' ) ); } if ( '' !== $url ) { $why = self::validate_url( $url ); if ( '' !== $why ) { wp_send_json_error( $why ); } } $format = sanitize_key( wp_unslash( $_POST['format'] ?? 'slack' ) ); $events = self::parse_events( sanitize_text_field( wp_unslash( $_POST['events'] ?? '' ) ) ); update_option( self::OPTION_URL, $url, false ); update_option( self::OPTION_FORMAT, 'json' === $format ? 'json' : 'slack', false ); update_option( self::OPTION_EVENTS, implode( ',', $events ), false ); // The secret field is write-only: blank keeps the stored one, so the page never has to // print it. The separate clear box removes it. $secret = sanitize_text_field( wp_unslash( $_POST['secret'] ?? '' ) ); if ( '1' === sanitize_key( wp_unslash( $_POST['clear_secret'] ?? '' ) ) ) { update_option( self::OPTION_SECRET, '', false ); } elseif ( '' !== $secret ) { update_option( self::OPTION_SECRET, $secret, false ); } wp_send_json_success( array( 'msg' => '' === $url ? __( 'Saved. The webhook is off.', 'cloudscale-cyber-devtools' ) : __( 'Saved. Use Send test to confirm delivery.', 'cloudscale-cyber-devtools' ), ) ); } /** Sends a test event to the saved URL and reports the answer. */ public static function ajax_test(): void { check_ajax_referer( CSDT_DevTools::SMTP_NONCE, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } $r = self::send( 'test', __( 'CloudScale Cyber DevTools webhook test', 'cloudscale-cyber-devtools' ), sprintf( /* translators: %s: site URL. */ __( 'Test message from %s. If you can read this, the webhook works.', 'cloudscale-cyber-devtools' ), home_url() ), 'info', true ); if ( $r['ok'] ) { /* translators: %d: HTTP status code. */ wp_send_json_success( array( 'msg' => sprintf( __( 'Delivered, HTTP %d.', 'cloudscale-cyber-devtools' ), $r['code'] ) ) ); } wp_send_json_error( $r['error'] ); } /** Renders the webhook panel on the Alerts tab. */ public static function render_panel(): void { $url = self::url(); $format = self::format(); $enabled = self::enabled_events(); $has_secret = '' !== (string) CSDT_Options::get( self::OPTION_SECRET ); $tg_ready = class_exists( 'CSDT_Telegram' ) && CSDT_Telegram::is_configured() && '1' === (string) CSDT_Options::get( 'csdt_notify_telegram_enabled' ); ?>
🔗 'Webhook URL', 'rec' => 'Optional, off by default', 'html' => 'An HTTPS address that accepts a POST with a JSON body. Leave it empty to turn the webhook off. Addresses inside a private network (localhost, 10.x, 192.168.x, .local, .internal and single-word host names) are refused, so the setting cannot be used to make this server call into its own network. Only plain https:// is accepted.', ), array( 'name' => 'Shared secret', 'rec' => 'Recommended', 'html' => 'When set, every request carries ' . esc_html( self::SIGNATURE_HEADER ) . ': sha256=<hex>, the HMAC-SHA256 of the raw request body keyed with this secret. Verify it over the exact bytes you received, before parsing the JSON. The field is write-only: leave it blank to keep the saved secret.', ), array( 'name' => 'Format', 'rec' => 'How it works', 'html' => 'Slack-compatible posts {"text": "..."}, which Slack, Mattermost and most chat tools accept as an incoming webhook.

Generic JSON posts event, level, site, title, text and sent_at, for your own receiver or an automation service. The ' . esc_html( self::EVENT_HEADER ) . ' header carries the event in both formats.', ), array( 'name' => 'Events', 'rec' => 'How it works', 'html' => 'Critical alerts, auto-blocks, new administrators and vulnerability findings are mirrored from the alerts this site sends to Telegram, at the moment they are sent. That means Telegram must be set up for those four; the webhook adds a destination, it does not replace the alert pipeline. The weekly report is sent to the webhook directly and needs no Telegram.', ), array( 'name' => 'Send test', 'rec' => 'How it works', 'html' => 'Posts a test event to the saved URL and shows the HTTP status it answered. Save first. Alerts are sent in the background with a ' . (int) self::TIMEOUT_BACKGROUND . ' second timeout so a slow endpoint cannot slow your site; a refused connection is recorded in the alert log below.', ), array( 'name' => 'Restored copies', 'rec' => 'Honest limits', 'html' => 'A copy of the site with alerts muted sends nothing to the webhook either, the same as Telegram and email.', ), ) ); ?>

$label ) : ?>