true, 'email_alerts' => true, 'alert_emails' => array(), 'digest_emails' => array(), 'mcp_disabled' => false, 'mcp_disabled_reason' => '', 'mcp_disabled_at' => '', 'daily_digest' => false, 'rules' => array( 'velocity' => array( 'enabled' => true, 'threshold' => 50, 'window_minutes' => 5, 'severity' => 'critical', 'auto_disable' => false, ), 'destructive_tool' => array( 'enabled' => true, 'tools' => array( 'wp_delete_post', 'wp_delete_page', 'wp_delete_user', 'wp_delete_comment', 'wp_delete_media_item', 'clear-old-logs', ), 'severity' => 'critical', 'auto_disable' => false, ), 'error_storm' => array( 'enabled' => true, 'threshold' => 10, 'window_minutes' => 15, 'severity' => 'warning', 'auto_disable' => false, ), ), ); /** * Initialize security hooks. */ public static function init() { // Register daily digest cron. if ( ! wp_next_scheduled( 'alfmcp_daily_digest' ) ) { wp_schedule_event( time(), 'daily', 'alfmcp_daily_digest' ); } add_action( 'alfmcp_daily_digest', array( __CLASS__, 'send_daily_digest' ) ); // Kill switch handler for email links. add_action( 'admin_post_alfmcp_kill_switch', array( __CLASS__, 'handle_kill_switch_action' ) ); } /** * Evaluate security rules after a request is logged. * * @param int $log_id The log entry ID. * @param string|null $session_id MCP session ID. * @param string|null $tool_name Tool name. * @param string $tool_status Tool execution status. * @param bool $was_blocked Whether the request was blocked by the kill switch. */ public static function evaluate( $log_id, $session_id, $tool_name, $tool_status, $was_blocked = false ) { $settings = self::get_settings(); if ( ! $settings['enabled'] ) { return; } // Session-based rules require a session ID. if ( ! empty( $session_id ) ) { if ( $settings['rules']['velocity']['enabled'] ) { self::check_velocity( $session_id, $settings['rules']['velocity'], $settings, $was_blocked ); } if ( $settings['rules']['error_storm']['enabled'] && 'error' === $tool_status ) { self::check_error_storm( $session_id, $settings['rules']['error_storm'], $settings, $was_blocked ); } } // Destructive tool alert doesn't require session ID. if ( $settings['rules']['destructive_tool']['enabled'] && $tool_name ) { self::check_destructive_tool( $tool_name, $session_id, $settings['rules']['destructive_tool'], $settings, $was_blocked ); } } /** * Check velocity rule: too many requests in a time window. * * @param string $session_id MCP session ID. * @param array $rule Rule configuration. * @param array $settings Full settings. * @param bool $was_blocked Whether the triggering request was blocked. */ private static function check_velocity( $session_id, $rule, $settings, $was_blocked = false ) { global $wpdb; $table = ALFMCP_DB::get_table_name(); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . absint( $rule['window_minutes'] ) . ' minutes', strtotime( current_time( 'mysql' ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Real-time count for velocity detection; caching would defeat the rolling-window check. $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE mcp_session_id = %s AND created_at >= %s', $table, $session_id, $cutoff ) ); if ( $count >= absint( $rule['threshold'] ) ) { self::fire_alert( 'velocity', $session_id, $rule['severity'], $settings, array( 'count' => $count, 'threshold' => $rule['threshold'], 'window_minutes' => $rule['window_minutes'], ), $rule, $was_blocked ); } } /** * Check destructive tool rule. * * @param string $tool_name Tool name. * @param string|null $session_id MCP session ID. * @param array $rule Rule configuration. * @param array $settings Full settings. * @param bool $was_blocked Whether the triggering request was blocked. */ private static function check_destructive_tool( $tool_name, $session_id, $rule, $settings, $was_blocked = false ) { $tools = isset( $rule['tools'] ) ? $rule['tools'] : array(); if ( in_array( $tool_name, $tools, true ) ) { self::fire_alert( 'destructive_tool', $session_id, $rule['severity'], $settings, array( 'tool_name' => $tool_name, ), $rule, $was_blocked ); } } /** * Check error storm rule: too many errors in a time window. * * @param string $session_id MCP session ID. * @param array $rule Rule configuration. * @param array $settings Full settings. * @param bool $was_blocked Whether the triggering request was blocked. */ private static function check_error_storm( $session_id, $rule, $settings, $was_blocked = false ) { global $wpdb; $table = ALFMCP_DB::get_table_name(); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . absint( $rule['window_minutes'] ) . ' minutes', strtotime( current_time( 'mysql' ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Real-time count for error-storm detection; caching would defeat the rolling-window check. $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE tool_status = 'error' AND mcp_session_id = %s AND created_at >= %s", $table, $session_id, $cutoff ) ); if ( $count >= absint( $rule['threshold'] ) ) { self::fire_alert( 'error_storm', $session_id, $rule['severity'], $settings, array( 'count' => $count, 'threshold' => $rule['threshold'], 'window_minutes' => $rule['window_minutes'], ), $rule, $was_blocked ); } } /** * Fire a security alert: log event, optionally send email, optionally disable MCP. * * @param string $rule_name Rule that fired. * @param string|null $session_id MCP session ID. * @param string $severity 'warning' or 'critical'. * @param array $settings Full settings. * @param array $details Event details. * @param array $rule Rule configuration. * @param bool $was_blocked Whether the triggering request was blocked by the kill switch. */ private static function fire_alert( $rule_name, $session_id, $severity, $settings, $details, $rule = array(), $was_blocked = false ) { $details['request_status'] = $was_blocked ? 'blocked' : 'executed'; // Always log the event. ALFMCP_DB::insert_security_event( array( 'rule_name' => sanitize_text_field( $rule_name ), 'session_id' => $session_id ? sanitize_text_field( $session_id ) : null, 'details' => wp_json_encode( $details ), 'severity' => in_array( $severity, array( 'warning', 'critical' ), true ) ? $severity : 'warning', ) ); // Auto-disable MCP if configured for this rule. if ( ! empty( $rule['auto_disable'] ) && ! $settings['mcp_disabled'] ) { $settings['mcp_disabled'] = true; $settings['mcp_disabled_reason'] = self::build_disable_reason( $rule_name, $details ); $settings['mcp_disabled_at'] = current_time( 'mysql' ); self::save_settings( $settings ); ALFMCP_DB::insert_security_event( array( 'rule_name' => 'mcp_auto_disabled', 'session_id' => $session_id ? sanitize_text_field( $session_id ) : null, 'details' => wp_json_encode( array( 'triggered_by' => $rule_name, 'request_status' => 'executed', 'note' => 'The triggering request was executed. All subsequent MCP requests are now blocked.', ) ), 'severity' => 'critical', ) ); } // Check cooldown before sending email. if ( ! $settings['email_alerts'] ) { return; } $cooldown_key = 'alfmcp_cd_' . $rule_name . '_' . md5( (string) $session_id ); if ( get_transient( $cooldown_key ) ) { return; } self::send_alert_email( $rule_name, $session_id, $severity, $details, $settings ); set_transient( $cooldown_key, true, 15 * MINUTE_IN_SECONDS ); } /** * Send an alert email. * * @param string $rule_name Rule name. * @param string|null $session_id Session ID. * @param string $severity Severity level. * @param array $details Event details. * @param array $settings Full settings. */ private static function send_alert_email( $rule_name, $session_id, $severity, $details, $settings ) { $recipients = self::get_recipients( 'alert', $settings ); if ( empty( $recipients ) ) { return; } $rule_labels = array( 'velocity' => 'Velocity Alert', 'destructive_tool' => 'Destructive Tool Detected', 'error_storm' => 'Error Storm Detected', ); $label = isset( $rule_labels[ $rule_name ] ) ? $rule_labels[ $rule_name ] : $rule_name; $subject = '[MCP Logs] Security Alert: ' . $label; // Build detail rows. $detail_rows = ''; if ( 'destructive_tool' === $rule_name && isset( $details['tool_name'] ) ) { $detail_rows .= self::email_detail_row( 'Tool', esc_html( $details['tool_name'] ) ); } elseif ( isset( $details['count'] ) ) { $detail_rows .= self::email_detail_row( 'Count', esc_html( $details['count'] ) . ' (threshold: ' . esc_html( $details['threshold'] ) . ')' ); $detail_rows .= self::email_detail_row( 'Window', esc_html( $details['window_minutes'] ) . ' minutes' ); } $detail_rows .= self::email_detail_row( 'Severity', esc_html( $severity ) ); if ( ! empty( $details['request_status'] ) ) { $status_label = 'executed' === $details['request_status'] ? 'Executed — the request was allowed through' : 'Blocked — the request was stopped'; $detail_rows .= self::email_detail_row( 'Request', $status_label ); } if ( $session_id ) { $detail_rows .= self::email_detail_row( 'Session', esc_html( $session_id ) ); } $detail_rows .= self::email_detail_row( 'Time', esc_html( current_time( 'mysql' ) ) ); /** * Filter alert email detail rows HTML. * * @param string $detail_rows HTML table rows. * @param string $rule_name Rule that fired. * @param array $details Event details. */ $detail_rows = apply_filters( 'alfmcp_alert_email_details', $detail_rows, $rule_name, $details ); $kill_url = admin_url( 'admin-post.php?action=alfmcp_kill_switch&_wpnonce=' . wp_create_nonce( 'alfmcp_kill_switch' ) ); $details_url = admin_url( 'tools.php?page=alfmcp-requests#security-events' ); $content = '
| Disable MCP Access | '; $content .= 'View Details | '; $content .= '
No security events detected.
'; } $content .= ''; /** * Filter the digest email body content before wrapping in the template. * * @param string $content HTML content. * @param array $stats Request stats. * @param array $sec_stats Security stats. */ $content = apply_filters( 'alfmcp_digest_email_content', $content, $stats, $sec_stats ); $body = self::email_template( 'Daily Digest — ' . $yesterday, $content ); self::send_html_email( $recipients, $subject, $body ); } /** * Send a test alert email. * * @return bool Whether wp_mail succeeded. */ public static function send_test_alert() { $settings = self::get_settings(); $recipients = self::get_recipients( 'alert', $settings ); if ( empty( $recipients ) ) { $recipients = array( get_option( 'admin_email' ) ); } $subject = '[MCP Logs] Test Alert — Email Delivery Verified'; $content = 'This is a test alert from Activity Log for MCP.
'; $content .= 'If you\'re reading this, your email alerts are working correctly.
'; $content .= 'Sent: ' . esc_html( current_time( 'mysql' ) ) . '
'; $body = self::email_template( 'Test Alert', $content ); return self::send_html_email( $recipients, $subject, $body ); } /** * Get email recipients for a given type. * * @param string $type 'alert' or 'digest'. * @param array $settings Security settings. * @return array Email addresses. */ private static function get_recipients( $type, $settings ) { $key = 'alert' === $type ? 'alert_emails' : 'digest_emails'; $emails = isset( $settings[ $key ] ) ? $settings[ $key ] : array(); // Legacy support: migrate string 'alert_email' to array. if ( empty( $emails ) && ! empty( $settings['alert_email'] ) ) { $emails = array( $settings['alert_email'] ); } // Filter out empty values and sanitize. $emails = array_filter( array_map( 'sanitize_email', (array) $emails ) ); // Fall back to admin email if no recipients configured. if ( empty( $emails ) ) { $emails = array( get_option( 'admin_email' ) ); } /** * Filter email recipients. * * @param array $emails Email addresses. * @param string $type 'alert' or 'digest'. */ return apply_filters( 'alfmcp_email_recipients', $emails, $type ); } /** * Send an HTML email. * * @param array $to Recipient email addresses. * @param string $subject Email subject. * @param string $body HTML body. * @return bool */ private static function send_html_email( $to, $subject, $body ) { $headers = array( 'Content-Type: text/html; charset=UTF-8' ); /** * Filter email headers. * * @param array $headers Email headers. * @param string $subject Email subject. */ $headers = apply_filters( 'alfmcp_email_headers', $headers, $subject ); return wp_mail( $to, $subject, $body, $headers ); } /** * Generate a single detail row for email tables. * * @param string $label Row label. * @param string $value Row value (pre-escaped). * @return string HTML table row. */ private static function email_detail_row( $label, $value ) { return 'You\'re receiving this because security monitoring is enabled in Activity Log for MCP on ' . esc_html( $site_name ) . '.
Manage settings
';
$html .= '
|