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}1112function subject(action: Exclude<AlertAction, null>, severity: number): string {13 if (action === "resolve") return "[nzip] Enumeration incident resolved";14 if (action === "summary") return "[nzip] Enumeration incident summary";15 return `[nzip] ${16 severity >= 2 ? "Confirmed" : "Possible"17 } enumeration ${action}`;18}1920function emailText(21 action: Exclude<AlertAction, null>,22 stats: WindowStats,23 incident: IncidentRow,24): string {25 return [26 `Enumeration incident: ${action}`,27 `Severity: ${incident.severity >= 2 ? "confirmed" : "warning"}`,28 `Opened: ${new Date(incident.opened_at * 1000).toISOString()}`,29 `This window: ${stats.addresses} distinct scanner/address pairs, ${stats.scanners} scanners`,30 `Top scanner: ${stats.topScannerAddresses} distinct addresses`,31 `Live hits: ${stats.liveHits}; rate-limited scanners: ${stats.rateLimited}`,32 `Longest adjacent run: ${stats.longestSequence}`,33 `Incident totals: ${incident.total_addresses} observations, ${incident.total_live_hits} live hits, ${incident.total_rate_limited} rate-limit confirmations`,34 `Countries: ${stats.countries.join(", ") || "unknown"}`,35 `ASNs: ${stats.asns.join(", ") || "unknown"}`,36 "Re-alert policy: immediate on escalation; otherwise at most hourly; resolves after 15 quiet minutes.",37 ].join("\n");38}3940export function notificationId(41 incidentName: string,42 action: string,43 bucket: number,44): string {45 return `${incidentName}:${action}:${bucket}`;46}4748export function enqueueNotification(49 env: Env,50 id: string,51 incidentName: string,52 action: Exclude<AlertAction, null>,53 bucket: number,54 stats: WindowStats,55 incident: IncidentRow,56 now: number,57): D1PreparedStatement {58 const text = `${emailText(action, stats, incident)}\nNotification ID: ${id}`;59 const html =60 `<pre style="font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap">${61 text.replaceAll("&", "&").replaceAll("<", "<")62 }</pre>`;63 return env.DB.prepare(64 `INSERT OR IGNORE INTO security_notifications65 (id, incident_name, action, window_bucket, subject, text, html, created_at)66 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,67 ).bind(68 id,69 incidentName,70 action,71 bucket,72 subject(action, incident.severity),73 text,74 html,75 now,76 );77}7879function errorMessage(error: unknown): string {80 return (error instanceof Error ? error.message : String(error)).slice(81 0,82 1000,83 );84}8586export async function drainSecurityNotifications(87 env: Env,88 now = Math.floor(Date.now() / 1000),89): Promise<void> {90 const pending = await env.DB.prepare(91 `SELECT id, subject, text, html, attempts FROM security_notifications92 WHERE sent_at IS NULL ORDER BY created_at, id LIMIT 10`,93 ).all<NotificationRow>();9495 for (const notification of pending.results) {96 const attempt = notification.attempts + 1;97 try {98 await env.EMAIL.send({99 from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },100 to: env.ALERT_EMAIL_TO,101 subject: notification.subject,102 text: notification.text,103 ...(notification.html ? { html: notification.html } : {}),104 });105 await env.DB.prepare(106 `UPDATE security_notifications SET sent_at = ?, attempts = attempts + 1,107 last_error = NULL WHERE id = ? AND sent_at IS NULL`,108 ).bind(now, notification.id).run();109 console.log({110 event: "security.notification_sent",111 notificationId: notification.id,112 attempt,113 });114 } catch (error) {115 const message = errorMessage(error);116 await env.DB.prepare(117 `UPDATE security_notifications SET attempts = attempts + 1, last_error = ?118 WHERE id = ? AND sent_at IS NULL`,119 ).bind(message, notification.id).run();120 console.error({121 event: "security.notification_failed",122 notificationId: notification.id,123 attempt,124 error: message,125 });126 }127 }128}129130export async function sendAlertTest(env: Env): Promise<void> {131 await env.EMAIL.send({132 from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },133 to: env.ALERT_EMAIL_TO,134 subject: "[nzip] Security alert delivery test",135 text: "nzip security alert delivery is configured correctly.",136 });137}