/** * Webhook notification provider (Feature 12, Story 12.2) * * Backward-compatible with existing webhook delivery + retry with exponential backoff. */ import { createHmac, timingSafeEqual } from 'node:crypto'; import type { NotificationPayload, DeliveryResult, NotificationChannel, WebhookChannelConfig } from '@agentkitai/agentlens-core'; import type { NotificationProvider } from '../provider.js'; import { validateExternalUrl } from '../ssrf.js'; import { createLogger } from '../../logger.js'; const log = createLogger('WebhookProvider'); const RETRY_DELAYS = [1000, 5000, 30000]; // 1s, 5s, 30s /** * HMAC-sign a webhook body so a receiver can authenticate it (#125). Signs * `timestamp.body` to bind the timestamp (replay resistance); header value is * `sha256=`. Returns {} when no secret is configured. */ export function webhookSignatureHeaders(bodyStr: string, timestamp: string): Record { const secret = process.env.AGENTLENS_WEBHOOK_SIGNING_SECRET?.trim(); if (!secret) return {}; const sig = createHmac('sha256', secret).update(`${timestamp}.${bodyStr}`).digest('hex'); return { 'X-AgentLens-Timestamp': timestamp, 'X-AgentLens-Signature': `sha256=${sig}` }; } /** Verify a webhook HMAC signature (constant-time). */ export function verifyWebhookSignature(bodyStr: string, header: string, timestamp: string, secret: string): boolean { const expected = `sha256=${createHmac('sha256', secret).update(`${timestamp}.${bodyStr}`).digest('hex')}`; const got = Buffer.from(header ?? ''); const exp = Buffer.from(expected); return got.length === exp.length && timingSafeEqual(got, exp); } export class WebhookProvider implements NotificationProvider { readonly type = 'webhook'; async send(channel: NotificationChannel, payload: NotificationPayload): Promise { const config = channel.config as unknown as WebhookChannelConfig; return this.deliver(channel.id, config, payload); } async testSend(channel: NotificationChannel): Promise { const testPayload: NotificationPayload = { source: 'alert_rule', severity: 'info', title: 'Test Notification', message: `This is a test from AgentLens notification channel "${channel.name}"`, metadata: { test: true }, triggeredAt: new Date().toISOString(), ruleId: 'test', ruleName: 'Test', }; return this.send(channel, testPayload); } /** Send with inline URL (for backward compat with raw URL notifyChannels) */ async sendToUrl(url: string, payload: NotificationPayload): Promise { const config: WebhookChannelConfig = { url, method: 'POST' }; return this.deliver('inline', config, payload); } private async deliver( channelId: string, config: WebhookChannelConfig, payload: NotificationPayload, ): Promise { const urlCheck = validateExternalUrl(config.url); if (!urlCheck.valid) { return { success: false, channelId, channelType: 'webhook', attempt: 1, error: urlCheck.reason }; } const method = config.method ?? 'POST'; const bodyStr = JSON.stringify(payload); const headers: Record = { 'Content-Type': 'application/json', ...webhookSignatureHeaders(bodyStr, new Date().toISOString()), ...(config.headers ?? {}), }; for (let attempt = 1; attempt <= RETRY_DELAYS.length + 1; attempt++) { try { const res = await fetch(config.url, { method, headers, body: bodyStr, signal: AbortSignal.timeout(10_000), }); if (res.ok) { return { success: true, channelId, channelType: 'webhook', attempt, httpStatus: res.status }; } log.warn(`Webhook ${config.url} returned HTTP ${res.status} (attempt ${attempt})`); if (attempt <= RETRY_DELAYS.length) { await sleep(RETRY_DELAYS[attempt - 1]!); continue; } return { success: false, channelId, channelType: 'webhook', attempt, httpStatus: res.status, error: `HTTP ${res.status}` }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.warn(`Webhook ${config.url} error (attempt ${attempt}): ${msg}`); if (attempt <= RETRY_DELAYS.length) { await sleep(RETRY_DELAYS[attempt - 1]!); continue; } return { success: false, channelId, channelType: 'webhook', attempt, error: msg }; } } return { success: false, channelId, channelType: 'webhook', attempt: RETRY_DELAYS.length + 1, error: 'Max retries exceeded' }; } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }