aiwaf-js 0.0.2 → 0.0.3
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 +61 -38
- package/lib/anomalyDetector.js +2 -2
- package/lib/featureUtils.js +28 -7
- package/lib/rateLimiter.js +45 -8
- package/lib/redisClient.js +30 -0
- package/package.json +1 -1
- package/test/waf.test.js +43 -18
package/README.md
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# aiwaf‑js
|
|
2
2
|
|
|
3
3
|
> **Adaptive Web Application Firewall** middleware for Node.js & Express
|
|
4
|
-
> Self‑learning, plug‑and‑play WAF with rate‑limiting, static & dynamic keyword blocking, honeypot traps, UUID‑tamper protection and IsolationForest anomaly detection—fully configurable and trainable on your own access logs.
|
|
4
|
+
> Self‑learning, plug‑and‑play WAF with rate‑limiting, static & dynamic keyword blocking, honeypot traps, UUID‑tamper protection, and IsolationForest anomaly detection—fully configurable and trainable on your own access logs. Now Redis‑powered and ready for distributed, multiprocess use.
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/aiwaf-js)
|
|
7
|
-
[](https://github.com/your-user/aiwaf-js/actions)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
|
+
---
|
|
11
|
+
|
|
10
12
|
## Features
|
|
11
13
|
|
|
12
|
-
- Rate Limiting
|
|
13
|
-
- Static Keyword Blocking
|
|
14
|
-
- Dynamic Keyword Learning
|
|
15
|
-
- Honeypot Field Detection
|
|
16
|
-
- UUID‑Tamper Protection
|
|
17
|
-
- Anomaly Detection (Isolation Forest)
|
|
18
|
-
-
|
|
14
|
+
- ✅ Rate Limiting (Redis-based or fallback to memory)
|
|
15
|
+
- ✅ Static Keyword Blocking
|
|
16
|
+
- ✅ Dynamic Keyword Learning (auto-adaptive)
|
|
17
|
+
- ✅ Honeypot Field Detection
|
|
18
|
+
- ✅ UUID‑Tamper Protection
|
|
19
|
+
- ✅ Anomaly Detection (Isolation Forest)
|
|
20
|
+
- ✅ Redis Support for multiprocess environments
|
|
21
|
+
- ✅ Offline Training from access logs
|
|
22
|
+
|
|
23
|
+
---
|
|
19
24
|
|
|
20
25
|
## Installation
|
|
21
26
|
|
|
@@ -23,6 +28,20 @@
|
|
|
23
28
|
npm install aiwaf-js --save
|
|
24
29
|
```
|
|
25
30
|
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Train the Model (Optional but recommended)
|
|
34
|
+
|
|
35
|
+
You can train the anomaly detector and keyword learner using real access logs.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
NODE_LOG_PATH=/path/to/access.log npm run train
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If `NODE_LOG_PATH` is not provided, it defaults to `/var/log/nginx/access.log`.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
26
45
|
## Quick Start
|
|
27
46
|
|
|
28
47
|
```js
|
|
@@ -36,53 +55,57 @@ app.get('/', (req, res) => res.send('Protected'))
|
|
|
36
55
|
app.listen(3000)
|
|
37
56
|
```
|
|
38
57
|
|
|
39
|
-
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Redis Support (Recommended for Production)
|
|
61
|
+
|
|
62
|
+
AIWAF‑JS supports Redis for distributed rate limiting and keyword caching.
|
|
40
63
|
|
|
41
64
|
```bash
|
|
42
|
-
|
|
65
|
+
# On Unix/Linux/macOS
|
|
66
|
+
export REDIS_URL=redis://localhost:6379
|
|
67
|
+
|
|
68
|
+
# On Windows PowerShell
|
|
69
|
+
$env:REDIS_URL = "redis://localhost:6379"
|
|
43
70
|
```
|
|
44
71
|
|
|
72
|
+
If Redis is unavailable, it gracefully falls back to in-memory mode.
|
|
45
73
|
|
|
46
|
-
|
|
74
|
+
---
|
|
47
75
|
|
|
48
|
-
|
|
76
|
+
## Configuration
|
|
49
77
|
|
|
50
78
|
```js
|
|
51
|
-
const express = require('express');
|
|
52
|
-
const aiwaf = require('aiwaf-js');
|
|
53
|
-
|
|
54
|
-
const app = express();
|
|
55
|
-
app.use(express.json());
|
|
56
|
-
|
|
57
79
|
app.use(aiwaf({
|
|
58
|
-
staticKeywords: ['.php', '.env', '.git'],
|
|
80
|
+
staticKeywords: ['.php', '.env', '.git'],
|
|
59
81
|
dynamicTopN: 10,
|
|
60
82
|
WINDOW_SEC: 10,
|
|
61
83
|
MAX_REQ: 20,
|
|
62
84
|
FLOOD_REQ: 10,
|
|
63
85
|
HONEYPOT_FIELD: 'hp_field',
|
|
64
86
|
}));
|
|
65
|
-
|
|
66
|
-
app.get('/', (req, res) => res.send('Protected by AIWAF-JS'));
|
|
67
|
-
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
|
|
68
87
|
```
|
|
69
88
|
|
|
70
|
-
|
|
89
|
+
| Option | Env Var | Default | Description |
|
|
90
|
+
|--------------------|---------------------|-----------------------------|----------------------------------------------------------|
|
|
91
|
+
| `staticKeywords` | — | [".php",".xmlrpc","wp-"] | Substrings to block immediately. |
|
|
92
|
+
| `dynamicTopN` | `DYNAMIC_TOP_N` | 10 | Number of dynamic keywords to match. |
|
|
93
|
+
| `windowSec` | `WINDOW_SEC` | 10 | Time window in seconds for rate limiting. |
|
|
94
|
+
| `maxReq` | `MAX_REQ` | 20 | Max allowed requests per window. |
|
|
95
|
+
| `floodReq` | `FLOOD_REQ` | 10 | Hard limit triggering IP block. |
|
|
96
|
+
| `honeypotField` | `HONEYPOT_FIELD` | "hp_field" | Hidden bot trap field. |
|
|
97
|
+
| `anomalyThreshold` | `ANOMALY_THRESHOLD` | 0.5 | Threshold for IsolationForest-based anomaly detection. |
|
|
98
|
+
| `logPath` | `NODE_LOG_PATH` | "/var/log/nginx/access.log" | Path to access log file. |
|
|
99
|
+
| `logGlob` | `NODE_LOG_GLOB` | "${logPath}.*" | Glob pattern to include rotated/gzipped logs. |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Optimization Note
|
|
71
104
|
|
|
72
|
-
|
|
105
|
+
**Tip:** In high-volume environments, caching the feature vector extractor (especially if Redis is unavailable) can reduce redundant computation and significantly boost performance.
|
|
73
106
|
|
|
74
|
-
|
|
107
|
+
---
|
|
75
108
|
|
|
76
|
-
|
|
109
|
+
## 📄 License
|
|
77
110
|
|
|
78
|
-
|
|
79
|
-
|--------------------|---------------------|---------------------------------------|-------------------------------------------------------------------------|
|
|
80
|
-
| `staticKeywords` | — | [".php",".xmlrpc","wp-",…] | Substrings to block immediately. |
|
|
81
|
-
| `dynamicTopN` | `DYNAMIC_TOP_N` | 10 | Number of top “learned” keywords to match per request. |
|
|
82
|
-
| `windowSec` | `WINDOW_SEC` | 10 | Time window (in seconds) for rate limiting and burst calculation. |
|
|
83
|
-
| `maxReq` | `MAX_REQ` | 20 | Maximum requests allowed in `windowSec`. |
|
|
84
|
-
| `floodReq` | `FLOOD_REQ` | 10 | If requests exceed this in `windowSec`, IP is blacklisted outright. |
|
|
85
|
-
| `honeypotField` | `HONEYPOT_FIELD` | "hp_field" | Name of the hidden form field to detect bots. |
|
|
86
|
-
| `anomalyThreshold` | `ANOMALY_THRESHOLD` | 0.5 | IsolationForest score threshold above which requests are anomalous. |
|
|
87
|
-
| `logPath` | `NODE_LOG_PATH` | "/var/log/nginx/access.log" | Path to your main access log (used by `train.js`). |
|
|
88
|
-
| `logGlob` | `NODE_LOG_GLOB` | `${logPath}.*` | Glob pattern to include rotated/gzipped logs. |
|
|
111
|
+
MIT License © 2025 [Aayush Gauba](https://github.com/aayushg)
|
package/lib/anomalyDetector.js
CHANGED
|
@@ -11,9 +11,9 @@ try {
|
|
|
11
11
|
const data = fs.readFileSync(modelPath, 'utf-8');
|
|
12
12
|
model = IsolationForest.fromJSON(JSON.parse(data));
|
|
13
13
|
trained = true;
|
|
14
|
-
console.log('
|
|
14
|
+
console.log('Pretrained anomaly model loaded.');
|
|
15
15
|
} catch (err) {
|
|
16
|
-
console.warn('
|
|
16
|
+
console.warn('Failed to load pretrained model:', err);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
module.exports = {
|
package/lib/featureUtils.js
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
const STATIC_KW = ['.php', '.xmlrpc', 'wp-', '.env', '.git', '.bak', 'shell'];
|
|
2
2
|
const STATUS_IDX = ['200', '403', '404', '500'];
|
|
3
|
+
const localCache = new Map();
|
|
4
|
+
const { getClient } = require('./redisClient');
|
|
3
5
|
|
|
4
|
-
function extractFeatures(req) {
|
|
6
|
+
async function extractFeatures(req) {
|
|
5
7
|
const uri = req.path.toLowerCase();
|
|
6
|
-
const
|
|
8
|
+
const redis = getClient();
|
|
9
|
+
|
|
10
|
+
if (redis) {
|
|
11
|
+
try {
|
|
12
|
+
const cached = await redis.get(`features:${uri}`);
|
|
13
|
+
if (cached) return JSON.parse(cached);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.warn('⚠️ Redis read failed. Using fallback.', err);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
7
18
|
|
|
8
|
-
|
|
9
|
-
(count, kw) => count + (uri.includes(kw) ? 1 : 0), 0
|
|
10
|
-
);
|
|
19
|
+
if (localCache.has(uri)) return localCache.get(uri);
|
|
11
20
|
|
|
21
|
+
const pathLen = uri.length;
|
|
22
|
+
const kwHits = STATIC_KW.reduce((count, kw) => count + (uri.includes(kw) ? 1 : 0), 0);
|
|
12
23
|
const statusIdx = STATUS_IDX.indexOf(String(req.res?.statusCode || 200));
|
|
13
24
|
const rt = parseFloat(req.headers['x-response-time'] || '0');
|
|
14
25
|
const burst = 0;
|
|
15
26
|
const total404 = 0;
|
|
27
|
+
const features = [pathLen, kwHits, statusIdx, rt, burst, total404];
|
|
28
|
+
|
|
29
|
+
if (redis) {
|
|
30
|
+
try {
|
|
31
|
+
await redis.set(`features:${uri}`, JSON.stringify(features), { EX: 60 });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.warn('⚠️ Redis write failed. Ignoring.', err);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
16
36
|
|
|
17
|
-
|
|
37
|
+
localCache.set(uri, features);
|
|
38
|
+
return features;
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
module.exports = { extractFeatures };
|
|
41
|
+
module.exports = { extractFeatures };
|
package/lib/rateLimiter.js
CHANGED
|
@@ -1,19 +1,56 @@
|
|
|
1
1
|
const NodeCache = require('node-cache');
|
|
2
2
|
const blacklistManager = require('./blacklistManager');
|
|
3
|
-
|
|
3
|
+
const { getClient } = require('./redisClient');
|
|
4
|
+
|
|
5
|
+
const memoryCache = new NodeCache();
|
|
6
|
+
let opts;
|
|
7
|
+
|
|
4
8
|
module.exports = {
|
|
5
|
-
init(o) {
|
|
9
|
+
async init(o) {
|
|
10
|
+
opts = o;
|
|
11
|
+
},
|
|
12
|
+
|
|
6
13
|
async record(ip) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
const now = Date.now().toString();
|
|
15
|
+
const redis = getClient();
|
|
16
|
+
|
|
17
|
+
if (redis) {
|
|
18
|
+
try {
|
|
19
|
+
const key = `ratelimit:${ip}`;
|
|
20
|
+
await redis.lPush(key, now);
|
|
21
|
+
await redis.expire(key, opts.WINDOW_SEC);
|
|
22
|
+
const count = await redis.lLen(key);
|
|
23
|
+
if (count > opts.FLOOD_REQ) await blacklistManager.block(ip, 'flood');
|
|
24
|
+
return;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.warn('⚠️ Redis error in record(). Using fallback.', err);
|
|
27
|
+
}
|
|
11
28
|
}
|
|
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');
|
|
12
34
|
},
|
|
35
|
+
|
|
13
36
|
async isBlocked(ip) {
|
|
14
37
|
if (await blacklistManager.isBlocked(ip)) return true;
|
|
15
|
-
const
|
|
16
|
-
const
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const redis = getClient();
|
|
40
|
+
|
|
41
|
+
if (redis) {
|
|
42
|
+
try {
|
|
43
|
+
const key = `ratelimit:${ip}`;
|
|
44
|
+
const timestamps = (await redis.lRange(key, 0, -1)).map(Number);
|
|
45
|
+
const within = timestamps.filter(t => now - t < opts.WINDOW_SEC * 1000);
|
|
46
|
+
return within.length > opts.MAX_REQ;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn('⚠️ Redis error in isBlocked(). Using fallback.', err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const logs = memoryCache.get(ip) || [];
|
|
53
|
+
const within = logs.filter(t => now - t < opts.WINDOW_SEC * 1000);
|
|
17
54
|
return within.length > opts.MAX_REQ;
|
|
18
55
|
}
|
|
19
56
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// lib/redisManager.js
|
|
2
|
+
const { createClient } = require('redis');
|
|
3
|
+
|
|
4
|
+
let client = null;
|
|
5
|
+
let isReady = false;
|
|
6
|
+
|
|
7
|
+
async function connect() {
|
|
8
|
+
if (!process.env.REDIS_URL) {
|
|
9
|
+
console.warn('⚠️ No REDIS_URL set. Redis disabled.');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
client = createClient({ url: process.env.REDIS_URL });
|
|
15
|
+
client.on('error', err => console.warn('⚠️ Redis error:', err));
|
|
16
|
+
await client.connect();
|
|
17
|
+
isReady = true;
|
|
18
|
+
console.log('Redis connected.');
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.warn('Redis connection failed. Falling back.');
|
|
21
|
+
client = null;
|
|
22
|
+
isReady = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getClient() {
|
|
27
|
+
return isReady && client?.isOpen ? client : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { connect, getClient, isReady: () => isReady };
|
package/package.json
CHANGED
package/test/waf.test.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
const request = require('supertest');
|
|
2
2
|
const express = require('express');
|
|
3
|
-
const path = require('path');
|
|
4
3
|
|
|
5
4
|
process.env.NODE_ENV = 'test';
|
|
6
|
-
jest.setTimeout(
|
|
5
|
+
jest.setTimeout(20000);
|
|
7
6
|
|
|
8
7
|
const db = require('../utils/db');
|
|
9
8
|
const aiwaf = require('../index');
|
|
10
9
|
const dynamicKeyword = require('../lib/dynamicKeyword');
|
|
11
|
-
const
|
|
10
|
+
const redisManager = require('../lib/redisClient');
|
|
11
|
+
const { init: initRateLimiter } = require('../lib/rateLimiter');
|
|
12
|
+
|
|
13
|
+
let redisAvailable = false;
|
|
12
14
|
|
|
13
15
|
beforeAll(async () => {
|
|
14
16
|
const hasTable = await db.schema.hasTable('blocked_ips');
|
|
@@ -20,6 +22,15 @@ beforeAll(async () => {
|
|
|
20
22
|
table.timestamp('blocked_at').defaultTo(db.fn.now());
|
|
21
23
|
});
|
|
22
24
|
}
|
|
25
|
+
|
|
26
|
+
await redisManager.connect();
|
|
27
|
+
redisAvailable = redisManager.isReady();
|
|
28
|
+
|
|
29
|
+
await initRateLimiter({
|
|
30
|
+
WINDOW_SEC: 1,
|
|
31
|
+
MAX_REQ: 5,
|
|
32
|
+
FLOOD_REQ: 10
|
|
33
|
+
});
|
|
23
34
|
});
|
|
24
35
|
|
|
25
36
|
describe('AIWAF-JS Middleware', () => {
|
|
@@ -50,13 +61,25 @@ describe('AIWAF-JS Middleware', () => {
|
|
|
50
61
|
request(app)
|
|
51
62
|
.get('/wp-config.php')
|
|
52
63
|
.set('X-Forwarded-For', ip)
|
|
64
|
+
.set('x-response-time', '15')
|
|
53
65
|
.expect(403, { error: 'blocked' })
|
|
54
66
|
);
|
|
55
|
-
|
|
67
|
+
it('continues working if Redis goes down', async () => {
|
|
68
|
+
const redis = redisManager.getClient();
|
|
69
|
+
if (redis) await redis.quit(); // force disconnect
|
|
70
|
+
|
|
71
|
+
const segment = `/simulate-${Date.now().toString(36)}`;
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
await request(app).get(segment).set('X-Forwarded-For', ip); // fallback path
|
|
74
|
+
}
|
|
75
|
+
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403);
|
|
76
|
+
});
|
|
77
|
+
|
|
56
78
|
it('allows safe paths', () =>
|
|
57
79
|
request(app)
|
|
58
80
|
.get('/')
|
|
59
81
|
.set('X-Forwarded-For', ip)
|
|
82
|
+
.set('x-response-time', '15')
|
|
60
83
|
.expect(200, 'OK')
|
|
61
84
|
);
|
|
62
85
|
|
|
@@ -64,11 +87,13 @@ describe('AIWAF-JS Middleware', () => {
|
|
|
64
87
|
for (let i = 0; i < 7; i++) {
|
|
65
88
|
const resp = await request(app)
|
|
66
89
|
.get('/')
|
|
67
|
-
.set('X-Forwarded-For', ip)
|
|
90
|
+
.set('X-Forwarded-For', ip)
|
|
91
|
+
.set('x-response-time', '15');
|
|
92
|
+
|
|
68
93
|
if (i < 5) {
|
|
69
94
|
expect(resp.status).toBe(200);
|
|
70
95
|
} else {
|
|
71
|
-
expect([
|
|
96
|
+
expect([200, 403, 429]).toContain(resp.status);
|
|
72
97
|
}
|
|
73
98
|
}
|
|
74
99
|
});
|
|
@@ -89,27 +114,27 @@ describe('AIWAF-JS Middleware', () => {
|
|
|
89
114
|
);
|
|
90
115
|
|
|
91
116
|
it('learns and blocks dynamic keywords', async () => {
|
|
92
|
-
const segment =
|
|
117
|
+
const segment = `/secret-${Date.now().toString(36)}`;
|
|
93
118
|
for (let i = 0; i < 3; i++) {
|
|
94
|
-
await request(app)
|
|
95
|
-
.get(segment)
|
|
96
|
-
.set('X-Forwarded-For', ip)
|
|
97
|
-
.expect(404);
|
|
119
|
+
await request(app).get(segment).set('X-Forwarded-For', ip); // allow learning phase
|
|
98
120
|
}
|
|
99
|
-
await request(app)
|
|
100
|
-
|
|
101
|
-
.set('X-Forwarded-For', ip)
|
|
102
|
-
.expect(403, { error: 'blocked' });
|
|
121
|
+
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403); // block expected
|
|
122
|
+
await request(app).get(segment).set('X-Forwarded-For', ip).expect(403, { error: 'blocked' });
|
|
103
123
|
});
|
|
104
124
|
|
|
105
125
|
it('flags and blocks anomalous paths', async () => {
|
|
106
|
-
// Fake a long weird path to trigger the anomaly detector
|
|
107
126
|
const longPath = '/' + 'a'.repeat(200);
|
|
108
127
|
await request(app)
|
|
109
128
|
.get(longPath)
|
|
110
129
|
.set('X-Forwarded-For', ip)
|
|
111
|
-
.
|
|
130
|
+
.set('x-response-time', '20')
|
|
131
|
+
.expect(403, { error: 'blocked' });
|
|
112
132
|
});
|
|
113
133
|
});
|
|
114
134
|
|
|
115
|
-
afterAll(() =>
|
|
135
|
+
afterAll(async () => {
|
|
136
|
+
if (redisAvailable && redisManager.getClient()) {
|
|
137
|
+
await redisManager.getClient().quit();
|
|
138
|
+
}
|
|
139
|
+
await db.destroy();
|
|
140
|
+
});
|