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 const [now] = this.args;151 return {152 results: [...this.db.notifications.values()]153 .filter((row) =>154 row.sent_at === null &&155 (row.lease_expires_at === null || row.lease_expires_at < now)156 )157 .sort((a, b) => a.created_at - b.created_at),158 };159 }160 throw new Error(`unexpected all(): ${this.sql}`);161 }162163 async first() {164 if (165 this.sql.includes("COUNT(*) AS addresses") &&166 this.sql.includes("FROM security_probes")167 ) {168 return {169 addresses: this.db.probes.length,170 scanners: new Set(this.db.probes.map((probe) => probe.scanner_id)).size,171 live_hits: this.db.probes.reduce(172 (sum, probe) => sum + probe.is_live,173 0,174 ),175 };176 }177 if (this.sql.includes("FROM security_signals")) {178 return { count: this.db.rateLimited };179 }180 if (this.sql.includes("FROM security_incidents")) {181 return this.db.incident;182 }183 throw new Error(`unexpected first(): ${this.sql}`);184 }185186 async run() {187 if (this.sql.includes("INSERT INTO security_incidents")) {188 const [189 status,190 severity,191 opened_at,192 last_seen_at,193 last_alerted_at,194 quiet_windows,195 peak_addresses,196 total_addresses,197 total_live_hits,198 total_rate_limited,199 vault_mask,200 ] = this.args;201 this.db.incident = {202 status,203 severity,204 opened_at,205 last_seen_at,206 last_alerted_at,207 quiet_windows,208 peak_addresses,209 total_addresses,210 total_live_hits,211 total_rate_limited,212 vault_mask,213 };214 return { success: true };215 }216 if (217 this.sql.includes("INSERT OR IGNORE INTO security_notifications") &&218 this.sql.includes("'daily-digest'")219 ) {220 const [id, window_bucket, subject, text, created_at] = this.args;221 if (!this.db.notifications.has(id)) {222 this.db.notifications.set(id, {223 id,224 incident_name: "security",225 action: "daily-digest",226 window_bucket,227 subject,228 text,229 html: null,230 created_at,231 sent_at: null,232 attempts: 0,233 last_error: null,234 lease_owner: null,235 lease_expires_at: null,236 });237 }238 return { success: true };239 }240 if (this.sql.includes("INSERT OR IGNORE INTO security_notifications")) {241 const [242 id,243 incident_name,244 action,245 window_bucket,246 subject,247 text,248 html,249 created_at,250 ] = this.args;251 if (!this.db.notifications.has(id)) {252 this.db.notifications.set(id, {253 id,254 incident_name,255 action,256 window_bucket,257 subject,258 text,259 html,260 created_at,261 sent_at: null,262 attempts: 0,263 last_error: null,264 lease_owner: null,265 lease_expires_at: null,266 });267 }268 return { success: true };269 }270 if (this.sql.includes("SET lease_owner = ?")) {271 const [owner, leaseExpiresAt, id, now] = this.args;272 const row = this.db.notifications.get(id);273 if (274 row?.sent_at === null &&275 (row.lease_expires_at === null || row.lease_expires_at < now)276 ) {277 row.lease_owner = owner;278 row.lease_expires_at = leaseExpiresAt;279 return { success: true, meta: { changes: 1 } };280 }281 return { success: true, meta: { changes: 0 } };282 }283 if (this.sql.includes("SET sent_at = ?")) {284 const [sentAt, id, owner] = this.args;285 const row = this.db.notifications.get(id);286 if (row?.sent_at === null && row.lease_owner === owner) {287 row.sent_at = sentAt;288 row.attempts += 1;289 row.last_error = null;290 row.lease_owner = null;291 row.lease_expires_at = null;292 }293 return { success: true, meta: { changes: 1 } };294 }295 if (this.sql.includes("SET attempts = attempts + 1")) {296 const [lastError, id, owner] = this.args;297 const row = this.db.notifications.get(id);298 if (row?.sent_at === null && row.lease_owner === owner) {299 row.attempts += 1;300 row.last_error = lastError;301 row.lease_owner = null;302 row.lease_expires_at = null;303 }304 return { success: true, meta: { changes: 1 } };305 }306 throw new Error(`unexpected run(): ${this.sql}`);307 }308}309310class FakeDb {311 probes = [];312 rateLimited = 0;313 incident = null;314 notifications = new Map();315 batches = 0;316317 prepare(sql) {318 return new FakeStatement(this, sql);319 }320321 async batch(statements) {322 this.batches += 1;323 const results = [];324 for (const statement of statements) results.push(await statement.run());325 return results;326 }327}328329test("a transient email failure preserves and retries the original alert", async () => {330 const db = new FakeDb();331 db.probes = Array.from({ length: 20 }, (_, address) => ({332 scanner_id: "scanner-a",333 address,334 is_live: 0,335 vault_slot: 0,336 country: "US",337 asn: 64500,338 }));339 const deliveries = [];340 let failNext = true;341 const env = {342 DB: db,343 ALERT_EMAIL_FROM: "security@example.com",344 ALERT_EMAIL_TO: "operator@example.com",345 EMAIL: {346 async send(message) {347 // Both durable records must exist before delivery is attempted.348 assert.equal(db.incident?.status, "open");349 assert.equal(db.notifications.size, 1);350 deliveries.push(message);351 if (failNext) {352 failNext = false;353 throw new Error("temporary email outage");354 }355 },356 },357 };358359 await evaluateEnumerationWindow(env, 600);360361 const [notification] = db.notifications.values();362 assert.equal(db.batches, 1);363 assert.equal(notification.id, "enumeration:open:300");364 assert.equal(notification.sent_at, null);365 assert.equal(notification.attempts, 1);366 assert.equal(notification.last_error, "temporary email outage");367 assert.equal(db.incident.last_alerted_at, 600);368369 // A later cron drain retries the exact stored payload, not a new window.370 await drainSecurityNotifications(env, 900);371372 assert.equal(deliveries.length, 2);373 assert.deepEqual(deliveries[1], deliveries[0]);374 assert.match(deliveries[1].text, /Notification ID: enumeration:open:300/);375 assert.equal(notification.sent_at, 900);376 assert.equal(notification.attempts, 2);377 assert.equal(notification.last_error, null);378});379380test("the daily digest is durably deduplicated by UTC day", async () => {381 const db = new FakeDb();382 db.probes = [{383 scanner_id: "scanner-a",384 address: 1,385 is_live: 0,386 vault_slot: 0,387 country: null,388 asn: null,389 }];390 let sends = 0;391 const env = {392 DB: db,393 ALERT_EMAIL_FROM: "security@example.com",394 ALERT_EMAIL_TO: "operator@example.com",395 EMAIL: {396 async send() {397 sends += 1;398 if (sends === 1) throw new Error("temporary email outage");399 },400 },401 };402 const now = 2 * 86400 + 123;403404 await sendDailySecurityDigest(env, now);405 await sendDailySecurityDigest(env, now + 60);406407 assert.equal(db.notifications.size, 1);408 const [notification] = db.notifications.values();409 assert.equal(notification.id, `security:daily-digest:${2 * 86400}`);410 assert.match(411 notification.text,412 new RegExp(`Notification ID: ${notification.id}`),413 );414 assert.equal(notification.sent_at, now + 60);415 assert.equal(notification.attempts, 2);416});417418test("concurrent cron drains claim a security email only once", async () => {419 const db = new FakeDb();420 db.notifications.set("security:daily-digest:0", {421 id: "security:daily-digest:0",422 incident_name: "security",423 action: "daily-digest",424 window_bucket: 0,425 subject: "[nzip] Daily security activity summary",426 text: "activity",427 html: null,428 created_at: 100,429 sent_at: null,430 attempts: 0,431 last_error: null,432 lease_owner: null,433 lease_expires_at: null,434 });435 let sends = 0;436 const env = {437 DB: db,438 ALERT_EMAIL_FROM: "security@example.com",439 ALERT_EMAIL_TO: "operator@example.com",440 EMAIL: {441 async send() {442 sends += 1;443 await new Promise((resolve) => setImmediate(resolve));444 },445 },446 };447448 await Promise.all([449 drainSecurityNotifications(env, 200),450 drainSecurityNotifications(env, 200),451 ]);452453 assert.equal(sends, 1);454 const notification = db.notifications.get("security:daily-digest:0");455 assert.equal(notification.sent_at, 200);456 assert.equal(notification.attempts, 1);457 assert.equal(notification.lease_owner, null);458 assert.equal(notification.lease_expires_at, null);459});