@chiranthmoger/fortifyjs 1.1.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 +9 -0
- package/README.md +186 -0
- package/bin/fortifyjs.js +135 -0
- package/examples/fastify.js +18 -0
- package/examples/hono.js +13 -0
- package/examples/kitchen-sink.js +50 -0
- package/examples/koa.js +14 -0
- package/examples/minimal-express.js +13 -0
- package/examples/production-express.js +20 -0
- package/index.d.ts +101 -0
- package/package.json +70 -0
- package/src/adapters/express.js +1027 -0
- package/src/adapters/fastify.js +56 -0
- package/src/adapters/generic.js +15 -0
- package/src/adapters/hono.js +77 -0
- package/src/adapters/koa.js +58 -0
- package/src/adapters/nestjs.js +15 -0
- package/src/adapters/nextjs.js +84 -0
- package/src/analyzers/adaptive.js +92 -0
- package/src/analyzers/behavioral.js +264 -0
- package/src/core/confidence.js +23 -0
- package/src/core/engine.js +162 -0
- package/src/core/normalizer.js +149 -0
- package/src/core/whitelist.js +40 -0
- package/src/dashboard/handler.js +307 -0
- package/src/detectors/cmdi.js +82 -0
- package/src/detectors/crlf.js +33 -0
- package/src/detectors/graphql.js +56 -0
- package/src/detectors/hpp.js +38 -0
- package/src/detectors/ldap.js +50 -0
- package/src/detectors/nosqli.js +134 -0
- package/src/detectors/open-redirect.js +42 -0
- package/src/detectors/path-traversal.js +55 -0
- package/src/detectors/prototype-pollution.js +65 -0
- package/src/detectors/sqli.js +447 -0
- package/src/detectors/sqli.js.bak +446 -0
- package/src/detectors/ssrf.js +64 -0
- package/src/detectors/template-injection.js +34 -0
- package/src/detectors/xss.js +191 -0
- package/src/detectors/xxe.js +45 -0
- package/src/forensics/reporter.js +74 -0
- package/src/index.js +132 -0
- package/src/logger.js +110 -0
- package/src/presets.js +183 -0
- package/src/shields/bot-detector.js +88 -0
- package/src/shields/cors.js +95 -0
- package/src/shields/csrf.js +120 -0
- package/src/shields/file-upload.js +160 -0
- package/src/shields/headers.js +99 -0
- package/src/shields/rate-limiter.js +70 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { shield } = require('../presets');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fastify plugin for fortifyjs
|
|
7
|
+
* @param {Object} fastify
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {Function} done
|
|
10
|
+
*/
|
|
11
|
+
function fastifyPlugin(fastify, options, done) {
|
|
12
|
+
// We use the shield factory to get an Express-style middleware stack
|
|
13
|
+
// Fastify can consume Express middleware using @fastify/middie or fastify-express,
|
|
14
|
+
// but since we want zero dependencies, we will wrap the Express middleware pattern manually.
|
|
15
|
+
|
|
16
|
+
const middlewareStack = shield(options.tier || 'basic', options);
|
|
17
|
+
|
|
18
|
+
fastify.addHook('onRequest', (request, reply, next) => {
|
|
19
|
+
// Mimic the req/res interface for our middleware
|
|
20
|
+
const req = request.raw;
|
|
21
|
+
const res = reply.raw;
|
|
22
|
+
|
|
23
|
+
// Polyfill properties that Express adds which our shields might expect
|
|
24
|
+
req.ip = request.ip;
|
|
25
|
+
req.path = request.routeOptions.url || request.raw.url.split('?')[0];
|
|
26
|
+
req.query = request.query || {};
|
|
27
|
+
req.body = request.body || {};
|
|
28
|
+
req.cookies = request.cookies || {};
|
|
29
|
+
req.params = request.params || {};
|
|
30
|
+
|
|
31
|
+
// For sending responses from middleware (e.g., blocking)
|
|
32
|
+
const originalSend = res.end;
|
|
33
|
+
res.status = function(code) {
|
|
34
|
+
reply.code(code);
|
|
35
|
+
return res;
|
|
36
|
+
};
|
|
37
|
+
res.send = function(body) {
|
|
38
|
+
reply.send(body);
|
|
39
|
+
};
|
|
40
|
+
res.json = function(body) {
|
|
41
|
+
reply.send(body);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
middlewareStack(req, res, (err) => {
|
|
45
|
+
if (err) return next(err);
|
|
46
|
+
next();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
done();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Support fastify-plugin syntax
|
|
54
|
+
fastifyPlugin[Symbol.for('skip-override')] = true;
|
|
55
|
+
|
|
56
|
+
module.exports = fastifyPlugin;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { shield } = require('../presets');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic Node.js http.Server adapter
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @returns {Function} (req, res, next)
|
|
9
|
+
*/
|
|
10
|
+
function genericAdapter(options = {}) {
|
|
11
|
+
// Directly returns the standard connect-style middleware (req, res, next)
|
|
12
|
+
return shield(options.tier || 'basic', options);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { genericAdapter };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { shield } = require('../presets');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hono middleware for fortifyjs
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @returns {Function}
|
|
9
|
+
*/
|
|
10
|
+
function honoMiddleware(options = {}) {
|
|
11
|
+
const middlewareStack = shield(options.tier || 'basic', options);
|
|
12
|
+
|
|
13
|
+
return async (c, next) => {
|
|
14
|
+
// Create an express-like mock object for the request
|
|
15
|
+
const req = {
|
|
16
|
+
ip: c.env?.remoteAddress || '127.0.0.1',
|
|
17
|
+
path: c.req.path,
|
|
18
|
+
method: c.req.method,
|
|
19
|
+
query: c.req.query(),
|
|
20
|
+
headers: c.req.header(),
|
|
21
|
+
body: {}, // We'll try to parse JSON if applicable
|
|
22
|
+
cookies: {}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Parse cookies from headers
|
|
26
|
+
if (req.headers.cookie) {
|
|
27
|
+
req.headers.cookie.split(';').forEach(cookie => {
|
|
28
|
+
const parts = cookie.split('=');
|
|
29
|
+
req.cookies[parts.shift().trim()] = decodeURI(parts.join('='));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Try to safely parse body
|
|
34
|
+
if (req.headers['content-type'] && req.headers['content-type'].includes('application/json')) {
|
|
35
|
+
try {
|
|
36
|
+
req.body = await c.req.json();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let blockedResponse = null;
|
|
43
|
+
|
|
44
|
+
// Create a mock response object
|
|
45
|
+
const res = {
|
|
46
|
+
statusCode: 200,
|
|
47
|
+
headers: {},
|
|
48
|
+
setHeader(key, value) {
|
|
49
|
+
this.headers[key.toLowerCase()] = value;
|
|
50
|
+
},
|
|
51
|
+
status(code) {
|
|
52
|
+
this.statusCode = code;
|
|
53
|
+
return this;
|
|
54
|
+
},
|
|
55
|
+
send(body) {
|
|
56
|
+
blockedResponse = c.body(body, this.statusCode, this.headers);
|
|
57
|
+
},
|
|
58
|
+
json(body) {
|
|
59
|
+
this.headers['content-type'] = 'application/json';
|
|
60
|
+
blockedResponse = c.json(body, this.statusCode, this.headers);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
middlewareStack(req, res, async (err) => {
|
|
66
|
+
if (err) return reject(err);
|
|
67
|
+
if (blockedResponse) return resolve(blockedResponse);
|
|
68
|
+
|
|
69
|
+
// Pass to next Hono middleware
|
|
70
|
+
await next();
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { honoMiddleware };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { shield } = require('../presets');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Koa middleware for fortifyjs
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @returns {Function}
|
|
9
|
+
*/
|
|
10
|
+
function koaMiddleware(options = {}) {
|
|
11
|
+
const middlewareStack = shield(options.tier || 'basic', options);
|
|
12
|
+
|
|
13
|
+
return async function(ctx, next) {
|
|
14
|
+
// Mimic the req/res interface for our middleware
|
|
15
|
+
const req = ctx.req;
|
|
16
|
+
const res = ctx.res;
|
|
17
|
+
|
|
18
|
+
// Polyfill properties
|
|
19
|
+
req.ip = ctx.ip;
|
|
20
|
+
req.path = ctx.path;
|
|
21
|
+
req.query = ctx.query;
|
|
22
|
+
req.body = ctx.request.body || {};
|
|
23
|
+
req.cookies = {}; // Koa uses ctx.cookies.get(), but we can do a simple parse of headers
|
|
24
|
+
if (ctx.headers.cookie) {
|
|
25
|
+
ctx.headers.cookie.split(';').forEach(cookie => {
|
|
26
|
+
const parts = cookie.split('=');
|
|
27
|
+
req.cookies[parts.shift().trim()] = decodeURI(parts.join('='));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Override res methods used by middleware
|
|
32
|
+
const originalEnd = res.end;
|
|
33
|
+
let blocked = false;
|
|
34
|
+
|
|
35
|
+
res.status = function(code) {
|
|
36
|
+
ctx.status = code;
|
|
37
|
+
return res;
|
|
38
|
+
};
|
|
39
|
+
res.send = function(body) {
|
|
40
|
+
ctx.body = body;
|
|
41
|
+
blocked = true;
|
|
42
|
+
};
|
|
43
|
+
res.json = function(body) {
|
|
44
|
+
ctx.body = body;
|
|
45
|
+
blocked = true;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
middlewareStack(req, res, (err) => {
|
|
50
|
+
if (err) return reject(err);
|
|
51
|
+
if (blocked) return resolve();
|
|
52
|
+
resolve(next());
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { koaMiddleware };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { expressMiddleware } = require('./express');
|
|
3
|
+
function nestjsMiddleware(options = {}) {
|
|
4
|
+
return expressMiddleware(options);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function createNestMiddleware(options = {}) {
|
|
8
|
+
const middleware = nestjsMiddleware(options);
|
|
9
|
+
return class fortifyjsNestMiddleware {
|
|
10
|
+
use(req, res, next) {
|
|
11
|
+
return middleware(req, res, next);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
module.exports = { nestjsMiddleware, createNestMiddleware };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { DetectionEngine } = require('../core/engine');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a Next.js middleware and API route wrapper for FortifyJS.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} options - FortifyJS configuration options
|
|
9
|
+
* @returns {Object} { middleware, withFortify }
|
|
10
|
+
*/
|
|
11
|
+
function nextjsAdapter(options = {}) {
|
|
12
|
+
const engine = new DetectionEngine(options);
|
|
13
|
+
|
|
14
|
+
// 1. Next.js Middleware (Edge Runtime compatible)
|
|
15
|
+
// Usage: export const middleware = fortify.middleware;
|
|
16
|
+
const middleware = async (request) => {
|
|
17
|
+
// NextRequest uses standard Web Request API
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
const path = url.pathname;
|
|
20
|
+
|
|
21
|
+
// Scan Path
|
|
22
|
+
let result = engine.detect(path, { source: 'path' });
|
|
23
|
+
if (result.label !== 'benign' && result.label !== 'anomaly') {
|
|
24
|
+
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Scan Query Params
|
|
28
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
29
|
+
result = engine.detect(value, { source: 'query' });
|
|
30
|
+
if (result.label !== 'benign' && result.label !== 'anomaly') {
|
|
31
|
+
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Scan Headers (excluding safe/standard headers to save time/false positives)
|
|
36
|
+
const safeHeaders = ['host', 'connection', 'accept', 'accept-encoding', 'content-length'];
|
|
37
|
+
for (const [key, value] of request.headers.entries()) {
|
|
38
|
+
if (!safeHeaders.includes(key.toLowerCase())) {
|
|
39
|
+
result = engine.detect(value, { source: 'header' });
|
|
40
|
+
if (result.label !== 'benign' && result.label !== 'anomaly') {
|
|
41
|
+
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// We don't read the body in middleware by default as it consumes the stream,
|
|
47
|
+
// which breaks subsequent route handlers. Body scanning is handled in API routes.
|
|
48
|
+
|
|
49
|
+
return null; // Signals Next.js to continue to the next middleware/route
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 2. Next.js API Route Wrapper (Pages Router)
|
|
53
|
+
// Usage: export default fortify.withFortify(async (req, res) => { ... })
|
|
54
|
+
const withFortify = (handler) => {
|
|
55
|
+
return async (req, res) => {
|
|
56
|
+
// Check query
|
|
57
|
+
if (req.query) {
|
|
58
|
+
for (const key in req.query) {
|
|
59
|
+
const value = req.query[key];
|
|
60
|
+
const valStr = typeof value === 'string' ? value : JSON.stringify(value);
|
|
61
|
+
const result = engine.detect(valStr, { source: 'query' });
|
|
62
|
+
if (result.label !== 'benign' && result.label !== 'anomaly') {
|
|
63
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check body
|
|
69
|
+
if (req.body) {
|
|
70
|
+
const bodyStr = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
|
|
71
|
+
const result = engine.detect(bodyStr, { source: 'body' });
|
|
72
|
+
if (result.label !== 'benign' && result.label !== 'anomaly') {
|
|
73
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return handler(req, res);
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return { middleware, withFortify, engine };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = nextjsAdapter;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class AdaptiveBlocker {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.windowMs = options.windowMs || 60000;
|
|
6
|
+
this.threshold = options.threshold || 5;
|
|
7
|
+
this.blockDurationMs = options.blockDurationMs || 300000;
|
|
8
|
+
this.anomalies = new Map();
|
|
9
|
+
this.blocks = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
recordAnomaly(ip) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
this.cleanup(now);
|
|
15
|
+
|
|
16
|
+
if (this.isBlocked(ip, now)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!this.anomalies.has(ip)) {
|
|
21
|
+
this.anomalies.set(ip, []);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const timestamps = this.anomalies.get(ip);
|
|
25
|
+
timestamps.push(now);
|
|
26
|
+
|
|
27
|
+
const windowStart = now - this.windowMs;
|
|
28
|
+
while (timestamps.length > 0 && timestamps[0] < windowStart) {
|
|
29
|
+
timestamps.shift();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (timestamps.length > this.threshold) {
|
|
33
|
+
this.blocks.set(ip, now + this.blockDurationMs);
|
|
34
|
+
this.anomalies.delete(ip);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
isBlocked(ip, now = Date.now()) {
|
|
42
|
+
const expiry = this.blocks.get(ip);
|
|
43
|
+
if (!expiry) return false;
|
|
44
|
+
|
|
45
|
+
if (now >= expiry) {
|
|
46
|
+
this.blocks.delete(ip);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cleanup(now = Date.now()) {
|
|
54
|
+
for (const [ip, expiry] of this.blocks.entries()) {
|
|
55
|
+
if (now >= expiry) {
|
|
56
|
+
this.blocks.delete(ip);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const windowStart = now - this.windowMs;
|
|
60
|
+
for (const [ip, timestamps] of this.anomalies.entries()) {
|
|
61
|
+
while (timestamps.length > 0 && timestamps[0] < windowStart) {
|
|
62
|
+
timestamps.shift();
|
|
63
|
+
}
|
|
64
|
+
if (timestamps.length === 0) {
|
|
65
|
+
this.anomalies.delete(ip);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
middleware() {
|
|
71
|
+
return (req, res, next) => {
|
|
72
|
+
const ip = req.ip || (req.connection && req.connection.remoteAddress) || 'unknown';
|
|
73
|
+
|
|
74
|
+
if (this.isBlocked(ip)) {
|
|
75
|
+
return res.status(429).json({ error: 'IP temporarily blocked due to repeated anomalies' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
res.on('finish', () => {
|
|
79
|
+
const detections = req.fortifyjsDetections || [];
|
|
80
|
+
// We consider it an anomaly if there are any detections that are malicious (detected = true) or labeled anomaly.
|
|
81
|
+
const hasAnomaly = detections.some(d => d.detected || d.label === 'anomaly');
|
|
82
|
+
if (hasAnomaly) {
|
|
83
|
+
this.recordAnomaly(ip);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
next();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { AdaptiveBlocker };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculates Shannon entropy of a string
|
|
5
|
+
* @param {string} str
|
|
6
|
+
* @returns {number}
|
|
7
|
+
*/
|
|
8
|
+
function shannonEntropy(str) {
|
|
9
|
+
const freq = {};
|
|
10
|
+
for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
|
|
11
|
+
const len = str.length;
|
|
12
|
+
let entropy = 0;
|
|
13
|
+
for (const count of Object.values(freq)) {
|
|
14
|
+
const p = count / len;
|
|
15
|
+
entropy -= p * Math.log2(p);
|
|
16
|
+
}
|
|
17
|
+
return entropy;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class BehavioralAnalyzer {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} options
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.options = {
|
|
26
|
+
entropyThreshold: 4.5,
|
|
27
|
+
maxEncodingDepth: 3,
|
|
28
|
+
specialCharRatio: 0.5,
|
|
29
|
+
learningRequests: 1000,
|
|
30
|
+
onAnomaly: null,
|
|
31
|
+
...options
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
this.learningData = {
|
|
35
|
+
requestCount: 0,
|
|
36
|
+
routes: {}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Analyzes payload for anomalies
|
|
42
|
+
* @param {string} normalizedPayload
|
|
43
|
+
* @param {Object} context
|
|
44
|
+
* @returns {Array<Object>}
|
|
45
|
+
*/
|
|
46
|
+
analyze(normalizedPayload, context = {}) {
|
|
47
|
+
const signals = [];
|
|
48
|
+
const {
|
|
49
|
+
decodingIterations = 0,
|
|
50
|
+
paramName = '',
|
|
51
|
+
source = 'query',
|
|
52
|
+
contentType = '',
|
|
53
|
+
route = '',
|
|
54
|
+
method = 'GET'
|
|
55
|
+
} = context;
|
|
56
|
+
|
|
57
|
+
const payload = normalizedPayload || '';
|
|
58
|
+
const len = payload.length;
|
|
59
|
+
|
|
60
|
+
// 6.1 Entropy
|
|
61
|
+
if (len > 5) {
|
|
62
|
+
const entropy = shannonEntropy(payload);
|
|
63
|
+
if (entropy > this.options.entropyThreshold) {
|
|
64
|
+
signals.push({ id: 'high-entropy-payload', confidence: 0.40, label: 'anomaly' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 6.2 Encoding Depth
|
|
69
|
+
if (decodingIterations >= 5) {
|
|
70
|
+
signals.push({ id: 'extreme-encoding', confidence: 0.70, label: 'anomaly' });
|
|
71
|
+
} else if (decodingIterations >= 3) {
|
|
72
|
+
signals.push({ id: 'deep-encoding', confidence: 0.50, label: 'anomaly' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 6.3 Structural Anomaly
|
|
76
|
+
if (len > 0) {
|
|
77
|
+
let specialChars = 0;
|
|
78
|
+
let hasNullByte = false;
|
|
79
|
+
let hasControlChar = false;
|
|
80
|
+
let hasUnusualUnicode = false;
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < len; i++) {
|
|
83
|
+
const code = payload.charCodeAt(i);
|
|
84
|
+
if (code === 0) hasNullByte = true;
|
|
85
|
+
else if (code > 0 && code < 32 && code !== 9 && code !== 10 && code !== 13) hasControlChar = true;
|
|
86
|
+
|
|
87
|
+
if (!(code >= 48 && code <= 57) && !(code >= 65 && code <= 90) && !(code >= 97 && code <= 122) && code !== 32) {
|
|
88
|
+
specialChars++;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (code >= 0x0400 && code <= 0x04FF) hasUnusualUnicode = true; // Cyrillic
|
|
92
|
+
if (code >= 0x2200 && code <= 0x22FF) hasUnusualUnicode = true; // Math Operators
|
|
93
|
+
if (code >= 0xFF00 && code <= 0xFFEF) hasUnusualUnicode = true; // Fullwidth
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (specialChars / len > this.options.specialCharRatio) {
|
|
97
|
+
signals.push({ id: 'high-special-char-ratio', confidence: 0.35, label: 'anomaly' });
|
|
98
|
+
}
|
|
99
|
+
if (hasNullByte) {
|
|
100
|
+
signals.push({ id: 'null-bytes-present', confidence: 0.50, label: 'anomaly' });
|
|
101
|
+
}
|
|
102
|
+
if (hasControlChar) {
|
|
103
|
+
signals.push({ id: 'control-chars-present', confidence: 0.40, label: 'anomaly' });
|
|
104
|
+
}
|
|
105
|
+
if (hasUnusualUnicode) {
|
|
106
|
+
signals.push({ id: 'unusual-unicode', confidence: 0.45, label: 'anomaly' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 6.4 Payload Length
|
|
111
|
+
if (source === 'query' && len > 500) {
|
|
112
|
+
signals.push({ id: 'oversized-query-param', confidence: 0.30, label: 'anomaly' });
|
|
113
|
+
}
|
|
114
|
+
if (source === 'cookie' && len > 4096) {
|
|
115
|
+
signals.push({ id: 'oversized-cookie', confidence: 0.35, label: 'anomaly' });
|
|
116
|
+
}
|
|
117
|
+
if (source === 'header' && len > 8192) {
|
|
118
|
+
signals.push({ id: 'oversized-header', confidence: 0.35, label: 'anomaly' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Excessively deep JSON nesting (>10 levels)
|
|
122
|
+
if (source === 'body' && contentType && contentType.includes('application/json')) {
|
|
123
|
+
let maxDepth = 0;
|
|
124
|
+
let currentDepth = 0;
|
|
125
|
+
for (let i = 0; i < len; i++) {
|
|
126
|
+
if (payload[i] === '{' || payload[i] === '[') {
|
|
127
|
+
currentDepth++;
|
|
128
|
+
if (currentDepth > maxDepth) maxDepth = currentDepth;
|
|
129
|
+
} else if (payload[i] === '}' || payload[i] === ']') {
|
|
130
|
+
currentDepth--;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (maxDepth > 10) {
|
|
134
|
+
signals.push({ id: 'excessively-deep-json', confidence: 0.80, label: 'anomaly' });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Abnormal content-length vs actual body size
|
|
139
|
+
if (source === 'body' && context.contentLengthHeader) {
|
|
140
|
+
const declaredLen = parseInt(context.contentLengthHeader, 10);
|
|
141
|
+
if (!isNaN(declaredLen)) {
|
|
142
|
+
// Flag if difference is more than 50% or minimum 50 bytes
|
|
143
|
+
if (Math.abs(declaredLen - len) > Math.max(50, declaredLen * 0.5)) {
|
|
144
|
+
signals.push({ id: 'abnormal-content-length', confidence: 0.60, label: 'anomaly' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Unusually high entropy strings (encoded shellcode)
|
|
150
|
+
if (len > 20) {
|
|
151
|
+
const entropy = shannonEntropy(payload);
|
|
152
|
+
if (entropy > this.options.entropyThreshold + 1.5) { // e.g. > 6.0
|
|
153
|
+
signals.push({ id: 'high-entropy-shellcode', confidence: 0.70, label: 'anomaly' });
|
|
154
|
+
} else if (entropy > this.options.entropyThreshold) {
|
|
155
|
+
// Prevent duplicate high-entropy-payload signal if we already pushed one in 6.1
|
|
156
|
+
if (!signals.some(s => s.id === 'high-entropy-payload')) {
|
|
157
|
+
signals.push({ id: 'high-entropy-payload', confidence: 0.40, label: 'anomaly' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 6.5 Content-Type Mismatch
|
|
163
|
+
if (source === 'body' && contentType) {
|
|
164
|
+
const isJson = contentType.includes('application/json');
|
|
165
|
+
const isForm = contentType.includes('application/x-www-form-urlencoded') || contentType.includes('text/plain');
|
|
166
|
+
|
|
167
|
+
const looksLikeJson = (payload.startsWith('{') && payload.endsWith('}')) || (payload.startsWith('[') && payload.endsWith(']'));
|
|
168
|
+
if (looksLikeJson && isForm) {
|
|
169
|
+
signals.push({ id: 'content-type-mismatch', confidence: 0.55, label: 'anomaly' });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const looksLikeXml = payload.includes('<!DOCTYPE') || payload.includes('<?xml');
|
|
173
|
+
if (looksLikeXml && isJson) {
|
|
174
|
+
signals.push({ id: 'xml-in-json-endpoint', confidence: 0.60, label: 'anomaly' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 6.6 Request Fingerprinting
|
|
179
|
+
if (route) {
|
|
180
|
+
const routeKey = `${method} ${route}`;
|
|
181
|
+
if (!this.learningData.routes[routeKey]) {
|
|
182
|
+
this.learningData.routes[routeKey] = {
|
|
183
|
+
params: new Set(),
|
|
184
|
+
maxLengths: {},
|
|
185
|
+
contentTypes: new Set()
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const rData = this.learningData.routes[routeKey];
|
|
190
|
+
|
|
191
|
+
if (this.learningData.requestCount < this.options.learningRequests) {
|
|
192
|
+
// Learning Mode
|
|
193
|
+
if (paramName) {
|
|
194
|
+
rData.params.add(paramName);
|
|
195
|
+
rData.maxLengths[paramName] = Math.max(rData.maxLengths[paramName] || 0, len);
|
|
196
|
+
}
|
|
197
|
+
if (contentType) rData.contentTypes.add(contentType);
|
|
198
|
+
} else {
|
|
199
|
+
// Enforcement Mode
|
|
200
|
+
if (paramName && !rData.params.has(paramName)) {
|
|
201
|
+
signals.push({ id: 'unknown-parameter', confidence: 0.30, label: 'anomaly' });
|
|
202
|
+
}
|
|
203
|
+
if (paramName && rData.maxLengths[paramName] && len > (rData.maxLengths[paramName] * 3)) {
|
|
204
|
+
signals.push({ id: 'value-length-deviation', confidence: 0.35, label: 'anomaly' });
|
|
205
|
+
}
|
|
206
|
+
if (contentType && rData.contentTypes.size > 0 && !rData.contentTypes.has(contentType)) {
|
|
207
|
+
signals.push({ id: 'unexpected-content-type', confidence: 0.40, label: 'anomaly' });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Repeated rapid requests from same fingerprint and Scanner fingerprints
|
|
213
|
+
const fingerprint = context.ip ? (context.userAgent ? `${context.ip}|${context.userAgent}` : context.ip) : null;
|
|
214
|
+
|
|
215
|
+
if (fingerprint) {
|
|
216
|
+
if (!this.learningData.fingerprints) {
|
|
217
|
+
this.learningData.fingerprints = {};
|
|
218
|
+
}
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const fpData = this.learningData.fingerprints[fingerprint] || { count: 0, firstSeen: now, routes: new Set() };
|
|
221
|
+
|
|
222
|
+
// Time window of 10 seconds for rapid requests
|
|
223
|
+
if (now - fpData.firstSeen > 10000) {
|
|
224
|
+
fpData.count = 1;
|
|
225
|
+
fpData.firstSeen = now;
|
|
226
|
+
fpData.routes.clear();
|
|
227
|
+
} else {
|
|
228
|
+
fpData.count++;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (route) {
|
|
232
|
+
fpData.routes.add(route);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.learningData.fingerprints[fingerprint] = fpData;
|
|
236
|
+
|
|
237
|
+
if (fpData.count > 50) {
|
|
238
|
+
signals.push({ id: 'repeated-rapid-requests', confidence: 0.75, label: 'anomaly' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (fpData.routes.size > 15) {
|
|
242
|
+
signals.push({ id: 'scanner-fingerprint', confidence: 0.80, label: 'anomaly' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (signals.length > 0 && typeof this.options.onAnomaly === 'function') {
|
|
247
|
+
try {
|
|
248
|
+
this.options.onAnomaly(signals, context);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
// ignore callback errors
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return signals;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
incrementRequestCount() {
|
|
258
|
+
if (this.learningData.requestCount < this.options.learningRequests) {
|
|
259
|
+
this.learningData.requestCount++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { BehavioralAnalyzer };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function matchSignals(variants, signalDefinitions, label) {
|
|
4
|
+
const matchesById = new Map();
|
|
5
|
+
for (const variant of variants) {
|
|
6
|
+
for (const signal of signalDefinitions) {
|
|
7
|
+
const matched = signal.pattern ? signal.pattern.test(variant) : signal.test(variant);
|
|
8
|
+
if (!matched || matchesById.has(signal.id)) continue;
|
|
9
|
+
matchesById.set(signal.id, {
|
|
10
|
+
id: signal.id,
|
|
11
|
+
label,
|
|
12
|
+
confidence: signal.confidence
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return [...matchesById.values()];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function combineConfidence(matches) {
|
|
20
|
+
return matches.reduce((total, match) => total + match.confidence * (1 - total), 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { matchSignals, combineConfidence };
|