clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

main
nzip / worker / src / security / notifications.ts
notifications.ts 5.0K
1import type { Env } from "../env.ts";2import type { AlertAction, IncidentRow, WindowStats } from "./policy.ts";34interface NotificationRow {5 id: string;6 subject: string;7 text: string;8 html: string | null;9 attempts: number;10}1112const DELIVERY_LEASE_SECONDS = 60;1314function subject(action: Exclude<AlertAction, null>, severity: number): string {15 if (action === "resolve") return "[nzip] Enumeration incident resolved";16 if (action === "summary") return "[nzip] Enumeration incident summary";17 return `[nzip] ${18 severity >= 2 ? "Confirmed" : "Possible"19 } enumeration ${action}`;20}2122function emailText(23 action: Exclude<AlertAction, null>,24 stats: WindowStats,25 incident: IncidentRow,26): string {27 return [28 `Enumeration incident: ${action}`,29 `Severity: ${incident.severity >= 2 ? "confirmed" : "warning"}`,30 `Opened: ${new Date(incident.opened_at * 1000).toISOString()}`,31 `This window: ${stats.addresses} distinct scanner/address pairs, ${stats.scanners} scanners`,32 `Top scanner: ${stats.topScannerAddresses} distinct addresses`,33 `Live hits: ${stats.liveHits}; rate-limited scanners: ${stats.rateLimited}`,34 `Longest adjacent run: ${stats.longestSequence}`,35 `Incident totals: ${incident.total_addresses} observations, ${incident.total_live_hits} live hits, ${incident.total_rate_limited} rate-limit confirmations`,36 `Countries: ${stats.countries.join(", ") || "unknown"}`,37 `ASNs: ${stats.asns.join(", ") || "unknown"}`,38 "Re-alert policy: immediate on escalation; otherwise at most hourly; resolves after 15 quiet minutes.",39 ].join("\n");40}4142export function notificationId(43 incidentName: string,44 action: string,45 bucket: number,46): string {47 return `${incidentName}:${action}:${bucket}`;48}4950export function enqueueNotification(51 env: Env,52 id: string,53 incidentName: string,54 action: Exclude<AlertAction, null>,55 bucket: number,56 stats: WindowStats,57 incident: IncidentRow,58 now: number,59): D1PreparedStatement {60 const text = `${emailText(action, stats, incident)}\nNotification ID: ${id}`;61 const html =62 `<pre style="font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap">${63 text.replaceAll("&", "&amp;").replaceAll("<", "&lt;")64 }</pre>`;65 return env.DB.prepare(66 `INSERT OR IGNORE INTO security_notifications67 (id, incident_name, action, window_bucket, subject, text, html, created_at)68 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,69 ).bind(70 id,71 incidentName,72 action,73 bucket,74 subject(action, incident.severity),75 text,76 html,77 now,78 );79}8081function errorMessage(error: unknown): string {82 return (error instanceof Error ? error.message : String(error)).slice(83 0,84 1000,85 );86}8788export async function drainSecurityNotifications(89 env: Env,90 now = Math.floor(Date.now() / 1000),91): Promise<void> {92 const candidates = await env.DB.prepare(93 `SELECT id, subject, text, html, attempts FROM security_notifications94 WHERE sent_at IS NULL AND (lease_expires_at IS NULL OR lease_expires_at < ?)95 ORDER BY created_at, id LIMIT 10`,96 ).bind(now).all<NotificationRow>();9798 const owner = crypto.randomUUID();99 for (const notification of candidates.results) {100 const claim = await env.DB.prepare(101 `UPDATE security_notifications SET lease_owner = ?, lease_expires_at = ?102 WHERE id = ? AND sent_at IS NULL103 AND (lease_expires_at IS NULL OR lease_expires_at < ?)`,104 ).bind(105 owner,106 now + DELIVERY_LEASE_SECONDS,107 notification.id,108 now,109 ).run();110 if (claim.meta.changes !== 1) continue;111112 const attempt = notification.attempts + 1;113 try {114 await env.EMAIL.send({115 from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },116 to: env.ALERT_EMAIL_TO,117 subject: notification.subject,118 text: notification.text,119 ...(notification.html ? { html: notification.html } : {}),120 });121 await env.DB.prepare(122 `UPDATE security_notifications SET sent_at = ?, attempts = attempts + 1,123 last_error = NULL, lease_owner = NULL, lease_expires_at = NULL124 WHERE id = ? AND sent_at IS NULL AND lease_owner = ?`,125 ).bind(now, notification.id, owner).run();126 console.log({127 event: "security.notification_sent",128 notificationId: notification.id,129 attempt,130 });131 } catch (error) {132 const message = errorMessage(error);133 await env.DB.prepare(134 `UPDATE security_notifications SET attempts = attempts + 1, last_error = ?,135 lease_owner = NULL, lease_expires_at = NULL136 WHERE id = ? AND sent_at IS NULL AND lease_owner = ?`,137 ).bind(message, notification.id, owner).run();138 console.error({139 event: "security.notification_failed",140 notificationId: notification.id,141 attempt,142 error: message,143 });144 }145 }146}147148export async function sendAlertTest(env: Env): Promise<void> {149 await env.EMAIL.send({150 from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },151 to: env.ALERT_EMAIL_TO,152 subject: "[nzip] Security alert delivery test",153 text: "nzip security alert delivery is configured correctly.",154 });155}