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/src/storage.js ADDED
@@ -0,0 +1,404 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // MemoryStorage — in-process storage for iri-shield
5
+ // ---------------------------------------------------------------------------
6
+
7
+ class MemoryStorage {
8
+ constructor(options = {}) {
9
+ this.maxEvents = options.maxEvents || 500;
10
+ this.maxRequests = options.maxRequests || 500;
11
+ this.maxClients = options.maxClients || 1000;
12
+ this.clear();
13
+ }
14
+
15
+ clear() {
16
+ this.totalRequests = 0;
17
+ this.blockedRequests = 0;
18
+ this.totalLatencyMs = 0;
19
+ this.redactions = 0;
20
+ this.events = [];
21
+ this.requests = [];
22
+ this.clients = new Map();
23
+ this.endpointStats = new Map();
24
+ this.ipWindows = new Map();
25
+ this.failedAuth = new Map();
26
+ this.blocks = new Map();
27
+ this.alerts = new Map();
28
+ this.threatDistribution = new Map();
29
+ this.behaviourStore = new Map(); // IP -> behaviour baseline record
30
+ this.sequenceStore = new Map(); // IP -> request sequence[]
31
+ }
32
+
33
+ // -------------------------------------------------------------------------
34
+ // Request recording
35
+ // -------------------------------------------------------------------------
36
+
37
+ recordRequest(request) {
38
+ const row = {
39
+ timestamp: new Date().toISOString(),
40
+ ip: request.ip || 'unknown',
41
+ clientId: request.clientId || null,
42
+ fingerprint: request.fingerprint || null,
43
+ userAgent: request.userAgent || '',
44
+ cookie: request.cookie || '',
45
+ sessionId: request.sessionId || '',
46
+ endpoint: request.endpoint || '/',
47
+ method: request.method || 'GET',
48
+ statusCode: request.statusCode || 0,
49
+ durationMs: request.durationMs || 0,
50
+ blocked: Boolean(request.blocked)
51
+ };
52
+
53
+ this.requests.unshift(row);
54
+ // Enforce size limit
55
+ if (this.requests.length > this.maxRequests) {
56
+ this.requests = this.requests.slice(0, this.maxRequests);
57
+ }
58
+
59
+ this.totalRequests += 1;
60
+ if (request.blocked) this.blockedRequests += 1;
61
+ this.totalLatencyMs += request.durationMs || 0;
62
+
63
+ const key = `${request.method || 'GET'} ${request.endpoint || '/'}`;
64
+ const current = this.endpointStats.get(key) || {
65
+ endpoint: request.endpoint || '/',
66
+ method: request.method || 'GET',
67
+ count: 0,
68
+ errors: 0,
69
+ avgLatencyMs: 0
70
+ };
71
+ current.count += 1;
72
+ current.errors += request.statusCode >= 400 ? 1 : 0;
73
+ current.avgLatencyMs =
74
+ (current.avgLatencyMs * (current.count - 1) + (request.durationMs || 0)) / current.count;
75
+ this.endpointStats.set(key, current);
76
+ }
77
+
78
+ // -------------------------------------------------------------------------
79
+ // Rate limiting
80
+ // -------------------------------------------------------------------------
81
+
82
+ hitRateLimit(ip, config) {
83
+ if (!config?.enabled) return { blocked: false, count: 0 };
84
+ const now = Date.now();
85
+ const windowMs = config.windowMs || 60000;
86
+ const max = config.max || 120;
87
+ const window = this.ipWindows.get(ip) || { startedAt: now, count: 0, endpoints: new Map() };
88
+
89
+ if (now - window.startedAt > windowMs) {
90
+ window.startedAt = now;
91
+ window.count = 0;
92
+ window.endpoints = new Map();
93
+ }
94
+
95
+ window.count += 1;
96
+ this.ipWindows.set(ip, window);
97
+ return { blocked: window.count > max, count: window.count, remaining: Math.max(0, max - window.count) };
98
+ }
99
+
100
+ recordEndpointHit(ip, endpoint) {
101
+ const window = this.ipWindows.get(ip);
102
+ if (!window) return 0;
103
+ const current = window.endpoints.get(endpoint) || 0;
104
+ window.endpoints.set(endpoint, current + 1);
105
+ return current + 1;
106
+ }
107
+
108
+ // -------------------------------------------------------------------------
109
+ // Failed auth tracking
110
+ // -------------------------------------------------------------------------
111
+
112
+ recordFailedAuth(ip) {
113
+ const current = this.failedAuth.get(ip) || { count: 0, startedAt: Date.now() };
114
+ current.count += 1;
115
+ this.failedAuth.set(ip, current);
116
+ return current.count;
117
+ }
118
+
119
+ getFailedAuth(ip) {
120
+ return this.failedAuth.get(ip)?.count || 0;
121
+ }
122
+
123
+ // -------------------------------------------------------------------------
124
+ // Block management
125
+ // -------------------------------------------------------------------------
126
+
127
+ blockIp(ip, block) {
128
+ this.blocks.set(ip, {
129
+ ...block,
130
+ blockedAt: block.blockedAt || new Date().toISOString(),
131
+ manual: block.manual || false
132
+ });
133
+ }
134
+
135
+ unblockIp(ip) {
136
+ return this.blocks.delete(ip);
137
+ }
138
+
139
+ manualBlockIp(ip, { reason = 'manual_block', durationMs = 60 * 60 * 1000, score = 100 } = {}) {
140
+ this.blockIp(ip, {
141
+ expiresAt: durationMs > 0 ? Date.now() + durationMs : null, // null = permanent
142
+ reason,
143
+ score,
144
+ manual: true,
145
+ blockedAt: new Date().toISOString()
146
+ });
147
+ }
148
+
149
+ getBlock(ip) {
150
+ const block = this.blocks.get(ip);
151
+ if (!block) return null;
152
+ if (block.expiresAt && block.expiresAt <= Date.now()) {
153
+ return null; // Expired, do not block incoming traffic
154
+ }
155
+ return block;
156
+ }
157
+
158
+ getBlockedIps({ page = 1, perPage = 20, status = 'all' } = {}) {
159
+ const now = Date.now();
160
+ let all = Array.from(this.blocks.entries()).map(([ip, block]) => {
161
+ const isPermanent = !block.expiresAt;
162
+ const isExpired = block.expiresAt ? block.expiresAt <= now : false;
163
+ const itemStatus = isPermanent ? 'permanent' : (isExpired ? 'expired' : 'active');
164
+ return {
165
+ ip,
166
+ reason: block.reason || '',
167
+ score: block.score || 0,
168
+ manual: Boolean(block.manual),
169
+ status: itemStatus,
170
+ isExpired,
171
+ blockedAt: block.blockedAt || null,
172
+ expiresAt: block.expiresAt ? new Date(block.expiresAt).toISOString() : null
173
+ };
174
+ });
175
+
176
+ if (status === 'active') {
177
+ all = all.filter((b) => !b.isExpired);
178
+ } else if (status === 'expired') {
179
+ all = all.filter((b) => b.isExpired);
180
+ }
181
+
182
+ all.sort((a, b) => {
183
+ if (a.isExpired !== b.isExpired) return a.isExpired ? 1 : -1;
184
+ return (b.blockedAt || '') > (a.blockedAt || '') ? 1 : -1;
185
+ });
186
+
187
+ return paginate(all, page, perPage);
188
+ }
189
+
190
+ // -------------------------------------------------------------------------
191
+ // Events
192
+ // -------------------------------------------------------------------------
193
+
194
+ recordEvent(event) {
195
+ const stored = Object.assign({}, event);
196
+ // Ensure breakdown array is always present (may be undefined in old code paths)
197
+ if (!Array.isArray(stored.breakdown)) stored.breakdown = [];
198
+ if (stored.confidence == null) stored.confidence = 0;
199
+ this.events.unshift(stored);
200
+ if (this.events.length > this.maxEvents) {
201
+ this.events = this.events.slice(0, this.maxEvents);
202
+ }
203
+ const threat = event.threat || 'unknown';
204
+ this.threatDistribution.set(threat, (this.threatDistribution.get(threat) || 0) + 1);
205
+ }
206
+
207
+ getEvents({ page = 1, perPage = 20, riskLevel = null } = {}) {
208
+ let filtered = this.events;
209
+ if (riskLevel) filtered = filtered.filter((e) => e.riskLevel === riskLevel);
210
+ return paginate(filtered, page, perPage);
211
+ }
212
+
213
+ // -------------------------------------------------------------------------
214
+ // Client tracking
215
+ // -------------------------------------------------------------------------
216
+
217
+ recordClient(client) {
218
+ const current = this.clients.get(client.clientId) || {
219
+ clientId: client.clientId,
220
+ userId: client.userId || client.clientId,
221
+ firstSeenAt: client.timestamp,
222
+ lastSeenAt: client.timestamp,
223
+ requestCount: 0,
224
+ ips: [],
225
+ userAgents: [],
226
+ fingerprints: [],
227
+ platforms: [],
228
+ changes: 0,
229
+ lastRisk: 'none'
230
+ };
231
+
232
+ const beforeIps = new Set(current.ips);
233
+ const beforeAgents = new Set(current.userAgents);
234
+ const beforePrints = new Set(current.fingerprints);
235
+
236
+ current.lastSeenAt = client.timestamp;
237
+ current.requestCount += 1;
238
+ current.lastIp = client.ip;
239
+ current.lastUserAgent = client.userAgent;
240
+ current.lastFingerprint = client.fingerprint;
241
+ current.userId = client.userId || current.userId || client.clientId;
242
+ current.lastRisk = client.riskLevel || current.lastRisk;
243
+
244
+ pushUnique(current.ips, client.ip);
245
+ pushUnique(current.userAgents, client.userAgent);
246
+ pushUnique(current.fingerprints, client.fingerprint);
247
+ if (client.secChUaPlatform) pushUnique(current.platforms, client.secChUaPlatform);
248
+
249
+ if (
250
+ (client.ip && !beforeIps.has(client.ip)) ||
251
+ (client.userAgent && !beforeAgents.has(client.userAgent)) ||
252
+ (client.fingerprint && !beforePrints.has(client.fingerprint))
253
+ ) {
254
+ current.changes += current.requestCount === 1 ? 0 : 1;
255
+ }
256
+
257
+ this.clients.set(client.clientId, current);
258
+
259
+ // Enforce client map size limit (LRU-like: remove oldest by lastSeenAt)
260
+ if (this.clients.size > this.maxClients) {
261
+ pruneOldestClient(this.clients);
262
+ }
263
+
264
+ return current;
265
+ }
266
+
267
+ findClientsByUserId(userId) {
268
+ if (!userId) return [];
269
+ return Array.from(this.clients.values()).filter((c) => c.userId === userId);
270
+ }
271
+
272
+ getClients({ page = 1, perPage = 20 } = {}) {
273
+ const all = Array.from(this.clients.values()).sort(
274
+ (a, b) => b.requestCount - a.requestCount
275
+ );
276
+ return paginate(all, page, perPage);
277
+ }
278
+
279
+ // -------------------------------------------------------------------------
280
+ // Alerts
281
+ // -------------------------------------------------------------------------
282
+
283
+ recordAlert(clientId, alertData) {
284
+ const existing = this.alerts.get(clientId) || {
285
+ clientId,
286
+ firstAlertAt: new Date().toISOString(),
287
+ count: 0,
288
+ dismissed: false
289
+ };
290
+ existing.lastAlertAt = new Date().toISOString();
291
+ existing.count += 1;
292
+ existing.lastScore = alertData.score;
293
+ existing.lastRisk = alertData.riskLevel;
294
+ existing.lastIp = alertData.ip;
295
+ existing.threats = alertData.threats || [];
296
+ existing.dismissed = false; // re-activate on new alert
297
+ this.alerts.set(clientId, existing);
298
+ }
299
+
300
+ dismissAlert(clientId) {
301
+ const alert = this.alerts.get(clientId);
302
+ if (alert) {
303
+ alert.dismissed = true;
304
+ this.alerts.set(clientId, alert);
305
+ return true;
306
+ }
307
+ return false;
308
+ }
309
+
310
+ getAlerts({ page = 1, perPage = 20, dismissed = false } = {}) {
311
+ const all = Array.from(this.alerts.values())
312
+ .filter((a) => a.dismissed === dismissed)
313
+ .sort((a, b) => (b.lastAlertAt > a.lastAlertAt ? 1 : -1));
314
+ return paginate(all, page, perPage);
315
+ }
316
+
317
+ // -------------------------------------------------------------------------
318
+ // Redaction
319
+ // -------------------------------------------------------------------------
320
+
321
+ recordRedaction(count) {
322
+ this.redactions += count || 0;
323
+ }
324
+
325
+ // -------------------------------------------------------------------------
326
+ // Stats
327
+ // -------------------------------------------------------------------------
328
+
329
+ getStats() {
330
+ const activeBlocks = Array.from(this.blocks.entries()).filter(
331
+ ([, block]) => !block.expiresAt || block.expiresAt > Date.now()
332
+ );
333
+ const activeAlerts = Array.from(this.alerts.values()).filter((a) => !a.dismissed);
334
+
335
+ return {
336
+ totalRequests: this.totalRequests,
337
+ detectedThreats: this.events.length,
338
+ blockedRequests: this.blockedRequests,
339
+ anomalyEvents: this.events.filter((e) =>
340
+ ['medium', 'high', 'critical'].includes(e.riskLevel)
341
+ ).length,
342
+ redactions: this.redactions,
343
+ averageLatencyMs: this.totalRequests
344
+ ? Number((this.totalLatencyMs / this.totalRequests).toFixed(2))
345
+ : 0,
346
+ storageMode: 'memory',
347
+ activeAlerts: activeAlerts.length,
348
+ blockedIps: activeBlocks.map(([ip, block]) => ({
349
+ ip,
350
+ reason: block.reason,
351
+ score: block.score || 0,
352
+ manual: Boolean(block.manual),
353
+ blockedAt: block.blockedAt || null,
354
+ expiresAt: block.expiresAt ? new Date(block.expiresAt).toISOString() : null
355
+ })),
356
+ endpoints: Array.from(this.endpointStats.values())
357
+ .sort((a, b) => b.count - a.count)
358
+ .slice(0, 20),
359
+ recentEvents: this.events.slice(0, 50),
360
+ threatDistribution: Array.from(this.threatDistribution.entries()).map(([name, count]) => ({
361
+ name,
362
+ count
363
+ })),
364
+ clients: Array.from(this.clients.values())
365
+ .sort((a, b) => b.requestCount - a.requestCount)
366
+ .slice(0, 50),
367
+ recentRequests: this.requests.slice(0, 50)
368
+ };
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Helpers
374
+ // ---------------------------------------------------------------------------
375
+
376
+ function pushUnique(list, value) {
377
+ if (!value || list.includes(value)) return;
378
+ list.push(value);
379
+ if (list.length > 10) list.shift();
380
+ }
381
+
382
+ function paginate(array, page, perPage) {
383
+ const total = array.length;
384
+ const totalPages = Math.ceil(total / perPage) || 1;
385
+ const safePage = Math.max(1, Math.min(page, totalPages));
386
+ const start = (safePage - 1) * perPage;
387
+ const data = array.slice(start, start + perPage);
388
+ return { data, total, page: safePage, perPage, totalPages };
389
+ }
390
+
391
+ function pruneOldestClient(clientsMap) {
392
+ let oldestKey = null;
393
+ let oldestTime = Infinity;
394
+ for (const [key, client] of clientsMap.entries()) {
395
+ const t = new Date(client.lastSeenAt || 0).getTime();
396
+ if (t < oldestTime) {
397
+ oldestTime = t;
398
+ oldestKey = key;
399
+ }
400
+ }
401
+ if (oldestKey) clientsMap.delete(oldestKey);
402
+ }
403
+
404
+ module.exports = { MemoryStorage, paginate };