diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index ba26e0460..61131620b 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -60,6 +60,7 @@ import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { safeParseJson } from "./server-methods/nodes.helpers.js"; import { initSecurityShield } from "../security/shield.js"; import { initFirewallManager } from "../security/firewall/manager.js"; +import { initAlertManager } from "../security/alerting/manager.js"; import { loadGatewayPlugins } from "./server-plugins.js"; import { createGatewayReloadHandlers } from "./server-reload-handlers.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; @@ -230,6 +231,11 @@ export async function startGatewayServer( }); } + // Initialize alert manager + if (cfgAtStart.security?.alerting) { + initAlertManager(cfgAtStart.security.alerting); + } + initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); diff --git a/src/security/alerting/manager.ts b/src/security/alerting/manager.ts new file mode 100644 index 000000000..91cb999d0 --- /dev/null +++ b/src/security/alerting/manager.ts @@ -0,0 +1,221 @@ +/** + * Security alert manager + * Coordinates alert triggers and channels + */ + +import { randomUUID } from "node:crypto"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { SecurityEvent } from "../events/schema.js"; +import { SecurityActions, AttackPatterns } from "../events/schema.js"; +import type { AlertChannelInterface, AlertingConfig, SecurityAlert } from "./types.js"; +import { TelegramAlertChannel } from "./telegram.js"; + +const log = createSubsystemLogger("security:alerting"); + +export class AlertManager { + private config: AlertingConfig; + private channels: AlertChannelInterface[] = []; + private lastAlertTime = new Map(); + + constructor(config: AlertingConfig) { + this.config = config; + this.initializeChannels(); + } + + private initializeChannels(): void { + // Telegram channel + if (this.config.channels.telegram?.enabled) { + const telegram = new TelegramAlertChannel({ + enabled: true, + botToken: this.config.channels.telegram.botToken, + chatId: this.config.channels.telegram.chatId, + }); + if (telegram.isEnabled()) { + this.channels.push(telegram); + log.info("telegram alert channel enabled"); + } else { + log.warn("telegram alert channel configured but missing botToken or chatId"); + } + } + + if (this.channels.length === 0) { + log.info("no alert channels enabled"); + } + } + + /** + * Check if alerting is enabled + */ + isEnabled(): boolean { + return this.config.enabled && this.channels.length > 0; + } + + /** + * Send an alert through all enabled channels + */ + async sendAlert(alert: SecurityAlert): Promise { + if (!this.isEnabled()) { + return; + } + + // Check throttling + const throttleMs = this.getThrottleMs(alert.trigger); + if (throttleMs > 0) { + const lastTime = this.lastAlertTime.get(alert.trigger) || 0; + const now = Date.now(); + if (now - lastTime < throttleMs) { + log.debug(`alert throttled: trigger=${alert.trigger} throttle=${throttleMs}ms`); + return; + } + this.lastAlertTime.set(alert.trigger, now); + } + + // Send to all channels + const results = await Promise.allSettled( + this.channels.map((channel) => channel.send(alert)), + ); + + // Log results + let successCount = 0; + let failureCount = 0; + for (const result of results) { + if (result.status === "fulfilled" && result.value.ok) { + successCount++; + } else { + failureCount++; + const error = + result.status === "fulfilled" ? result.value.error : String(result.reason); + log.error(`alert send failed: ${error}`); + } + } + + if (successCount > 0) { + log.info( + `alert sent: trigger=${alert.trigger} severity=${alert.severity} channels=${successCount}`, + ); + } + } + + /** + * Handle security event and trigger alerts if needed + */ + async handleEvent(event: SecurityEvent): Promise { + if (!this.isEnabled()) { + return; + } + + // Critical events + if ( + event.severity === "critical" && + this.config.triggers.criticalEvents?.enabled + ) { + await this.sendAlert({ + id: randomUUID(), + severity: "critical", + title: "Critical Security Event", + message: `${event.action} on ${event.resource}`, + timestamp: event.timestamp, + details: { + ip: event.ip, + action: event.action, + outcome: event.outcome, + ...event.details, + }, + trigger: "critical_event", + }); + } + + // IP blocked + if ( + event.action === SecurityActions.IP_BLOCKED && + this.config.triggers.ipBlocked?.enabled + ) { + await this.sendAlert({ + id: randomUUID(), + severity: "warn", + title: "IP Address Blocked", + message: `IP ${event.ip} has been blocked`, + timestamp: event.timestamp, + details: { + reason: event.details.reason, + expiresAt: event.details.expiresAt, + source: event.details.source, + }, + trigger: "ip_blocked", + }); + } + + // Intrusion detected + if ( + [ + SecurityActions.BRUTE_FORCE_DETECTED, + SecurityActions.SSRF_BYPASS_ATTEMPT, + SecurityActions.PATH_TRAVERSAL_ATTEMPT, + SecurityActions.PORT_SCANNING_DETECTED, + ].includes(event.action) + ) { + const pattern = event.attackPattern || "unknown"; + await this.sendAlert({ + id: randomUUID(), + severity: "critical", + title: "Intrusion Detected", + message: `${this.getAttackName(pattern)} detected from IP ${event.ip}`, + timestamp: event.timestamp, + details: { + pattern, + ip: event.ip, + attempts: event.details.failedAttempts || event.details.attempts || event.details.connections, + threshold: event.details.threshold, + }, + trigger: "intrusion_detected", + }); + } + } + + private getThrottleMs(trigger: string): number { + switch (trigger) { + case "critical_event": + return this.config.triggers.criticalEvents?.throttleMs || 0; + case "ip_blocked": + return this.config.triggers.ipBlocked?.throttleMs || 0; + case "intrusion_detected": + return 300_000; // 5 minutes default + default: + return 0; + } + } + + private getAttackName(pattern: string): string { + switch (pattern) { + case AttackPatterns.BRUTE_FORCE: + return "Brute force attack"; + case AttackPatterns.SSRF_BYPASS: + return "SSRF bypass attempt"; + case AttackPatterns.PATH_TRAVERSAL: + return "Path traversal attempt"; + case AttackPatterns.PORT_SCANNING: + return "Port scanning"; + default: + return "Security attack"; + } + } +} + +/** + * Singleton alert manager + */ +let alertManager: AlertManager | null = null; + +/** + * Initialize alert manager with config + */ +export function initAlertManager(config: AlertingConfig): void { + alertManager = new AlertManager(config); +} + +/** + * Get alert manager instance + */ +export function getAlertManager(): AlertManager | null { + return alertManager; +} diff --git a/src/security/alerting/telegram.ts b/src/security/alerting/telegram.ts new file mode 100644 index 000000000..4f2f7f73e --- /dev/null +++ b/src/security/alerting/telegram.ts @@ -0,0 +1,105 @@ +/** + * Telegram alert channel + * Sends security alerts via Telegram Bot API + */ + +import type { AlertChannelInterface, SecurityAlert } from "./types.js"; + +export interface TelegramChannelConfig { + enabled: boolean; + botToken: string; + chatId: string; +} + +export class TelegramAlertChannel implements AlertChannelInterface { + private config: TelegramChannelConfig; + private apiUrl: string; + + constructor(config: TelegramChannelConfig) { + this.config = config; + this.apiUrl = `https://api.telegram.org/bot${config.botToken}`; + } + + isEnabled(): boolean { + return this.config.enabled && Boolean(this.config.botToken) && Boolean(this.config.chatId); + } + + async send(alert: SecurityAlert): Promise<{ ok: boolean; error?: string }> { + if (!this.isEnabled()) { + return { ok: false, error: "telegram_channel_not_enabled" }; + } + + try { + const message = this.formatMessage(alert); + const response = await fetch(`${this.apiUrl}/sendMessage`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: this.config.chatId, + text: message, + parse_mode: "Markdown", + disable_web_page_preview: true, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + ok: false, + error: `telegram_api_error: ${response.status} ${errorText}`, + }; + } + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: `telegram_send_failed: ${String(err)}`, + }; + } + } + + private formatMessage(alert: SecurityAlert): string { + const severityEmoji = this.getSeverityEmoji(alert.severity); + const lines: string[] = []; + + // Header + lines.push(`${severityEmoji} *${alert.severity.toUpperCase()}*: ${alert.title}`); + lines.push(""); + + // Message + lines.push(alert.message); + + // Details (if any) + const detailKeys = Object.keys(alert.details); + if (detailKeys.length > 0) { + lines.push(""); + lines.push("*Details:*"); + for (const key of detailKeys) { + const value = alert.details[key]; + lines.push(`• ${key}: \`${String(value)}\``); + } + } + + // Footer + lines.push(""); + lines.push(`_${new Date(alert.timestamp).toLocaleString()}_`); + + return lines.join("\n"); + } + + private getSeverityEmoji(severity: string): string { + switch (severity) { + case "critical": + return "🚨"; + case "warn": + return "⚠️"; + case "info": + return "ℹ️"; + default: + return "📢"; + } + } +} diff --git a/src/security/alerting/types.ts b/src/security/alerting/types.ts new file mode 100644 index 000000000..ee43ce3ef --- /dev/null +++ b/src/security/alerting/types.ts @@ -0,0 +1,56 @@ +/** + * Security alerting types + */ + +export type AlertSeverity = "info" | "warn" | "critical"; + +export interface SecurityAlert { + id: string; + severity: AlertSeverity; + title: string; + message: string; + timestamp: string; // ISO 8601 + details: Record; + trigger: string; // What triggered the alert +} + +export interface AlertChannelConfig { + enabled: boolean; +} + +export interface AlertChannelInterface { + /** + * Send an alert through this channel + */ + send(alert: SecurityAlert): Promise<{ ok: boolean; error?: string }>; + + /** + * Check if this channel is enabled + */ + isEnabled(): boolean; +} + +export interface AlertTriggerConfig { + enabled: boolean; + throttleMs?: number; +} + +export interface AlertingConfig { + enabled: boolean; + triggers: { + criticalEvents?: AlertTriggerConfig; + failedAuthSpike?: AlertTriggerConfig & { threshold: number; windowMs: number }; + ipBlocked?: AlertTriggerConfig; + }; + channels: { + telegram?: { + enabled: boolean; + botToken: string; + chatId: string; + }; + webhook?: { + enabled: boolean; + url: string; + }; + }; +} diff --git a/src/security/events/logger.ts b/src/security/events/logger.ts index 75c813e7f..539bf634d 100644 --- a/src/security/events/logger.ts +++ b/src/security/events/logger.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import type { SecurityEvent, SecurityEventSeverity, SecurityEventCategory, SecurityEventOutcome } from "./schema.js"; import { DEFAULT_LOG_DIR } from "../../logging/logger.js"; import { getChildLogger } from "../../logging/index.js"; +import { getAlertManager } from "../alerting/manager.js"; const SECURITY_LOG_PREFIX = "security"; const SECURITY_LOG_SUFFIX = ".jsonl"; @@ -58,6 +59,14 @@ class SecurityEventLogger { // Also log to main logger for OTEL export and console output this.logToMainLogger(fullEvent); + + // Trigger alerts (async, fire-and-forget) + const alertManager = getAlertManager(); + if (alertManager?.isEnabled()) { + alertManager.handleEvent(fullEvent).catch((err) => { + this.logger.error(`failed to send alert: ${String(err)}`); + }); + } } /**