import { createHmac } from "node:crypto"; import { db, sql } from "@websensor/db"; import { postJson } from "@websensor/connectors"; import { log } from "./config"; import { m } from "./metrics"; /** * Server-side alert evaluation (spec §43–44). Rules are matched against every published event; * matches become `notifications` rows (channel web = read by the UI) and, for webhook alerts, an * HTTP POST signed with HMAC-SHA256 (`X-WebSensor-Signature: sha256=`). Delivery goes through * the same SSRF policy as every other URL the engine touches. */ export interface AlertRule { importance_min?: number; signal_min?: number; event_types?: string[]; groups?: string[]; entities?: string[]; sources?: string[]; keywords?: string[]; categories?: string[]; countries?: string[]; silent_only?: boolean; first_party_only?: boolean; confirmed_only?: boolean; } interface AlertRow { id: string; owner_token: string; name: string; rule: AlertRule; channel: string; channel_config: { url?: string; secret?: string } | null; enabled: boolean; } export interface PublishedEvent { id: string; slug: string; type: string; group?: string; title: string; summary: string; importance: number; signal?: number; confidence: number; silent: boolean; evidence: string; firstParty?: boolean; country?: string | null; source: { id: string; name: string; domain: string; tier?: string }; entities: { id: string; name: string; type: string }[]; categories: string[]; url: string; detectedAt: string; } let cache: { at: number; rows: AlertRow[] } | null = null; async function loadAlerts(): Promise { if (cache && Date.now() - cache.at < 30_000) return cache.rows; const r = await db.execute>(sql`select id, owner_token, name, rule, channel, channel_config, enabled from alerts where enabled`); cache = { at: Date.now(), rows: r.rows as unknown as AlertRow[] }; return cache.rows; } export function invalidateAlerts(): void { cache = null; } export function matchesRule(rule: AlertRule, e: PublishedEvent): boolean { if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false; if (rule.signal_min !== undefined && (e.signal ?? e.importance) < rule.signal_min) return false; if (rule.silent_only && !e.silent) return false; if (rule.first_party_only && e.firstParty === false) return false; if (rule.confirmed_only && e.evidence !== "CONFIRMED") return false; if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false; if (rule.groups?.length && !(e.group && rule.groups.includes(e.group))) return false; if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false; if (rule.countries?.length && !(e.country && rule.countries.includes(e.country))) return false; if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false; if (rule.sources?.length && !rule.sources.includes(e.source.id)) return false; if (rule.keywords?.length) { const hay = `${e.title} ${e.summary}`.toLowerCase(); if (!rule.keywords.some((k) => hay.includes(k.toLowerCase()))) return false; } // A rule with no positive constraint at all would match everything — require at least one. const constrained = rule.importance_min !== undefined || rule.signal_min !== undefined || rule.silent_only || rule.event_types?.length || rule.groups?.length || rule.categories?.length || rule.countries?.length || rule.entities?.length || rule.sources?.length || rule.keywords?.length; return Boolean(constrained); } export async function evaluateAlerts(e: PublishedEvent): Promise { let alerts: AlertRow[]; try { alerts = await loadAlerts(); } catch (err) { log.warn({ err: (err as Error).message }, "alerts load failed"); return 0; } let fired = 0; for (const a of alerts) { if (!matchesRule(a.rule ?? {}, e)) continue; fired++; try { const ins = await db.execute<{ id: number }>(sql`insert into notifications (alert_id, event_id, channel, status) values (${a.id}, ${e.id}, ${a.channel}, ${a.channel === "web" ? "delivered" : "queued"}) returning id`); await db.execute(sql`update alerts set last_fired_at = now(), fired_count = fired_count + 1 where id = ${a.id}`); m.alertsFired.inc({ channel: a.channel }); if (a.channel === "webhook" && a.channel_config?.url) void deliverWebhook(ins.rows[0]!.id, a, e); } catch (err) { log.warn({ alert: a.id, err: (err as Error).message }, "notification insert failed"); } } return fired; } async function deliverWebhook(notificationId: number, a: AlertRow, e: PublishedEvent): Promise { const url = String(a.channel_config?.url ?? ""); const body = JSON.stringify({ alert: { id: a.id, name: a.name }, event: e, delivered_at: new Date().toISOString() }); const headers: Record = { "content-type": "application/json", "user-agent": "WebSensor-Alerts/1.0 (+https://www.websensor.io/api)", "x-websensor-alert": a.id, "x-websensor-event": e.id }; if (a.channel_config?.secret) headers["x-websensor-signature"] = "sha256=" + createHmac("sha256", String(a.channel_config.secret)).update(body).digest("hex"); try { const res = await postJson(url, body, headers); const ok = res.status >= 200 && res.status < 300; await db.execute(sql`update notifications set status = ${ok ? "delivered" : "failed"}, delivered_at = ${ok ? new Date() : null}, error = ${ok ? null : (res.error ?? `HTTP ${res.status}`).slice(0, 300)} where id = ${notificationId}`); m.webhookDeliveries.inc({ ok: String(ok) }); } catch (err) { await db.execute(sql`update notifications set status = 'failed', error = ${String((err as Error).message).slice(0, 300)} where id = ${notificationId}`).catch(() => undefined); m.webhookDeliveries.inc({ ok: "false" }); log.warn({ alert: a.id, err: (err as Error).message }, "webhook delivery failed"); } }