feat(security): implement Telegram alerting system

This commit is contained in:
Ulrich Diedrichsen 2026-01-30 10:58:58 +01:00
parent 88bcb61c7b
commit c2bd42b89f
5 changed files with 397 additions and 0 deletions

View File

@ -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);

View File

@ -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<string, number>();
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<void> {
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<void> {
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;
}

View File

@ -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 "📢";
}
}
}

View File

@ -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<string, unknown>;
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;
};
};
}

View File

@ -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)}`);
});
}
}
/**