@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,191 @@
1
+ 'use strict';
2
+
3
+ const JS_EXECUTION_SINKS = new Set(['alert', 'confirm', 'prompt', 'eval', 'fetch', 'function', 'settimeout', 'setinterval']);
4
+ const JS_GLOBAL_OBJECTS = new Set(['window', 'globalthis', 'self', 'top', 'parent']);
5
+ function isAsciiLetter(ch) {
6
+ return /[A-Za-z]/.test(ch);
7
+ }
8
+
9
+ function isAsciiDigit(ch) {
10
+ return /[0-9]/.test(ch);
11
+ }
12
+
13
+ function isSqlWordStart(ch) {
14
+ return isAsciiLetter(ch) || ch === '_' || ch === '$';
15
+ }
16
+
17
+ function isSqlWordPart(ch) {
18
+ return isSqlWordStart(ch) || isAsciiDigit(ch);
19
+ }
20
+
21
+ function tokenizeJsFragment(value) {
22
+ const text = String(value);
23
+ const tokens = [];
24
+ let i = 0;
25
+
26
+ while (i < text.length && tokens.length < 800) {
27
+ const ch = text[i];
28
+ if (/\s/.test(ch)) {
29
+ i++;
30
+ continue;
31
+ }
32
+ if (isSqlWordStart(ch)) {
33
+ const start = i;
34
+ i++;
35
+ while (i < text.length && isSqlWordPart(text[i])) i++;
36
+ tokens.push({ type: 'word', value: text.slice(start, i).toLowerCase() });
37
+ continue;
38
+ }
39
+ if (ch === "'" || ch === '"' || ch === '`') {
40
+ const quote = ch;
41
+ let valueText = '';
42
+ i++;
43
+ while (i < text.length) {
44
+ if (text[i] === '\\') {
45
+ valueText += text[i + 1] || '';
46
+ i += 2;
47
+ continue;
48
+ }
49
+ if (text[i] === quote) {
50
+ i++;
51
+ break;
52
+ }
53
+ valueText += text[i];
54
+ i++;
55
+ }
56
+ tokens.push({ type: 'string', value: valueText.toLowerCase() });
57
+ continue;
58
+ }
59
+ if ('[]().,:;+-*/%{}='.includes(ch)) {
60
+ tokens.push({ type: 'punct', value: ch });
61
+ }
62
+ i++;
63
+ }
64
+
65
+ return tokens;
66
+ }
67
+
68
+ function javascriptUrlBodies(value) {
69
+ const text = String(value);
70
+ const bodies = [];
71
+ const protocol = /javascript\s*:/ig;
72
+ let match;
73
+
74
+ while ((match = protocol.exec(text)) !== null) {
75
+ bodies.push(text.slice(protocol.lastIndex, protocol.lastIndex + 300));
76
+ }
77
+
78
+ return bodies;
79
+ }
80
+
81
+ function hasStructuralJavascriptUrlSink(value) {
82
+ for (const body of javascriptUrlBodies(value)) {
83
+ const tokens = tokenizeJsFragment(body);
84
+ let constructorReferences = 0;
85
+ let hasCall = false;
86
+
87
+ for (let i = 0; i < tokens.length; i++) {
88
+ const token = tokens[i];
89
+ if (token.type === 'punct' && token.value === '(') hasCall = true;
90
+ if ((token.type === 'word' || token.type === 'string') && token.value === 'constructor') constructorReferences++;
91
+
92
+ if (token.type !== 'word') continue;
93
+ if (JS_EXECUTION_SINKS.has(token.value) && tokens.slice(i + 1, i + 4).some(next => next.value === '(')) return true;
94
+ if (token.value === 'document' && tokens.slice(i + 1, i + 3).some(next => next.value === '.')) return true;
95
+ if (JS_GLOBAL_OBJECTS.has(token.value) && tokens.slice(i + 1, i + 3).some(next => next.value === '.' || next.value === '[')) return true;
96
+ }
97
+
98
+ if (constructorReferences >= 2 && hasCall) return true;
99
+ }
100
+
101
+ return false;
102
+ }
103
+
104
+ module.exports = {
105
+ name: 'xss',
106
+ label: 'xss',
107
+ getSignals() {
108
+ return [
109
+ {
110
+ id: 'script-tag',
111
+ confidence: 0.85,
112
+ pattern: /<\s*script\b/i
113
+ },
114
+ {
115
+ id: 'html-event-attribute',
116
+ confidence: 0.75,
117
+ pattern: /<[^>]+\bon\w+\s*=/i
118
+ },
119
+ {
120
+ id: 'event-handler-payload',
121
+ confidence: 0.75,
122
+ pattern: /\bon\w+\s*=\s*["']?[^"'>]*(?:alert|confirm|prompt|document\.|window\.|eval|fetch|Function\s*\()/i
123
+ },
124
+ {
125
+ id: 'javascript-url-with-sink',
126
+ confidence: 0.75,
127
+ pattern: /\bjavascript\s*:[\s\S]{0,240}(?:(?:alert|confirm|prompt|eval|fetch|Function|setTimeout|setInterval)\s*\(|document\s*\.|(?:window|globalThis|self|top|parent)\s*(?:\.|\[)|(?:\[\s*["']constructor["']\s*\]\s*){2})/i
128
+ },
129
+ {
130
+ id: 'javascript-url-structural-sink',
131
+ confidence: 0.75,
132
+ test: hasStructuralJavascriptUrlSink
133
+ },
134
+ {
135
+ id: 'javascript-url-attribute',
136
+ confidence: 0.75,
137
+ pattern: /\b(?:href|src|xlink:href|formaction|action)\s*=\s*["']?\s*javascript\s*:/i
138
+ },
139
+ {
140
+ id: 'javascript-url',
141
+ confidence: 0.3,
142
+ pattern: /\bjavascript\s*:/i
143
+ },
144
+ {
145
+ id: 'dangerous-html-container',
146
+ confidence: 0.7,
147
+ pattern: /<\s*(?:iframe|object|embed|applet)\b/i
148
+ },
149
+ {
150
+ id: 'srcdoc-html',
151
+ confidence: 0.65,
152
+ pattern: /\bsrcdoc\s*=/i
153
+ },
154
+ {
155
+ id: 'html-data-url',
156
+ confidence: 0.65,
157
+ pattern: /\bdata\s*:\s*text\/html/i
158
+ },
159
+ {
160
+ id: 'svg-data-url',
161
+ confidence: 0.65,
162
+ pattern: /(?:\bdata\s*:\s*image\/svg\+xml|SVG_DATA_URI)/i
163
+ },
164
+ {
165
+ id: 'mathml-xss-container',
166
+ confidence: 0.7,
167
+ pattern: /<\s*(?:math|mtext|mglyph|annotation-xml)\b/i
168
+ },
169
+ {
170
+ id: 'svg-xss-container',
171
+ confidence: 0.7,
172
+ pattern: /<\s*foreignObject\b/i
173
+ },
174
+ {
175
+ id: 'css-injection',
176
+ confidence: 0.8,
177
+ pattern: /(?:<\s*style[^>]*>[\s\S]*?|\bstyle\s*=\s*["']?[^"'>]*)(?:expression\s*\(|url\s*\(\s*["']?\s*javascript\s*:)/i
178
+ },
179
+ {
180
+ id: 'autofocus-event-bypass',
181
+ confidence: 0.8,
182
+ pattern: /<\s*[^>]*\bonfocus\b[^>]*\bautofocus\b|<\s*[^>]*\bautofocus\b[^>]*\bonfocus\b/i
183
+ },
184
+ {
185
+ id: 'media-error-bypass',
186
+ confidence: 0.8,
187
+ pattern: /<\s*(?:video|audio|picture)[^>]*>[\s\S]*?<\s*source[^>]*\bonerror\s*=/i
188
+ }
189
+ ]
190
+ }
191
+ };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ name: 'xxe',
5
+ label: 'xxe',
6
+ getSignals() {
7
+ return [
8
+ {
9
+ id: 'xml-entity-declaration',
10
+ confidence: 0.85,
11
+ pattern: /<!ENTITY\s+/i
12
+ },
13
+ {
14
+ id: 'xml-system-entity',
15
+ confidence: 0.90,
16
+ pattern: /<!ENTITY\s+[^>]+SYSTEM\s+["'](?:file|http|https|expect):\/\//i
17
+ },
18
+ {
19
+ id: 'xml-public-entity',
20
+ confidence: 0.80,
21
+ pattern: /<!ENTITY\s+[^>]+PUBLIC\s+["']/i
22
+ },
23
+ {
24
+ id: 'xml-parameter-entity',
25
+ confidence: 0.80,
26
+ pattern: /<!ENTITY\s+%\s+[^>]+>/i
27
+ },
28
+ {
29
+ id: 'xml-entity-expansion',
30
+ confidence: 0.85,
31
+ pattern: /<!ENTITY\s+[^>]+>\s*<!ENTITY\s+[^>]+&(?:[a-zA-Z0-9_]+);/i
32
+ },
33
+ {
34
+ id: 'xml-doctype',
35
+ confidence: 0.50,
36
+ pattern: /<!DOCTYPE\s+/i
37
+ },
38
+ {
39
+ id: 'xml-cdata-injection',
40
+ confidence: 0.60,
41
+ pattern: /<!\[CDATA\[.*(?:<script|javascript:|on[a-z]+(?:=>|=)).*]]>/i
42
+ }
43
+ ];
44
+ }
45
+ };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ class ForensicsReporter {
4
+ constructor(maxSize = 1000) {
5
+ this.maxSize = maxSize;
6
+ this.buffer = [];
7
+ this.pointer = 0;
8
+ }
9
+
10
+ logBlock(details = {}) {
11
+ const {
12
+ ip = 'unknown',
13
+ method = 'UNKNOWN',
14
+ path = '/',
15
+ detector = 'unknown',
16
+ confidence = 0,
17
+ payload = '',
18
+ matchedStrings = []
19
+ } = details;
20
+
21
+ let sanitizedPayload = typeof payload === 'string' ? payload : String(payload);
22
+
23
+ if (Array.isArray(matchedStrings) && matchedStrings.length > 0) {
24
+ matchedStrings.forEach(str => {
25
+ if (str && typeof str === 'string') {
26
+ sanitizedPayload = sanitizedPayload.split(str).join('*'.repeat(str.length));
27
+ }
28
+ });
29
+ } else {
30
+ // Basic fallback masking if no specific matched strings provided but payload contains typical patterns
31
+ sanitizedPayload = sanitizedPayload.replace(/(<script>|UNION SELECT|DROP TABLE|--|;)/gi, '***');
32
+ }
33
+
34
+ // truncate to 200 chars
35
+ if (sanitizedPayload.length > 200) {
36
+ sanitizedPayload = sanitizedPayload.substring(0, 200) + '...';
37
+ }
38
+
39
+ const entry = {
40
+ timestamp: new Date().toISOString(),
41
+ ip,
42
+ method,
43
+ path,
44
+ detector,
45
+ confidence,
46
+ payloadPreview: sanitizedPayload
47
+ };
48
+
49
+ if (this.buffer.length < this.maxSize) {
50
+ this.buffer.push(entry);
51
+ } else {
52
+ this.buffer[this.pointer] = entry;
53
+ this.pointer = (this.pointer + 1) % this.maxSize;
54
+ }
55
+ }
56
+
57
+ getReport() {
58
+ if (this.buffer.length < this.maxSize) {
59
+ return [...this.buffer];
60
+ }
61
+ const result = [];
62
+ for (let i = 0; i < this.maxSize; i++) {
63
+ result.push(this.buffer[(this.pointer + i) % this.maxSize]);
64
+ }
65
+ return result;
66
+ }
67
+
68
+ clear() {
69
+ this.buffer = [];
70
+ this.pointer = 0;
71
+ }
72
+ }
73
+
74
+ module.exports = { ForensicsReporter };
package/src/index.js ADDED
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+ const { shield } = require('./presets');
3
+ const { DetectionEngine } = require('./core/engine');
4
+ const { Normalizer } = require('./core/normalizer');
5
+ const { expressMiddleware, fortifyjs, secureRouter } = require('./adapters/express');
6
+ const { createNestMiddleware, nestjsMiddleware } = require('./adapters/nestjs');
7
+ const fastifyPlugin = require('./adapters/fastify');
8
+ const { koaMiddleware } = require('./adapters/koa');
9
+ const { honoMiddleware } = require('./adapters/hono');
10
+ const { genericAdapter } = require('./adapters/generic');
11
+ const nextjsAdapter = require('./adapters/nextjs');
12
+ const DEFAULT_THRESHOLD = 0.5;
13
+
14
+ function samplePayload(sample) {
15
+ if (typeof sample === 'string') return sample;
16
+ if (sample && typeof sample === 'object') {
17
+ return sample.payload !== undefined ? sample.payload : (sample.query !== undefined ? sample.query : JSON.stringify(sample));
18
+ }
19
+ return String(sample);
20
+ }
21
+
22
+ function expectedMaliciousLabel(label) {
23
+ if (typeof label === 'boolean') return label;
24
+ const str = String(label).toLowerCase();
25
+ return str !== 'benign' && str !== 'safe' && str !== 'false' && str !== '0';
26
+ }
27
+
28
+ function resolveDetectionSettings(options) {
29
+ const threshold = typeof options.threshold === 'number' ? options.threshold : DEFAULT_THRESHOLD;
30
+ return { threshold };
31
+ }
32
+
33
+ class fortifyjsQueryError extends Error {
34
+ constructor(result) {
35
+ super(`fortifyjs detected a SQL injection attempt (confidence: ${result.confidence.toFixed(2)})`);
36
+ this.name = 'fortifyjsQueryError';
37
+ this.result = result;
38
+ }
39
+ }
40
+
41
+ function scanSqlQuery(query, options = {}) {
42
+ if (typeof query !== 'string') {
43
+ throw new TypeError('query must be a string');
44
+ }
45
+ const detector = options.detector || new DetectionEngine({
46
+ maxPayloadLength: options.maxPayloadLength,
47
+ maxDecodeIterations: options.maxDecodeIterations
48
+ });
49
+ return detector.detect(query);
50
+ }
51
+
52
+ function assertSafeSqlQuery(query, options = {}) {
53
+ const result = scanSqlQuery(query, options);
54
+ const { threshold } = resolveDetectionSettings(options);
55
+ if (result.label === 'sqli' && result.confidence >= threshold) {
56
+ throw new fortifyjsQueryError(result);
57
+ }
58
+ return result;
59
+ }
60
+ function evaluatePayloads(samples, options = {}) {
61
+ if (!Array.isArray(samples)) {
62
+ throw new TypeError('samples must be an array');
63
+ }
64
+
65
+ const detector = options.detector || new DetectionEngine({
66
+ maxPayloadLength: options.maxPayloadLength,
67
+ maxDecodeIterations: options.maxDecodeIterations
68
+ });
69
+ const { threshold } = resolveDetectionSettings(options);
70
+ const results = [];
71
+ const summary = {
72
+ total: samples.length,
73
+ blocked: 0,
74
+ allowed: 0,
75
+ labeled: 0,
76
+ falsePositives: 0,
77
+ falseNegatives: 0,
78
+ truePositives: 0,
79
+ trueNegatives: 0,
80
+ falsePositiveRate: 0,
81
+ falseNegativeRate: 0
82
+ };
83
+
84
+ for (const sample of samples) {
85
+ const payload = String(samplePayload(sample));
86
+ const expectedMalicious = typeof sample === 'object' && sample !== null
87
+ ? expectedMaliciousLabel(sample.label ?? sample.expected ?? sample.kind)
88
+ : null;
89
+ const result = detector.detect(payload);
90
+ const blocked = result.label !== 'benign' && result.confidence >= threshold;
91
+ if (blocked) summary.blocked++;
92
+ else summary.allowed++;
93
+
94
+ if (expectedMalicious !== null) {
95
+ summary.labeled++;
96
+ if (blocked && expectedMalicious) summary.truePositives++;
97
+ else if (blocked && !expectedMalicious) summary.falsePositives++;
98
+ else if (!blocked && expectedMalicious) summary.falseNegatives++;
99
+ else summary.trueNegatives++;
100
+ }
101
+
102
+ results.push({ payload, expectedMalicious, blocked, result });
103
+ }
104
+
105
+ const benignCount = summary.trueNegatives + summary.falsePositives;
106
+ const maliciousCount = summary.truePositives + summary.falseNegatives;
107
+ summary.falsePositiveRate = benignCount === 0 ? 0 : summary.falsePositives / benignCount;
108
+ summary.falseNegativeRate = maliciousCount === 0 ? 0 : summary.falseNegatives / maliciousCount;
109
+
110
+ return { threshold, summary, results };
111
+ }
112
+
113
+ module.exports = {
114
+ shield,
115
+ DetectionEngine,
116
+ Normalizer,
117
+ evaluatePayloads,
118
+ fortifyjsQueryError,
119
+ expressMiddleware,
120
+ createNestMiddleware,
121
+ fortifyjs,
122
+ secureRouter,
123
+ nestjsMiddleware,
124
+ scanSqlQuery,
125
+ assertSafeSqlQuery,
126
+ evaluatePayloads,
127
+ fastifyPlugin,
128
+ koaMiddleware,
129
+ honoMiddleware,
130
+ genericAdapter,
131
+ nextjsAdapter
132
+ };
package/src/logger.js ADDED
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+
7
+ const LOG_LEVELS = {
8
+ silent: 0,
9
+ error: 1,
10
+ warn: 2,
11
+ info: 3,
12
+ debug: 4
13
+ };
14
+
15
+ class Logger {
16
+ constructor(options = {}) {
17
+ this.level = LOG_LEVELS[options.level || 'info'];
18
+ this.format = options.format || 'json'; // json, text
19
+ this.maxLogs = options.maxLogs || 500;
20
+ this.transport = options.transport || null;
21
+
22
+ // Rate limiting: max N logs per second
23
+ this.rateLimit = options.rateLimit || { max: 100, windowMs: 1000 };
24
+ this.logCounts = new Map();
25
+
26
+ this.inMemoryLogs = [];
27
+ }
28
+
29
+ _checkRateLimit(category) {
30
+ const now = Date.now();
31
+ const windowStart = now - this.rateLimit.windowMs;
32
+
33
+ // Cleanup old entries
34
+ for (const [key, data] of this.logCounts.entries()) {
35
+ if (data.timestamp < windowStart) {
36
+ this.logCounts.delete(key);
37
+ }
38
+ }
39
+
40
+ let record = this.logCounts.get(category);
41
+ if (!record) {
42
+ record = { count: 0, timestamp: now };
43
+ this.logCounts.set(category, record);
44
+ } else if (record.timestamp < windowStart) {
45
+ record.count = 0;
46
+ record.timestamp = now;
47
+ }
48
+
49
+ record.count++;
50
+ return record.count <= this.rateLimit.max;
51
+ }
52
+
53
+ log(levelName, message, meta = {}) {
54
+ const levelVal = LOG_LEVELS[levelName];
55
+ if (this.level < levelVal || this.level === 0) return;
56
+
57
+ if (!this._checkRateLimit('global')) {
58
+ return; // Rate limited
59
+ }
60
+
61
+ const event = {
62
+ timestamp: new Date().toISOString(),
63
+ id: crypto.randomBytes(8).toString('hex'),
64
+ level: levelName,
65
+ message,
66
+ ...meta
67
+ };
68
+
69
+ this.inMemoryLogs.unshift(event);
70
+ if (this.inMemoryLogs.length > this.maxLogs) {
71
+ this.inMemoryLogs.pop();
72
+ }
73
+
74
+ if (this.transport && typeof this.transport === 'function') {
75
+ try {
76
+ this.transport(event);
77
+ } catch (e) {
78
+ // ignore transport errors
79
+ }
80
+ } else if (!this.transport && this.format === 'json') {
81
+ if (levelName === 'error') {
82
+ console.error(JSON.stringify(event));
83
+ } else if (levelName === 'warn') {
84
+ console.warn(JSON.stringify(event));
85
+ } else {
86
+ console.log(JSON.stringify(event));
87
+ }
88
+ } else if (!this.transport && this.format === 'text') {
89
+ const msg = `[${event.timestamp}] ${levelName.toUpperCase()}: ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : ''}`;
90
+ if (levelName === 'error') console.error(msg);
91
+ else if (levelName === 'warn') console.warn(msg);
92
+ else console.log(msg);
93
+ }
94
+ }
95
+
96
+ error(message, meta) { this.log('error', message, meta); }
97
+ warn(message, meta) { this.log('warn', message, meta); }
98
+ info(message, meta) { this.log('info', message, meta); }
99
+ debug(message, meta) { this.log('debug', message, meta); }
100
+
101
+ getLogs(limit = 100) {
102
+ return this.inMemoryLogs.slice(0, limit);
103
+ }
104
+
105
+ clearLogs() {
106
+ this.inMemoryLogs = [];
107
+ }
108
+ }
109
+
110
+ module.exports = { Logger };