aiwaf-js 0.0.3 → 0.0.4
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/README.md +28 -0
- package/lib/featureUtils.js +24 -6
- package/lib/rateLimiter.js +41 -34
- package/lib/wafMiddleware.js +2 -1
- package/package.json +1 -1
- package/test/waf.test.js +28 -13
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- ✅ Anomaly Detection (Isolation Forest)
|
|
20
20
|
- ✅ Redis Support for multiprocess environments
|
|
21
21
|
- ✅ Offline Training from access logs
|
|
22
|
+
- ✅ **Custom Cache Logic Support**
|
|
22
23
|
|
|
23
24
|
---
|
|
24
25
|
|
|
@@ -73,6 +74,31 @@ If Redis is unavailable, it gracefully falls back to in-memory mode.
|
|
|
73
74
|
|
|
74
75
|
---
|
|
75
76
|
|
|
77
|
+
## Custom Cache Logic (Advanced)
|
|
78
|
+
|
|
79
|
+
You can inject your own cache logic (in-memory, Redis, hybrid, or file-based) by passing a `cache` object implementing the following interface:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
const myCustomCache = {
|
|
83
|
+
get: async (key) => { /* return cached value */ },
|
|
84
|
+
set: async (key, value, options) => { /* store with optional TTL */ },
|
|
85
|
+
del: async (key) => { /* delete entry */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
app.use(aiwaf({
|
|
89
|
+
cache: myCustomCache,
|
|
90
|
+
staticKeywords: ['.php'],
|
|
91
|
+
dynamicTopN: 5,
|
|
92
|
+
MAX_REQ: 10,
|
|
93
|
+
WINDOW_SEC: 15,
|
|
94
|
+
FLOOD_REQ: 20,
|
|
95
|
+
}))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This overrides Redis/in-memory usage with your custom strategy for all cache operations.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
76
102
|
## Configuration
|
|
77
103
|
|
|
78
104
|
```js
|
|
@@ -83,6 +109,7 @@ app.use(aiwaf({
|
|
|
83
109
|
MAX_REQ: 20,
|
|
84
110
|
FLOOD_REQ: 10,
|
|
85
111
|
HONEYPOT_FIELD: 'hp_field',
|
|
112
|
+
cache: myCustomCache, // optional custom cache injection
|
|
86
113
|
}));
|
|
87
114
|
```
|
|
88
115
|
|
|
@@ -97,6 +124,7 @@ app.use(aiwaf({
|
|
|
97
124
|
| `anomalyThreshold` | `ANOMALY_THRESHOLD` | 0.5 | Threshold for IsolationForest-based anomaly detection. |
|
|
98
125
|
| `logPath` | `NODE_LOG_PATH` | "/var/log/nginx/access.log" | Path to access log file. |
|
|
99
126
|
| `logGlob` | `NODE_LOG_GLOB` | "${logPath}.*" | Glob pattern to include rotated/gzipped logs. |
|
|
127
|
+
| `cache` | — | undefined | Custom cache implementation (overrides Redis/memory) |
|
|
100
128
|
|
|
101
129
|
---
|
|
102
130
|
|
package/lib/featureUtils.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
const STATIC_KW = ['.php', '.xmlrpc', 'wp-', '.env', '.git', '.bak', 'shell'];
|
|
2
2
|
const STATUS_IDX = ['200', '403', '404', '500'];
|
|
3
|
-
const
|
|
3
|
+
const defaultCache = new Map();
|
|
4
4
|
const { getClient } = require('./redisClient');
|
|
5
5
|
|
|
6
|
+
let customCache = null;
|
|
7
|
+
|
|
8
|
+
function init(opts = {}) {
|
|
9
|
+
customCache = opts.cache || null;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
async function extractFeatures(req) {
|
|
7
13
|
const uri = req.path.toLowerCase();
|
|
8
14
|
const redis = getClient();
|
|
9
15
|
|
|
16
|
+
// Custom cache logic
|
|
17
|
+
if (customCache?.get && customCache?.set) {
|
|
18
|
+
const cached = await customCache.get(uri);
|
|
19
|
+
if (cached) return cached;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Redis check
|
|
10
23
|
if (redis) {
|
|
11
24
|
try {
|
|
12
25
|
const cached = await redis.get(`features:${uri}`);
|
|
13
26
|
if (cached) return JSON.parse(cached);
|
|
14
27
|
} catch (err) {
|
|
15
|
-
console.warn('⚠️ Redis read failed.
|
|
28
|
+
console.warn('⚠️ Redis read failed. Falling back.', err);
|
|
16
29
|
}
|
|
17
30
|
}
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
// Local cache fallback
|
|
33
|
+
if (defaultCache.has(uri)) return defaultCache.get(uri);
|
|
20
34
|
|
|
35
|
+
// Compute features
|
|
21
36
|
const pathLen = uri.length;
|
|
22
37
|
const kwHits = STATIC_KW.reduce((count, kw) => count + (uri.includes(kw) ? 1 : 0), 0);
|
|
23
38
|
const statusIdx = STATUS_IDX.indexOf(String(req.res?.statusCode || 200));
|
|
@@ -26,7 +41,10 @@ async function extractFeatures(req) {
|
|
|
26
41
|
const total404 = 0;
|
|
27
42
|
const features = [pathLen, kwHits, statusIdx, rt, burst, total404];
|
|
28
43
|
|
|
29
|
-
|
|
44
|
+
// Write back
|
|
45
|
+
if (customCache?.set) {
|
|
46
|
+
await customCache.set(uri, features);
|
|
47
|
+
} else if (redis) {
|
|
30
48
|
try {
|
|
31
49
|
await redis.set(`features:${uri}`, JSON.stringify(features), { EX: 60 });
|
|
32
50
|
} catch (err) {
|
|
@@ -34,8 +52,8 @@ async function extractFeatures(req) {
|
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
|
|
37
|
-
|
|
55
|
+
defaultCache.set(uri, features);
|
|
38
56
|
return features;
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
module.exports = { extractFeatures };
|
|
59
|
+
module.exports = { extractFeatures, init };
|
package/lib/rateLimiter.js
CHANGED
|
@@ -1,56 +1,63 @@
|
|
|
1
1
|
const NodeCache = require('node-cache');
|
|
2
2
|
const blacklistManager = require('./blacklistManager');
|
|
3
|
-
const { getClient } = require('./redisClient');
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
let cacheBackend;
|
|
6
5
|
let opts;
|
|
7
6
|
|
|
7
|
+
const defaultMemoryCache = new NodeCache();
|
|
8
|
+
const fallbackCache = {
|
|
9
|
+
get: (key) => Promise.resolve(defaultMemoryCache.get(key)),
|
|
10
|
+
set: (key, value, ttl) => Promise.resolve(defaultMemoryCache.set(key, value, ttl)),
|
|
11
|
+
lPush: async (key, value) => {
|
|
12
|
+
const arr = defaultMemoryCache.get(key) || [];
|
|
13
|
+
arr.unshift(value);
|
|
14
|
+
defaultMemoryCache.set(key, arr, opts.WINDOW_SEC);
|
|
15
|
+
},
|
|
16
|
+
expire: (key, ttl) => Promise.resolve(defaultMemoryCache.ttl(key, ttl)),
|
|
17
|
+
lLen: async (key) => {
|
|
18
|
+
const arr = defaultMemoryCache.get(key) || [];
|
|
19
|
+
return arr.length;
|
|
20
|
+
},
|
|
21
|
+
lRange: async (key, start, end) => {
|
|
22
|
+
const arr = defaultMemoryCache.get(key) || [];
|
|
23
|
+
return arr.slice(start, end + 1);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
8
27
|
module.exports = {
|
|
9
28
|
async init(o) {
|
|
10
29
|
opts = o;
|
|
30
|
+
cacheBackend = o.cache || fallbackCache;
|
|
11
31
|
},
|
|
12
32
|
|
|
13
33
|
async record(ip) {
|
|
14
34
|
const now = Date.now().toString();
|
|
15
|
-
const redis = getClient();
|
|
16
35
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return;
|
|
25
|
-
} catch (err) {
|
|
26
|
-
console.warn('⚠️ Redis error in record(). Using fallback.', err);
|
|
36
|
+
try {
|
|
37
|
+
const key = `ratelimit:${ip}`;
|
|
38
|
+
await cacheBackend.lPush(key, now);
|
|
39
|
+
await cacheBackend.expire(key, opts.WINDOW_SEC);
|
|
40
|
+
const count = await cacheBackend.lLen(key);
|
|
41
|
+
if (count > opts.FLOOD_REQ) {
|
|
42
|
+
await blacklistManager.block(ip, 'flood');
|
|
27
43
|
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.warn('⚠️ Cache error in record().', err);
|
|
28
46
|
}
|
|
29
|
-
|
|
30
|
-
const logs = memoryCache.get(ip) || [];
|
|
31
|
-
logs.push(now);
|
|
32
|
-
memoryCache.set(ip, logs, opts.WINDOW_SEC);
|
|
33
|
-
if (logs.length > opts.FLOOD_REQ) await blacklistManager.block(ip, 'flood');
|
|
34
47
|
},
|
|
35
48
|
|
|
36
49
|
async isBlocked(ip) {
|
|
37
50
|
if (await blacklistManager.isBlocked(ip)) return true;
|
|
38
|
-
const now = Date.now();
|
|
39
|
-
const redis = getClient();
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
try {
|
|
53
|
+
const key = `ratelimit:${ip}`;
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const timestamps = (await cacheBackend.lRange(key, 0, -1)).map(Number);
|
|
56
|
+
const within = timestamps.filter(t => now - t < opts.WINDOW_SEC * 1000);
|
|
57
|
+
return within.length > opts.MAX_REQ;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.warn('⚠️ Cache error in isBlocked().', err);
|
|
60
|
+
return false;
|
|
50
61
|
}
|
|
51
|
-
|
|
52
|
-
const logs = memoryCache.get(ip) || [];
|
|
53
|
-
const within = logs.filter(t => now - t < opts.WINDOW_SEC * 1000);
|
|
54
|
-
return within.length > opts.MAX_REQ;
|
|
55
62
|
}
|
|
56
|
-
};
|
|
63
|
+
};
|
package/lib/wafMiddleware.js
CHANGED
|
@@ -8,6 +8,7 @@ const anomalyDetector = require('./anomalyDetector');
|
|
|
8
8
|
const { extractFeatures } = require('./featureUtils');
|
|
9
9
|
|
|
10
10
|
module.exports = function aiwaf(opts = {}) {
|
|
11
|
+
// Allow injecting custom cache backend
|
|
11
12
|
rateLimiter.init(opts);
|
|
12
13
|
keywordDetector.init(opts);
|
|
13
14
|
dynamicKeyword.init(opts);
|
|
@@ -77,4 +78,4 @@ module.exports = function aiwaf(opts = {}) {
|
|
|
77
78
|
|
|
78
79
|
next();
|
|
79
80
|
};
|
|
80
|
-
};
|
|
81
|
+
};
|
package/package.json
CHANGED
package/test/waf.test.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
const request = require('supertest');
|
|
2
2
|
const express = require('express');
|
|
3
|
-
|
|
4
|
-
process.env.NODE_ENV = 'test';
|
|
5
|
-
jest.setTimeout(20000);
|
|
6
|
-
|
|
7
3
|
const db = require('../utils/db');
|
|
8
4
|
const aiwaf = require('../index');
|
|
9
5
|
const dynamicKeyword = require('../lib/dynamicKeyword');
|
|
10
6
|
const redisManager = require('../lib/redisClient');
|
|
11
7
|
const { init: initRateLimiter } = require('../lib/rateLimiter');
|
|
12
8
|
|
|
9
|
+
process.env.NODE_ENV = 'test';
|
|
10
|
+
jest.setTimeout(20000);
|
|
11
|
+
|
|
13
12
|
let redisAvailable = false;
|
|
14
13
|
|
|
14
|
+
const testCache = (() => {
|
|
15
|
+
const store = new Map();
|
|
16
|
+
return {
|
|
17
|
+
async get(key) {
|
|
18
|
+
return store.has(key) ? store.get(key) : null;
|
|
19
|
+
},
|
|
20
|
+
async set(key, val) {
|
|
21
|
+
store.set(key, val);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
})();
|
|
25
|
+
|
|
15
26
|
beforeAll(async () => {
|
|
16
27
|
const hasTable = await db.schema.hasTable('blocked_ips');
|
|
17
28
|
if (!hasTable) {
|
|
@@ -29,7 +40,8 @@ beforeAll(async () => {
|
|
|
29
40
|
await initRateLimiter({
|
|
30
41
|
WINDOW_SEC: 1,
|
|
31
42
|
MAX_REQ: 5,
|
|
32
|
-
FLOOD_REQ: 10
|
|
43
|
+
FLOOD_REQ: 10,
|
|
44
|
+
cache: testCache // inject test cache
|
|
33
45
|
});
|
|
34
46
|
});
|
|
35
47
|
|
|
@@ -48,7 +60,9 @@ describe('AIWAF-JS Middleware', () => {
|
|
|
48
60
|
WINDOW_SEC: 1,
|
|
49
61
|
MAX_REQ: 5,
|
|
50
62
|
FLOOD_REQ: 10,
|
|
51
|
-
HONEYPOT_FIELD: 'hp_field'
|
|
63
|
+
HONEYPOT_FIELD: 'hp_field',
|
|
64
|
+
cache: testCache,
|
|
65
|
+
logger: console
|
|
52
66
|
}));
|
|
53
67
|
|
|
54
68
|
app.get('/', (req, res) => res.send('OK'));
|
|
@@ -64,17 +78,18 @@ describe('AIWAF-JS Middleware', () => {
|
|
|
64
78
|
.set('x-response-time', '15')
|
|
65
79
|
.expect(403, { error: 'blocked' })
|
|
66
80
|
);
|
|
81
|
+
|
|
67
82
|
it('continues working if Redis goes down', async () => {
|
|
68
83
|
const redis = redisManager.getClient();
|
|
69
|
-
if (redis) await redis.quit();
|
|
70
|
-
|
|
84
|
+
if (redis) await redis.quit();
|
|
85
|
+
|
|
71
86
|
const segment = `/simulate-${Date.now().toString(36)}`;
|
|
72
87
|
for (let i = 0; i < 3; i++) {
|
|
73
|
-
await request(app).get(segment).set('X-Forwarded-For', ip);
|
|
88
|
+
await request(app).get(segment).set('X-Forwarded-For', ip);
|
|
74
89
|
}
|
|
75
90
|
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403);
|
|
76
91
|
});
|
|
77
|
-
|
|
92
|
+
|
|
78
93
|
it('allows safe paths', () =>
|
|
79
94
|
request(app)
|
|
80
95
|
.get('/')
|
|
@@ -116,9 +131,9 @@ describe('AIWAF-JS Middleware', () => {
|
|
|
116
131
|
it('learns and blocks dynamic keywords', async () => {
|
|
117
132
|
const segment = `/secret-${Date.now().toString(36)}`;
|
|
118
133
|
for (let i = 0; i < 3; i++) {
|
|
119
|
-
await request(app).get(segment).set('X-Forwarded-For', ip);
|
|
134
|
+
await request(app).get(segment).set('X-Forwarded-For', ip);
|
|
120
135
|
}
|
|
121
|
-
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403);
|
|
136
|
+
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403);
|
|
122
137
|
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403, { error: 'blocked' });
|
|
123
138
|
});
|
|
124
139
|
|
|
@@ -137,4 +152,4 @@ afterAll(async () => {
|
|
|
137
152
|
await redisManager.getClient().quit();
|
|
138
153
|
}
|
|
139
154
|
await db.destroy();
|
|
140
|
-
});
|
|
155
|
+
});
|