TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import { createHmac } from "node:crypto";2import { db, sql } from "@websensor/db";3import { postJson } from "@websensor/connectors";4import { log } from "./config";5import { m } from "./metrics";67/**8 * Server-side alert evaluation (spec §43–44). Rules are matched against every published event;9 * matches become `notifications` rows (channel web = read by the UI) and, for webhook alerts, an10 * HTTP POST signed with HMAC-SHA256 (`X-WebSensor-Signature: sha256=<hex>`). Delivery goes through11 * the same SSRF policy as every other URL the engine touches.12 */13export interface AlertRule {14 importance_min?: number;15 signal_min?: number;16 event_types?: string[];17 groups?: string[];18 entities?: string[];19 sources?: string[];20 keywords?: string[];21 categories?: string[];22 countries?: string[];23 silent_only?: boolean;24 first_party_only?: boolean;25 confirmed_only?: boolean;26}2728interface AlertRow {29 id: string;30 owner_token: string;31 name: string;32 rule: AlertRule;33 channel: string;34 channel_config: { url?: string; secret?: string } | null;35 enabled: boolean;36}3738export interface PublishedEvent {39 id: string;40 slug: string;41 type: string;42 group?: string;43 title: string;44 summary: string;45 importance: number;46 signal?: number;47 confidence: number;48 silent: boolean;49 evidence: string;50 firstParty?: boolean;51 country?: string | null;52 source: { id: string; name: string; domain: string; tier?: string };53 entities: { id: string; name: string; type: string }[];54 categories: string[];55 url: string;56 detectedAt: string;57}5859let cache: { at: number; rows: AlertRow[] } | null = null;6061async function loadAlerts(): Promise<AlertRow[]> {62 if (cache && Date.now() - cache.at < 30_000) return cache.rows;63 const r = await db.execute<Record<string, unknown>>(sql`select id, owner_token, name, rule, channel, channel_config, enabled from alerts where enabled`);64 cache = { at: Date.now(), rows: r.rows as unknown as AlertRow[] };65 return cache.rows;66}6768export function invalidateAlerts(): void {69 cache = null;70}7172export function matchesRule(rule: AlertRule, e: PublishedEvent): boolean {73 if (rule.importance_min !== undefined && e.importance < rule.importance_min) return false;74 if (rule.signal_min !== undefined && (e.signal ?? e.importance) < rule.signal_min) return false;75 if (rule.silent_only && !e.silent) return false;76 if (rule.first_party_only && e.firstParty === false) return false;77 if (rule.confirmed_only && e.evidence !== "CONFIRMED") return false;78 if (rule.event_types?.length && !rule.event_types.includes(e.type)) return false;79 if (rule.groups?.length && !(e.group && rule.groups.includes(e.group))) return false;80 if (rule.categories?.length && !rule.categories.some((c) => e.categories.includes(c))) return false;81 if (rule.countries?.length && !(e.country && rule.countries.includes(e.country))) return false;82 if (rule.entities?.length && !e.entities.some((x) => rule.entities!.includes(x.id))) return false;83 if (rule.sources?.length && !rule.sources.includes(e.source.id)) return false;84 if (rule.keywords?.length) {85 const hay = `${e.title} ${e.summary}`.toLowerCase();86 if (!rule.keywords.some((k) => hay.includes(k.toLowerCase()))) return false;87 }88 // A rule with no positive constraint at all would match everything — require at least one.89 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;90 return Boolean(constrained);91}9293export async function evaluateAlerts(e: PublishedEvent): Promise<number> {94 let alerts: AlertRow[];95 try {96 alerts = await loadAlerts();97 } catch (err) {98 log.warn({ err: (err as Error).message }, "alerts load failed");99 return 0;100 }101 let fired = 0;102 for (const a of alerts) {103 if (!matchesRule(a.rule ?? {}, e)) continue;104 fired++;105 try {106 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`);107 await db.execute(sql`update alerts set last_fired_at = now(), fired_count = fired_count + 1 where id = ${a.id}`);108 m.alertsFired.inc({ channel: a.channel });109 if (a.channel === "webhook" && a.channel_config?.url) void deliverWebhook(ins.rows[0]!.id, a, e);110 } catch (err) {111 log.warn({ alert: a.id, err: (err as Error).message }, "notification insert failed");112 }113 }114 return fired;115}116117async function deliverWebhook(notificationId: number, a: AlertRow, e: PublishedEvent): Promise<void> {118 const url = String(a.channel_config?.url ?? "");119 const body = JSON.stringify({ alert: { id: a.id, name: a.name }, event: e, delivered_at: new Date().toISOString() });120 const headers: Record<string, string> = { "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 };121 if (a.channel_config?.secret) headers["x-websensor-signature"] = "sha256=" + createHmac("sha256", String(a.channel_config.secret)).update(body).digest("hex");122 try {123 const res = await postJson(url, body, headers);124 const ok = res.status >= 200 && res.status < 300;125 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}`);126 m.webhookDeliveries.inc({ ok: String(ok) });127 } catch (err) {128 await db.execute(sql`update notifications set status = 'failed', error = ${String((err as Error).message).slice(0, 300)} where id = ${notificationId}`).catch(() => undefined);129 m.webhookDeliveries.inc({ ok: "false" });130 log.warn({ alert: a.id, err: (err as Error).message }, "webhook delivery failed");131 }132}133