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_alerts.ts
security_alerts.ts 7.4K
1import type { Env } from "./env.ts";2import {3 drainSecurityNotifications,4 enqueueNotification,5 notificationId,6} from "./security/notifications.ts";7import {8 chooseAlertAction,9 type IncidentRow,10 longestAdjacentRun,11 signalSeverity,12 type WindowStats,13} from "./security/policy.ts";1415export {16 drainSecurityNotifications,17 sendAlertTest,18} from "./security/notifications.ts";19export { chooseAlertAction, signalSeverity } from "./security/policy.ts";20export type { AlertAction, WindowStats } from "./security/policy.ts";2122async function loadWindow(env: Env, bucket: number): Promise<WindowStats> {23 const probes = await env.DB.prepare(24 `SELECT scanner_id, address, is_live, vault_slot, country, asn25 FROM security_probes WHERE bucket = ?`,26 ).bind(bucket).all<{27 scanner_id: string;28 address: number;29 is_live: number;30 vault_slot: number;31 country: string | null;32 asn: number | null;33 }>();34 const signals = await env.DB.prepare(35 `SELECT COUNT(*) AS count FROM security_signals36 WHERE bucket = ? AND kind = 'rate_limited'`,37 ).bind(bucket).first<{ count: number }>();3839 const byScanner = new Map<string, number[]>();40 let liveHits = 0;41 let vaultMask = 0;42 const countries = new Set<string>();43 const asns = new Set<number>();44 for (const probe of probes.results) {45 const addresses = byScanner.get(probe.scanner_id) ?? [];46 addresses.push(probe.address);47 byScanner.set(probe.scanner_id, addresses);48 liveHits += probe.is_live;49 vaultMask |= 1 << probe.vault_slot;50 if (probe.country) countries.add(probe.country);51 if (probe.asn !== null) asns.add(probe.asn);52 }53 const scannerAddresses = [...byScanner.values()];54 return {55 bucket,56 addresses: probes.results.length,57 scanners: byScanner.size,58 misses: probes.results.length - liveHits,59 liveHits,60 rateLimited: signals?.count ?? 0,61 topScannerAddresses: Math.max(62 0,63 ...scannerAddresses.map((values) => values.length),64 ),65 longestSequence: Math.max(0, ...scannerAddresses.map(longestAdjacentRun)),66 vaultMask,67 countries: [...countries].sort().slice(0, 10),68 asns: [...asns].sort((a, b) => a - b).slice(0, 10),69 };70}7172export async function evaluateEnumerationWindow(73 env: Env,74 now = Math.floor(Date.now() / 1000),75): Promise<void> {76 const bucket = now - (now % 300) - 300;77 const stats = await loadWindow(env, bucket);78 const current = await env.DB.prepare(79 "SELECT * FROM security_incidents WHERE name = 'enumeration'",80 ).first<IncidentRow>();81 const action = chooseAlertAction(current ?? null, stats, now);82 const severity = signalSeverity(stats, current?.status === "open");8384 let incident: IncidentRow;85 if (!current || current.status === "closed") {86 incident = {87 status: severity > 0 ? "open" : "closed",88 severity,89 opened_at: now,90 last_seen_at: severity > 0 ? now : 0,91 last_alerted_at: action ? now : 0,92 quiet_windows: 0,93 peak_addresses: stats.addresses,94 total_addresses: stats.addresses,95 total_live_hits: stats.liveHits,96 total_rate_limited: stats.rateLimited,97 vault_mask: stats.vaultMask,98 };99 } else {100 incident = {101 ...current,102 status: action === "resolve" ? "closed" : "open",103 severity: Math.max(current.severity, severity),104 last_seen_at: severity > 0 ? now : current.last_seen_at,105 last_alerted_at: action ? now : current.last_alerted_at,106 quiet_windows: severity > 0 ? 0 : current.quiet_windows + 1,107 peak_addresses: Math.max(current.peak_addresses, stats.addresses),108 total_addresses: current.total_addresses + stats.addresses,109 total_live_hits: current.total_live_hits + stats.liveHits,110 total_rate_limited: current.total_rate_limited + stats.rateLimited,111 vault_mask: current.vault_mask | stats.vaultMask,112 };113 }114115 const upsertIncident = env.DB.prepare(116 `INSERT INTO security_incidents117 (name, status, severity, opened_at, last_seen_at, last_alerted_at, quiet_windows,118 peak_addresses, total_addresses, total_live_hits, total_rate_limited, vault_mask)119 VALUES ('enumeration', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)120 ON CONFLICT(name) DO UPDATE SET status=excluded.status, severity=excluded.severity,121 opened_at=excluded.opened_at, last_seen_at=excluded.last_seen_at,122 last_alerted_at=excluded.last_alerted_at, quiet_windows=excluded.quiet_windows,123 peak_addresses=excluded.peak_addresses, total_addresses=excluded.total_addresses,124 total_live_hits=excluded.total_live_hits, total_rate_limited=excluded.total_rate_limited,125 vault_mask=excluded.vault_mask`,126 ).bind(127 incident.status,128 incident.severity,129 incident.opened_at,130 incident.last_seen_at,131 incident.last_alerted_at,132 incident.quiet_windows,133 incident.peak_addresses,134 incident.total_addresses,135 incident.total_live_hits,136 incident.total_rate_limited,137 incident.vault_mask,138 );139140 if (action) {141 const id = notificationId("enumeration", action, bucket);142 await env.DB.batch([143 upsertIncident,144 enqueueNotification(145 env,146 id,147 "enumeration",148 action,149 bucket,150 stats,151 incident,152 now,153 ),154 ]);155 } else {156 await upsertIncident.run();157 }158159 await drainSecurityNotifications(env, now);160161 console.log({ event: "security.enumeration_window", action, ...stats });162}163164export async function sendDailySecurityDigest(165 env: Env,166 now = Math.floor(Date.now() / 1000),167): Promise<void> {168 const since = now - 86400;169 const probes = await env.DB.prepare(170 `SELECT COUNT(*) AS addresses, COUNT(DISTINCT scanner_id) AS scanners,171 SUM(is_live) AS live_hits172 FROM security_probes WHERE bucket >= ?`,173 ).bind(since).first<174 { addresses: number; scanners: number; live_hits: number | null }175 >();176 if (!probes || probes.addresses === 0) {177 await drainSecurityNotifications(env, now);178 return;179 }180 const signals = await env.DB.prepare(181 `SELECT COUNT(*) AS count FROM security_signals182 WHERE bucket >= ? AND kind = 'rate_limited'`,183 ).bind(since).first<{ count: number }>();184 const day = now - (now % 86400);185 const id = notificationId("security", "daily-digest", day);186 const text = [187 "nzip security activity for the last 24 hours",188 `Distinct scanner/address pairs: ${probes.addresses}`,189 `Scanner identities: ${probes.scanners}`,190 `Live-address hits: ${probes.live_hits ?? 0}`,191 `Rate-limit confirmations: ${signals?.count ?? 0}`,192 "No digest is sent on days without bare-address probe activity.",193 `Notification ID: ${id}`,194 ].join("\n");195 await env.DB.prepare(196 `INSERT OR IGNORE INTO security_notifications197 (id, incident_name, action, window_bucket, subject, text, html, created_at)198 VALUES (?, 'security', 'daily-digest', ?, ?, ?, NULL, ?)`,199 ).bind(200 id,201 day,202 "[nzip] Daily security activity summary",203 text,204 now,205 ).run();206 await drainSecurityNotifications(env, now);207}208209export async function pruneSecurityTelemetry(210 env: Env,211 now = Math.floor(Date.now() / 1000),212): Promise<void> {213 const cutoff = now - 7 * 86400;214 const notificationCutoff = now - 30 * 86400;215 await env.DB.batch([216 env.DB.prepare("DELETE FROM security_probes WHERE bucket < ?").bind(cutoff),217 env.DB.prepare("DELETE FROM security_signals WHERE bucket < ?").bind(218 cutoff,219 ),220 env.DB.prepare(221 "DELETE FROM security_notifications WHERE sent_at IS NOT NULL AND sent_at < ?",222 ).bind(notificationCutoff),223 ]);224}