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,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 };
|
package/src/redactor.js
ADDED
|
@@ -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 };
|