SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
5.9 KB · 133 lines typescript
Raw Blame History
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