@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,162 @@
1
+ 'use strict';
2
+
3
+ const { Normalizer } = require('./normalizer');
4
+ const { matchSignals, combineConfidence } = require('./confidence');
5
+ const { Whitelist } = require('./whitelist');
6
+ const { BehavioralAnalyzer } = require('../analyzers/behavioral');
7
+ const sqliDetector = require('../detectors/sqli');
8
+ const xssDetector = require('../detectors/xss');
9
+ const nosqliDetector = require('../detectors/nosqli');
10
+ const cmdiDetector = require('../detectors/cmdi');
11
+ const pathTraversalDetector = require('../detectors/path-traversal');
12
+ const ssrfDetector = require('../detectors/ssrf');
13
+ const xxeDetector = require('../detectors/xxe');
14
+ const prototypePollutionDetector = require('../detectors/prototype-pollution');
15
+ const hppDetector = require('../detectors/hpp');
16
+ const openRedirectDetector = require('../detectors/open-redirect');
17
+ const crlfDetector = require('../detectors/crlf');
18
+ const templateInjectionDetector = require('../detectors/template-injection');
19
+ const ldapDetector = require('../detectors/ldap');
20
+ const graphqlDetector = require('../detectors/graphql');
21
+
22
+ function classifyInputType(payload) {
23
+ const str = String(payload).trim();
24
+ const upper = str.toUpperCase();
25
+
26
+ const startsWithSqlKeyword = /^(?:SELECT|INSERT|UPDATE|DELETE|WITH|CREATE|ALTER)\b/.test(upper);
27
+
28
+ // Simple heuristic for quote breakouts
29
+ const hasUnmatchedQuote = (str.match(/'/g) || []).length % 2 !== 0;
30
+ const hasCommentBreakout = /'--|'\/\*/.test(str);
31
+
32
+ if (startsWithSqlKeyword && !hasUnmatchedQuote && !hasCommentBreakout) {
33
+ return 'complete-statement';
34
+ }
35
+ return 'fragment';
36
+ }
37
+
38
+ class DetectionEngine {
39
+ constructor(options = {}) {
40
+ this.options = options;
41
+ this.detectors = [
42
+ sqliDetector,
43
+ xssDetector,
44
+ nosqliDetector,
45
+ cmdiDetector,
46
+ pathTraversalDetector,
47
+ ssrfDetector,
48
+ xxeDetector,
49
+ prototypePollutionDetector,
50
+ hppDetector,
51
+ openRedirectDetector,
52
+ crlfDetector,
53
+ templateInjectionDetector,
54
+ ldapDetector,
55
+ graphqlDetector
56
+ ];
57
+ this.behavioralAnalyzer = new BehavioralAnalyzer(options.behavioral || {});
58
+ this.whitelist = new Whitelist();
59
+ if (options.whitelist) {
60
+ if (Array.isArray(options.whitelist.exact)) {
61
+ options.whitelist.exact.forEach(e => this.whitelist.addExact(e));
62
+ }
63
+ if (Array.isArray(options.whitelist.prefix)) {
64
+ options.whitelist.prefix.forEach(p => this.whitelist.addPrefix(p));
65
+ }
66
+ if (Array.isArray(options.whitelist.pattern)) {
67
+ options.whitelist.pattern.forEach(r => this.whitelist.addPattern(r));
68
+ }
69
+ }
70
+ }
71
+
72
+ detect(payload, context = {}) {
73
+ if (this.whitelist.isWhitelisted(payload)) {
74
+ return { label: 'benign', confidence: 0, whitelisted: true };
75
+ }
76
+ const variants = Normalizer.payloadVariants(payload, this.options);
77
+ let allMatches = [];
78
+ let maxConfidence = 0;
79
+ let maxLabel = 'benign';
80
+ let scores = {};
81
+
82
+ let activeDetectors = this.detectors;
83
+ if (context && context.source) {
84
+ if (context.source === 'filename') {
85
+ activeDetectors = this.detectors.filter(d => ['path-traversal', 'file-upload'].includes(d.name));
86
+ } else if (context.source === 'header') {
87
+ const headerChecks = ['crlf', 'xss', 'sqli', 'nosqli', 'cmdi', 'template-injection', 'ldap', 'graphql'];
88
+ activeDetectors = this.detectors.filter(d => headerChecks.includes(d.name));
89
+ }
90
+ }
91
+
92
+ const classification = classifyInputType(payload);
93
+ const isQueryMode = this.options.mode === 'query';
94
+
95
+ for (const detector of activeDetectors) {
96
+ let signals = detector.getSignals();
97
+ if (isQueryMode && detector.name === 'sqli') {
98
+ signals = signals.filter(s => s.id !== 'sql-structural-boolean');
99
+ }
100
+
101
+ const matches = matchSignals(variants, signals, detector.label);
102
+ const confidence = combineConfidence(matches);
103
+ allMatches.push(...matches);
104
+ scores[detector.name] = matches.length;
105
+ if (confidence > maxConfidence) {
106
+ maxConfidence = confidence;
107
+ maxLabel = detector.label;
108
+ }
109
+ }
110
+
111
+ const primaryVariant = variants.length > 0 ? variants[0] : payload;
112
+ const anomalySignals = this.behavioralAnalyzer.analyze(primaryVariant, context);
113
+
114
+ if (anomalySignals.length > 0) {
115
+ const anomalyConfidence = combineConfidence(anomalySignals);
116
+ allMatches.push(...anomalySignals);
117
+ scores['behavioral'] = anomalySignals.length;
118
+
119
+ if (anomalyConfidence > maxConfidence) {
120
+ maxConfidence = anomalyConfidence;
121
+ maxLabel = 'anomaly';
122
+ }
123
+ }
124
+
125
+ if (context.route) {
126
+ this.behavioralAnalyzer.incrementRequestCount();
127
+ }
128
+
129
+ let totalConfidence = Math.min(1.0, combineConfidence(allMatches));
130
+
131
+ if (isQueryMode) {
132
+ if (classification === 'complete-statement') {
133
+ totalConfidence = totalConfidence * 0.3;
134
+ maxConfidence = maxConfidence * 0.3;
135
+ }
136
+ }
137
+
138
+ const blockThreshold = this.options.blockThreshold !== undefined ? this.options.blockThreshold : 0.5;
139
+ const minimumSignals = this.options.minimumSignals !== undefined ? this.options.minimumSignals : 1;
140
+
141
+ let finalLabel = totalConfidence === 0 ? 'benign' : maxLabel;
142
+
143
+ if (finalLabel !== 'benign' && finalLabel !== 'anomaly') {
144
+ let requiredSignals = minimumSignals;
145
+ if (finalLabel === 'path-traversal') {
146
+ requiredSignals = Math.max(requiredSignals, 2);
147
+ }
148
+
149
+ if (maxConfidence < blockThreshold || allMatches.length < requiredSignals) {
150
+ finalLabel = 'anomaly';
151
+ }
152
+ }
153
+
154
+ return {
155
+ label: finalLabel,
156
+ confidence: totalConfidence,
157
+ scores,
158
+ matches: allMatches
159
+ };
160
+ }
161
+ }
162
+ module.exports = { DetectionEngine };
@@ -0,0 +1,149 @@
1
+ 'use strict';
2
+
3
+ const SQL_IDENTIFIER = '(?:`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
4
+ const SQL_WORD_BOOLEAN_OPERATOR = '(?:OR|AND|XOR)';
5
+ const SQL_SYMBOL_BOOLEAN_OPERATOR = '(?:\\|\\||&&)';
6
+ const SQL_BOOLEAN_OPERATOR = `(?:(?:\\b${SQL_WORD_BOOLEAN_OPERATOR}\\b)|${SQL_SYMBOL_BOOLEAN_OPERATOR})`;
7
+ const SQL_COMPARISON_OPERATOR = '(?:=|LIKE|!=|<>|<=|>=|<|>)';
8
+ const SQL_FUNCTION_CALL = `${SQL_IDENTIFIER}\\s*\\((?:[^()]|\\([^()]{0,120}\\)){0,240}\\)`;
9
+ const SQL_CONSTANT_VALUE = `(?:${SQL_FUNCTION_CALL}|\\d+(?:\\.\\d+)?|N?[\'"][^\'"]{0,80}[\'"]?|NULL)`;
10
+ const SQL_VALUE = `(?:${SQL_FUNCTION_CALL}|\\d+(?:\\.\\d+)?|N?[\'"][^\'"]{0,80}[\'"]?|[A-Za-z_][\\w.]*|NULL)`;
11
+ const SQL_CONSTANT_COMPARISON_EXPRESSION = `${SQL_CONSTANT_VALUE}\\s*${SQL_COMPARISON_OPERATOR}\\s*${SQL_CONSTANT_VALUE}`;
12
+ const SQL_COMPARISON_EXPRESSION = `${SQL_VALUE}\\s*${SQL_COMPARISON_OPERATOR}\\s*${SQL_VALUE}`;
13
+ const SQL_BETWEEN_EXPRESSION = `${SQL_VALUE}\\s+BETWEEN\\s+${SQL_VALUE}\\s+AND\\s+${SQL_VALUE}`;
14
+ const SQL_IS_EXPRESSION = `${SQL_VALUE}\\s+IS\\s+(?:NOT\\s+)?NULL`;
15
+ const SQL_EXISTS_EXPRESSION = `EXISTS\\s*\\(\\s*SELECT\\b`;
16
+ const SQL_BOOLEAN_LITERAL_EXPRESSION = '(?:TRUE|FALSE|UNKNOWN|NULL)';
17
+ const SQL_BOOLEAN_EXPRESSION = `(?:${SQL_COMPARISON_EXPRESSION}|${SQL_BETWEEN_EXPRESSION}|${SQL_IS_EXPRESSION}|${SQL_EXISTS_EXPRESSION}|${SQL_BOOLEAN_LITERAL_EXPRESSION})`;
18
+ const SQL_CONSTANT_BOOLEAN_EXPRESSION = `(?:${SQL_CONSTANT_COMPARISON_EXPRESSION}|${SQL_BETWEEN_EXPRESSION}|${SQL_IS_EXPRESSION}|${SQL_EXISTS_EXPRESSION}|${SQL_BOOLEAN_LITERAL_EXPRESSION})`;
19
+ const SQL_STACKED_STATEMENT_KEYWORD = '(?:SELECT|WITH|UNION|DROP|INSERT|UPDATE|DELETE|ALTER|CREATE|EXEC|EXECUTE|CALL|MERGE|TRUNCATE)';
20
+ const SQL_METADATA_OBJECT = '(?:information_schema(?:\\.[A-Za-z_][\\w$]*)?|sysobjects|sys\\.(?:tables|columns|objects|databases|schemas|indexes|all_columns)|sqlite_master|sqlite_schema|pg_catalog(?:\\.[A-Za-z_][\\w$]*)?|pg_(?:class|tables|namespace|attribute|database|user)|mysql\\.(?:innodb_table_stats|innodb_index_stats|user|db|tables_priv|columns_priv|proc|tables)|(?:all|user|dba)_(?:tables|tab_columns|objects|users|catalog|constraints|cons_columns|views))';
21
+ const SQL_METADATA_QUERY_CONTEXT = '(?:SELECT|FROM|JOIN|WHERE|COUNT\\s*\\(|EXISTS\\s*\\(|SHOW\\s+(?:FULL\\s+)?(?:TABLES|COLUMNS)|DESCRIBE|DESC)';
22
+ const HTTP_METHODS = ['all', 'get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
23
+ const DEFAULT_REDACT_KEYS = ['password', 'passwd', 'pwd', 'token', 'secret', 'authorization', 'cookie', 'api_key', 'apikey'];
24
+ const NAMED_ENTITIES = {
25
+ lt: '<',
26
+ gt: '>',
27
+ quot: '"',
28
+ apos: "'",
29
+ amp: '&',
30
+ colon: ':',
31
+ sol: '/',
32
+ equals: '=',
33
+ lpar: '(',
34
+ rpar: ')',
35
+ tab: '\t',
36
+ newline: '\n',
37
+ grave: '`'
38
+ };
39
+ const NAMED_ENTITY_PATTERN = new RegExp(`&(${Object.keys(NAMED_ENTITIES).sort((a, b) => b.length - a.length).join('|')});?`, 'gi');
40
+ const sqlWord = (word) => word.split('').join('[\\s\\u00a0]*');
41
+ class Normalizer {
42
+ static decodeDeeply(payload, maxPayloadLength = 50000, maxDecodeIterations = 8) {
43
+ return Normalizer.normalizePayload(payload, { sqlCommentMode: 'space' });
44
+ }
45
+
46
+ static normalizePayload(payload, { sqlCommentMode = 'space', maxPayloadLength = 50000, maxDecodeIterations = 8 } = {}) {
47
+ if (Buffer.isBuffer(payload)) payload = payload.toString('utf8');
48
+ if (typeof payload !== 'string') return '';
49
+ if (payload.length > maxPayloadLength) {
50
+ const headLength = Math.ceil(maxPayloadLength / 2);
51
+ const tailLength = Math.floor(maxPayloadLength / 2);
52
+ payload = `${payload.slice(0, headLength)}\nfortifyjs_TRUNCATED\n${tailLength > 0 ? payload.slice(-tailLength) : ''}`;
53
+ }
54
+ const decodeEntity = (match, hex, dec) => {
55
+ const code = parseInt(hex || dec, hex ? 16 : 10);
56
+ return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : match;
57
+ };
58
+ const decodeCodePoint = (match, hex) => {
59
+ const code = parseInt(hex, 16);
60
+ return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : match;
61
+ };
62
+
63
+ const normalize = (value) => {
64
+ let normalized = value
65
+ .replace(/%u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
66
+ .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, decodeCodePoint)
67
+ .replace(/\\u([0-9a-fA-F]{4})/g, decodeCodePoint)
68
+ .replace(/\\x([0-9a-fA-F]{2})/g, decodeCodePoint)
69
+ .replace(/&#x([0-9a-fA-F]+);?|&#(\d+);?/g, decodeEntity)
70
+ .replace(NAMED_ENTITY_PATTERN, (match, name) => NAMED_ENTITIES[name.toLowerCase()] ?? match)
71
+ .replace(/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/g, ' ')
72
+ .replace(/[\u200b-\u200d\ufeff]/g, '')
73
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
74
+ try {
75
+ normalized = normalized.normalize('NFKC');
76
+ } catch (e) {}
77
+ return normalized;
78
+ };
79
+ const decodePrintableBase64 = (candidate) => {
80
+ try {
81
+ const b64Decoded = Buffer.from(candidate, 'base64').toString('utf8');
82
+ const nonPrintableCount = b64Decoded.replace(/[\t\r\n\x20-\x7E]/g, '').length;
83
+ const isMostlyPrintable = b64Decoded.length > 0 && nonPrintableCount / b64Decoded.length < 0.1;
84
+ return isMostlyPrintable && b64Decoded !== candidate ? b64Decoded : null;
85
+ } catch (e) {
86
+ return null;
87
+ }
88
+ };
89
+
90
+ // Iterate decoding to catch multi-layer encoding
91
+ let decoded = normalize(payload);
92
+ let previous = "";
93
+ let iterations = 0;
94
+ while (decoded !== previous && iterations < maxDecodeIterations) {
95
+ previous = decoded;
96
+ try {
97
+ decoded = normalize(decodeURIComponent(decoded));
98
+ } catch (e) {
99
+ decoded = normalize(decoded.replace(/%([0-9a-fA-F]{2})/g, (match, hex) => {
100
+ try {
101
+ return decodeURIComponent(match);
102
+ } catch {
103
+ return String.fromCharCode(parseInt(hex, 16));
104
+ }
105
+ }));
106
+ }
107
+ iterations++;
108
+ }
109
+ const base64Candidate = decoded;
110
+ decoded = decoded.replace(/\bdata\s*:\s*([a-z0-9.+-]+\/[a-z0-9.+-]+)(?:;[a-z0-9=.+-]+)*;base64\s*,([A-Za-z0-9+/]+={0,2})/ig, (match, mimeType, data) => {
111
+ if (!/^(?:text\/html|image\/svg\+xml|application\/xhtml\+xml)$/i.test(mimeType)) return match;
112
+ const dataDecoded = decodePrintableBase64(data);
113
+ const marker = /^image\/svg\+xml$/i.test(mimeType) ? '\nSVG_DATA_URI' : '';
114
+ return dataDecoded ? `${match}${marker}\n${normalize(dataDecoded).replace(/\+/g, ' ')}` : match;
115
+ });
116
+ decoded = decoded.replace(/\+/g, ' ');
117
+ if (/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64Candidate)) {
118
+ const b64Decoded = decodePrintableBase64(base64Candidate);
119
+ if (b64Decoded) decoded += `\n${normalize(b64Decoded).replace(/\+/g, ' ')}`;
120
+ }
121
+ if (sqlCommentMode === 'preserve') return decoded;
122
+ // Preserve SQL block comments as separators so UNION/**/SELECT stays tokenized.
123
+ // Detection also checks a removal variant to catch mid-keyword comment splits.
124
+ decoded = decoded.replace(/\/\*!\d{0,6}\s*([\s\S]*?)\*\//g, (_, inner) => {
125
+ const executableSql = inner.trim();
126
+ if (sqlCommentMode === 'remove') return executableSql;
127
+ return executableSql ? ` MYSQL_VERSIONED_COMMENT ${executableSql} ` : ' MYSQL_VERSIONED_COMMENT ';
128
+ });
129
+ decoded = decoded.replace(/\/\*[\s\S]*?\*\//g, sqlCommentMode === 'remove' ? '' : ' ');
130
+ decoded = decoded.replace(/--[^\r\n]*(?=\r?\n|$)/g, ' ');
131
+ decoded = decoded.replace(/#[^\r\n]*(?=\r?\n|$)/g, ' ');
132
+ return decoded;
133
+ }
134
+
135
+ static payloadVariants(payload, options = {}) {
136
+ const variants = [
137
+ Normalizer.normalizePayload(payload, { sqlCommentMode: 'preserve' }),
138
+ Normalizer.normalizePayload(payload, { sqlCommentMode: 'space' }),
139
+ Normalizer.normalizePayload(payload, { sqlCommentMode: 'remove' })
140
+ ];
141
+ return [...new Set(variants.filter(Boolean))];
142
+ }
143
+ }
144
+ module.exports = {
145
+ Normalizer,
146
+ SQL_IDENTIFIER,
147
+ NAMED_ENTITIES,
148
+ sqlWord
149
+ };
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ class Whitelist {
4
+ constructor() {
5
+ this.patterns = [];
6
+ this.exacts = new Set();
7
+ this.prefixes = [];
8
+ }
9
+
10
+ addPattern(regex) {
11
+ this.patterns.push(regex);
12
+ }
13
+
14
+ addExact(str) {
15
+ this.exacts.add(str);
16
+ }
17
+
18
+ addPrefix(str) {
19
+ this.prefixes.push(str);
20
+ }
21
+
22
+ isWhitelisted(payload) {
23
+ if (this.exacts.has(payload)) {
24
+ return true;
25
+ }
26
+ for (const prefix of this.prefixes) {
27
+ if (payload.startsWith(prefix)) {
28
+ return true;
29
+ }
30
+ }
31
+ for (const pattern of this.patterns) {
32
+ if (pattern.test(payload)) {
33
+ return true;
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+ }
39
+
40
+ module.exports = { Whitelist };
@@ -0,0 +1,307 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Creates the dashboard middleware
5
+ * @param {Object} options
6
+ * @param {Object} logger Instance of Logger
7
+ * @returns {Function} Express/Connect middleware
8
+ */
9
+ function createDashboardHandler(options = {}, logger) {
10
+ const path = options.path || '/__fortifyjs/dashboard';
11
+ const apiEventsPath = `${path}/api/events`;
12
+ const apiStatsPath = `${path}/api/stats`;
13
+
14
+ const authMiddleware = options.auth || ((req, res, next) => {
15
+ logger.warn('Dashboard accessed without authentication configured!');
16
+ next();
17
+ });
18
+
19
+ const htmlTemplate = `<!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
24
+ <title>FortifyJS Dashboard</title>
25
+ <style>
26
+ :root {
27
+ --bg: #0f172a;
28
+ --card-bg: rgba(30, 41, 59, 0.7);
29
+ --border: rgba(255, 255, 255, 0.1);
30
+ --text: #f8fafc;
31
+ --text-muted: #94a3b8;
32
+ --accent: #38bdf8;
33
+ --danger: #f43f5e;
34
+ --warning: #fbbf24;
35
+ --success: #10b981;
36
+ }
37
+ body {
38
+ margin: 0;
39
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
40
+ background: var(--bg);
41
+ color: var(--text);
42
+ min-height: 100vh;
43
+ display: flex;
44
+ flex-direction: column;
45
+ }
46
+ .glass {
47
+ background: var(--card-bg);
48
+ backdrop-filter: blur(10px);
49
+ -webkit-backdrop-filter: blur(10px);
50
+ border: 1px solid var(--border);
51
+ border-radius: 12px;
52
+ }
53
+ header {
54
+ padding: 1rem 2rem;
55
+ border-bottom: 1px solid var(--border);
56
+ display: flex;
57
+ justify-content: space-between;
58
+ align-items: center;
59
+ }
60
+ header h1 {
61
+ margin: 0;
62
+ font-size: 1.5rem;
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 0.5rem;
66
+ }
67
+ .badge {
68
+ padding: 0.25rem 0.5rem;
69
+ border-radius: 999px;
70
+ font-size: 0.75rem;
71
+ font-weight: bold;
72
+ text-transform: uppercase;
73
+ }
74
+ .badge-danger { background: rgba(244, 63, 94, 0.2); color: var(--danger); }
75
+ .badge-warning { background: rgba(251, 191, 36, 0.2); color: var(--warning); }
76
+ .badge-success { background: rgba(16, 185, 129, 0.2); color: var(--success); }
77
+ main {
78
+ padding: 2rem;
79
+ flex: 1;
80
+ display: grid;
81
+ grid-template-columns: 1fr 1fr;
82
+ gap: 1.5rem;
83
+ max-width: 1400px;
84
+ margin: 0 auto;
85
+ width: 100%;
86
+ box-sizing: border-box;
87
+ }
88
+ .panel {
89
+ padding: 1.5rem;
90
+ display: flex;
91
+ flex-direction: column;
92
+ }
93
+ .panel h2 {
94
+ margin-top: 0;
95
+ font-size: 1.25rem;
96
+ border-bottom: 1px solid var(--border);
97
+ padding-bottom: 0.75rem;
98
+ }
99
+ .full-width { grid-column: 1 / -1; }
100
+ table {
101
+ width: 100%;
102
+ border-collapse: collapse;
103
+ text-align: left;
104
+ }
105
+ th, td {
106
+ padding: 0.75rem 1rem;
107
+ border-bottom: 1px solid var(--border);
108
+ }
109
+ th {
110
+ color: var(--text-muted);
111
+ font-weight: 600;
112
+ font-size: 0.875rem;
113
+ }
114
+ tbody tr:hover {
115
+ background: rgba(255, 255, 255, 0.05);
116
+ }
117
+ .empty-state {
118
+ padding: 3rem;
119
+ text-align: center;
120
+ color: var(--text-muted);
121
+ }
122
+ .stat-grid {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
125
+ gap: 1rem;
126
+ margin-bottom: 1.5rem;
127
+ }
128
+ .stat-card {
129
+ padding: 1.5rem;
130
+ text-align: center;
131
+ }
132
+ .stat-value {
133
+ font-size: 2.5rem;
134
+ font-weight: bold;
135
+ margin: 0.5rem 0;
136
+ }
137
+ .stat-label {
138
+ color: var(--text-muted);
139
+ font-size: 0.875rem;
140
+ text-transform: uppercase;
141
+ }
142
+ </style>
143
+ </head>
144
+ <body>
145
+ <header class="glass">
146
+ <h1>🛡️ FortifyJS</h1>
147
+ <div>
148
+ <span class="badge badge-success">Live Monitoring</span>
149
+ </div>
150
+ </header>
151
+
152
+ <main>
153
+ <div class="full-width stat-grid" id="stats">
154
+ <!-- Stats will load here -->
155
+ </div>
156
+
157
+ <div class="panel glass full-width">
158
+ <h2>Recent Threats Feed</h2>
159
+ <div style="overflow-x: auto;">
160
+ <table id="events-table">
161
+ <thead>
162
+ <tr>
163
+ <th>Time</th>
164
+ <th>Threat</th>
165
+ <th>Action</th>
166
+ <th>IP / Actor</th>
167
+ <th>Route</th>
168
+ <th>Details</th>
169
+ </tr>
170
+ </thead>
171
+ <tbody>
172
+ <!-- Events load here -->
173
+ </tbody>
174
+ </table>
175
+ </div>
176
+ </div>
177
+ </main>
178
+
179
+ <script>
180
+ async function loadData() {
181
+ try {
182
+ const eventsRes = await fetch('${apiEventsPath}');
183
+ const events = await eventsRes.json();
184
+
185
+ const statsRes = await fetch('${apiStatsPath}');
186
+ const stats = await statsRes.json();
187
+
188
+ renderStats(stats);
189
+ renderEvents(events);
190
+ } catch (err) {
191
+ console.error('Failed to load dashboard data', err);
192
+ }
193
+ }
194
+
195
+ function renderStats(stats) {
196
+ document.getElementById('stats').innerHTML = \`
197
+ <div class="stat-card glass">
198
+ <div class="stat-label">Total Blocked</div>
199
+ <div class="stat-value" style="color: var(--danger)">\${stats.blocked}</div>
200
+ </div>
201
+ <div class="stat-card glass">
202
+ <div class="stat-label">Total Allowed</div>
203
+ <div class="stat-value" style="color: var(--success)">\${stats.allowed}</div>
204
+ </div>
205
+ <div class="stat-card glass">
206
+ <div class="stat-label">Top Threat</div>
207
+ <div class="stat-value" style="color: var(--warning); font-size: 1.5rem; line-height: 2.5rem;">
208
+ \${stats.topThreat || 'None'}
209
+ </div>
210
+ </div>
211
+ \`;
212
+ }
213
+
214
+ function renderEvents(events) {
215
+ const tbody = document.querySelector('#events-table tbody');
216
+ if (!events || events.length === 0) {
217
+ tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state">No threats detected yet.</div></td></tr>';
218
+ return;
219
+ }
220
+
221
+ tbody.innerHTML = events.map(e => {
222
+ const date = new Date(e.timestamp).toLocaleTimeString();
223
+ const badgeClass = e.meta?.action === 'block' ? 'badge-danger' : (e.level === 'warn' ? 'badge-warning' : 'badge-success');
224
+ const action = e.meta?.action || 'observe';
225
+ const label = e.meta?.label || 'unknown';
226
+ const ip = e.meta?.ip || 'unknown';
227
+ const route = e.meta?.route || 'unknown';
228
+
229
+ return \`
230
+ <tr>
231
+ <td style="color: var(--text-muted)">\${date}</td>
232
+ <td><span class="badge \${badgeClass}">\${label}</span></td>
233
+ <td>\${action.toUpperCase()}</td>
234
+ <td><code>\${ip}</code></td>
235
+ <td><code>\${route}</code></td>
236
+ <td style="font-size: 0.875rem; color: var(--text-muted); max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title='\${e.message}'>
237
+ \${e.message}
238
+ </td>
239
+ </tr>
240
+ \`;
241
+ }).join('');
242
+ }
243
+
244
+ // Refresh every 5 seconds
245
+ loadData();
246
+ setInterval(loadData, 5000);
247
+ </script>
248
+ </body>
249
+ </html>`;
250
+
251
+ return function dashboardMiddleware(req, res, next) {
252
+ if (req.path === path || req.path === path + '/') {
253
+ return authMiddleware(req, res, (err) => {
254
+ if (err) return next(err);
255
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
256
+ res.end(htmlTemplate);
257
+ });
258
+ }
259
+
260
+ if (req.path === apiEventsPath) {
261
+ return authMiddleware(req, res, (err) => {
262
+ if (err) return next(err);
263
+ const logs = logger.getLogs(100).filter(l => l.level === 'warn' || l.level === 'error');
264
+ res.setHeader('Content-Type', 'application/json');
265
+ res.end(JSON.stringify(logs));
266
+ });
267
+ }
268
+
269
+ if (req.path === apiStatsPath) {
270
+ return authMiddleware(req, res, (err) => {
271
+ if (err) return next(err);
272
+ const allLogs = logger.getLogs(1000);
273
+ const stats = {
274
+ blocked: 0,
275
+ allowed: 0,
276
+ threatCounts: {}
277
+ };
278
+
279
+ allLogs.forEach(l => {
280
+ if (l.meta?.action === 'block') stats.blocked++;
281
+ else stats.allowed++;
282
+
283
+ if (l.meta?.label) {
284
+ stats.threatCounts[l.meta.label] = (stats.threatCounts[l.meta.label] || 0) + 1;
285
+ }
286
+ });
287
+
288
+ let topThreat = null;
289
+ let maxCount = 0;
290
+ for (const [threat, count] of Object.entries(stats.threatCounts)) {
291
+ if (count > maxCount) {
292
+ maxCount = count;
293
+ topThreat = threat;
294
+ }
295
+ }
296
+ stats.topThreat = topThreat;
297
+
298
+ res.setHeader('Content-Type', 'application/json');
299
+ res.end(JSON.stringify(stats));
300
+ });
301
+ }
302
+
303
+ next();
304
+ };
305
+ }
306
+
307
+ module.exports = { createDashboardHandler };