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.
- package/LICENSE +21 -0
- package/README.md +459 -0
- package/benchmark/README.md +35 -0
- package/benchmark/datasets/attacks.json +2596 -0
- package/benchmark/datasets/generate-datasets.js +406 -0
- package/benchmark/datasets/identity-scenarios.json +6002 -0
- package/benchmark/datasets/redaction-samples.json +8664 -0
- package/benchmark/false-positive-evaluate.js +188 -0
- package/benchmark/generate-charts.js +248 -0
- package/benchmark/identity-evaluate.js +150 -0
- package/benchmark/redaction-evaluate.js +143 -0
- package/benchmark/research-evaluate.js +254 -0
- package/benchmark/run.js +346 -0
- package/benchmark/security-baseline-compare.js +241 -0
- package/benchmark/security-evaluate.js +251 -0
- package/index.js +3 -0
- package/package.json +68 -0
- package/src/behaviour.js +135 -0
- package/src/correlation.js +120 -0
- package/src/dashboard.js +1682 -0
- package/src/identity.js +299 -0
- package/src/index.js +672 -0
- package/src/mongodb-storage.js +237 -0
- package/src/redactor.js +104 -0
- package/src/rules.js +113 -0
- package/src/sqlite-storage.js +793 -0
- package/src/storage.js +404 -0
- package/src/threats.js +297 -0
|
@@ -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 };
|