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
package/src/index.js
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const bcrypt = require('bcryptjs');
|
|
4
|
+
const cors = require('cors');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const helmet = require('helmet');
|
|
7
|
+
const jwt = require('jsonwebtoken');
|
|
8
|
+
const pino = require('pino');
|
|
9
|
+
const { randomUUID } = crypto;
|
|
10
|
+
const { createDashboardRouter } = require('./dashboard');
|
|
11
|
+
const { MemoryStorage } = require('./storage');
|
|
12
|
+
const { SQLiteStorage } = require('./sqlite-storage');
|
|
13
|
+
const { MongoStorage } = require('./mongodb-storage');
|
|
14
|
+
const { analyzeRequest, riskFromScore, actionFromRisk } = require('./threats');
|
|
15
|
+
const { redactPayload, redactRequestBody } = require('./redactor');
|
|
16
|
+
const { buildClientContext, detectIdentityChange, getClientIp } = require('./identity');
|
|
17
|
+
const { applyCustomRules } = require('./rules');
|
|
18
|
+
const { recordBehaviour, getBehaviourDeviation } = require('./behaviour');
|
|
19
|
+
const { recordSequence, detectCorrelation } = require('./correlation');
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Security mode presets
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const SECURITY_MODE_PRESETS = {
|
|
26
|
+
low: {
|
|
27
|
+
rateLimit: { max: 300, windowMs: 60 * 1000 },
|
|
28
|
+
block: { threshold: 90, durationMs: 6 * 60 * 60 * 1000 },
|
|
29
|
+
anomaly: { mediumThreshold: 45, highThreshold: 75, criticalThreshold: 92 }
|
|
30
|
+
},
|
|
31
|
+
medium: {
|
|
32
|
+
rateLimit: { max: 120, windowMs: 60 * 1000 },
|
|
33
|
+
block: { threshold: 80, durationMs: 24 * 60 * 60 * 1000 },
|
|
34
|
+
anomaly: { mediumThreshold: 35, highThreshold: 65, criticalThreshold: 90 }
|
|
35
|
+
},
|
|
36
|
+
high: {
|
|
37
|
+
rateLimit: { max: 30, windowMs: 60 * 1000 },
|
|
38
|
+
block: { threshold: 60, durationMs: 7 * 24 * 60 * 60 * 1000 },
|
|
39
|
+
anomaly: { mediumThreshold: 25, highThreshold: 50, criticalThreshold: 80 }
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Default configuration
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const defaultConfig = {
|
|
48
|
+
appName: 'iri-shield',
|
|
49
|
+
security: 'medium',
|
|
50
|
+
trustProxy: false,
|
|
51
|
+
failureMode: 'fail-open', // 'fail-open' | 'fail-closed'
|
|
52
|
+
helmet: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
contentSecurityPolicy: false,
|
|
55
|
+
crossOriginEmbedderPolicy: false
|
|
56
|
+
},
|
|
57
|
+
cors: false,
|
|
58
|
+
logger: true,
|
|
59
|
+
requestIdHeader: 'x-iri-request-id',
|
|
60
|
+
testing: {
|
|
61
|
+
enabled: false,
|
|
62
|
+
allowClientOverrides: false
|
|
63
|
+
},
|
|
64
|
+
rateLimit: {
|
|
65
|
+
enabled: true,
|
|
66
|
+
windowMs: 60 * 1000,
|
|
67
|
+
max: 120
|
|
68
|
+
},
|
|
69
|
+
block: {
|
|
70
|
+
enabled: true,
|
|
71
|
+
threshold: 80,
|
|
72
|
+
durationMs: 24 * 60 * 60 * 1000
|
|
73
|
+
},
|
|
74
|
+
alert: {
|
|
75
|
+
enabled: true,
|
|
76
|
+
threshold: 35
|
|
77
|
+
},
|
|
78
|
+
anomaly: {
|
|
79
|
+
mediumThreshold: 35,
|
|
80
|
+
highThreshold: 65,
|
|
81
|
+
criticalThreshold: 90,
|
|
82
|
+
singleEndpointMax: 80,
|
|
83
|
+
failedAuthMax: 5,
|
|
84
|
+
allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
85
|
+
sensitiveEndpoints: ['/admin', '/internal', '/debug', '/.env', '/config']
|
|
86
|
+
},
|
|
87
|
+
rules: {
|
|
88
|
+
sqlInjection: true,
|
|
89
|
+
xss: true,
|
|
90
|
+
pathTraversal: true,
|
|
91
|
+
commandInjection: true,
|
|
92
|
+
ssti: true,
|
|
93
|
+
nosqlInjection: true,
|
|
94
|
+
ldapInjection: true,
|
|
95
|
+
xxe: true,
|
|
96
|
+
openRedirect: true,
|
|
97
|
+
base64Payload: true,
|
|
98
|
+
headerInjection: true,
|
|
99
|
+
secretProbe: true,
|
|
100
|
+
scannerDetection: true,
|
|
101
|
+
headerAnomaly: true,
|
|
102
|
+
customRules: []
|
|
103
|
+
},
|
|
104
|
+
redaction: {
|
|
105
|
+
enabled: true,
|
|
106
|
+
mask: '[REDACTED]',
|
|
107
|
+
fields: [
|
|
108
|
+
'password', 'token', 'accessToken', 'refreshToken',
|
|
109
|
+
'authorization', 'apiKey', 'secret', 'ssn',
|
|
110
|
+
'aadhaar', 'email', 'phone', 'creditCard', 'cvv'
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
privacy: {
|
|
114
|
+
hashIp: false,
|
|
115
|
+
retainRawIp: true,
|
|
116
|
+
retentionDays: 30
|
|
117
|
+
},
|
|
118
|
+
dashboard: {
|
|
119
|
+
enabled: true,
|
|
120
|
+
path: '/iri-shield',
|
|
121
|
+
username: 'admin',
|
|
122
|
+
password: 'admin',
|
|
123
|
+
refreshMs: 5 * 60 * 1000
|
|
124
|
+
},
|
|
125
|
+
storage: {
|
|
126
|
+
mode: 'memory',
|
|
127
|
+
sqliteFile: './data/iri-shield.sqlite',
|
|
128
|
+
mongoUrl: 'mongodb://localhost:27017/iri-shield'
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Config helpers
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function mergeConfig(base, override) {
|
|
137
|
+
const output = Object.assign({}, base);
|
|
138
|
+
for (const [key, value] of Object.entries(override || {})) {
|
|
139
|
+
if (
|
|
140
|
+
value &&
|
|
141
|
+
typeof value === 'object' &&
|
|
142
|
+
!Array.isArray(value) &&
|
|
143
|
+
base[key] &&
|
|
144
|
+
typeof base[key] === 'object'
|
|
145
|
+
) {
|
|
146
|
+
output[key] = mergeConfig(base[key], value);
|
|
147
|
+
} else {
|
|
148
|
+
output[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return output;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function applySecurityMode(config, userOptions) {
|
|
155
|
+
const mode = config.security || 'medium';
|
|
156
|
+
const preset = SECURITY_MODE_PRESETS[mode] || SECURITY_MODE_PRESETS.medium;
|
|
157
|
+
if (!userOptions.rateLimit || !userOptions.rateLimit.max) config.rateLimit.max = preset.rateLimit.max;
|
|
158
|
+
if (!userOptions.rateLimit || !userOptions.rateLimit.windowMs) config.rateLimit.windowMs = preset.rateLimit.windowMs;
|
|
159
|
+
if (!userOptions.block || !userOptions.block.threshold) config.block.threshold = preset.block.threshold;
|
|
160
|
+
if (!userOptions.block || !userOptions.block.durationMs) config.block.durationMs = preset.block.durationMs;
|
|
161
|
+
if (!userOptions.anomaly || !userOptions.anomaly.mediumThreshold) config.anomaly.mediumThreshold = preset.anomaly.mediumThreshold;
|
|
162
|
+
if (!userOptions.anomaly || !userOptions.anomaly.highThreshold) config.anomaly.highThreshold = preset.anomaly.highThreshold;
|
|
163
|
+
if (!userOptions.anomaly || !userOptions.anomaly.criticalThreshold) config.anomaly.criticalThreshold = preset.anomaly.criticalThreshold;
|
|
164
|
+
return config;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Hash an IP address for privacy mode
|
|
169
|
+
*/
|
|
170
|
+
function hashIp(ip) {
|
|
171
|
+
return 'sha256:' + crypto.createHash('sha256').update(ip || '').digest('hex').slice(0, 16);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Main factory
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function createShield(options = {}) {
|
|
179
|
+
let config = mergeConfig(defaultConfig, options);
|
|
180
|
+
config = applySecurityMode(config, options);
|
|
181
|
+
|
|
182
|
+
const storage =
|
|
183
|
+
options.storage && typeof options.storage.getBlock === 'function'
|
|
184
|
+
? options.storage
|
|
185
|
+
: (options._storage || createStorage(config.storage));
|
|
186
|
+
const logger = options._logger || (config.logger ? pino({ name: config.appName }) : null);
|
|
187
|
+
|
|
188
|
+
// --- Base middlewares ---
|
|
189
|
+
const baseMiddlewares = [];
|
|
190
|
+
if (config.helmet) {
|
|
191
|
+
const helmetOptions =
|
|
192
|
+
typeof config.helmet === 'object'
|
|
193
|
+
? Object.assign({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }, config.helmet)
|
|
194
|
+
: { contentSecurityPolicy: false, crossOriginEmbedderPolicy: false };
|
|
195
|
+
if (helmetOptions.enabled !== false) {
|
|
196
|
+
delete helmetOptions.enabled;
|
|
197
|
+
baseMiddlewares.push(helmet(helmetOptions));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (config.cors) {
|
|
201
|
+
baseMiddlewares.push(cors(typeof config.cors === 'object' ? config.cors : undefined));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Core middleware ---
|
|
205
|
+
const middleware = async function iriShield(req, res, next) {
|
|
206
|
+
const start = process.hrtime.bigint();
|
|
207
|
+
const requestId = req.headers[config.requestIdHeader] || randomUUID();
|
|
208
|
+
res.setHeader(config.requestIdHeader, requestId);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
// Skip dashboard routes
|
|
212
|
+
const reqPath = String(req.originalUrl || req.url);
|
|
213
|
+
if (
|
|
214
|
+
config.dashboard && config.dashboard.enabled &&
|
|
215
|
+
config.dashboard.path &&
|
|
216
|
+
reqPath.startsWith(config.dashboard.path)
|
|
217
|
+
) {
|
|
218
|
+
return next();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Build client context
|
|
222
|
+
const client = buildClientContext(req, res, config);
|
|
223
|
+
req.iriShieldClient = client;
|
|
224
|
+
const rawIp = client.ip;
|
|
225
|
+
|
|
226
|
+
// Privacy: optionally hash IP for storage
|
|
227
|
+
const storageIp = config.privacy && config.privacy.hashIp ? hashIp(rawIp) : rawIp;
|
|
228
|
+
|
|
229
|
+
// Find existing client record for identity change detection
|
|
230
|
+
const knownClient = (storage.clients && storage.clients.get) ? storage.clients.get(client.clientId) : null;
|
|
231
|
+
const sameUserClients =
|
|
232
|
+
typeof storage.findClientsByUserId === 'function'
|
|
233
|
+
? storage.findClientsByUserId(client.userId)
|
|
234
|
+
: [];
|
|
235
|
+
const identityChange = detectIdentityChange(client, knownClient || sameUserClients[0]);
|
|
236
|
+
|
|
237
|
+
// --- Check active block (always use rawIp for blocking enforcement) ---
|
|
238
|
+
const activeBlock = storage.getBlock(rawIp);
|
|
239
|
+
if (activeBlock) {
|
|
240
|
+
const event = buildEvent(req, {
|
|
241
|
+
requestId,
|
|
242
|
+
threat: 'blocked_ip',
|
|
243
|
+
riskLevel: 'critical',
|
|
244
|
+
riskScore: activeBlock.score || config.block.threshold,
|
|
245
|
+
action: 'blocked',
|
|
246
|
+
reason: activeBlock.reason || 'ip_block_active',
|
|
247
|
+
storageIp
|
|
248
|
+
});
|
|
249
|
+
storage.recordEvent(event);
|
|
250
|
+
storage.recordRequest(Object.assign({}, client, {
|
|
251
|
+
ip: storageIp,
|
|
252
|
+
blocked: true,
|
|
253
|
+
endpoint: event.endpoint,
|
|
254
|
+
method: req.method,
|
|
255
|
+
statusCode: 403,
|
|
256
|
+
durationMs: 0
|
|
257
|
+
}));
|
|
258
|
+
return res.status(403).json({ error: 'Request blocked by iri-shield', requestId });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Rate limit ---
|
|
262
|
+
const rateDecision = storage.hitRateLimit(rawIp, config.rateLimit);
|
|
263
|
+
if (rateDecision.blocked) {
|
|
264
|
+
const event = buildEvent(req, {
|
|
265
|
+
requestId,
|
|
266
|
+
threat: 'rate_limit_exceeded',
|
|
267
|
+
riskLevel: 'medium',
|
|
268
|
+
riskScore: 55,
|
|
269
|
+
action: 'rate_limited',
|
|
270
|
+
reason: 'rate_limit_' + config.rateLimit.max + '_per_' + config.rateLimit.windowMs + 'ms',
|
|
271
|
+
storageIp
|
|
272
|
+
});
|
|
273
|
+
storage.recordEvent(event);
|
|
274
|
+
storage.recordRequest(Object.assign({}, client, {
|
|
275
|
+
ip: storageIp,
|
|
276
|
+
blocked: true,
|
|
277
|
+
endpoint: event.endpoint,
|
|
278
|
+
method: req.method,
|
|
279
|
+
statusCode: 429,
|
|
280
|
+
durationMs: 0
|
|
281
|
+
}));
|
|
282
|
+
return res.status(429).json({
|
|
283
|
+
error: 'Too many requests — rate limit exceeded',
|
|
284
|
+
requestId,
|
|
285
|
+
retryAfter: Math.ceil(config.rateLimit.windowMs / 1000)
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- Threat analysis ---
|
|
290
|
+
const analysis = analyzeRequest(req, storage, config);
|
|
291
|
+
|
|
292
|
+
// --- Custom rule engine ---
|
|
293
|
+
const customResult = applyCustomRules(req, config);
|
|
294
|
+
if (customResult.score > 0) {
|
|
295
|
+
analysis.score = Math.min(100, analysis.score + customResult.score);
|
|
296
|
+
analysis.threats.push(...customResult.threats);
|
|
297
|
+
analysis.reasons.push(...customResult.reasons);
|
|
298
|
+
analysis.breakdown.push(...customResult.breakdown);
|
|
299
|
+
analysis.riskLevel = riskFromScore(analysis.score, config);
|
|
300
|
+
analysis.action = actionFromRisk(analysis.riskLevel);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- Identity change penalty ---
|
|
304
|
+
if (identityChange.score > 0) {
|
|
305
|
+
analysis.score = Math.min(100, analysis.score + identityChange.score);
|
|
306
|
+
analysis.threats.push(...identityChange.threats);
|
|
307
|
+
analysis.reasons.push(...identityChange.reasons);
|
|
308
|
+
if (identityChange.score > 0) {
|
|
309
|
+
analysis.breakdown.push({
|
|
310
|
+
rule: 'identity_drift',
|
|
311
|
+
label: 'Identity / device change detected',
|
|
312
|
+
points: identityChange.score,
|
|
313
|
+
category: 'anomaly',
|
|
314
|
+
confidence: 75
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
analysis.riskLevel = riskFromScore(analysis.score, config);
|
|
318
|
+
analysis.action = actionFromRisk(analysis.riskLevel);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Behaviour baseline tracking ---
|
|
322
|
+
if (storage.behaviourStore) {
|
|
323
|
+
recordBehaviour(rawIp, req.originalUrl || req.url, req.method, 0, storage.behaviourStore);
|
|
324
|
+
const deviation = getBehaviourDeviation(rawIp, storage.behaviourStore);
|
|
325
|
+
if (deviation.deviationPercent >= 80) {
|
|
326
|
+
const devPts = Math.round(deviation.deviationPercent * 0.2);
|
|
327
|
+
analysis.score = Math.min(100, analysis.score + devPts);
|
|
328
|
+
analysis.threats.push('behaviour_deviation');
|
|
329
|
+
analysis.reasons.push('behaviour_deviation_' + deviation.deviationPercent + 'pct');
|
|
330
|
+
analysis.breakdown.push({
|
|
331
|
+
rule: 'behaviour_deviation',
|
|
332
|
+
label: 'Behaviour deviation from baseline (' + deviation.deviationPercent + '% spike)',
|
|
333
|
+
points: devPts,
|
|
334
|
+
category: 'anomaly',
|
|
335
|
+
confidence: 78,
|
|
336
|
+
meta: deviation
|
|
337
|
+
});
|
|
338
|
+
analysis.riskLevel = riskFromScore(analysis.score, config);
|
|
339
|
+
analysis.action = actionFromRisk(analysis.riskLevel);
|
|
340
|
+
}
|
|
341
|
+
analysis.behaviourDeviation = deviation;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- Attack sequence correlation ---
|
|
345
|
+
if (storage.sequenceStore) {
|
|
346
|
+
recordSequence(rawIp, req.originalUrl || req.url, analysis.threats, storage.sequenceStore);
|
|
347
|
+
const correlation = detectCorrelation(rawIp, storage.sequenceStore);
|
|
348
|
+
if (correlation) {
|
|
349
|
+
analysis.score = Math.min(100, analysis.score + correlation.riskBonus);
|
|
350
|
+
analysis.threats.push('correlated_attack_' + correlation.pattern);
|
|
351
|
+
analysis.breakdown.push({
|
|
352
|
+
rule: 'correlated_attack_' + correlation.pattern,
|
|
353
|
+
label: correlation.label,
|
|
354
|
+
points: correlation.riskBonus,
|
|
355
|
+
category: 'correlation',
|
|
356
|
+
confidence: correlation.confidence
|
|
357
|
+
});
|
|
358
|
+
analysis.correlatedAttack = correlation;
|
|
359
|
+
analysis.riskLevel = riskFromScore(analysis.score, config);
|
|
360
|
+
analysis.action = actionFromRisk(analysis.riskLevel);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Redact request body fields before storing in logs
|
|
365
|
+
let safeBody = null;
|
|
366
|
+
if (config.redaction && config.redaction.enabled && req.body) {
|
|
367
|
+
const redacted = redactPayload(req.body, config.redaction);
|
|
368
|
+
safeBody = redacted.value;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
client.riskLevel = analysis.riskLevel;
|
|
372
|
+
storage.recordClient(Object.assign({}, client, { ip: storageIp }));
|
|
373
|
+
req.iriShield = { requestId, ip: rawIp, storageIp, client, analysis };
|
|
374
|
+
|
|
375
|
+
// --- Auto block if score >= block threshold ---
|
|
376
|
+
if (analysis.score >= config.block.threshold && config.block.enabled) {
|
|
377
|
+
storage.blockIp(rawIp, {
|
|
378
|
+
expiresAt: Date.now() + config.block.durationMs,
|
|
379
|
+
reason: analysis.reasons.join(', '),
|
|
380
|
+
score: analysis.score
|
|
381
|
+
});
|
|
382
|
+
const event = buildEvent(req, {
|
|
383
|
+
requestId,
|
|
384
|
+
threat: analysis.threats.join(', ') || 'blocked_threat',
|
|
385
|
+
riskLevel: analysis.riskLevel,
|
|
386
|
+
riskScore: analysis.score,
|
|
387
|
+
action: 'blocked',
|
|
388
|
+
reason: analysis.reasons.join('; '),
|
|
389
|
+
breakdown: analysis.breakdown,
|
|
390
|
+
confidence: analysis.confidence || 0,
|
|
391
|
+
correlatedAttack: analysis.correlatedAttack || null,
|
|
392
|
+
storageIp
|
|
393
|
+
});
|
|
394
|
+
storage.recordEvent(event);
|
|
395
|
+
storage.recordRequest(Object.assign({}, client, {
|
|
396
|
+
ip: storageIp,
|
|
397
|
+
blocked: true,
|
|
398
|
+
endpoint: req.originalUrl || req.url,
|
|
399
|
+
method: req.method,
|
|
400
|
+
statusCode: 403,
|
|
401
|
+
durationMs: 0
|
|
402
|
+
}));
|
|
403
|
+
return res.status(403).json({
|
|
404
|
+
error: 'Request blocked — threat detected by iri-shield',
|
|
405
|
+
requestId,
|
|
406
|
+
threat: analysis.threats.join(', ')
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- Alerts ---
|
|
411
|
+
const alertThreshold = (config.alert && config.alert.threshold) || config.anomaly.mediumThreshold;
|
|
412
|
+
if (
|
|
413
|
+
config.alert && config.alert.enabled &&
|
|
414
|
+
analysis.score >= alertThreshold &&
|
|
415
|
+
analysis.score < config.block.threshold
|
|
416
|
+
) {
|
|
417
|
+
storage.recordAlert(client.clientId, {
|
|
418
|
+
score: analysis.score,
|
|
419
|
+
riskLevel: analysis.riskLevel,
|
|
420
|
+
ip: rawIp,
|
|
421
|
+
threats: analysis.threats
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- Record security event ---
|
|
426
|
+
if (analysis.score >= config.anomaly.mediumThreshold) {
|
|
427
|
+
const event = buildEvent(req, {
|
|
428
|
+
requestId,
|
|
429
|
+
threat: analysis.threats.join(', ') || 'anomaly',
|
|
430
|
+
riskLevel: analysis.riskLevel,
|
|
431
|
+
riskScore: analysis.score,
|
|
432
|
+
action: analysis.action,
|
|
433
|
+
reason: analysis.reasons.join('; '),
|
|
434
|
+
breakdown: analysis.breakdown,
|
|
435
|
+
confidence: analysis.confidence || 0,
|
|
436
|
+
correlatedAttack: analysis.correlatedAttack || null,
|
|
437
|
+
storageIp
|
|
438
|
+
});
|
|
439
|
+
storage.recordEvent(event);
|
|
440
|
+
logger && logger.warn(event, 'iri-shield security event');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- Block on critical score ---
|
|
444
|
+
if (analysis.score >= config.anomaly.criticalThreshold) {
|
|
445
|
+
storage.recordRequest(Object.assign({}, client, {
|
|
446
|
+
ip: storageIp,
|
|
447
|
+
blocked: true,
|
|
448
|
+
endpoint: req.originalUrl || req.url,
|
|
449
|
+
method: req.method,
|
|
450
|
+
statusCode: 403,
|
|
451
|
+
durationMs: 0
|
|
452
|
+
}));
|
|
453
|
+
return res.status(403).json({
|
|
454
|
+
error: 'Request blocked — critical threat detected by iri-shield',
|
|
455
|
+
requestId
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// --- Patch response for redaction ---
|
|
460
|
+
patchResponse(res, storage, config);
|
|
461
|
+
|
|
462
|
+
// --- Record on finish ---
|
|
463
|
+
res.on('finish', function() {
|
|
464
|
+
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
|
|
465
|
+
storage.recordRequest(Object.assign({}, client, {
|
|
466
|
+
ip: storageIp,
|
|
467
|
+
endpoint: (req.route && req.route.path) || req.originalUrl || req.url,
|
|
468
|
+
method: req.method,
|
|
469
|
+
statusCode: res.statusCode,
|
|
470
|
+
durationMs,
|
|
471
|
+
blocked: res.statusCode === 403 || res.statusCode === 429
|
|
472
|
+
}));
|
|
473
|
+
// Update behaviour store with real status code
|
|
474
|
+
if (storage.behaviourStore) {
|
|
475
|
+
recordBehaviour(rawIp, req.originalUrl || req.url, req.method, res.statusCode, storage.behaviourStore);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
return next();
|
|
480
|
+
} catch (error) {
|
|
481
|
+
logger && logger.error({ err: error, requestId }, 'iri-shield middleware failure');
|
|
482
|
+
if (config.failureMode === 'fail-closed') {
|
|
483
|
+
return res.status(503).json({ error: 'Security layer unavailable — request rejected', requestId });
|
|
484
|
+
}
|
|
485
|
+
return next(error); // fail-open: let traffic through on internal errors
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// Chain base middlewares then core
|
|
490
|
+
const runBase = function iriShieldBase(req, res, next) {
|
|
491
|
+
let index = 0;
|
|
492
|
+
const step = function(err) {
|
|
493
|
+
if (err || index >= baseMiddlewares.length) return err ? next(err) : middleware(req, res, next);
|
|
494
|
+
return baseMiddlewares[index++](req, res, step);
|
|
495
|
+
};
|
|
496
|
+
return step();
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
config,
|
|
501
|
+
storage,
|
|
502
|
+
middleware: runBase,
|
|
503
|
+
dashboard: createDashboardRouter({ storage, config }),
|
|
504
|
+
getStats: function() { return storage.getStats(); },
|
|
505
|
+
getConfig: function() { return sanitizeConfig(config); },
|
|
506
|
+
updateConfig: function(patch) { return mergeInto(config, patch); },
|
|
507
|
+
clear: function() { return storage.clear(); }
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// Storage factory
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
function createStorage(storageConfig) {
|
|
516
|
+
storageConfig = storageConfig || {};
|
|
517
|
+
const mode = storageConfig.mode || 'memory';
|
|
518
|
+
if (mode === 'sqlite') {
|
|
519
|
+
return new SQLiteStorage({
|
|
520
|
+
file: storageConfig.sqliteFile || './data/iri-shield.sqlite',
|
|
521
|
+
maxRequestRows: storageConfig.maxRequestRows,
|
|
522
|
+
maxEventRows: storageConfig.maxEventRows,
|
|
523
|
+
retentionDays: storageConfig.retentionDays
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (mode === 'mongodb') {
|
|
527
|
+
return new MongoStorage({
|
|
528
|
+
mongoUrl: storageConfig.mongoUrl || 'mongodb://localhost:27017/iri-shield',
|
|
529
|
+
dbName: storageConfig.dbName || 'iri-shield'
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return new MemoryStorage();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Response redaction patch
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
function patchResponse(res, storage, config) {
|
|
540
|
+
if (!config.redaction || !config.redaction.enabled || res.locals.iriShieldRedactionPatched) return;
|
|
541
|
+
res.locals.iriShieldRedactionPatched = true;
|
|
542
|
+
|
|
543
|
+
const originalJson = res.json.bind(res);
|
|
544
|
+
const originalSend = res.send.bind(res);
|
|
545
|
+
|
|
546
|
+
res.json = function(payload) {
|
|
547
|
+
const result = redactPayload(payload, config.redaction);
|
|
548
|
+
if (result.redactions > 0) storage.recordRedaction(result.redactions);
|
|
549
|
+
return originalJson(result.value);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
res.send = function(payload) {
|
|
553
|
+
if (typeof payload !== 'string') return originalSend(payload);
|
|
554
|
+
const result = redactPayload(payload, config.redaction);
|
|
555
|
+
if (result.redactions > 0) storage.recordRedaction(result.redactions);
|
|
556
|
+
return originalSend(result.value);
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Auth helpers
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
function apiKeyAuth(validKeys, options) {
|
|
565
|
+
options = options || {};
|
|
566
|
+
const keys = new Set(Array.isArray(validKeys) ? validKeys : [validKeys].filter(Boolean));
|
|
567
|
+
const headerName = (options.header || 'x-api-key').toLowerCase();
|
|
568
|
+
return function iriShieldApiKeyAuth(req, res, next) {
|
|
569
|
+
const key = req.headers[headerName];
|
|
570
|
+
if (!key || !keys.has(key)) {
|
|
571
|
+
return res.status(401).json({ error: 'Invalid or missing API key' });
|
|
572
|
+
}
|
|
573
|
+
return next();
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function signToken(payload, secret, options) {
|
|
578
|
+
options = options || {};
|
|
579
|
+
if (!secret) throw new Error('JWT secret is required');
|
|
580
|
+
return jwt.sign(payload, secret, Object.assign({ expiresIn: '1h' }, options));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function jwtAuth(secret, options) {
|
|
584
|
+
options = options || {};
|
|
585
|
+
if (!secret) throw new Error('JWT secret is required');
|
|
586
|
+
return function iriShieldJwtAuth(req, res, next) {
|
|
587
|
+
const header = req.headers.authorization || '';
|
|
588
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
|
589
|
+
if (!token) return res.status(401).json({ error: 'Missing bearer token' });
|
|
590
|
+
try {
|
|
591
|
+
req.user = jwt.verify(token, secret, options.verify || {});
|
|
592
|
+
return next();
|
|
593
|
+
} catch (_) {
|
|
594
|
+
return res.status(401).json({ error: 'Invalid bearer token' });
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function hashPassword(password, rounds) {
|
|
600
|
+
rounds = rounds || 10;
|
|
601
|
+
return bcrypt.hash(password, rounds);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function comparePassword(password, hash) {
|
|
605
|
+
return bcrypt.compare(password, hash);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Internal utilities
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
function buildEvent(req, extra) {
|
|
613
|
+
const client = req.iriShieldClient || {};
|
|
614
|
+
return Object.assign({
|
|
615
|
+
id: randomUUID(),
|
|
616
|
+
timestamp: new Date().toISOString(),
|
|
617
|
+
ip: extra.storageIp || client.ip || getClientIp(req),
|
|
618
|
+
clientId: client.clientId || null,
|
|
619
|
+
userId: client.userId || null,
|
|
620
|
+
sessionId: client.sessionId || null,
|
|
621
|
+
deviceId: client.deviceId || null,
|
|
622
|
+
fingerprint: client.fingerprint || null,
|
|
623
|
+
platform: client.secChUaPlatform || null,
|
|
624
|
+
method: req.method,
|
|
625
|
+
endpoint: req.originalUrl || req.url,
|
|
626
|
+
userAgent: client.userAgent || req.headers['user-agent'] || '',
|
|
627
|
+
referer: client.referer || req.headers['referer'] || '',
|
|
628
|
+
acceptLanguage: client.acceptLanguage || req.headers['accept-language'] || ''
|
|
629
|
+
}, extra);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function mergeInto(target, patch) {
|
|
633
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
634
|
+
if (
|
|
635
|
+
value &&
|
|
636
|
+
typeof value === 'object' &&
|
|
637
|
+
!Array.isArray(value) &&
|
|
638
|
+
target[key] &&
|
|
639
|
+
typeof target[key] === 'object'
|
|
640
|
+
) {
|
|
641
|
+
mergeInto(target[key], value);
|
|
642
|
+
} else {
|
|
643
|
+
target[key] = value;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return sanitizeConfig(target);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function sanitizeConfig(config) {
|
|
650
|
+
const copy = JSON.parse(JSON.stringify(config));
|
|
651
|
+
if (copy.dashboard && copy.dashboard.password) copy.dashboard.password = '';
|
|
652
|
+
return copy;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// Exports
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
module.exports = {
|
|
660
|
+
createShield,
|
|
661
|
+
MemoryStorage,
|
|
662
|
+
SQLiteStorage,
|
|
663
|
+
MongoStorage,
|
|
664
|
+
redactPayload,
|
|
665
|
+
analyzeRequest,
|
|
666
|
+
apiKeyAuth,
|
|
667
|
+
signToken,
|
|
668
|
+
jwtAuth,
|
|
669
|
+
hashPassword,
|
|
670
|
+
comparePassword,
|
|
671
|
+
SECURITY_MODE_PRESETS
|
|
672
|
+
};
|