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,237 @@
1
+ 'use strict';
2
+
3
+ const { MemoryStorage } = require('./storage');
4
+
5
+ /**
6
+ * MongoStorage — MongoDB-backed storage for iri-shield.
7
+ *
8
+ * Graceful fallback: if MongoDB is not available (connection fails within 3s),
9
+ * the class silently falls back to pure in-memory operation.
10
+ * No error is thrown — the server continues running normally.
11
+ */
12
+ class MongoStorage extends MemoryStorage {
13
+ constructor(options = {}) {
14
+ super(options);
15
+ this.mongoUrl = options.mongoUrl || 'mongodb://localhost:27017/iri-shield';
16
+ this.dbName = options.dbName || 'iri-shield';
17
+ this.connected = false;
18
+ this.client = null;
19
+ this.db = null;
20
+ this._cols = {};
21
+
22
+ // Start connection asynchronously — does not block constructor
23
+ this._connect();
24
+ }
25
+
26
+ async _connect() {
27
+ try {
28
+ let MongoClient;
29
+ try {
30
+ ({ MongoClient } = require('mongodb'));
31
+ } catch {
32
+ // mongodb package not installed — silently use memory-only
33
+ return;
34
+ }
35
+
36
+ const client = new MongoClient(this.mongoUrl, {
37
+ serverSelectionTimeoutMS: 3000,
38
+ connectTimeoutMS: 3000,
39
+ socketTimeoutMS: 3000
40
+ });
41
+
42
+ await client.connect();
43
+ this.client = client;
44
+ this.db = client.db(this.dbName);
45
+ this.connected = true;
46
+
47
+ // Initialize collections and TTL indexes
48
+ await this._initCollections();
49
+
50
+ // Load persistent blocks into memory
51
+ await this._loadPersistentBlocks();
52
+
53
+ console.log('[iri-shield] MongoDB connected:', this.mongoUrl);
54
+ } catch (err) {
55
+ // Graceful fallback — not a fatal error
56
+ console.warn('[iri-shield] MongoDB not available, using memory storage. Reason:', err.message);
57
+ this.connected = false;
58
+ }
59
+ }
60
+
61
+ async _initCollections() {
62
+ const db = this.db;
63
+
64
+ // Requests: TTL 7 days
65
+ const requests = db.collection('iri_requests');
66
+ await requests.createIndex({ createdAt: 1 }, { expireAfterSeconds: 7 * 24 * 60 * 60 });
67
+ await requests.createIndex({ ip: 1 });
68
+
69
+ // Events: TTL 30 days
70
+ const events = db.collection('iri_events');
71
+ await events.createIndex({ createdAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 });
72
+ await events.createIndex({ riskLevel: 1 });
73
+
74
+ // Clients: no TTL
75
+ const clients = db.collection('iri_clients');
76
+ await clients.createIndex({ clientId: 1 }, { unique: true });
77
+
78
+ // Blocks: no TTL (managed manually)
79
+ const blocks = db.collection('iri_blocks');
80
+ await blocks.createIndex({ ip: 1 }, { unique: true });
81
+
82
+ // Alerts: no TTL
83
+ const alerts = db.collection('iri_alerts');
84
+ await alerts.createIndex({ clientId: 1 }, { unique: true });
85
+
86
+ this._cols = { requests, events, clients, blocks, alerts };
87
+ }
88
+
89
+ async _loadPersistentBlocks() {
90
+ if (!this.connected) return;
91
+ try {
92
+ const rows = await this._cols.blocks.find({}).toArray();
93
+ for (const row of rows) {
94
+ if (!row.expiresAt || row.expiresAt > Date.now()) {
95
+ this.blocks.set(row.ip, {
96
+ reason: row.reason || '',
97
+ score: row.score || 0,
98
+ manual: Boolean(row.manual),
99
+ blockedAt: row.blockedAt || null,
100
+ expiresAt: row.expiresAt || null
101
+ });
102
+ }
103
+ }
104
+ } catch { /* ignore */ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Helpers
109
+ // ---------------------------------------------------------------------------
110
+
111
+ _safe(fn) {
112
+ // Fire-and-forget — never awaited, never throws
113
+ if (!this.connected) return;
114
+ Promise.resolve().then(fn).catch(() => {});
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Request recording
119
+ // ---------------------------------------------------------------------------
120
+
121
+ recordRequest(request) {
122
+ super.recordRequest(request);
123
+ this._safe(() =>
124
+ this._cols.requests.insertOne({
125
+ ...request,
126
+ createdAt: new Date(),
127
+ blocked: Boolean(request.blocked)
128
+ })
129
+ );
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Events
134
+ // ---------------------------------------------------------------------------
135
+
136
+ recordEvent(event) {
137
+ super.recordEvent(event);
138
+ this._safe(() =>
139
+ this._cols.events.updateOne(
140
+ { id: event.id },
141
+ { $set: { ...event, createdAt: new Date() } },
142
+ { upsert: true }
143
+ )
144
+ );
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Clients
149
+ // ---------------------------------------------------------------------------
150
+
151
+ recordClient(client) {
152
+ const row = super.recordClient(client);
153
+ this._safe(() =>
154
+ this._cols.clients.updateOne(
155
+ { clientId: row.clientId },
156
+ { $set: { ...row, updatedAt: new Date() } },
157
+ { upsert: true }
158
+ )
159
+ );
160
+ return row;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Blocks (persistent)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ blockIp(ip, block) {
168
+ super.blockIp(ip, block);
169
+ this._safe(() =>
170
+ this._cols.blocks.updateOne(
171
+ { ip },
172
+ { $set: { ip, ...block, updatedAt: new Date() } },
173
+ { upsert: true }
174
+ )
175
+ );
176
+ }
177
+
178
+ unblockIp(ip) {
179
+ const result = super.unblockIp(ip);
180
+ this._safe(() => this._cols.blocks.deleteOne({ ip }));
181
+ return result;
182
+ }
183
+
184
+ manualBlockIp(ip, options = {}) {
185
+ super.manualBlockIp(ip, options);
186
+ const block = this.blocks.get(ip);
187
+ if (block) {
188
+ this._safe(() =>
189
+ this._cols.blocks.updateOne(
190
+ { ip },
191
+ { $set: { ip, ...block, manual: true, updatedAt: new Date() } },
192
+ { upsert: true }
193
+ )
194
+ );
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Alerts (persistent)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ recordAlert(clientId, alertData) {
203
+ super.recordAlert(clientId, alertData);
204
+ const a = this.alerts.get(clientId);
205
+ if (!a) return;
206
+ this._safe(() =>
207
+ this._cols.alerts.updateOne(
208
+ { clientId },
209
+ { $set: { ...a, updatedAt: new Date() } },
210
+ { upsert: true }
211
+ )
212
+ );
213
+ }
214
+
215
+ dismissAlert(clientId) {
216
+ const result = super.dismissAlert(clientId);
217
+ this._safe(() =>
218
+ this._cols.alerts.updateOne({ clientId }, { $set: { dismissed: true } })
219
+ );
220
+ return result;
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Stats
225
+ // ---------------------------------------------------------------------------
226
+
227
+ getStats() {
228
+ return {
229
+ ...super.getStats(),
230
+ storageMode: this.connected ? 'mongodb' : 'mongodb_fallback_memory',
231
+ mongoUrl: this.mongoUrl,
232
+ mongoConnected: this.connected
233
+ };
234
+ }
235
+ }
236
+
237
+ module.exports = { MongoStorage };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Redactor — PII and secrets redaction for responses AND request logs
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const valuePatterns = [
8
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
9
+ /\b(?:\+?91[-\s]?)?[6-9]\d{9}\b/g,
10
+ /\b(?:\+?1[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}\b/g,
11
+ /\b(?:Bearer\s+)?[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\b/g,
12
+ /\b(?:sk|pk|api|key|secret)(?:_mock|_live|_test|_key|_secret)?_[A-Za-z0-9_-]{10,}\b/gi
13
+ ];
14
+
15
+ /**
16
+ * Deep-redact a payload (response body or object)
17
+ * @param {*} payload
18
+ * @param {object} options — { mask, fields[] }
19
+ * @returns {{ value: *, redactions: number }}
20
+ */
21
+ function redactPayload(payload, options) {
22
+ options = options || {};
23
+ const state = { redactions: 0, mask: options.mask || '[REDACTED]', fields: normalizeFields(options.fields || []) };
24
+ const value = redactValue(payload, state);
25
+ return { value, redactions: state.redactions };
26
+ }
27
+
28
+ /**
29
+ * Redact a request body object before storing in logs
30
+ * Only redacts known sensitive field names — does NOT scan string values
31
+ * (to avoid false positives on legitimate request data)
32
+ * @param {object|string} body
33
+ * @param {object} options
34
+ * @returns {object|string}
35
+ */
36
+ function redactRequestBody(body, options) {
37
+ options = options || {};
38
+ if (!body) return body;
39
+ if (typeof body === 'string') {
40
+ // Best-effort: replace known field patterns in JSON strings
41
+ try {
42
+ const parsed = JSON.parse(body);
43
+ const result = redactPayload(parsed, options);
44
+ return JSON.stringify(result.value);
45
+ } catch (_) {
46
+ return body; // not JSON, return as-is
47
+ }
48
+ }
49
+ if (typeof body === 'object') {
50
+ return redactPayload(body, options).value;
51
+ }
52
+ return body;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Internals
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function normalizeFields(fields) {
60
+ return new Set(fields.map(function(f) { return String(f).toLowerCase().replace(/[^a-z0-9]/g, ''); }));
61
+ }
62
+
63
+ function isSensitiveKey(key, fieldsSet) {
64
+ if (!key) return false;
65
+ const clean = String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
66
+ if (fieldsSet.has(clean)) return true;
67
+ for (const f of fieldsSet) {
68
+ if (f.length >= 4 && clean.includes(f)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ function redactValue(value, state, key) {
74
+ key = key || '';
75
+ if (value == null) return value;
76
+ if (isSensitiveKey(key, state.fields)) {
77
+ state.redactions += 1;
78
+ return state.mask;
79
+ }
80
+ if (typeof value === 'string') return redactString(value, state);
81
+ if (Array.isArray(value)) return value.map(function(item) { return redactValue(item, state); });
82
+ if (typeof value === 'object') {
83
+ const output = {};
84
+ for (const childKey of Object.keys(value)) {
85
+ output[childKey] = redactValue(value[childKey], state, childKey);
86
+ }
87
+ return output;
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function redactString(value, state) {
93
+ let output = value;
94
+ for (const pattern of valuePatterns) {
95
+ pattern.lastIndex = 0;
96
+ output = output.replace(pattern, function() {
97
+ state.redactions += 1;
98
+ return state.mask;
99
+ });
100
+ }
101
+ return output;
102
+ }
103
+
104
+ module.exports = { redactPayload, redactRequestBody, valuePatterns };
package/src/rules.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Custom & Built-in Rule Engine
5
+ // Evaluates configurable rules against incoming requests
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Apply custom rules defined in config.rules.customRules[]
10
+ * Each custom rule can match on: endpoint, method, ip, userAgent, body fields
11
+ */
12
+ function applyCustomRules(req, config) {
13
+ const customRules = config.rules && config.rules.customRules ? config.rules.customRules : [];
14
+ const scoreParts = [];
15
+ const threats = [];
16
+ const reasons = [];
17
+ const breakdown = [];
18
+
19
+ if (!customRules.length) return { score: 0, threats, reasons, breakdown };
20
+
21
+ const endpoint = (req.originalUrl || req.url || '/').toLowerCase();
22
+ const method = (req.method || 'GET').toUpperCase();
23
+ const userAgent = (
24
+ (req.iriShieldClient && req.iriShieldClient.userAgent) ||
25
+ req.headers['user-agent'] || ''
26
+ ).toLowerCase();
27
+ const ip = (req.iriShieldClient && req.iriShieldClient.ip) || '';
28
+
29
+ for (const rule of customRules) {
30
+ if (!rule || !rule.name) continue;
31
+
32
+ let matched = false;
33
+
34
+ // Endpoint match (string or regex)
35
+ if (rule.match && rule.match.endpoint) {
36
+ const pattern = rule.match.endpoint;
37
+ if (typeof pattern === 'string') {
38
+ matched = endpoint.includes(pattern.toLowerCase());
39
+ } else if (pattern instanceof RegExp) {
40
+ matched = pattern.test(endpoint);
41
+ }
42
+ }
43
+
44
+ // Method match
45
+ if (matched && rule.match && rule.match.method) {
46
+ const methods = Array.isArray(rule.match.method)
47
+ ? rule.match.method.map(function(m) { return m.toUpperCase(); })
48
+ : [rule.match.method.toUpperCase()];
49
+ if (!methods.includes(method)) matched = false;
50
+ }
51
+
52
+ // UserAgent match
53
+ if (rule.match && rule.match.userAgent) {
54
+ const uaPattern = rule.match.userAgent;
55
+ if (typeof uaPattern === 'string') {
56
+ matched = userAgent.includes(uaPattern.toLowerCase());
57
+ } else if (uaPattern instanceof RegExp) {
58
+ matched = uaPattern.test(userAgent);
59
+ }
60
+ }
61
+
62
+ // IP match (exact or prefix)
63
+ if (rule.match && rule.match.ip) {
64
+ matched = ip.startsWith(rule.match.ip);
65
+ }
66
+
67
+ // Body field match
68
+ if (rule.match && rule.match.bodyField && req.body && typeof req.body === 'object') {
69
+ const entries = Object.entries(rule.match.bodyField);
70
+ if (entries.length > 0) {
71
+ const field = entries[0][0];
72
+ const value = entries[0][1];
73
+ if (field && value !== undefined) {
74
+ matched = String(req.body[field] || '').toLowerCase().includes(String(value).toLowerCase());
75
+ }
76
+ }
77
+ }
78
+
79
+ if (matched) {
80
+ const pts = rule.score || 25;
81
+ scoreParts.push(pts);
82
+ threats.push('custom_rule_' + rule.name);
83
+ reasons.push(rule.reason || 'custom_rule_' + rule.name + '_matched');
84
+ breakdown.push({
85
+ rule: 'custom_rule_' + rule.name,
86
+ label: rule.label || 'Custom Rule: ' + rule.name,
87
+ points: pts,
88
+ category: 'custom',
89
+ confidence: rule.confidence || 85
90
+ });
91
+ }
92
+ }
93
+
94
+ const score = scoreParts.reduce(function(s, v) { return s + v; }, 0);
95
+ return { score, threats, reasons, breakdown };
96
+ }
97
+
98
+ /**
99
+ * Check if a built-in rule is enabled via config.rules.{ruleName}: true/false
100
+ * Defaults to true
101
+ */
102
+ function isRuleEnabled(config, ruleName) {
103
+ if (!config.rules) return true;
104
+ const key = camelCase(ruleName);
105
+ if (config.rules[key] === false) return false;
106
+ return true;
107
+ }
108
+
109
+ function camelCase(str) {
110
+ return str.replace(/_([a-z])/g, function(_, c) { return c.toUpperCase(); });
111
+ }
112
+
113
+ module.exports = { applyCustomRules, isRuleEnabled, camelCase };