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 { safeParseJson } from "./server-methods/nodes.helpers.js";
|
||||||
import { initSecurityShield } from "../security/shield.js";
|
import { initSecurityShield } from "../security/shield.js";
|
||||||
import { initFirewallManager } from "../security/firewall/manager.js";
|
import { initFirewallManager } from "../security/firewall/manager.js";
|
||||||
|
import { initAlertManager } from "../security/alerting/manager.js";
|
||||||
import { loadGatewayPlugins } from "./server-plugins.js";
|
import { loadGatewayPlugins } from "./server-plugins.js";
|
||||||
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
|
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
|
||||||
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.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();
|
initSubagentRegistry();
|
||||||
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
||||||
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
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 type { SecurityEvent, SecurityEventSeverity, SecurityEventCategory, SecurityEventOutcome } from "./schema.js";
|
||||||
import { DEFAULT_LOG_DIR } from "../../logging/logger.js";
|
import { DEFAULT_LOG_DIR } from "../../logging/logger.js";
|
||||||
import { getChildLogger } from "../../logging/index.js";
|
import { getChildLogger } from "../../logging/index.js";
|
||||||
|
import { getAlertManager } from "../alerting/manager.js";
|
||||||
|
|
||||||
const SECURITY_LOG_PREFIX = "security";
|
const SECURITY_LOG_PREFIX = "security";
|
||||||
const SECURITY_LOG_SUFFIX = ".jsonl";
|
const SECURITY_LOG_SUFFIX = ".jsonl";
|
||||||
@ -58,6 +59,14 @@ class SecurityEventLogger {
|
|||||||
|
|
||||||
// Also log to main logger for OTEL export and console output
|
// Also log to main logger for OTEL export and console output
|
||||||
this.logToMainLogger(fullEvent);
|
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