@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.
Files changed (50) hide show
  1. package/LICENSE +9 -0
  2. package/README.md +186 -0
  3. package/bin/fortifyjs.js +135 -0
  4. package/examples/fastify.js +18 -0
  5. package/examples/hono.js +13 -0
  6. package/examples/kitchen-sink.js +50 -0
  7. package/examples/koa.js +14 -0
  8. package/examples/minimal-express.js +13 -0
  9. package/examples/production-express.js +20 -0
  10. package/index.d.ts +101 -0
  11. package/package.json +70 -0
  12. package/src/adapters/express.js +1027 -0
  13. package/src/adapters/fastify.js +56 -0
  14. package/src/adapters/generic.js +15 -0
  15. package/src/adapters/hono.js +77 -0
  16. package/src/adapters/koa.js +58 -0
  17. package/src/adapters/nestjs.js +15 -0
  18. package/src/adapters/nextjs.js +84 -0
  19. package/src/analyzers/adaptive.js +92 -0
  20. package/src/analyzers/behavioral.js +264 -0
  21. package/src/core/confidence.js +23 -0
  22. package/src/core/engine.js +162 -0
  23. package/src/core/normalizer.js +149 -0
  24. package/src/core/whitelist.js +40 -0
  25. package/src/dashboard/handler.js +307 -0
  26. package/src/detectors/cmdi.js +82 -0
  27. package/src/detectors/crlf.js +33 -0
  28. package/src/detectors/graphql.js +56 -0
  29. package/src/detectors/hpp.js +38 -0
  30. package/src/detectors/ldap.js +50 -0
  31. package/src/detectors/nosqli.js +134 -0
  32. package/src/detectors/open-redirect.js +42 -0
  33. package/src/detectors/path-traversal.js +55 -0
  34. package/src/detectors/prototype-pollution.js +65 -0
  35. package/src/detectors/sqli.js +447 -0
  36. package/src/detectors/sqli.js.bak +446 -0
  37. package/src/detectors/ssrf.js +64 -0
  38. package/src/detectors/template-injection.js +34 -0
  39. package/src/detectors/xss.js +191 -0
  40. package/src/detectors/xxe.js +45 -0
  41. package/src/forensics/reporter.js +74 -0
  42. package/src/index.js +132 -0
  43. package/src/logger.js +110 -0
  44. package/src/presets.js +183 -0
  45. package/src/shields/bot-detector.js +88 -0
  46. package/src/shields/cors.js +95 -0
  47. package/src/shields/csrf.js +120 -0
  48. package/src/shields/file-upload.js +160 -0
  49. package/src/shields/headers.js +99 -0
  50. 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 };