iri-shield 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,793 @@
1
+ 'use strict';
2
+
3
+ const { dirname } = require('path');
4
+ const { mkdirSync } = require('fs');
5
+ const { DatabaseSync } = require('node:sqlite');
6
+ const { MemoryStorage, paginate } = require('./storage');
7
+
8
+ // Max rows before cleanup (FIFO — oldest rows deleted first)
9
+ const DEFAULT_MAX_REQUEST_ROWS = 50_000;
10
+ const DEFAULT_MAX_EVENT_ROWS = 10_000;
11
+ const CLEANUP_RATIO = 0.1; // delete 10% oldest when limit exceeded
12
+
13
+ class SQLiteStorage extends MemoryStorage {
14
+ constructor(options = {}) {
15
+ super(options);
16
+ this.file = options.file || options.filename || './data/iri-shield.sqlite';
17
+ this.maxRequestRows = options.maxRequestRows || DEFAULT_MAX_REQUEST_ROWS;
18
+ this.maxEventRows = options.maxEventRows || DEFAULT_MAX_EVENT_ROWS;
19
+ this.retentionDays = options.retentionDays || 0;
20
+
21
+ mkdirSync(dirname(this.file), { recursive: true });
22
+ this.db = new DatabaseSync(this.file);
23
+ this._initSchema();
24
+ this._loadAllPersistentData();
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Schema & Migration
29
+ // ---------------------------------------------------------------------------
30
+
31
+ _initSchema() {
32
+ this.db.exec(`
33
+ PRAGMA journal_mode = WAL;
34
+ PRAGMA synchronous = NORMAL;
35
+
36
+ CREATE TABLE IF NOT EXISTS iri_requests (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ timestamp TEXT,
39
+ ip TEXT,
40
+ client_id TEXT,
41
+ fingerprint TEXT,
42
+ user_agent TEXT,
43
+ session_id TEXT,
44
+ endpoint TEXT,
45
+ method TEXT,
46
+ status_code INTEGER,
47
+ duration_ms REAL,
48
+ blocked INTEGER
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS iri_events (
52
+ id TEXT PRIMARY KEY,
53
+ timestamp TEXT,
54
+ ip TEXT,
55
+ method TEXT,
56
+ endpoint TEXT,
57
+ user_agent TEXT,
58
+ request_id TEXT,
59
+ threat TEXT,
60
+ risk_level TEXT,
61
+ risk_score INTEGER,
62
+ action TEXT,
63
+ reason TEXT,
64
+ data TEXT
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS iri_clients (
68
+ client_id TEXT PRIMARY KEY,
69
+ data TEXT NOT NULL
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS iri_blocks (
73
+ ip TEXT PRIMARY KEY,
74
+ reason TEXT,
75
+ score INTEGER,
76
+ manual INTEGER DEFAULT 0,
77
+ blocked_at TEXT,
78
+ expires_at INTEGER
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS iri_alerts (
82
+ client_id TEXT PRIMARY KEY,
83
+ first_alert_at TEXT,
84
+ last_alert_at TEXT,
85
+ count INTEGER DEFAULT 0,
86
+ last_score INTEGER,
87
+ last_risk TEXT,
88
+ last_ip TEXT,
89
+ threats TEXT,
90
+ dismissed INTEGER DEFAULT 0
91
+ );
92
+
93
+ CREATE INDEX IF NOT EXISTS idx_req_ip ON iri_requests (ip);
94
+ CREATE INDEX IF NOT EXISTS idx_req_ts ON iri_requests (timestamp);
95
+ CREATE INDEX IF NOT EXISTS idx_evt_risk ON iri_events (risk_level);
96
+ CREATE INDEX IF NOT EXISTS idx_evt_ts ON iri_events (timestamp);
97
+ CREATE INDEX IF NOT EXISTS idx_blk_exp ON iri_blocks (expires_at);
98
+ `);
99
+
100
+ // Automatic migration for existing tables
101
+ try {
102
+ const eventCols = this.db.prepare('PRAGMA table_info(iri_events)').all().map((c) => c.name);
103
+ if (eventCols.length && !eventCols.includes('data')) {
104
+ this.db.exec('ALTER TABLE iri_events ADD COLUMN data TEXT;');
105
+ }
106
+ if (eventCols.length && !eventCols.includes('breakdown')) {
107
+ this.db.exec('ALTER TABLE iri_events ADD COLUMN breakdown TEXT;');
108
+ }
109
+ if (eventCols.length && !eventCols.includes('confidence')) {
110
+ this.db.exec('ALTER TABLE iri_events ADD COLUMN confidence INTEGER DEFAULT 0;');
111
+ }
112
+ if (eventCols.length && !eventCols.includes('correlated_attack')) {
113
+ this.db.exec('ALTER TABLE iri_events ADD COLUMN correlated_attack TEXT;');
114
+ }
115
+ const blockCols = this.db.prepare('PRAGMA table_info(iri_blocks)').all().map((c) => c.name);
116
+ if (blockCols.length && !blockCols.includes('blocked_at')) {
117
+ this.db.exec('ALTER TABLE iri_blocks ADD COLUMN blocked_at TEXT;');
118
+ }
119
+ if (blockCols.length && !blockCols.includes('manual')) {
120
+ this.db.exec('ALTER TABLE iri_blocks ADD COLUMN manual INTEGER DEFAULT 0;');
121
+ }
122
+ } catch { /* table is fresh */ }
123
+
124
+ // Retention purge: remove old rows older than retentionDays
125
+ if (this.retentionDays && this.retentionDays > 0) {
126
+ const cutoff = new Date(Date.now() - this.retentionDays * 24 * 60 * 60 * 1000).toISOString();
127
+ try {
128
+ this.db.prepare('DELETE FROM iri_requests WHERE timestamp < ?').run(cutoff);
129
+ this.db.prepare('DELETE FROM iri_events WHERE timestamp < ?').run(cutoff);
130
+ } catch (_) { /* ignore */ }
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Load All Persistent Data from SQLite on Startup / Restart
136
+ // ---------------------------------------------------------------------------
137
+
138
+ _loadAllPersistentData() {
139
+ this._loadPersistentBlocks();
140
+ this._loadPersistentClients();
141
+ this._loadPersistentAlerts();
142
+ this._loadPersistentEvents();
143
+ this._loadPersistentRequests();
144
+ }
145
+
146
+ _loadPersistentBlocks() {
147
+ try {
148
+ const rows = this.db.prepare('SELECT * FROM iri_blocks').all();
149
+ for (const row of rows) {
150
+ this.blocks.set(row.ip, {
151
+ reason: row.reason || '',
152
+ score: row.score || 0,
153
+ manual: Boolean(row.manual),
154
+ blockedAt: row.blocked_at || null,
155
+ expiresAt: row.expires_at || null
156
+ });
157
+ }
158
+ } catch { /* ignore */ }
159
+ }
160
+
161
+ _loadPersistentClients() {
162
+ try {
163
+ const rows = this.db.prepare('SELECT data FROM iri_clients').all();
164
+ for (const row of rows) {
165
+ if (row.data) {
166
+ try {
167
+ const client = JSON.parse(row.data);
168
+ if (client && client.clientId) {
169
+ this.clients.set(client.clientId, client);
170
+ }
171
+ } catch { /* ignore bad row */ }
172
+ }
173
+ }
174
+ } catch { /* ignore */ }
175
+ }
176
+
177
+ _loadPersistentAlerts() {
178
+ try {
179
+ const rows = this.db.prepare('SELECT * FROM iri_alerts').all();
180
+ for (const row of rows) {
181
+ let threats = [];
182
+ try { threats = JSON.parse(row.threats || '[]'); } catch { /* ignore */ }
183
+ this.alerts.set(row.client_id, {
184
+ clientId: row.client_id,
185
+ firstAlertAt: row.first_alert_at,
186
+ lastAlertAt: row.last_alert_at,
187
+ count: row.count || 1,
188
+ lastScore: row.last_score || 0,
189
+ lastRisk: row.last_risk || 'medium',
190
+ lastIp: row.last_ip || '',
191
+ threats,
192
+ dismissed: Boolean(row.dismissed)
193
+ });
194
+ }
195
+ } catch { /* ignore */ }
196
+ }
197
+
198
+ _loadPersistentEvents() {
199
+ try {
200
+ const rows = this.db
201
+ .prepare('SELECT * FROM iri_events ORDER BY rowid DESC LIMIT ?')
202
+ .all(this.maxEvents);
203
+
204
+ this.events = [];
205
+ this.threatDistribution = new Map();
206
+
207
+ for (const row of rows) {
208
+ let eventObj = null;
209
+ if (row.data) {
210
+ try { eventObj = JSON.parse(row.data); } catch { /* ignore */ }
211
+ }
212
+ if (!eventObj) {
213
+ eventObj = {
214
+ id: row.id,
215
+ timestamp: row.timestamp,
216
+ ip: row.ip,
217
+ method: row.method,
218
+ endpoint: row.endpoint,
219
+ userAgent: row.user_agent,
220
+ requestId: row.request_id,
221
+ threat: row.threat,
222
+ riskLevel: row.risk_level,
223
+ riskScore: row.risk_score,
224
+ action: row.action,
225
+ reason: row.reason
226
+ };
227
+ }
228
+ this.events.push(eventObj);
229
+ const threat = eventObj.threat || row.threat || 'unknown';
230
+ this.threatDistribution.set(threat, (this.threatDistribution.get(threat) || 0) + 1);
231
+ }
232
+ } catch { /* ignore */ }
233
+ }
234
+
235
+ _loadPersistentRequests() {
236
+ try {
237
+ // Aggregates for totals
238
+ const { total_count, blocked_count, sum_lat } =
239
+ this.db.prepare(`
240
+ SELECT
241
+ COUNT(*) as total_count,
242
+ SUM(CASE WHEN blocked = 1 THEN 1 ELSE 0 END) as blocked_count,
243
+ SUM(duration_ms) as sum_lat
244
+ FROM iri_requests
245
+ `).get() || {};
246
+
247
+ this.totalRequests = total_count || 0;
248
+ this.blockedRequests = blocked_count || 0;
249
+ this.totalLatencyMs = sum_lat || 0;
250
+
251
+ // Endpoint stats aggregation
252
+ const epRows = this.db.prepare(`
253
+ SELECT
254
+ method,
255
+ endpoint,
256
+ COUNT(*) as count,
257
+ SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as errors,
258
+ AVG(duration_ms) as avgLatencyMs
259
+ FROM iri_requests
260
+ GROUP BY method, endpoint
261
+ ORDER BY count DESC
262
+ LIMIT 50
263
+ `).all();
264
+
265
+ this.endpointStats = new Map();
266
+ for (const row of epRows) {
267
+ const key = `${row.method || 'GET'} ${row.endpoint || '/'}`;
268
+ this.endpointStats.set(key, {
269
+ endpoint: row.endpoint || '/',
270
+ method: row.method || 'GET',
271
+ count: row.count || 0,
272
+ errors: row.errors || 0,
273
+ avgLatencyMs: row.avgLatencyMs || 0
274
+ });
275
+ }
276
+
277
+ // Recent requests list
278
+ const reqRows = this.db
279
+ .prepare('SELECT * FROM iri_requests ORDER BY id DESC LIMIT ?')
280
+ .all(this.maxRequests);
281
+
282
+ this.requests = reqRows.map((r) => ({
283
+ timestamp: r.timestamp,
284
+ ip: r.ip,
285
+ clientId: r.client_id,
286
+ fingerprint: r.fingerprint,
287
+ userAgent: r.user_agent,
288
+ sessionId: r.session_id,
289
+ endpoint: r.endpoint,
290
+ method: r.method,
291
+ statusCode: r.status_code,
292
+ durationMs: r.duration_ms,
293
+ blocked: Boolean(r.blocked)
294
+ }));
295
+ } catch { /* ignore */ }
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Clear
300
+ // ---------------------------------------------------------------------------
301
+
302
+ clear() {
303
+ super.clear();
304
+ if (this.db) {
305
+ this.db.exec(
306
+ 'DELETE FROM iri_requests; DELETE FROM iri_events; DELETE FROM iri_clients; DELETE FROM iri_blocks; DELETE FROM iri_alerts;'
307
+ );
308
+ }
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Requests
313
+ // ---------------------------------------------------------------------------
314
+
315
+ recordRequest(request) {
316
+ super.recordRequest(request);
317
+ this.db
318
+ .prepare(
319
+ `INSERT INTO iri_requests
320
+ (timestamp, ip, client_id, fingerprint, user_agent, session_id, endpoint, method, status_code, duration_ms, blocked)
321
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
322
+ )
323
+ .run(
324
+ new Date().toISOString(),
325
+ request.ip || 'unknown',
326
+ request.clientId || null,
327
+ request.fingerprint || null,
328
+ request.userAgent || '',
329
+ request.sessionId || '',
330
+ request.endpoint || '/',
331
+ request.method || 'GET',
332
+ request.statusCode || 0,
333
+ request.durationMs || 0,
334
+ request.blocked ? 1 : 0
335
+ );
336
+
337
+ this._maybeCleanRequests();
338
+ }
339
+
340
+ _maybeCleanRequests() {
341
+ const { count } = this.db.prepare('SELECT COUNT(*) as count FROM iri_requests').get() || {};
342
+ if (count > this.maxRequestRows) {
343
+ const deleteCount = Math.ceil(this.maxRequestRows * CLEANUP_RATIO);
344
+ this.db.exec(
345
+ `DELETE FROM iri_requests WHERE id IN (SELECT id FROM iri_requests ORDER BY id ASC LIMIT ${deleteCount})`
346
+ );
347
+ }
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Events
352
+ // ---------------------------------------------------------------------------
353
+
354
+ recordEvent(event) {
355
+ super.recordEvent(event);
356
+ this.db
357
+ .prepare(
358
+ `INSERT OR REPLACE INTO iri_events
359
+ (id, timestamp, ip, method, endpoint, user_agent, request_id, threat, risk_level, risk_score, action, reason, breakdown, confidence, correlated_attack, data)
360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
361
+ )
362
+ .run(
363
+ event.id,
364
+ event.timestamp,
365
+ event.ip,
366
+ event.method,
367
+ event.endpoint,
368
+ event.userAgent,
369
+ event.requestId || '',
370
+ event.threat || '',
371
+ event.riskLevel || '',
372
+ event.riskScore || 0,
373
+ event.action || '',
374
+ event.reason || '',
375
+ event.breakdown ? JSON.stringify(event.breakdown) : '[]',
376
+ event.confidence || 0,
377
+ event.correlatedAttack ? JSON.stringify(event.correlatedAttack) : null,
378
+ JSON.stringify(event)
379
+ );
380
+
381
+ this._maybeCleanEvents();
382
+ }
383
+
384
+ _maybeCleanEvents() {
385
+ const { count } = this.db.prepare('SELECT COUNT(*) as count FROM iri_events').get() || {};
386
+ if (count > this.maxEventRows) {
387
+ const deleteCount = Math.ceil(this.maxEventRows * CLEANUP_RATIO);
388
+ this.db.exec(
389
+ `DELETE FROM iri_events WHERE id IN (SELECT id FROM iri_events ORDER BY rowid ASC LIMIT ${deleteCount})`
390
+ );
391
+ }
392
+ }
393
+
394
+ getEvents({ page = 1, perPage = 20, riskLevel = null } = {}) {
395
+ try {
396
+ const offset = (Math.max(1, page) - 1) * perPage;
397
+ let rows;
398
+ let total;
399
+
400
+ if (riskLevel) {
401
+ total = this.db.prepare('SELECT COUNT(*) as total FROM iri_events WHERE risk_level = ?').get(riskLevel)?.total || 0;
402
+ rows = this.db.prepare('SELECT * FROM iri_events WHERE risk_level = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?').all(riskLevel, perPage, offset);
403
+ } else {
404
+ total = this.db.prepare('SELECT COUNT(*) as total FROM iri_events').get()?.total || 0;
405
+ rows = this.db.prepare('SELECT * FROM iri_events ORDER BY timestamp DESC LIMIT ? OFFSET ?').all(perPage, offset);
406
+ }
407
+
408
+ const data = rows.map((row) => {
409
+ if (row.data) {
410
+ try { return JSON.parse(row.data); } catch { /* ignore */ }
411
+ }
412
+ return {
413
+ id: row.id,
414
+ timestamp: row.timestamp,
415
+ ip: row.ip,
416
+ method: row.method,
417
+ endpoint: row.endpoint,
418
+ userAgent: row.user_agent,
419
+ requestId: row.request_id,
420
+ threat: row.threat,
421
+ riskLevel: row.risk_level,
422
+ riskScore: row.risk_score,
423
+ action: row.action,
424
+ reason: row.reason
425
+ };
426
+ });
427
+
428
+ const totalPages = Math.ceil(total / perPage) || 1;
429
+ return { data, total, page: Math.max(1, page), perPage, totalPages };
430
+ } catch {
431
+ return super.getEvents({ page, perPage, riskLevel });
432
+ }
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Clients
437
+ // ---------------------------------------------------------------------------
438
+
439
+ recordClient(client) {
440
+ const row = super.recordClient(client);
441
+ row.userId = client.userId || row.userId;
442
+ this.db
443
+ .prepare('INSERT OR REPLACE INTO iri_clients (client_id, data) VALUES (?, ?)')
444
+ .run(row.clientId, JSON.stringify(row));
445
+ return row;
446
+ }
447
+
448
+ getClients({ page = 1, perPage = 20 } = {}) {
449
+ try {
450
+ const offset = (Math.max(1, page) - 1) * perPage;
451
+ const { total } = this.db.prepare('SELECT COUNT(*) as total FROM iri_clients').get() || { total: 0 };
452
+ const rows = this.db.prepare('SELECT data FROM iri_clients LIMIT ? OFFSET ?').all(perPage, offset);
453
+
454
+ const clients = rows.map((r) => {
455
+ try { return JSON.parse(r.data); } catch { return null; }
456
+ }).filter(Boolean);
457
+
458
+ clients.sort((a, b) => (b.requestCount || 0) - (a.requestCount || 0));
459
+ const totalPages = Math.ceil(total / perPage) || 1;
460
+ return { data: clients, total, page: Math.max(1, page), perPage, totalPages };
461
+ } catch {
462
+ return super.getClients({ page, perPage });
463
+ }
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Blocks (Persistent & History Retained)
468
+ // ---------------------------------------------------------------------------
469
+
470
+ blockIp(ip, block) {
471
+ const fullBlock = {
472
+ ...block,
473
+ blockedAt: block.blockedAt || new Date().toISOString(),
474
+ manual: Boolean(block.manual)
475
+ };
476
+ super.blockIp(ip, fullBlock);
477
+ this.db
478
+ .prepare(
479
+ `INSERT OR REPLACE INTO iri_blocks (ip, reason, score, manual, blocked_at, expires_at)
480
+ VALUES (?, ?, ?, ?, ?, ?)`
481
+ )
482
+ .run(
483
+ ip,
484
+ fullBlock.reason || '',
485
+ fullBlock.score || 0,
486
+ fullBlock.manual ? 1 : 0,
487
+ fullBlock.blockedAt,
488
+ fullBlock.expiresAt || null
489
+ );
490
+ }
491
+
492
+ unblockIp(ip) {
493
+ const result = super.unblockIp(ip);
494
+ this.db.prepare('DELETE FROM iri_blocks WHERE ip = ?').run(ip);
495
+ return result;
496
+ }
497
+
498
+ manualBlockIp(ip, options = {}) {
499
+ const duration = options.durationMs !== undefined ? options.durationMs : 24 * 60 * 60 * 1000;
500
+ const fullBlock = {
501
+ expiresAt: duration > 0 ? Date.now() + duration : null,
502
+ reason: options.reason || 'manual_block',
503
+ score: options.score !== undefined ? options.score : 100,
504
+ manual: true,
505
+ blockedAt: new Date().toISOString()
506
+ };
507
+ super.blockIp(ip, fullBlock);
508
+ this.db
509
+ .prepare(
510
+ `INSERT OR REPLACE INTO iri_blocks (ip, reason, score, manual, blocked_at, expires_at)
511
+ VALUES (?, ?, ?, 1, ?, ?)`
512
+ )
513
+ .run(ip, fullBlock.reason, fullBlock.score, fullBlock.blockedAt, fullBlock.expiresAt);
514
+ }
515
+
516
+ getBlock(ip) {
517
+ const block = this.blocks.get(ip);
518
+ if (!block) return null;
519
+ if (block.expiresAt && block.expiresAt <= Date.now()) {
520
+ return null; // Expired, do not block incoming traffic
521
+ }
522
+ return block;
523
+ }
524
+
525
+ getBlockedIps({ page = 1, perPage = 20, status = 'all' } = {}) {
526
+ try {
527
+ const now = Date.now();
528
+ let query;
529
+ let countQuery;
530
+ let params = [];
531
+
532
+ if (status === 'active') {
533
+ countQuery = 'SELECT COUNT(*) as total FROM iri_blocks WHERE expires_at IS NULL OR expires_at > ?';
534
+ query = 'SELECT * FROM iri_blocks WHERE expires_at IS NULL OR expires_at > ? ORDER BY blocked_at DESC LIMIT ? OFFSET ?';
535
+ params = [now];
536
+ } else if (status === 'expired') {
537
+ countQuery = 'SELECT COUNT(*) as total FROM iri_blocks WHERE expires_at IS NOT NULL AND expires_at <= ?';
538
+ query = 'SELECT * FROM iri_blocks WHERE expires_at IS NOT NULL AND expires_at <= ? ORDER BY blocked_at DESC LIMIT ? OFFSET ?';
539
+ params = [now];
540
+ } else {
541
+ // 'all' — show active/permanent first, then expired
542
+ countQuery = 'SELECT COUNT(*) as total FROM iri_blocks';
543
+ query = 'SELECT *, (CASE WHEN expires_at IS NULL OR expires_at > ? THEN 0 ELSE 1 END) as is_exp FROM iri_blocks ORDER BY is_exp ASC, blocked_at DESC LIMIT ? OFFSET ?';
544
+ params = [now];
545
+ }
546
+
547
+ const total = this.db.prepare(countQuery).get(...(status !== 'all' ? params : []))?.total || 0;
548
+ const offset = (Math.max(1, page) - 1) * perPage;
549
+ const queryParams = [...params, perPage, offset];
550
+ const rows = this.db.prepare(query).all(...queryParams);
551
+
552
+ const formatted = rows.map((row) => {
553
+ const isPermanent = !row.expires_at;
554
+ const isExpired = row.expires_at ? row.expires_at <= now : false;
555
+ const itemStatus = isPermanent ? 'permanent' : (isExpired ? 'expired' : 'active');
556
+ return {
557
+ ip: row.ip,
558
+ reason: row.reason || '',
559
+ score: row.score || 0,
560
+ manual: Boolean(row.manual),
561
+ status: itemStatus,
562
+ isExpired,
563
+ blockedAt: row.blocked_at || null,
564
+ expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null
565
+ };
566
+ });
567
+
568
+ const totalPages = Math.ceil(total / perPage) || 1;
569
+ return { data: formatted, total, page: Math.max(1, page), perPage, totalPages };
570
+ } catch {
571
+ return super.getBlockedIps({ page, perPage, status });
572
+ }
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // Alerts (Persistent)
577
+ // ---------------------------------------------------------------------------
578
+
579
+ recordAlert(clientId, alertData) {
580
+ super.recordAlert(clientId, alertData);
581
+ const a = this.alerts.get(clientId);
582
+ if (!a) return;
583
+ this.db
584
+ .prepare(
585
+ `INSERT OR REPLACE INTO iri_alerts
586
+ (client_id, first_alert_at, last_alert_at, count, last_score, last_risk, last_ip, threats, dismissed)
587
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
588
+ )
589
+ .run(
590
+ a.clientId,
591
+ a.firstAlertAt,
592
+ a.lastAlertAt,
593
+ a.count,
594
+ a.lastScore || 0,
595
+ a.lastRisk || '',
596
+ a.lastIp || '',
597
+ JSON.stringify(a.threats || []),
598
+ a.dismissed ? 1 : 0
599
+ );
600
+ }
601
+
602
+ dismissAlert(clientId) {
603
+ const result = super.dismissAlert(clientId);
604
+ this.db
605
+ .prepare('UPDATE iri_alerts SET dismissed = 1 WHERE client_id = ?')
606
+ .run(clientId);
607
+ return result;
608
+ }
609
+
610
+ getAlerts({ page = 1, perPage = 20, dismissed = false } = {}) {
611
+ try {
612
+ const offset = (Math.max(1, page) - 1) * perPage;
613
+ const { total } = this.db.prepare('SELECT COUNT(*) as total FROM iri_alerts WHERE dismissed = ?').get(dismissed ? 1 : 0) || { total: 0 };
614
+ const rows = this.db.prepare('SELECT * FROM iri_alerts WHERE dismissed = ? ORDER BY last_alert_at DESC LIMIT ? OFFSET ?').all(dismissed ? 1 : 0, perPage, offset);
615
+
616
+ const data = rows.map((r) => {
617
+ let threats = [];
618
+ try { threats = JSON.parse(r.threats || '[]'); } catch { /* ignore */ }
619
+ return {
620
+ clientId: r.client_id,
621
+ firstAlertAt: r.first_alert_at,
622
+ lastAlertAt: r.last_alert_at,
623
+ count: r.count || 1,
624
+ lastScore: r.last_score || 0,
625
+ lastRisk: r.last_risk || 'medium',
626
+ lastIp: r.last_ip || '',
627
+ threats,
628
+ dismissed: Boolean(r.dismissed)
629
+ };
630
+ });
631
+
632
+ const totalPages = Math.ceil(total / perPage) || 1;
633
+ return { data, total, page: Math.max(1, page), perPage, totalPages };
634
+ } catch {
635
+ return super.getAlerts({ page, perPage, dismissed });
636
+ }
637
+ }
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // Real-time Database Stats (Accurate after restarts)
641
+ // ---------------------------------------------------------------------------
642
+
643
+ getStats() {
644
+ const now = Date.now();
645
+
646
+ // Active blocks list (for stats)
647
+ const activeBlocks = this.db
648
+ .prepare(
649
+ 'SELECT * FROM iri_blocks WHERE expires_at IS NULL OR expires_at > ? ORDER BY blocked_at DESC'
650
+ )
651
+ .all(now)
652
+ .map((row) => ({
653
+ ip: row.ip,
654
+ reason: row.reason || '',
655
+ score: row.score || 0,
656
+ manual: Boolean(row.manual),
657
+ status: row.expires_at ? 'active' : 'permanent',
658
+ isExpired: false,
659
+ blockedAt: row.blocked_at || null,
660
+ expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null
661
+ }));
662
+
663
+ // Request metrics directly from SQLite
664
+ const { total_req, blocked_req, avg_lat } =
665
+ this.db.prepare(`
666
+ SELECT
667
+ COUNT(*) as total_req,
668
+ SUM(CASE WHEN blocked = 1 THEN 1 ELSE 0 END) as blocked_req,
669
+ AVG(duration_ms) as avg_lat
670
+ FROM iri_requests
671
+ `).get() || {};
672
+
673
+ // Event metrics directly from SQLite
674
+ const { total_evt, anomaly_evt } =
675
+ this.db.prepare(`
676
+ SELECT
677
+ COUNT(*) as total_evt,
678
+ SUM(CASE WHEN risk_level IN ('medium', 'high', 'critical') THEN 1 ELSE 0 END) as anomaly_evt
679
+ FROM iri_events
680
+ `).get() || {};
681
+
682
+ // Threat distribution aggregated directly from SQLite
683
+ const threatDistribution = this.db.prepare(`
684
+ SELECT threat as name, COUNT(*) as count
685
+ FROM iri_events
686
+ WHERE threat IS NOT NULL AND threat != ''
687
+ GROUP BY threat
688
+ ORDER BY count DESC
689
+ `).all();
690
+
691
+ // Top endpoints aggregated directly from SQLite
692
+ const endpoints = this.db.prepare(`
693
+ SELECT
694
+ endpoint,
695
+ method,
696
+ COUNT(*) as count,
697
+ SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as errors,
698
+ AVG(duration_ms) as avgLatencyMs
699
+ FROM iri_requests
700
+ GROUP BY method, endpoint
701
+ ORDER BY count DESC
702
+ LIMIT 20
703
+ `).all();
704
+
705
+ // Client counts
706
+ const { total_clients } =
707
+ this.db.prepare('SELECT COUNT(*) as total_clients FROM iri_clients').get() || {};
708
+
709
+ // Active alerts count
710
+ const activeAlerts =
711
+ this.db.prepare('SELECT COUNT(*) as cnt FROM iri_alerts WHERE dismissed = 0').get()?.cnt || 0;
712
+
713
+ // Recent events list (latest 50)
714
+ const recentEvents = this.db
715
+ .prepare('SELECT * FROM iri_events ORDER BY timestamp DESC LIMIT 50')
716
+ .all()
717
+ .map((row) => {
718
+ if (row.data) {
719
+ try { return JSON.parse(row.data); } catch { /* ignore */ }
720
+ }
721
+ return {
722
+ id: row.id,
723
+ timestamp: row.timestamp,
724
+ ip: row.ip,
725
+ method: row.method,
726
+ endpoint: row.endpoint,
727
+ userAgent: row.user_agent,
728
+ requestId: row.request_id,
729
+ threat: row.threat,
730
+ riskLevel: row.risk_level,
731
+ riskScore: row.risk_score,
732
+ action: row.action,
733
+ reason: row.reason
734
+ };
735
+ });
736
+
737
+ // Recent requests list (latest 50)
738
+ const recentRequests = this.db
739
+ .prepare('SELECT * FROM iri_requests ORDER BY id DESC LIMIT 50')
740
+ .all()
741
+ .map((r) => ({
742
+ timestamp: r.timestamp,
743
+ ip: r.ip,
744
+ clientId: r.client_id,
745
+ fingerprint: r.fingerprint,
746
+ userAgent: r.user_agent,
747
+ sessionId: r.session_id,
748
+ endpoint: r.endpoint,
749
+ method: r.method,
750
+ statusCode: r.status_code,
751
+ durationMs: r.duration_ms,
752
+ blocked: Boolean(r.blocked)
753
+ }));
754
+
755
+ // Clients list (top 50)
756
+ const clients = this.db
757
+ .prepare('SELECT data FROM iri_clients LIMIT 50')
758
+ .all()
759
+ .map((r) => {
760
+ try { return JSON.parse(r.data); } catch { return null; }
761
+ })
762
+ .filter(Boolean);
763
+
764
+ clients.sort((a, b) => (b.requestCount || 0) - (a.requestCount || 0));
765
+
766
+ return {
767
+ totalRequests: total_req || 0,
768
+ detectedThreats: total_evt || 0,
769
+ blockedRequests: blocked_req || 0,
770
+ anomalyEvents: anomaly_evt || 0,
771
+ redactions: this.redactions || 0,
772
+ averageLatencyMs: avg_lat ? Number(Number(avg_lat).toFixed(2)) : 0,
773
+ storageMode: 'sqlite',
774
+ sqliteFile: this.file,
775
+ activeAlerts,
776
+ blockedIps: activeBlocks,
777
+ endpoints,
778
+ threatDistribution,
779
+ recentEvents,
780
+ recentRequests,
781
+ clients,
782
+ dbStats: {
783
+ totalRequestRows: total_req || 0,
784
+ maxRequestRows: this.maxRequestRows,
785
+ totalEventRows: total_evt || 0,
786
+ maxEventRows: this.maxEventRows,
787
+ totalClientRows: total_clients || 0
788
+ }
789
+ };
790
+ }
791
+ }
792
+
793
+ module.exports = { SQLiteStorage };