1import assert from "node:assert/strict";2import test from "node:test";34import {5 chooseAlertAction,6 drainSecurityNotifications,7 evaluateEnumerationWindow,8 sendDailySecurityDigest,9 signalSeverity,10} from "../src/security_alerts.ts";1112function stats(overrides = {}) {13 return {14 bucket: 0,15 addresses: 0,16 scanners: 0,17 misses: 0,18 liveHits: 0,19 rateLimited: 0,20 topScannerAddresses: 0,21 longestSequence: 0,22 vaultMask: 0,23 countries: [],24 asns: [],25 ...overrides,26 };27}2829function incident(overrides = {}) {30 return {31 status: "open",32 severity: 1,33 opened_at: 100,34 last_seen_at: 100,35 last_alerted_at: 100,36 quiet_windows: 0,37 peak_addresses: 20,38 total_addresses: 20,39 total_live_hits: 0,40 total_rate_limited: 0,41 vault_mask: 1,42 ...overrides,43 };44}4546test("a scanner trying 20 distinct addresses opens a warning", () => {47 const window = stats({48 addresses: 20,49 scanners: 1,50 misses: 20,51 topScannerAddresses: 20,52 });53 assert.equal(signalSeverity(window), 1);54 assert.equal(chooseAlertAction(null, window, 300), "open");55});5657test("rate limiting and suspicious live hits are confirmed signals", () => {58 assert.equal(signalSeverity(stats({ rateLimited: 1 })), 2);59 assert.equal(60 signalSeverity(stats({ liveHits: 1, topScannerAddresses: 8 })),61 2,62 );63});6465test("distributed enumeration requires breadth, scanners, and mostly misses", () => {66 assert.equal(67 signalSeverity(stats({ addresses: 128, scanners: 10, misses: 120 })),68 1,69 );70 assert.equal(71 signalSeverity(stats({ addresses: 128, scanners: 9, misses: 128 })),72 0,73 );74 assert.equal(75 signalSeverity(stats({ addresses: 128, scanners: 10, misses: 100 })),76 0,77 );78});7980test("active incidents suppress duplicates but summarize hourly", () => {81 const window = stats({82 addresses: 20,83 scanners: 1,84 misses: 20,85 topScannerAddresses: 20,86 });87 assert.equal(chooseAlertAction(incident(), window, 200), null);88 assert.equal(chooseAlertAction(incident(), window, 3700), "summary");89});9091test("severity and volume changes bypass duplicate suppression", () => {92 assert.equal(93 chooseAlertAction(incident(), stats({ rateLimited: 1 }), 200),94 "escalate",95 );96 assert.equal(97 chooseAlertAction(98 incident(),99 stats({100 addresses: 40,101 scanners: 1,102 misses: 40,103 topScannerAddresses: 40,104 }),105 200,106 ),107 "escalate",108 );109});110111test("a newly targeted vault breaks suppression after 30 minutes", () => {112 const window = stats({113 addresses: 20,114 scanners: 1,115 misses: 20,116 topScannerAddresses: 20,117 vaultMask: 2,118 });119 assert.equal(chooseAlertAction(incident(), window, 1800), null);120 assert.equal(chooseAlertAction(incident(), window, 1900), "escalate");121});122123test("an incident resolves on its third quiet window", () => {124 assert.equal(125 chooseAlertAction(incident({ quiet_windows: 1 }), stats(), 600),126 null,127 );128 assert.equal(129 chooseAlertAction(incident({ quiet_windows: 2 }), stats(), 900),130 "resolve",131 );132});133134class FakeStatement {135 constructor(db, sql, args = []) {136 this.db = db;137 this.sql = sql;138 this.args = args;139 }140141 bind(...args) {142 return new FakeStatement(this.db, this.sql, args);143 }144145 async all() {146 if (this.sql.includes("FROM security_probes")) {147 return { results: this.db.probes };148 }149 if (this.sql.includes("FROM security_notifications")) {150 return {151 results: [...this.db.notifications.values()]152 .filter((row) => row.sent_at === null)153 .sort((a, b) => a.created_at - b.created_at),154 };155 }156 throw new Error(`unexpected all(): ${this.sql}`);157 }158159 async first() {160 if (161 this.sql.includes("COUNT(*) AS addresses") &&162 this.sql.includes("FROM security_probes")163 ) {164 return {165 addresses: this.db.probes.length,166 scanners: new Set(this.db.probes.map((probe) => probe.scanner_id)).size,167 live_hits: this.db.probes.reduce(168 (sum, probe) => sum + probe.is_live,169 0,170 ),171 };172 }173 if (this.sql.includes("FROM security_signals")) {174 return { count: this.db.rateLimited };175 }176 if (this.sql.includes("FROM security_incidents")) {177 return this.db.incident;178 }179 throw new Error(`unexpected first(): ${this.sql}`);180 }181182 async run() {183 if (this.sql.includes("INSERT INTO security_incidents")) {184 const [185 status,186 severity,187 opened_at,188 last_seen_at,189 last_alerted_at,190 quiet_windows,191 peak_addresses,192 total_addresses,193 total_live_hits,194 total_rate_limited,195 vault_mask,196 ] = this.args;197 this.db.incident = {198 status,199 severity,200 opened_at,201 last_seen_at,202 last_alerted_at,203 quiet_windows,204 peak_addresses,205 total_addresses,206 total_live_hits,207 total_rate_limited,208 vault_mask,209 };210 return { success: true };211 }212 if (213 this.sql.includes("INSERT OR IGNORE INTO security_notifications") &&214 this.sql.includes("'daily-digest'")215 ) {216 const [id, window_bucket, subject, text, created_at] = this.args;217 if (!this.db.notifications.has(id)) {218 this.db.notifications.set(id, {219 id,220 incident_name: "security",221 action: "daily-digest",222 window_bucket,223 subject,224 text,225 html: null,226 created_at,227 sent_at: null,228 attempts: 0,229 last_error: null,230 });231 }232 return { success: true };233 }234 if (this.sql.includes("INSERT OR IGNORE INTO security_notifications")) {235 const [236 id,237 incident_name,238 action,239 window_bucket,240 subject,241 text,242 html,243 created_at,244 ] = this.args;245 if (!this.db.notifications.has(id)) {246 this.db.notifications.set(id, {247 id,248 incident_name,249 action,250 window_bucket,251 subject,252 text,253 html,254 created_at,255 sent_at: null,256 attempts: 0,257 last_error: null,258 });259 }260 return { success: true };261 }262 if (this.sql.includes("SET sent_at = ?")) {263 const [sentAt, id] = this.args;264 const row = this.db.notifications.get(id);265 if (row?.sent_at === null) {266 row.sent_at = sentAt;267 row.attempts += 1;268 row.last_error = null;269 }270 return { success: true };271 }272 if (this.sql.includes("SET attempts = attempts + 1")) {273 const [lastError, id] = this.args;274 const row = this.db.notifications.get(id);275 if (row?.sent_at === null) {276 row.attempts += 1;277 row.last_error = lastError;278 }279 return { success: true };280 }281 throw new Error(`unexpected run(): ${this.sql}`);282 }283}284285class FakeDb {286 probes = [];287 rateLimited = 0;288 incident = null;289 notifications = new Map();290 batches = 0;291292 prepare(sql) {293 return new FakeStatement(this, sql);294 }295296 async batch(statements) {297 this.batches += 1;298 const results = [];299 for (const statement of statements) results.push(await statement.run());300 return results;301 }302}303304test("a transient email failure preserves and retries the original alert", async () => {305 const db = new FakeDb();306 db.probes = Array.from({ length: 20 }, (_, address) => ({307 scanner_id: "scanner-a",308 address,309 is_live: 0,310 vault_slot: 0,311 country: "US",312 asn: 64500,313 }));314 const deliveries = [];315 let failNext = true;316 const env = {317 DB: db,318 ALERT_EMAIL_FROM: "security@example.com",319 ALERT_EMAIL_TO: "operator@example.com",320 EMAIL: {321 async send(message) {322 // Both durable records must exist before delivery is attempted.323 assert.equal(db.incident?.status, "open");324 assert.equal(db.notifications.size, 1);325 deliveries.push(message);326 if (failNext) {327 failNext = false;328 throw new Error("temporary email outage");329 }330 },331 },332 };333334 await evaluateEnumerationWindow(env, 600);335336 const [notification] = db.notifications.values();337 assert.equal(db.batches, 1);338 assert.equal(notification.id, "enumeration:open:300");339 assert.equal(notification.sent_at, null);340 assert.equal(notification.attempts, 1);341 assert.equal(notification.last_error, "temporary email outage");342 assert.equal(db.incident.last_alerted_at, 600);343344 // A later cron drain retries the exact stored payload, not a new window.345 await drainSecurityNotifications(env, 900);346347 assert.equal(deliveries.length, 2);348 assert.deepEqual(deliveries[1], deliveries[0]);349 assert.match(deliveries[1].text, /Notification ID: enumeration:open:300/);350 assert.equal(notification.sent_at, 900);351 assert.equal(notification.attempts, 2);352 assert.equal(notification.last_error, null);353});354355test("the daily digest is durably deduplicated by UTC day", async () => {356 const db = new FakeDb();357 db.probes = [{358 scanner_id: "scanner-a",359 address: 1,360 is_live: 0,361 vault_slot: 0,362 country: null,363 asn: null,364 }];365 let sends = 0;366 const env = {367 DB: db,368 ALERT_EMAIL_FROM: "security@example.com",369 ALERT_EMAIL_TO: "operator@example.com",370 EMAIL: {371 async send() {372 sends += 1;373 if (sends === 1) throw new Error("temporary email outage");374 },375 },376 };377 const now = 2 * 86400 + 123;378379 await sendDailySecurityDigest(env, now);380 await sendDailySecurityDigest(env, now + 60);381382 assert.equal(db.notifications.size, 1);383 const [notification] = db.notifications.values();384 assert.equal(notification.id, `security:daily-digest:${2 * 86400}`);385 assert.match(386 notification.text,387 new RegExp(`Notification ID: ${notification.id}`),388 );389 assert.equal(notification.sent_at, now + 60);390 assert.equal(notification.attempts, 2);391});