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.
@@ -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
+ [![npm version](https://img.shields.io/npm/v/aiwaf-js.svg)](https://www.npmjs.com/package/aiwaf-js)
7
+ [![Build Status](https://img.shields.io/github/actions/workflow/status/your‑user/aiwaf-js/ci.yml)](https://github.com/your‑user/aiwaf-js/actions)
8
+ [![License](https://img.shields.io/npm/l/aiwaf-js.svg)](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
@@ -0,0 +1,3 @@
1
+ // aiwaf‑js/index.js
2
+ // Export the middleware factory from lib/wafMiddleware.js
3
+ module.exports = require('./lib/wafMiddleware'); // :contentReference[oaicite:0]{index=0}​:contentReference[oaicite:1]{index=1}
package/knexfile.js ADDED
@@ -0,0 +1,9 @@
1
+ module.exports = {
2
+ development: {
3
+ client: 'sqlite3',
4
+ connection: {
5
+ filename: './data/aiwaf.sqlite'
6
+ },
7
+ useNullAsDefault: true
8
+ }
9
+ };
@@ -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,5 @@
1
+ let field;
2
+ module.exports = {
3
+ init(o) { field = o.HONEYPOT_FIELD; },
4
+ isTriggered(req) { return req.body && req.body[field]; }
5
+ };
@@ -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,5 @@
1
+ let staticKeywords = [];
2
+ module.exports = {
3
+ init(o) { staticKeywords = o.staticKeywords || []; },
4
+ check(path) { return staticKeywords.find(kw => path.includes(kw)); }
5
+ };
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ const NodeCache = require('node-cache');
2
+ const redis = require('redis');
3
+ module.exports = (useRedis=false) => useRedis ? redis.createClient() : new NodeCache();
package/utils/db.js ADDED
@@ -0,0 +1,11 @@
1
+ const knex = require('knex');
2
+
3
+ const isTest = process.env.NODE_ENV === 'test';
4
+
5
+ module.exports = knex({
6
+ client: 'sqlite3',
7
+ connection: {
8
+ filename: isTest ? ':memory:' : './aiwaf.sqlite'
9
+ },
10
+ useNullAsDefault: true,
11
+ });