aiwaf-js 0.0.1
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/.github/workflows/node.js.yml +31 -0
- package/.github/workflows/npm-publish.yml +22 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/index.js +3 -0
- package/knexfile.js +9 -0
- package/lib/anomalyDetector.js +47 -0
- package/lib/blacklistManager.js +10 -0
- package/lib/dynamicKeyword.js +26 -0
- package/lib/honeypotDetector.js +5 -0
- package/lib/isolationForest.js +97 -0
- package/lib/keywordDetector.js +5 -0
- package/lib/rateLimiter.js +19 -0
- package/lib/uuidDetector.js +17 -0
- package/lib/wafMiddleware.js +80 -0
- package/migrations/001_create_blocked_ips.js +11 -0
- package/migrations/002_create_dynamic_keywords.js +11 -0
- package/package.json +32 -0
- package/test/waf.test.js +105 -0
- package/train.js +146 -0
- package/utils/cache.js +3 -0
- package/utils/db.js +11 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
|
3
|
+
|
|
4
|
+
name: Node.js CI
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [ "main" ]
|
|
9
|
+
pull_request:
|
|
10
|
+
branches: [ "main" ]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
strategy:
|
|
18
|
+
matrix:
|
|
19
|
+
node-version: [18.x, 20.x, 22.x]
|
|
20
|
+
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
25
|
+
uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: ${{ matrix.node-version }}
|
|
28
|
+
cache: 'npm'
|
|
29
|
+
- run: npm ci
|
|
30
|
+
- run: npm run build --if-present
|
|
31
|
+
- run: npm test
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
on:
|
|
2
|
+
release:
|
|
3
|
+
types: [created]
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "main" ]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build-and-publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 20
|
|
16
|
+
registry-url: https://registry.npmjs.org/
|
|
17
|
+
|
|
18
|
+
- run: npm install
|
|
19
|
+
- run: npm run test
|
|
20
|
+
- run: npm publish --access public
|
|
21
|
+
env:
|
|
22
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Aayush Gauba
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# aiwaf‑js
|
|
2
|
+
|
|
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.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/aiwaf-js)
|
|
7
|
+
[](https://github.com/your‑user/aiwaf-js/actions)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
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
|
+
- Offline Retraining
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install aiwaf-js --save
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const express = require('express')
|
|
30
|
+
const aiwaf = require('aiwaf-js')
|
|
31
|
+
|
|
32
|
+
const app = express()
|
|
33
|
+
app.use(express.json())
|
|
34
|
+
app.use(aiwaf())
|
|
35
|
+
app.get('/', (req, res) => res.send('Protected'))
|
|
36
|
+
app.listen(3000)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Training
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
NODE_LOG_PATH=/path/to/access.log npm run train
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## Usage Example
|
|
47
|
+
|
|
48
|
+
Here’s a simple Express app that uses `aiwaf-js` with custom settings:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
const express = require('express');
|
|
52
|
+
const aiwaf = require('aiwaf-js');
|
|
53
|
+
|
|
54
|
+
const app = express();
|
|
55
|
+
app.use(express.json());
|
|
56
|
+
|
|
57
|
+
app.use(aiwaf({
|
|
58
|
+
staticKeywords: ['.php', '.env', '.git'], // ← add .php here
|
|
59
|
+
dynamicTopN: 10,
|
|
60
|
+
WINDOW_SEC: 10,
|
|
61
|
+
MAX_REQ: 20,
|
|
62
|
+
FLOOD_REQ: 10,
|
|
63
|
+
HONEYPOT_FIELD: 'hp_field',
|
|
64
|
+
}));
|
|
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
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT License © 2025 Aayush Gauba
|
|
73
|
+
|
|
74
|
+
## Configuration Options
|
|
75
|
+
|
|
76
|
+
You can pass an options object to `aiwaf(opts)` or use environment variables.
|
|
77
|
+
|
|
78
|
+
| Option | Env Var | Default | Description |
|
|
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. |
|
package/index.js
ADDED
package/knexfile.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// lib/anomalyDetector.js
|
|
2
|
+
|
|
3
|
+
const { IsolationForest } = require('./isolationForest');
|
|
4
|
+
let model;
|
|
5
|
+
let trained = false;
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
/**
|
|
9
|
+
* Initialize a fresh forest instance.
|
|
10
|
+
* @param {Object} opts
|
|
11
|
+
* @param {number} opts.nTrees Number of trees to build (default 100)
|
|
12
|
+
* @param {number} opts.sampleSize Subsample size per tree (default 256)
|
|
13
|
+
*/
|
|
14
|
+
init({ nTrees = 100, sampleSize = 256 } = {}) {
|
|
15
|
+
model = new IsolationForest({ nTrees, sampleSize });
|
|
16
|
+
trained = false;
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Train the isolation forest on feature vectors.
|
|
21
|
+
* @param {Array<Array<number>>} data Array of feature vectors.
|
|
22
|
+
*/
|
|
23
|
+
train(data) {
|
|
24
|
+
if (!model) this.init(); // ensure model initialized
|
|
25
|
+
model.fit(data);
|
|
26
|
+
trained = true;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if a request is anomalous.
|
|
31
|
+
* @param {Object} req Express request object
|
|
32
|
+
* @returns {boolean} true if anomalous, false otherwise
|
|
33
|
+
*/
|
|
34
|
+
isAnomalous(req) {
|
|
35
|
+
if (!trained) return false;
|
|
36
|
+
// build your feature vector here; add more features as needed
|
|
37
|
+
const feat = [
|
|
38
|
+
req.path.length, // path length
|
|
39
|
+
0, // placeholder: keyword hits
|
|
40
|
+
0, // placeholder: status code index
|
|
41
|
+
0, // placeholder: response time
|
|
42
|
+
0, // placeholder: burst count
|
|
43
|
+
0 // placeholder: total 404s
|
|
44
|
+
];
|
|
45
|
+
return model.isAnomaly(feat);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const db = require('../utils/db');
|
|
2
|
+
module.exports = {
|
|
3
|
+
async isBlocked(ip) {
|
|
4
|
+
const row = await db('blocked_ips').where('ip_address', ip).first();
|
|
5
|
+
return !!row;
|
|
6
|
+
},
|
|
7
|
+
async block(ip, reason) {
|
|
8
|
+
await db('blocked_ips').insert({ ip_address: ip, reason }).onConflict('ip_address').ignore();
|
|
9
|
+
}
|
|
10
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// lib/dynamicKeyword.js
|
|
2
|
+
let opts, counts;
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
init(o = {}) {
|
|
6
|
+
// normalize option name
|
|
7
|
+
const topN = o.dynamicTopN ?? o.DYNAMIC_TOP_N ?? 10;
|
|
8
|
+
opts = { dynamicTopN: topN };
|
|
9
|
+
counts = {};
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
learn(path) {
|
|
13
|
+
const segments = path.split('/').filter(s => s.length > 3);
|
|
14
|
+
segments.forEach(s => {
|
|
15
|
+
counts[s] = (counts[s] || 0) + 1;
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
check(path) {
|
|
20
|
+
// only block segments whose count exceeds the threshold
|
|
21
|
+
return Object.entries(counts)
|
|
22
|
+
.find(([seg, cnt]) => cnt > opts.dynamicTopN && path.includes(seg))
|
|
23
|
+
?. [0] // return the segment string
|
|
24
|
+
|| null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// lib/isolationForest.js
|
|
2
|
+
|
|
3
|
+
class IsolationTree {
|
|
4
|
+
constructor(depth = 0, maxDepth = 0) {
|
|
5
|
+
this.depth = depth;
|
|
6
|
+
this.maxDepth = maxDepth;
|
|
7
|
+
this.left = null;
|
|
8
|
+
this.right = null;
|
|
9
|
+
this.splitAttr = null;
|
|
10
|
+
this.splitValue = null;
|
|
11
|
+
this.size = 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
fit(data) {
|
|
15
|
+
this.size = data.length;
|
|
16
|
+
// stop criteria
|
|
17
|
+
if (this.depth >= this.maxDepth || data.length <= 1) return;
|
|
18
|
+
// choose random feature
|
|
19
|
+
const numFeatures = data[0].length;
|
|
20
|
+
this.splitAttr = Math.floor(Math.random() * numFeatures);
|
|
21
|
+
// find min/max on that feature
|
|
22
|
+
let vals = data.map(row => row[this.splitAttr]);
|
|
23
|
+
const min = Math.min(...vals), max = Math.max(...vals);
|
|
24
|
+
if (min === max) return;
|
|
25
|
+
// choose random split
|
|
26
|
+
this.splitValue = min + Math.random() * (max - min);
|
|
27
|
+
// partition data
|
|
28
|
+
const leftData = [], rightData = [];
|
|
29
|
+
for (let row of data) {
|
|
30
|
+
(row[this.splitAttr] < this.splitValue ? leftData : rightData).push(row);
|
|
31
|
+
}
|
|
32
|
+
// build subtrees
|
|
33
|
+
this.left = new IsolationTree(this.depth+1, this.maxDepth);
|
|
34
|
+
this.right = new IsolationTree(this.depth+1, this.maxDepth);
|
|
35
|
+
this.left.fit(leftData);
|
|
36
|
+
this.right.fit(rightData);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pathLength(point) {
|
|
40
|
+
// if leaf or no split, path ends here
|
|
41
|
+
if (!this.left || !this.right) {
|
|
42
|
+
// use average c(size) for external nodes
|
|
43
|
+
return this.depth + c(this.size);
|
|
44
|
+
}
|
|
45
|
+
// descend
|
|
46
|
+
if (point[this.splitAttr] < this.splitValue) {
|
|
47
|
+
return this.left.pathLength(point);
|
|
48
|
+
} else {
|
|
49
|
+
return this.right.pathLength(point);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// average path length of unsuccessful search in a BST
|
|
55
|
+
function c(n) {
|
|
56
|
+
if (n <= 1) return 1;
|
|
57
|
+
return 2 * (Math.log(n - 1) + 0.5772156649) - (2*(n-1)/n);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class IsolationForest {
|
|
61
|
+
constructor({ nTrees = 100, sampleSize = 256 } = {}) {
|
|
62
|
+
this.nTrees = nTrees;
|
|
63
|
+
this.sampleSize = sampleSize;
|
|
64
|
+
this.trees = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fit(data) {
|
|
68
|
+
const heightLimit = Math.ceil(Math.log2(this.sampleSize));
|
|
69
|
+
for (let i = 0; i < this.nTrees; i++) {
|
|
70
|
+
// random subsample
|
|
71
|
+
const sample = [];
|
|
72
|
+
for (let j = 0; j < this.sampleSize; j++) {
|
|
73
|
+
sample.push(data[Math.floor(Math.random() * data.length)]);
|
|
74
|
+
}
|
|
75
|
+
const tree = new IsolationTree(0, heightLimit);
|
|
76
|
+
tree.fit(sample);
|
|
77
|
+
this.trees.push(tree);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
anomalyScore(point) {
|
|
82
|
+
// average path length
|
|
83
|
+
const pathLens = this.trees.map(t => t.pathLength(point));
|
|
84
|
+
const avgPath = pathLens.reduce((a,b) => a+b, 0) / this.nTrees;
|
|
85
|
+
// score: 2^(-E[h(x)]/c(sampleSize))
|
|
86
|
+
const cn = c(this.sampleSize);
|
|
87
|
+
return Math.pow(2, -avgPath / cn);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// simple threshold helper
|
|
91
|
+
isAnomaly(point, thresh = 0.5) {
|
|
92
|
+
return this.anomalyScore(point) > thresh;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { IsolationForest };
|
|
97
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const NodeCache = require('node-cache');
|
|
2
|
+
const blacklistManager = require('./blacklistManager');
|
|
3
|
+
let cache, opts;
|
|
4
|
+
module.exports = {
|
|
5
|
+
init(o) { opts = o; cache = new NodeCache({ stdTTL: opts.WINDOW_SEC }); },
|
|
6
|
+
async record(ip) {
|
|
7
|
+
const recs = cache.get(ip) || [];
|
|
8
|
+
recs.push(Date.now()); cache.set(ip, recs);
|
|
9
|
+
if (recs.length > opts.FLOOD_REQ) {
|
|
10
|
+
await blacklistManager.block(ip, 'flood');
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
async isBlocked(ip) {
|
|
14
|
+
if (await blacklistManager.isBlocked(ip)) return true;
|
|
15
|
+
const recs = cache.get(ip) || [];
|
|
16
|
+
const within = recs.filter(t => Date.now() - t < opts.WINDOW_SEC*1000);
|
|
17
|
+
return within.length > opts.MAX_REQ;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const { validate: isUuid } = require('uuid');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
init(opts = {}) {
|
|
5
|
+
this.prefix = opts.uuidRoutePrefix || '/user';
|
|
6
|
+
},
|
|
7
|
+
isSuspicious(req) {
|
|
8
|
+
const { path } = req;
|
|
9
|
+
const regex = new RegExp(`^${this.prefix}/([^/]+)$`);
|
|
10
|
+
const match = path.match(regex);
|
|
11
|
+
if (!match) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const uid = match[1];
|
|
15
|
+
return !isUuid(uid);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// lib/wafMiddleware.js
|
|
2
|
+
|
|
3
|
+
const rateLimiter = require('./rateLimiter');
|
|
4
|
+
const blacklistManager = require('./blacklistManager');
|
|
5
|
+
const keywordDetector = require('./keywordDetector');
|
|
6
|
+
const dynamicKeyword = require('./dynamicKeyword');
|
|
7
|
+
const honeypotDetector = require('./honeypotDetector');
|
|
8
|
+
const uuidDetector = require('./uuidDetector');
|
|
9
|
+
const anomalyDetector = require('./anomalyDetector');
|
|
10
|
+
|
|
11
|
+
module.exports = function aiwaf(opts = {}) {
|
|
12
|
+
// initialize all sub‑modules
|
|
13
|
+
rateLimiter.init(opts);
|
|
14
|
+
keywordDetector.init(opts);
|
|
15
|
+
dynamicKeyword.init(opts);
|
|
16
|
+
honeypotDetector.init(opts);
|
|
17
|
+
uuidDetector.init(opts);
|
|
18
|
+
anomalyDetector.init(opts);
|
|
19
|
+
|
|
20
|
+
return async (req, res, next) => {
|
|
21
|
+
// honor X‑Forwarded‑For for tests or real proxies
|
|
22
|
+
const ipHdr = req.headers['x-forwarded-for'];
|
|
23
|
+
const ip = ipHdr ? ipHdr.split(',')[0].trim() : req.ip;
|
|
24
|
+
const path = req.path.toLowerCase();
|
|
25
|
+
|
|
26
|
+
// learn every request for dynamic keywords
|
|
27
|
+
dynamicKeyword.learn(path);
|
|
28
|
+
|
|
29
|
+
// 1) IP blacklist
|
|
30
|
+
if (await blacklistManager.isBlocked(ip)) {
|
|
31
|
+
return res.status(403).json({ error: 'blocked' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2) Honeypot trap
|
|
35
|
+
if (honeypotDetector.isTriggered(req)) {
|
|
36
|
+
await blacklistManager.block(ip, 'honeypot');
|
|
37
|
+
return res.status(403).json({ error: 'bot_detected' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 3) Rate limiting
|
|
41
|
+
await rateLimiter.record(ip);
|
|
42
|
+
|
|
43
|
+
// If recs > MAX_REQ but not yet blacklisted (flood), return 429
|
|
44
|
+
if (await rateLimiter.isBlocked(ip)) {
|
|
45
|
+
if (await blacklistManager.isBlocked(ip)) {
|
|
46
|
+
return res.status(403).json({ error: 'blocked' });
|
|
47
|
+
}
|
|
48
|
+
return res.status(429).json({ error: 'too_many_requests' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4) Static keyword
|
|
52
|
+
const sk = keywordDetector.check(path);
|
|
53
|
+
if (sk) {
|
|
54
|
+
await blacklistManager.block(ip, `static:${sk}`);
|
|
55
|
+
return res.status(403).json({ error: 'blocked' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 5) Dynamic keyword
|
|
59
|
+
const dk = dynamicKeyword.check(path);
|
|
60
|
+
if (dk) {
|
|
61
|
+
await blacklistManager.block(ip, `dynamic:${dk}`);
|
|
62
|
+
return res.status(403).json({ error: 'blocked' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 6) UUID tamper
|
|
66
|
+
if (uuidDetector.isSuspicious(req)) {
|
|
67
|
+
await blacklistManager.block(ip, 'uuid');
|
|
68
|
+
return res.status(403).json({ error: 'blocked' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 7) Anomaly detection
|
|
72
|
+
if (await anomalyDetector.isAnomalous(req)) {
|
|
73
|
+
await blacklistManager.block(ip, 'anomaly');
|
|
74
|
+
return res.status(403).json({ error: 'blocked' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// no block, pass through
|
|
78
|
+
next();
|
|
79
|
+
};
|
|
80
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
exports.up = function(knex) {
|
|
2
|
+
return knex.schema.createTable('blocked_ips', table => {
|
|
3
|
+
table.increments('id');
|
|
4
|
+
table.string('ip_address').notNullable().unique();
|
|
5
|
+
table.string('reason');
|
|
6
|
+
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
7
|
+
});
|
|
8
|
+
};
|
|
9
|
+
exports.down = function(knex) {
|
|
10
|
+
return knex.schema.dropTable('blocked_ips');
|
|
11
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
exports.up = function(knex) {
|
|
2
|
+
return knex.schema.createTable('dynamic_keywords', table => {
|
|
3
|
+
table.increments('id');
|
|
4
|
+
table.string('keyword').notNullable().unique();
|
|
5
|
+
table.integer('count').notNullable().defaultTo(0);
|
|
6
|
+
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
|
7
|
+
});
|
|
8
|
+
};
|
|
9
|
+
exports.down = function(knex) {
|
|
10
|
+
return knex.schema.dropTable('dynamic_keywords');
|
|
11
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aiwaf-js",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Adaptive Web Application Firewall middleware for Node.js (Express, Fastify, Hapi, Next.js)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"retrain": "AIWAF_LOG_PATH=/var/log/nginx/access.log npm run train",
|
|
8
|
+
"train": "node train.js",
|
|
9
|
+
"test": "jest --runInBand"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"express": "^4.18.2",
|
|
13
|
+
"jest": "^29.7.0",
|
|
14
|
+
"knex": "^2.4.2",
|
|
15
|
+
"node-cache": "^5.1.2",
|
|
16
|
+
"redis": "^4.6.5",
|
|
17
|
+
"sqlite3": "^5.1.6",
|
|
18
|
+
"supertest": "^7.1.0",
|
|
19
|
+
"uuid": "^9.0.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"waf",
|
|
23
|
+
"firewall",
|
|
24
|
+
"security",
|
|
25
|
+
"express",
|
|
26
|
+
"nodejs",
|
|
27
|
+
"middleware",
|
|
28
|
+
"ml"
|
|
29
|
+
],
|
|
30
|
+
"author": "Aayush Gauba",
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|
package/test/waf.test.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
process.env.NODE_ENV = 'test';
|
|
6
|
+
jest.setTimeout(15000); // Set global test timeout to 15s
|
|
7
|
+
|
|
8
|
+
const db = require('../utils/db'); // Adjust path if needed
|
|
9
|
+
const aiwaf = require('../index');
|
|
10
|
+
const dynamicKeyword = require('../lib/dynamicKeyword');
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const hasTable = await db.schema.hasTable('blocked_ips');
|
|
14
|
+
if (!hasTable) {
|
|
15
|
+
await db.schema.createTable('blocked_ips', table => {
|
|
16
|
+
table.increments('id');
|
|
17
|
+
table.string('ip_address').unique();
|
|
18
|
+
table.string('reason');
|
|
19
|
+
table.timestamp('blocked_at').defaultTo(db.fn.now());
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('AIWAF-JS Middleware', () => {
|
|
25
|
+
let app, ip;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
ip = `192.0.2.${Math.floor(Math.random() * 254) + 1}`;
|
|
29
|
+
dynamicKeyword.init({ dynamicTopN: 3 });
|
|
30
|
+
|
|
31
|
+
app = express();
|
|
32
|
+
app.use(express.json());
|
|
33
|
+
app.use(aiwaf({
|
|
34
|
+
staticKeywords: ['.php', '.env', '.git'],
|
|
35
|
+
dynamicTopN: 3,
|
|
36
|
+
WINDOW_SEC: 1,
|
|
37
|
+
MAX_REQ: 5,
|
|
38
|
+
FLOOD_REQ: 10,
|
|
39
|
+
HONEYPOT_FIELD: 'hp_field'
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
app.get('/', (req, res) => res.send('OK'));
|
|
43
|
+
app.post('/', (req, res) => res.send('POST OK'));
|
|
44
|
+
app.get('/user/:uuid', (req, res) => res.send('USER OK'));
|
|
45
|
+
app.use((req, res) => res.status(404).send('Not Found'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('blocks static keyword .php', () =>
|
|
49
|
+
request(app)
|
|
50
|
+
.get('/wp-config.php')
|
|
51
|
+
.set('X-Forwarded-For', ip)
|
|
52
|
+
.expect(403, { error: 'blocked' })
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it('allows safe paths', () =>
|
|
56
|
+
request(app)
|
|
57
|
+
.get('/')
|
|
58
|
+
.set('X-Forwarded-For', ip)
|
|
59
|
+
.expect(200, 'OK')
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
it('blocks after exceeding rate limit', async () => {
|
|
63
|
+
for (let i = 0; i < 7; i++) {
|
|
64
|
+
const resp = await request(app)
|
|
65
|
+
.get('/')
|
|
66
|
+
.set('X-Forwarded-For', ip);
|
|
67
|
+
if (i < 5) {
|
|
68
|
+
expect(resp.status).toBe(200);
|
|
69
|
+
} else {
|
|
70
|
+
expect([429, 403]).toContain(resp.status);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('blocks honeypot field', () =>
|
|
76
|
+
request(app)
|
|
77
|
+
.post('/')
|
|
78
|
+
.set('X-Forwarded-For', ip)
|
|
79
|
+
.send({ hp_field: 'caught' })
|
|
80
|
+
.expect(403, { error: 'bot_detected' })
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
it('blocks invalid UUIDs', () =>
|
|
84
|
+
request(app)
|
|
85
|
+
.get('/user/not-a-uuid')
|
|
86
|
+
.set('X-Forwarded-For', ip)
|
|
87
|
+
.expect(403)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
it('learns and blocks dynamic keywords', async () => {
|
|
91
|
+
const segment = '/secretABC';
|
|
92
|
+
for (let i = 0; i < 3; i++) {
|
|
93
|
+
await request(app)
|
|
94
|
+
.get(segment)
|
|
95
|
+
.set('X-Forwarded-For', ip)
|
|
96
|
+
.expect(404);
|
|
97
|
+
}
|
|
98
|
+
await request(app)
|
|
99
|
+
.get(segment)
|
|
100
|
+
.set('X-Forwarded-For', ip)
|
|
101
|
+
.expect(403, { error: 'blocked' });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(() => db.destroy());
|
package/train.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// train.js
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const glob = require('glob');
|
|
6
|
+
const zlib = require('zlib');
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const { IsolationForest } = require('./lib/isolationForest');
|
|
9
|
+
|
|
10
|
+
//
|
|
11
|
+
// Configuration
|
|
12
|
+
//
|
|
13
|
+
|
|
14
|
+
// Static malicious keywords to count in URLs
|
|
15
|
+
const STATIC_KW = [
|
|
16
|
+
'.php', '.xmlrpc', 'wp-', '.env', '.git', '.bak',
|
|
17
|
+
'conflg', 'shell', 'filemanager'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Status codes we index
|
|
21
|
+
const STATUS_IDX = ['200','403','404','500'];
|
|
22
|
+
|
|
23
|
+
// Default log input paths (can be overridden with env vars)
|
|
24
|
+
const LOG_PATH = process.env.NODE_LOG_PATH
|
|
25
|
+
|| '/var/log/nginx/access.log';
|
|
26
|
+
const LOG_GLOB = process.env.NODE_LOG_GLOB
|
|
27
|
+
|| `${LOG_PATH}.*`;
|
|
28
|
+
|
|
29
|
+
// Regex to parse each access‐log line.
|
|
30
|
+
// Captures: 1) client IP, 2) request URI, 3) status code, 4) response‐time
|
|
31
|
+
const LINE_RX = /(\d+\.\d+\.\d+\.\d+).*"(?:GET|POST) (.*?) HTTP\/.*?" (\d{3}).*?response-time=(\d+\.\d+)/;
|
|
32
|
+
|
|
33
|
+
//
|
|
34
|
+
// Helpers to read log files
|
|
35
|
+
//
|
|
36
|
+
|
|
37
|
+
async function* readLines(file) {
|
|
38
|
+
const stream = file.endsWith('.gz')
|
|
39
|
+
? fs.createReadStream(file).pipe(zlib.createGunzip())
|
|
40
|
+
: fs.createReadStream(file);
|
|
41
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
42
|
+
for await (const line of rl) {
|
|
43
|
+
yield line;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readAllLogs() {
|
|
48
|
+
const lines = [];
|
|
49
|
+
|
|
50
|
+
// main log
|
|
51
|
+
if (fs.existsSync(LOG_PATH)) {
|
|
52
|
+
for await (const l of readLines(LOG_PATH)) {
|
|
53
|
+
lines.push(l);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// rotated / gzipped files
|
|
58
|
+
for (const f of glob.sync(LOG_GLOB)) {
|
|
59
|
+
for await (const l of readLines(f)) {
|
|
60
|
+
lines.push(l);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//
|
|
68
|
+
// Convert a single log line into a 6‑dimensional feature vector:
|
|
69
|
+
// [ pathLength, keywordHits, statusIdx, responseTime, burst=0, total404=0 ]
|
|
70
|
+
//
|
|
71
|
+
|
|
72
|
+
function parseLineToFeatures(line) {
|
|
73
|
+
const m = LINE_RX.exec(line);
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
|
|
76
|
+
// m[2] = URI with optional query
|
|
77
|
+
// m[3] = status code
|
|
78
|
+
// m[4] = response‐time
|
|
79
|
+
const uri = m[2].split('?')[0];
|
|
80
|
+
const status = m[3];
|
|
81
|
+
const rt = parseFloat(m[4]);
|
|
82
|
+
|
|
83
|
+
// Feature 1: length of the path
|
|
84
|
+
const pathLen = uri.length;
|
|
85
|
+
|
|
86
|
+
// Feature 2: count of static keywords in the path
|
|
87
|
+
const kwHits = STATIC_KW.reduce(
|
|
88
|
+
(sum, kw) => sum + (uri.toLowerCase().includes(kw) ? 1 : 0),
|
|
89
|
+
0
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Feature 3: status code index
|
|
93
|
+
const statusIdx = STATUS_IDX.indexOf(status) >= 0
|
|
94
|
+
? STATUS_IDX.indexOf(status)
|
|
95
|
+
: -1;
|
|
96
|
+
|
|
97
|
+
// Feature 4: response time
|
|
98
|
+
const respTime = rt;
|
|
99
|
+
|
|
100
|
+
// Features 5 & 6 are placeholders (burst count and total 404s)
|
|
101
|
+
const burst = 0;
|
|
102
|
+
const total404 = 0;
|
|
103
|
+
|
|
104
|
+
return [pathLen, kwHits, statusIdx, respTime, burst, total404];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//
|
|
108
|
+
// Main training routine
|
|
109
|
+
//
|
|
110
|
+
|
|
111
|
+
(async () => {
|
|
112
|
+
try {
|
|
113
|
+
const raw = await readAllLogs();
|
|
114
|
+
if (raw.length === 0) {
|
|
115
|
+
console.warn('No logs found – please set NODE_LOG_PATH to your access log.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Build feature matrix
|
|
120
|
+
const feats = raw
|
|
121
|
+
.map(parseLineToFeatures)
|
|
122
|
+
.filter(f => f !== null);
|
|
123
|
+
|
|
124
|
+
if (feats.length === 0) {
|
|
125
|
+
console.warn('No valid log lines parsed into features.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Train the Isolation Forest
|
|
130
|
+
const model = new IsolationForest({ nTrees: 100, sampleSize: 256 });
|
|
131
|
+
model.fit(feats);
|
|
132
|
+
|
|
133
|
+
// Persist the trained model
|
|
134
|
+
const outDir = path.join(__dirname, 'resources');
|
|
135
|
+
const outFile = path.join(outDir, 'model.json');
|
|
136
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
// Serialize the model; if your IsolationForest supports .serialize(), use that.
|
|
139
|
+
// Otherwise we simply JSON‐stringify the internal tree structure.
|
|
140
|
+
fs.writeFileSync(outFile, JSON.stringify(model), 'utf8');
|
|
141
|
+
|
|
142
|
+
console.log(`✅ Trained on ${feats.length} samples → ${outFile}`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error('Training failed:', err);
|
|
145
|
+
}
|
|
146
|
+
})();
|
package/utils/cache.js
ADDED
package/utils/db.js
ADDED