feat(security): implement Telegram alerting system
This commit is contained in:
parent
88bcb61c7b
commit
c2bd42b89f
@ -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);
|
||||
|
||||
221
src/security/alerting/manager.ts
Normal file
221
src/security/alerting/manager.ts
Normal 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;
|
||||
}
|
||||
105
src/security/alerting/telegram.ts
Normal file
105
src/security/alerting/telegram.ts
Normal 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 "📢";
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/security/alerting/types.ts
Normal file
56
src/security/alerting/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -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)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user