@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,1027 @@
1
+ 'use strict';
2
+ const { DetectionEngine } = require('../core/engine');
3
+ const { IPRateLimiter } = require('../shields/rate-limiter');
4
+ const crypto = require('crypto');
5
+
6
+ const DEFAULT_THRESHOLD = 0.5;
7
+ const DEFAULT_SUSPICIOUS_THRESHOLD = 0.2;
8
+ const DEFAULT_MAX_LOGS = 500;
9
+ const DEFAULT_MAX_PAYLOAD_LENGTH = 50000;
10
+ const DEFAULT_MAX_DECODE_ITERATIONS = 8;
11
+ const DEFAULT_MAX_DEPTH = 20;
12
+ const DEFAULT_MAX_FIELDS = 1000;
13
+ const DETECTION_LEVELS = Object.freeze({
14
+ strict: Object.freeze({
15
+ threshold: 0.25,
16
+ suspiciousThreshold: 0.1,
17
+ maxSuspiciousRequests: 2
18
+ }),
19
+ balanced: Object.freeze({
20
+ threshold: DEFAULT_THRESHOLD,
21
+ suspiciousThreshold: DEFAULT_SUSPICIOUS_THRESHOLD,
22
+ maxSuspiciousRequests: 3
23
+ }),
24
+ permissive: Object.freeze({
25
+ threshold: 0.85,
26
+ suspiciousThreshold: 0.5,
27
+ maxSuspiciousRequests: 5
28
+ })
29
+ });
30
+ const HTTP_METHODS = ['all', 'get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
31
+ const DEFAULT_REDACT_KEYS = ['password', 'passwd', 'pwd', 'token', 'secret', 'authorization', 'cookie', 'api_key', 'apikey'];
32
+ function sanitizeForLog(value) {
33
+ return String(value)
34
+ .replace(/\r/g, '\\r')
35
+ .replace(/\n/g, '\\n');
36
+ }
37
+
38
+ function truncateForLog(value, maxLength = 500) {
39
+ const text = sanitizeForLog(value);
40
+ if (text.length <= maxLength) return text;
41
+ return `${text.slice(0, maxLength)}...[truncated ${text.length - maxLength} chars]`;
42
+ }
43
+
44
+ function readRequestProperty(obj, key, fallback = undefined) {
45
+ try {
46
+ const value = obj?.[key];
47
+ return value === undefined ? fallback : value;
48
+ } catch (_) {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ function isURLSearchParams(value) {
54
+ return typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams;
55
+ }
56
+
57
+ function isMap(value) {
58
+ return value instanceof Map;
59
+ }
60
+
61
+ function isSet(value) {
62
+ return value instanceof Set;
63
+ }
64
+
65
+ function collectionEntries(value) {
66
+ if (isURLSearchParams(value) || isMap(value)) return [...value.entries()];
67
+ if (isSet(value)) return [...value.values()].map((entryValue, index) => [String(index), entryValue]);
68
+ return null;
69
+ }
70
+
71
+ function schemaKeys(source) {
72
+ if (isURLSearchParams(source) || isMap(source)) {
73
+ return [...new Set([...source.keys()].map(key => String(key)))];
74
+ }
75
+ return Object.keys(source);
76
+ }
77
+
78
+ function schemaHasKey(source, key) {
79
+ if (isURLSearchParams(source)) return source.has(key);
80
+ if (isMap(source)) return source.has(key);
81
+ return Object.prototype.hasOwnProperty.call(source, key);
82
+ }
83
+
84
+ function getIp(req) {
85
+ const ip = readRequestProperty(req, 'ip', null);
86
+ if (ip) return ip;
87
+ const connection = readRequestProperty(req, 'connection', null);
88
+ return readRequestProperty(connection, 'remoteAddress', 'unknown') || 'unknown';
89
+ }
90
+
91
+ function getRequestUrl(req) {
92
+ return readRequestProperty(req, 'originalUrl', null) || readRequestProperty(req, 'url', '') || '';
93
+ }
94
+
95
+ function getRoutePath(req) {
96
+ const route = readRequestProperty(req, 'route', null);
97
+ const routePathValue = route ? readRequestProperty(route, 'path', null) : null;
98
+ if (routePathValue) {
99
+ const routePath = Array.isArray(routePathValue) ? routePathValue.join('|') : String(routePathValue);
100
+ return `${readRequestProperty(req, 'baseUrl', '') || ''}${routePath}`;
101
+ }
102
+ return readRequestProperty(req, 'path', null) || getRequestUrl(req).split('?')[0] || '';
103
+ }
104
+
105
+ function sanitizeRequestId(value) {
106
+ if (value === null || value === undefined) return null;
107
+ return truncateForLog(value, 128);
108
+ }
109
+
110
+ function defaultRawRequestId(req) {
111
+ return req.id || req.requestId || req.headers?.['x-request-id'] || req.headers?.['x-correlation-id'] || null;
112
+ }
113
+
114
+ function callbackErrorMessage(error) {
115
+ try {
116
+ return sanitizeForLog(error && error.message ? error.message : String(error));
117
+ } catch (_) {
118
+ return '[unavailable]';
119
+ }
120
+ }
121
+
122
+ function callbackErrorContext(error, context = {}) {
123
+ return {
124
+ type: 'fortifyjs.callback_error',
125
+ timestamp: new Date().toISOString(),
126
+ hook: context.hook || 'unknown',
127
+ message: callbackErrorMessage(error),
128
+ eventType: context.event?.type || null,
129
+ eventLabel: context.event?.label || null,
130
+ eventPath: context.event?.path || null
131
+ };
132
+ }
133
+
134
+ function isSensitivePath(path, redactKeys = DEFAULT_REDACT_KEYS) {
135
+ const lowered = String(path || '').toLowerCase();
136
+ return redactKeys.some(key => lowered.includes(String(key).toLowerCase()));
137
+ }
138
+
139
+ function payloadPreview(payload, path, options = {}) {
140
+ if (isSensitivePath(path, options.redactKeys)) return '[redacted]';
141
+ return truncateForLog(payload, options.maxLogPayloadLength ?? 300);
142
+ }
143
+
144
+ function payloadFingerprint(payload) {
145
+ const normalized = String(payload)
146
+ .toLowerCase()
147
+ .replace(/[a-z]+/g, 'a')
148
+ .replace(/\d+/g, '0')
149
+ .replace(/\s+/g, ' ')
150
+ .slice(0, 1000);
151
+ return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
152
+ }
153
+
154
+ function createDetectionEvent(req, payload, detection, options = {}) {
155
+ const matches = detection.matches || [];
156
+ const action = options.dryRun ? 'observe' : (detection.action || 'block');
157
+ return {
158
+ type: 'fortifyjs.threat',
159
+ detected: true,
160
+ timestamp: new Date().toISOString(),
161
+ action,
162
+ blocked: action === 'block',
163
+ dryRun: options.dryRun === true,
164
+ requestId: options.getRequestId(req),
165
+ method: readRequestProperty(req, 'method', null),
166
+ url: getRequestUrl(req),
167
+ route: getRoutePath(req),
168
+ ip: getIp(req),
169
+ label: detection.label,
170
+ confidence: detection.confidence,
171
+ path: detection.path,
172
+ matches,
173
+ matchedSignalIds: matches.map(match => match.id),
174
+ payloadPreview: payloadPreview(payload, detection.path, options),
175
+ payloadLength: String(payload).length,
176
+ reason: detection.reason || null
177
+ };
178
+ }
179
+
180
+ function formatEvent(event, format = 'text') {
181
+ if (format === 'json') return event;
182
+ const safe = (value, fallback = '-') => {
183
+ if (value === null || value === undefined || value === '') return fallback;
184
+ return sanitizeForLog(value);
185
+ };
186
+ return `[fortifyjs] ${event.action === 'observe' ? 'Attack Observed' : 'Attack Blocked'}: ${safe(event.label)} from IP: ${safe(event.ip)} | requestId: ${safe(event.requestId)} | path: ${safe(event.path)} | confidence: ${safe(event.confidence)} | Payload: ${safe(event.payloadPreview)}`;
187
+ }
188
+
189
+ function createLearningEvent(req, payload, result, path, options = {}) {
190
+ const matchedSignalIds = (result.matches || []).map(match => match.id);
191
+ const fingerprint = payloadFingerprint(payload);
192
+ return {
193
+ type: 'fortifyjs.learning',
194
+ timestamp: new Date().toISOString(),
195
+ requestId: options.getRequestId(req),
196
+ method: readRequestProperty(req, 'method', null),
197
+ url: getRequestUrl(req),
198
+ route: getRoutePath(req),
199
+ ip: getIp(req),
200
+ label: result.label,
201
+ confidence: result.confidence,
202
+ path,
203
+ matches: result.matches || [],
204
+ matchedSignalIds,
205
+ clusterKey: `${result.label}:${matchedSignalIds.join('+') || 'unknown'}:${fingerprint}`,
206
+ payloadPreview: payloadPreview(payload, path, options),
207
+ payloadLength: String(payload).length
208
+ };
209
+ }
210
+
211
+ function normalizeDetectionLevel(level = 'balanced') {
212
+ const normalized = String(level || 'balanced').toLowerCase();
213
+ if (!DETECTION_LEVELS[normalized]) {
214
+ throw new Error(`Unknown fortifyjs detection level: ${level}`);
215
+ }
216
+ return normalized;
217
+ }
218
+
219
+ function resolveDetectionSettings(options = {}) {
220
+ const level = normalizeDetectionLevel(options.level ?? options.detectionLevel ?? 'balanced');
221
+ const defaults = DETECTION_LEVELS[level];
222
+ return {
223
+ level,
224
+ threshold: options.threshold ?? defaults.threshold,
225
+ suspiciousThreshold: options.suspiciousThreshold ?? defaults.suspiciousThreshold,
226
+ maxSuspiciousRequests: options.maxSuspiciousRequests ?? defaults.maxSuspiciousRequests
227
+ };
228
+ }
229
+
230
+ function normalizeMode(mode) {
231
+ if (mode === undefined || mode === null) return null;
232
+ const normalized = String(mode).toLowerCase();
233
+ if (['block', 'blocking', 'enforce', 'enforced'].includes(normalized)) return 'block';
234
+ if (['log', 'observe', 'monitor', 'dry-run', 'dryrun'].includes(normalized)) return 'log';
235
+ throw new Error(`Unknown fortifyjs mode: ${mode}`);
236
+ }
237
+
238
+ function toList(value) {
239
+ if (value === undefined || value === null) return [];
240
+ return Array.isArray(value) ? value : [value];
241
+ }
242
+
243
+ function mergeLists(...values) {
244
+ return values.flatMap(toList);
245
+ }
246
+
247
+ function isPlainRecord(value) {
248
+ return value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof RegExp);
249
+ }
250
+
251
+ function matchesPattern(pattern, value, req) {
252
+ const text = String(value || '');
253
+ if (typeof pattern === 'function') return pattern(text, req) === true;
254
+ if (pattern instanceof RegExp) {
255
+ pattern.lastIndex = 0;
256
+ return pattern.test(text);
257
+ }
258
+ const expected = String(pattern);
259
+ if (expected.endsWith('*')) return text.startsWith(expected.slice(0, -1));
260
+ return text === expected;
261
+ }
262
+
263
+ function patternMatchesAny(patterns, value, req) {
264
+ return patterns.some(pattern => matchesPattern(pattern, value, req));
265
+ }
266
+
267
+ function routeAllowPatterns(options = {}) {
268
+ const allowlist = options.allowlist || {};
269
+ return mergeLists(options.allowRoutes, options.allowedRoutes, allowlist.routes);
270
+ }
271
+
272
+ function paramAllowPatterns(req, options = {}) {
273
+ const allowlist = options.allowlist || {};
274
+ const values = mergeLists(
275
+ options.allowParams,
276
+ options.allowedParams,
277
+ options.allowParameters,
278
+ allowlist.params,
279
+ allowlist.parameters
280
+ );
281
+ const patterns = [];
282
+
283
+ for (const value of values) {
284
+ if (isPlainRecord(value)) {
285
+ for (const [routePattern, routePatterns] of Object.entries(value)) {
286
+ if (requestMatchesPattern(req, routePattern)) patterns.push(...toList(routePatterns));
287
+ }
288
+ } else {
289
+ patterns.push(value);
290
+ }
291
+ }
292
+
293
+ return patterns;
294
+ }
295
+
296
+ function requestMatchesPattern(req, pattern) {
297
+ return schemaCandidates(req).some(candidate => matchesPattern(pattern, candidate, req));
298
+ }
299
+
300
+ function isRouteAllowed(req, options = {}) {
301
+ return routeAllowPatterns(options).some(pattern => requestMatchesPattern(req, pattern));
302
+ }
303
+
304
+ function isParamAllowed(req, path, options = {}) {
305
+ return patternMatchesAny(paramAllowPatterns(req, options), path, req);
306
+ }
307
+
308
+ function routeDetectionMaps(options = {}) {
309
+ const allowlist = options.allowlist || {};
310
+ return [
311
+ options.routeLevels,
312
+ options.routeDetectionLevels,
313
+ options.routeThresholds,
314
+ allowlist.routeLevels
315
+ ].filter(isPlainRecord);
316
+ }
317
+
318
+ function resolveRouteDetectionOverride(req, options = {}) {
319
+ for (const map of routeDetectionMaps(options)) {
320
+ for (const [pattern, override] of Object.entries(map)) {
321
+ if (requestMatchesPattern(req, pattern)) return override;
322
+ }
323
+ }
324
+ return null;
325
+ }
326
+
327
+ function resolveRequestDetectionSettings(req, options = {}, baseSettings = resolveDetectionSettings(options)) {
328
+ const override = resolveRouteDetectionOverride(req, options);
329
+ if (!override) return baseSettings;
330
+ if (typeof override === 'string') return resolveDetectionSettings({ level: override });
331
+ return resolveDetectionSettings(override);
332
+ }
333
+
334
+ function normalizeMaxLogs(maxLogs) {
335
+ const numeric = Number(maxLogs ?? DEFAULT_MAX_LOGS);
336
+ return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : DEFAULT_MAX_LOGS;
337
+ }
338
+
339
+ function createMemoryLogStore(maxLogs = DEFAULT_MAX_LOGS) {
340
+ const limit = normalizeMaxLogs(maxLogs);
341
+ const entries = [];
342
+ return {
343
+ maxLogs: limit,
344
+ add(event) {
345
+ entries.push({ ...event });
346
+ if (entries.length > limit) entries.splice(0, entries.length - limit);
347
+ },
348
+ list() {
349
+ return entries.slice();
350
+ },
351
+ clear() {
352
+ entries.length = 0;
353
+ }
354
+ };
355
+ }
356
+
357
+ function parsePositiveInteger(value, fallback = null) {
358
+ if (value === undefined || value === null || value === '') return fallback;
359
+ const parsed = Number(value);
360
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
361
+ }
362
+
363
+ function createLogsHandler(logStore, options = {}) {
364
+ return (req, res) => {
365
+ const allLogs = logStore && typeof logStore.list === 'function' ? logStore.list() : [];
366
+ const queryLimit = readRequestProperty(readRequestProperty(req, 'query', {}), 'limit', null);
367
+ const limit = parsePositiveInteger(queryLimit, parsePositiveInteger(options.limit, null));
368
+ const logs = limit ? allLogs.slice(-limit) : allLogs;
369
+ return res.status(200).json(logs);
370
+ };
371
+ }
372
+
373
+ function normalizeSchemaRule(rule) {
374
+ if (!rule) return null;
375
+ if (Array.isArray(rule)) return { allowed: rule, required: [], allowUnknown: false };
376
+ const required = rule.required || [];
377
+ return {
378
+ allowed: rule.allowed || rule.fields || required,
379
+ required,
380
+ allowUnknown: rule.allowUnknown === true
381
+ };
382
+ }
383
+
384
+ function pathnameFromRequest(req) {
385
+ return (getRequestUrl(req).split('?')[0] || readRequestProperty(req, 'path', '') || '').replace(/\/+$/, '') || '/';
386
+ }
387
+
388
+ function schemaCandidates(req) {
389
+ const method = String(readRequestProperty(req, 'method', '') || '').toUpperCase();
390
+ const route = getRoutePath(req);
391
+ const path = pathnameFromRequest(req);
392
+ const candidates = [...new Set([route, path].filter(Boolean))];
393
+ return [
394
+ ...candidates.map(candidate => `${method} ${candidate}`),
395
+ ...candidates
396
+ ];
397
+ }
398
+
399
+ function resolveSchema(req, options = {}) {
400
+ if (options.schema) return options.schema;
401
+ if (!options.schemas) return null;
402
+ for (const key of schemaCandidates(req)) {
403
+ if (options.schemas[key]) return options.schemas[key];
404
+ }
405
+ return null;
406
+ }
407
+
408
+ function validateSchemaSource(sourceName, source, rule) {
409
+ const normalized = normalizeSchemaRule(rule);
410
+ if (!normalized) return null;
411
+
412
+ const required = new Set(normalized.required);
413
+
414
+ if (!source || typeof source !== 'object' || Buffer.isBuffer(source)) {
415
+ for (const key of required) {
416
+ return {
417
+ payload: key,
418
+ detection: {
419
+ label: 'schema_violation',
420
+ confidence: 1,
421
+ path: `${sourceName}.${key}`,
422
+ reason: 'missing_required_field',
423
+ matches: [{ id: 'schema-missing-required-field', label: 'schema', confidence: 1 }]
424
+ }
425
+ };
426
+ }
427
+ return null;
428
+ }
429
+
430
+ let keys;
431
+ try {
432
+ keys = schemaKeys(source);
433
+ } catch (_) {
434
+ return {
435
+ payload: `[Unreadable ${sourceName}]`,
436
+ detection: {
437
+ label: 'schema_violation',
438
+ confidence: 1,
439
+ path: sourceName,
440
+ reason: 'unreadable_object',
441
+ matches: [{ id: 'schema-unreadable-object', label: 'schema', confidence: 1 }]
442
+ }
443
+ };
444
+ }
445
+ const allowed = new Set(normalized.allowed);
446
+
447
+ if (!normalized.allowUnknown && allowed.size > 0) {
448
+ for (const key of keys) {
449
+ if (!allowed.has(key)) {
450
+ return {
451
+ payload: key,
452
+ detection: {
453
+ label: 'schema_violation',
454
+ confidence: 1,
455
+ path: `${sourceName}.${key}`,
456
+ reason: 'unexpected_field',
457
+ matches: [{ id: 'schema-unexpected-field', label: 'schema', confidence: 1 }]
458
+ }
459
+ };
460
+ }
461
+ }
462
+ }
463
+
464
+ for (const key of required) {
465
+ if (!schemaHasKey(source, key)) {
466
+ return {
467
+ payload: key,
468
+ detection: {
469
+ label: 'schema_violation',
470
+ confidence: 1,
471
+ path: `${sourceName}.${key}`,
472
+ reason: 'missing_required_field',
473
+ matches: [{ id: 'schema-missing-required-field', label: 'schema', confidence: 1 }]
474
+ }
475
+ };
476
+ }
477
+ }
478
+
479
+ return null;
480
+ }
481
+
482
+ function validateSchema(req, schema) {
483
+ if (!schema) return null;
484
+ return (
485
+ validateSchemaSource('query', req.query, schema.query) ||
486
+ validateSchemaSource('body', req.body, schema.body) ||
487
+ validateSchemaSource('params', req.params, schema.params) ||
488
+ validateSchemaSource('headers', req.headers, schema.headers) ||
489
+ validateSchemaSource('cookies', req.cookies, schema.cookies)
490
+ );
491
+ }
492
+
493
+ function expressMiddleware(options = {}) {
494
+ const detector = options.detector || new DetectionEngine({
495
+ maxPayloadLength: options.maxPayloadLength,
496
+ maxDecodeIterations: options.maxDecodeIterations
497
+ });
498
+ const detectionSettings = resolveDetectionSettings(options);
499
+ const learning = options.learning === true ? { enabled: true } : (options.learning || {});
500
+ const mode = normalizeMode(options.mode);
501
+ const dryRun = typeof options.dryRun === 'boolean'
502
+ ? options.dryRun
503
+ : (mode === 'log'
504
+ ? true
505
+ : (mode === 'block' ? false : (learning.enabled === true || options.learning === true || detectionSettings.level === 'permissive')));
506
+ const maxSuspiciousRequests = detectionSettings.maxSuspiciousRequests;
507
+ const maxRateLimitEventsPerKey = Math.max(options.maxRateLimitEventsPerKey ?? 1000, maxSuspiciousRequests);
508
+ const rateLimiter = new IPRateLimiter(
509
+ options.rateLimitWindowMs ?? 300000,
510
+ options.maxRateLimitCapacity ?? 10000,
511
+ maxRateLimitEventsPerKey
512
+ );
513
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
514
+ const maxFields = options.maxFields ?? DEFAULT_MAX_FIELDS;
515
+ const blockStatus = options.blockStatus ?? 403;
516
+ const scanQuery = options.scanQuery !== false;
517
+ const scanBody = options.scanBody !== false;
518
+ const scanHeaders = options.scanHeaders !== false;
519
+ const scanCookies = options.scanCookies !== false;
520
+ const scanParams = options.scanParams !== false;
521
+ const scanKeys = options.scanKeys !== false;
522
+ const scanRawBody = options.scanRawBody !== false;
523
+ const skip = typeof options.skip === 'function' ? options.skip : null;
524
+ const onThreat = typeof options.onThreat === 'function' ? options.onThreat : null;
525
+ const onLearningEvent = typeof options.onLearningEvent === 'function'
526
+ ? options.onLearningEvent
527
+ : (typeof learning.onEvent === 'function' ? learning.onEvent : null);
528
+ const learningEnabled = learning.enabled === true || options.learning === true || onLearningEvent !== null;
529
+ const logFormat = options.logFormat || (options.jsonLogs ? 'json' : 'text');
530
+ const logger = typeof options.logAttacks === 'function'
531
+ ? options.logAttacks
532
+ : (options.logAttacks ? console.warn : null);
533
+ const requestIdGetter = typeof options.getRequestId === 'function' ? options.getRequestId : defaultRawRequestId;
534
+ const rateLimitKeyGetter = typeof options.rateLimitKey === 'function' ? options.rateLimitKey : getIp;
535
+ const onCallbackError = typeof options.onCallbackError === 'function' ? options.onCallbackError : null;
536
+ const hasProvidedLogStore = Boolean(options.logStore);
537
+ const logStore = options.logStore || createMemoryLogStore(options.maxLogs);
538
+ const storeLogs = Boolean(
539
+ options.logRequests ||
540
+ options.logs ||
541
+ options.exposeLogs ||
542
+ options.storeLogs ||
543
+ dryRun ||
544
+ (hasProvidedLogStore && !options._internalLogStore) ||
545
+ learningEnabled
546
+ );
547
+
548
+ const reportCallbackError = (error, context = {}) => {
549
+ if (!onCallbackError) return;
550
+
551
+ const safeContext = callbackErrorContext(error, context);
552
+ try {
553
+ const result = onCallbackError(error, safeContext);
554
+ Promise.resolve(result).catch(() => {});
555
+ } catch (_) {
556
+ // User error reporting must never affect request handling.
557
+ }
558
+ };
559
+
560
+ const safeCall = (hook, callback, args, context = {}) => {
561
+ if (typeof callback !== 'function') return undefined;
562
+
563
+ try {
564
+ const result = callback(...args);
565
+ Promise.resolve(result).catch(error => reportCallbackError(error, { ...context, hook }));
566
+ return result;
567
+ } catch (error) {
568
+ reportCallbackError(error, { ...context, hook });
569
+ return undefined;
570
+ }
571
+ };
572
+
573
+ const safeRequestId = (req) => {
574
+ try {
575
+ return sanitizeRequestId(requestIdGetter(req));
576
+ } catch (error) {
577
+ reportCallbackError(error, { hook: 'getRequestId' });
578
+ return null;
579
+ }
580
+ };
581
+
582
+ const safeRateLimitKey = async (req, fallback) => {
583
+ try {
584
+ const key = rateLimitKeyGetter(req);
585
+ const resolvedKey = await key;
586
+ return String(resolvedKey || fallback || 'unknown');
587
+ } catch (error) {
588
+ reportCallbackError(error, { hook: 'rateLimitKey' });
589
+ return String(fallback || 'unknown');
590
+ }
591
+ };
592
+
593
+ const eventOptions = {
594
+ dryRun,
595
+ redactKeys: options.redactKeys || DEFAULT_REDACT_KEYS,
596
+ maxLogPayloadLength: options.maxLogPayloadLength,
597
+ getRequestId: safeRequestId
598
+ };
599
+
600
+ const writeLog = (event) => {
601
+ if (storeLogs && logStore && typeof logStore.add === 'function') logStore.add(event);
602
+ if (logger) safeCall('logAttacks', logger, [formatEvent(event, logFormat), event], { event });
603
+ };
604
+
605
+ const emitLearning = (req, payload, result, path) => {
606
+ if (!learningEnabled) return;
607
+ const event = createLearningEvent(req, payload, result, path, eventOptions);
608
+ req.fortifyjsLearning = req.fortifyjsLearning || [];
609
+ req.fortifyjsLearning.push(event);
610
+ if (storeLogs && logStore && typeof logStore.add === 'function') logStore.add(event);
611
+ safeCall('onLearningEvent', onLearningEvent, [event, req], { event });
612
+ };
613
+
614
+ const middleware = async (req, res, next) => {
615
+ try {
616
+ if (skip) {
617
+ try {
618
+ const skipResult = skip(req);
619
+ const shouldSkip = await skipResult;
620
+ if (shouldSkip) return next();
621
+ } catch (error) {
622
+ reportCallbackError(error, { hook: 'skip' });
623
+ }
624
+ }
625
+
626
+ if (isRouteAllowed(req, options)) return next();
627
+
628
+ const ip = getIp(req);
629
+ const rateLimitKey = await safeRateLimitKey(req, ip);
630
+ const requestDetectionSettings = resolveRequestDetectionSettings(req, options, detectionSettings);
631
+ const scannedSources = req.fortifyjsScannedSources instanceof Set
632
+ ? req.fortifyjsScannedSources
633
+ : new Set();
634
+ req.fortifyjsScannedSources = scannedSources;
635
+
636
+ let scannedFields = 0;
637
+
638
+ const reportDetection = (payload, detection) => {
639
+ const event = createDetectionEvent(req, payload, detection, eventOptions);
640
+ req.fortifyjsDetections = req.fortifyjsDetections || [];
641
+ req.fortifyjsDetections.push(event);
642
+ req.fortifyjs = req.fortifyjs || event;
643
+ safeCall('onThreat', onThreat, [event, req], { event });
644
+ writeLog(event);
645
+ return { isMalicious: true, label: detection.label };
646
+ };
647
+
648
+ const scanString = async (str, path) => {
649
+ if (typeof str !== 'string' || str.length === 0) return false;
650
+ if (isParamAllowed(req, path, options)) return false;
651
+ const result = detector.detect(str);
652
+ let finalLabel = result.label;
653
+ let finalConfidence = result.confidence;
654
+ let isMalicious = result.label !== 'benign' && result.confidence >= requestDetectionSettings.threshold;
655
+
656
+ if (!isMalicious && result.label !== 'benign' && result.confidence >= requestDetectionSettings.suspiciousThreshold) {
657
+ emitLearning(req, str, result, path);
658
+ const suspiciousCount = rateLimiter.recordSuspicious(rateLimitKey);
659
+ if (suspiciousCount >= requestDetectionSettings.maxSuspiciousRequests) {
660
+ isMalicious = true;
661
+ finalLabel = "rate_limit_escalation";
662
+ finalConfidence = requestDetectionSettings.threshold;
663
+ writeLog({
664
+ type: 'fortifyjs.rate_limit',
665
+ timestamp: new Date().toISOString(),
666
+ action: dryRun ? 'observe' : 'block',
667
+ blocked: !dryRun,
668
+ dryRun,
669
+ requestId: eventOptions.getRequestId(req),
670
+ method: readRequestProperty(req, 'method', null),
671
+ url: getRequestUrl(req),
672
+ route: getRoutePath(req),
673
+ ip: getIp(req),
674
+ label: finalLabel,
675
+ confidence: finalConfidence,
676
+ path,
677
+ matches: result.matches || [],
678
+ matchedSignalIds: (result.matches || []).map(match => match.id),
679
+ payloadPreview: payloadPreview(str, path, eventOptions),
680
+ payloadLength: String(str).length,
681
+ reason: 'repeated_suspicious_probe'
682
+ });
683
+ }
684
+ }
685
+
686
+ if (isMalicious) {
687
+ return reportDetection(str, {
688
+ label: finalLabel,
689
+ confidence: finalConfidence,
690
+ path,
691
+ matches: result.matches
692
+ });
693
+ }
694
+ return false;
695
+ };
696
+
697
+ const deepScan = async (obj, path, currentDepth = 0, seen = new WeakSet()) => {
698
+ if (!obj || typeof obj !== 'object') return false;
699
+ if (currentDepth > maxDepth) {
700
+ return reportDetection("[JSON Depth Exceeded]", { label: "dos", confidence: 1, path, reason: 'max_depth_exceeded' });
701
+ }
702
+ if (seen.has(obj)) return false;
703
+ seen.add(obj);
704
+ let attackFound = false;
705
+
706
+ const scanEntry = async (key, val, childPath, scanKey = true) => {
707
+ scannedFields++;
708
+ if (scannedFields > maxFields) {
709
+ return reportDetection("[Field Limit Exceeded]", { label: "dos", confidence: 1, path, reason: 'max_fields_exceeded' });
710
+ }
711
+
712
+ if (scanKey) {
713
+ const keyAttack = scanKeys ? await scanString(String(key), `${childPath}.__key`) : false;
714
+ if (keyAttack) {
715
+ if (!dryRun) return keyAttack;
716
+ attackFound = attackFound || keyAttack;
717
+ }
718
+ }
719
+
720
+ if (typeof val === 'string') {
721
+ const valAttack = await scanString(val, childPath);
722
+ if (valAttack) {
723
+ if (!dryRun) return valAttack;
724
+ attackFound = attackFound || valAttack;
725
+ }
726
+ } else if (Buffer.isBuffer(val)) {
727
+ const valAttack = await scanString(val.toString('utf8'), childPath);
728
+ if (valAttack) {
729
+ if (!dryRun) return valAttack;
730
+ attackFound = attackFound || valAttack;
731
+ }
732
+ } else if (typeof val === 'object' && val !== null) {
733
+ const nestedAttack = await deepScan(val, childPath, currentDepth + 1, seen);
734
+ if (nestedAttack) {
735
+ if (!dryRun) return nestedAttack;
736
+ attackFound = attackFound || nestedAttack;
737
+ }
738
+ }
739
+
740
+ return false;
741
+ };
742
+
743
+ let entries;
744
+ try {
745
+ entries = collectionEntries(obj);
746
+ } catch (_) {
747
+ return reportDetection("[Object Enumeration Failed]", {
748
+ label: "dos",
749
+ confidence: 1,
750
+ path,
751
+ reason: 'object_enumeration_failed',
752
+ matches: [{ id: 'object-enumeration-failed', label: 'dos', confidence: 1 }]
753
+ });
754
+ }
755
+
756
+ if (entries) {
757
+ const scanEntryKeys = !isSet(obj);
758
+ for (const [key, val] of entries) {
759
+ const stringKey = String(key);
760
+ const childPath = scanEntryKeys ? `${path}.${stringKey}` : `${path}[${stringKey}]`;
761
+ const entryAttack = await scanEntry(stringKey, val, childPath, scanEntryKeys);
762
+ if (entryAttack) {
763
+ if (!dryRun) return entryAttack;
764
+ attackFound = attackFound || entryAttack;
765
+ }
766
+ }
767
+ return attackFound;
768
+ }
769
+
770
+ let keys;
771
+
772
+ try {
773
+ keys = Object.keys(obj);
774
+ } catch (_) {
775
+ return reportDetection("[Object Enumeration Failed]", {
776
+ label: "dos",
777
+ confidence: 1,
778
+ path,
779
+ reason: 'object_enumeration_failed',
780
+ matches: [{ id: 'object-enumeration-failed', label: 'dos', confidence: 1 }]
781
+ });
782
+ }
783
+
784
+ for (const key of keys) {
785
+ const childPath = Array.isArray(obj) ? `${path}[${key}]` : `${path}.${key}`;
786
+ let val;
787
+ try {
788
+ val = obj[key];
789
+ } catch (_) {
790
+ return reportDetection("[Object Property Access Failed]", {
791
+ label: "dos",
792
+ confidence: 1,
793
+ path: childPath,
794
+ reason: 'object_property_access_failed',
795
+ matches: [{ id: 'object-property-access-failed', label: 'dos', confidence: 1 }]
796
+ });
797
+ }
798
+
799
+ const entryAttack = await scanEntry(key, val, childPath);
800
+ if (entryAttack) {
801
+ if (!dryRun) return entryAttack;
802
+ attackFound = attackFound || entryAttack;
803
+ }
804
+ }
805
+ return attackFound;
806
+ };
807
+
808
+ let schemaResult;
809
+ try {
810
+ schemaResult = validateSchema(req, resolveSchema(req, options));
811
+ } catch (_) {
812
+ schemaResult = {
813
+ payload: '[Schema Source Read Failed]',
814
+ detection: {
815
+ label: 'dos',
816
+ confidence: 1,
817
+ path: 'schema',
818
+ reason: 'schema_source_read_failed',
819
+ matches: [{ id: 'schema-source-read-failed', label: 'dos', confidence: 1 }]
820
+ }
821
+ };
822
+ }
823
+
824
+ if (schemaResult) {
825
+ const attack = reportDetection(schemaResult.payload, schemaResult.detection);
826
+ if (!dryRun) return res.status(blockStatus).json({
827
+ error: 'Forbidden',
828
+ message: 'Malicious payload detected by fortifyjs',
829
+ details: { label: attack.label }
830
+ });
831
+ }
832
+
833
+ const sources = [];
834
+ const sourceReadFailures = [];
835
+ const addSource = (enabled, sourceName, readSource, options = {}) => {
836
+ if (!enabled) return;
837
+ try {
838
+ const source = readSource();
839
+ if (options.skipUndefined && source === undefined) return;
840
+ sources.push([sourceName, source]);
841
+ } catch (_) {
842
+ sourceReadFailures.push(sourceName);
843
+ }
844
+ };
845
+
846
+ addSource(scanQuery, 'query', () => req.query);
847
+ addSource(scanBody, 'body', () => req.body);
848
+ addSource(scanRawBody, 'rawBody', () => req.rawBody, { skipUndefined: true });
849
+ addSource(scanHeaders, 'headers', () => req.headers);
850
+ addSource(scanParams, 'params', () => req.params);
851
+ addSource(scanCookies, 'cookies', () => req.cookies);
852
+
853
+ for (const sourceName of sourceReadFailures) {
854
+ const attack = reportDetection(`[${sourceName} Source Read Failed]`, {
855
+ label: 'dos',
856
+ confidence: 1,
857
+ path: sourceName,
858
+ reason: 'source_read_failed',
859
+ matches: [{ id: 'source-read-failed', label: 'dos', confidence: 1 }]
860
+ });
861
+ if (!dryRun) return res.status(blockStatus).json({
862
+ error: 'Forbidden',
863
+ message: 'Malicious payload detected by fortifyjs',
864
+ details: { label: attack.label }
865
+ });
866
+ }
867
+
868
+ for (const [sourceName, source] of sources) {
869
+ if (scannedSources.has(sourceName)) continue;
870
+ if (!source) continue;
871
+
872
+ let attack = false;
873
+ if (Buffer.isBuffer(source)) {
874
+ attack = await scanString(source.toString('utf8'), sourceName);
875
+ } else if (typeof source === 'string') {
876
+ attack = await scanString(source, sourceName);
877
+ } else if (typeof source === 'object') {
878
+ attack = await deepScan(source, sourceName);
879
+ }
880
+
881
+ if (attack) {
882
+ if (!dryRun) return res.status(blockStatus).json({
883
+ error: 'Forbidden',
884
+ message: 'Malicious payload detected by fortifyjs',
885
+ details: { label: attack.label }
886
+ });
887
+ }
888
+ scannedSources.add(sourceName);
889
+ }
890
+ next();
891
+ } catch (error) {
892
+ next(error);
893
+ }
894
+ };
895
+
896
+ middleware.logStore = logStore;
897
+ middleware.logsHandler = (handlerOptions = {}) => createLogsHandler(logStore, handlerOptions);
898
+ return middleware;
899
+ }
900
+ function mergeOptions(base, override) {
901
+ return { ...base, ...(override || {}) };
902
+ }
903
+
904
+ class fortifyjsQueryError extends Error {
905
+ constructor(result) {
906
+ super('Unsafe SQL query detected by fortifyjs');
907
+ this.name = 'fortifyjsQueryError';
908
+ this.result = result;
909
+ }
910
+ }
911
+ function samplePayload(sample) {
912
+ if (typeof sample === 'string') return sample;
913
+ if (!sample || typeof sample !== 'object') return '';
914
+ return sample.payload ?? sample.text ?? sample.value ?? '';
915
+ }
916
+
917
+ function expectedMaliciousLabel(label) {
918
+ if (label === undefined || label === null) return null;
919
+ const normalized = String(label).toLowerCase();
920
+ if (['benign', 'safe', 'normal', 'clean'].includes(normalized)) return false;
921
+ if (['sqli', 'xss', 'nosql', 'malicious', 'attack', 'blocked'].includes(normalized)) return true;
922
+ return null;
923
+ }
924
+ function fortifyjs(options = {}) {
925
+ const logStore = options.logStore || createMemoryLogStore(options.maxLogs);
926
+ const baseOptions = {
927
+ ...options,
928
+ logStore,
929
+ _internalLogStore: !options.logStore,
930
+ detector: options.detector || new DetectionEngine({
931
+ maxPayloadLength: options.maxPayloadLength,
932
+ maxDecodeIterations: options.maxDecodeIterations
933
+ })
934
+ };
935
+
936
+ return {
937
+ global(overrides = {}) {
938
+ return expressMiddleware(mergeOptions(baseOptions, overrides));
939
+ },
940
+ route(overrides = {}) {
941
+ return expressMiddleware(mergeOptions({
942
+ ...baseOptions,
943
+ scanParams: true
944
+ }, overrides));
945
+ },
946
+ verify(overrides = {}) {
947
+ return this.route(overrides);
948
+ },
949
+ middleware(overrides = {}) {
950
+ return expressMiddleware(mergeOptions(baseOptions, overrides));
951
+ },
952
+ nestjs(overrides = {}) {
953
+ return nestjsMiddleware(mergeOptions(baseOptions, overrides));
954
+ },
955
+ logs() {
956
+ return logStore.list();
957
+ },
958
+ clearLogs() {
959
+ logStore.clear();
960
+ },
961
+ logsHandler(handlerOptions = {}) {
962
+ return createLogsHandler(logStore, handlerOptions);
963
+ },
964
+ mountLogs(app, path = baseOptions.logsPath || '/admin/fortifyjs/logs', handlerOptions = {}) {
965
+ if (!app || typeof app.get !== 'function') {
966
+ throw new TypeError('mountLogs(app) requires an Express-compatible app with app.get().');
967
+ }
968
+ app.get(path, createLogsHandler(logStore, handlerOptions));
969
+ return app;
970
+ },
971
+ logStore,
972
+ detector: baseOptions.detector
973
+ };
974
+ }
975
+ function isPlainOptions(value) {
976
+ return value && typeof value === 'object' && !Array.isArray(value) && typeof value !== 'function';
977
+ }
978
+
979
+ function secureRouter(options = {}) {
980
+ let express;
981
+ try {
982
+ express = require('express');
983
+ } catch (e) {
984
+ throw new Error('secureRouter() requires express to be installed in the host application.');
985
+ }
986
+
987
+ const router = express.Router(options.routerOptions || {});
988
+ const guard = fortifyjs(options);
989
+ router.use(guard.global({ ...(options.globalOptions || {}), scanParams: false }));
990
+ if (options.exposeLogs) {
991
+ router.get(options.logsPath || '/admin/fortifyjs/logs', guard.logsHandler());
992
+ }
993
+ const routeGuard = (routeOptions = {}) => guard.route({
994
+ ...routeOptions,
995
+ schema: routeOptions.schema,
996
+ scanQuery: false,
997
+ scanBody: false,
998
+ scanHeaders: false,
999
+ scanCookies: false,
1000
+ scanRawBody: false,
1001
+ scanParams: routeOptions.scanParams !== false
1002
+ });
1003
+ const consumeRouteOptions = (handlers) => {
1004
+ let routeOptions = options.routeOptions || {};
1005
+ if (handlers.length > 0 && isPlainOptions(handlers[0])) {
1006
+ routeOptions = mergeOptions(routeOptions, handlers.shift());
1007
+ }
1008
+ return routeOptions;
1009
+ };
1010
+
1011
+ for (const method of HTTP_METHODS) {
1012
+ const original = router[method].bind(router);
1013
+ router[method] = (path, ...handlers) => {
1014
+ const routeOptions = consumeRouteOptions(handlers);
1015
+
1016
+ return original(
1017
+ path,
1018
+ routeGuard(routeOptions),
1019
+ ...handlers
1020
+ );
1021
+ };
1022
+ }
1023
+
1024
+ router.fortifyjs = guard;
1025
+ return router;
1026
+ }
1027
+ module.exports = { expressMiddleware, fortifyjs, secureRouter, expectedMaliciousLabel, samplePayload };