secure-api-guard 0.0.0-stage → 1.0.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.
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const rateLimit = require('express-rate-limit');
4
+ const defaults = require('../config/defaults');
5
+ const { validateRateLimiterConfig } = require('../config/validateConfig');
6
+ const { MemoryStore } = require('../stores/memoryStore');
7
+ const { RedisStore } = require('../stores/redisStore');
8
+ const { RateLimitError } = require('../utils/errors');
9
+ const { sendSecurityResponse } = require('../utils/response');
10
+ const { defaultLogger } = require('../utils/logger');
11
+
12
+ /**
13
+ * Normalizes an IP address to avoid IPv4-mapped IPv6 duplicates.
14
+ *
15
+ * @param {string} ip
16
+ * @returns {string}
17
+ */
18
+ function normalizeIp(ip) {
19
+ if (!ip) return '127.0.0.1';
20
+ if (ip.startsWith('::ffff:')) {
21
+ return ip.substring(7);
22
+ }
23
+ return ip;
24
+ }
25
+
26
+ /**
27
+ * Default key generator based on client IP.
28
+ *
29
+ * @param {import('express').Request} req
30
+ * @returns {string}
31
+ */
32
+ function defaultKeyGenerator(req) {
33
+ const rawIp = req.ip || (req.socket && req.socket.remoteAddress) || '127.0.0.1';
34
+ return normalizeIp(rawIp);
35
+ }
36
+
37
+ /**
38
+ * Creates a rate limiter middleware for Express.
39
+ *
40
+ * @param {Object} [userOptions] - Rate limiter configuration options.
41
+ * @param {number} [userOptions.windowMs=900000] - Window duration in ms (15 mins default).
42
+ * @param {number} [userOptions.maxRequests=100] - Maximum requests allowed per window.
43
+ * @param {number} [userOptions.max] - Alias for maxRequests.
44
+ * @param {number} [userOptions.statusCode=429] - HTTP status code on limit exceed.
45
+ * @param {string} [userOptions.message] - Custom message on rate limit.
46
+ * @param {Function} [userOptions.keyGenerator] - Custom key generation function (req => string).
47
+ * @param {Function} [userOptions.skip] - Function to skip rate limiting (req => boolean).
48
+ * @param {any} [userOptions.store] - Store instance ('memory', 'redis', or custom Store).
49
+ * @param {any} [userOptions.redisClient] - Redis client if store is 'redis'.
50
+ * @param {Function} [userOptions.handler] - Custom error handler.
51
+ * @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
52
+ * @returns {import('express').RequestHandler}
53
+ */
54
+ function createRateLimiter(userOptions = {}) {
55
+ validateRateLimiterConfig(userOptions);
56
+
57
+ const options = {
58
+ ...defaults.rateLimiter,
59
+ ...userOptions
60
+ };
61
+
62
+ const limit = options.maxRequests !== undefined ? options.maxRequests : (options.max !== undefined ? options.max : 100);
63
+ const logger = options.logger || defaultLogger;
64
+
65
+ // Resolve Store
66
+ let storeInstance;
67
+ if (options.store === 'redis' || (options.redisClient && !options.store)) {
68
+ if (!options.redisClient) {
69
+ throw new Error('RateLimiter: "redisClient" must be provided when store is "redis".');
70
+ }
71
+ storeInstance = new RedisStore({
72
+ client: options.redisClient,
73
+ windowMs: options.windowMs,
74
+ prefix: options.redisPrefix,
75
+ passOnConnectionError: options.passOnRedisError,
76
+ logger
77
+ });
78
+ } else if (typeof options.store === 'object' && options.store !== null) {
79
+ storeInstance = options.store;
80
+ } else {
81
+ // Default in-memory store
82
+ storeInstance = new MemoryStore({
83
+ windowMs: options.windowMs
84
+ });
85
+ }
86
+
87
+ const keyGenerator = options.keyGenerator || defaultKeyGenerator;
88
+
89
+ const limiterMiddleware = rateLimit({
90
+ windowMs: options.windowMs,
91
+ limit,
92
+ standardHeaders: options.standardHeaders,
93
+ legacyHeaders: options.legacyHeaders,
94
+ store: storeInstance,
95
+ keyGenerator: (req, res) => keyGenerator(req),
96
+ skip: options.skip || (() => false),
97
+ handler: (req, res, next, rateLimitOptions) => {
98
+ const clientKey = keyGenerator(req);
99
+ const retryAfterSeconds = Math.ceil(options.windowMs / 1000);
100
+
101
+ logger.warn('RATE_LIMIT_EXCEEDED', `Rate limit exceeded for client: ${clientKey}`, {
102
+ req,
103
+ ip: clientKey,
104
+ details: {
105
+ limit,
106
+ windowMs: options.windowMs,
107
+ retryAfter: retryAfterSeconds
108
+ }
109
+ });
110
+
111
+ const error = new RateLimitError(options.message, {
112
+ limit,
113
+ windowMs: options.windowMs,
114
+ retryAfter: retryAfterSeconds
115
+ });
116
+
117
+ sendSecurityResponse(req, res, error, options.statusCode, options.handler);
118
+ }
119
+ });
120
+
121
+ return limiterMiddleware;
122
+ }
123
+
124
+ module.exports = {
125
+ rateLimiter: createRateLimiter,
126
+ createRateLimiter,
127
+ defaultKeyGenerator
128
+ };
129
+
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const bytes = require('bytes');
4
+ const defaults = require('../config/defaults');
5
+ const { validateRequestSizeConfig } = require('../config/validateConfig');
6
+ const { PayloadTooLargeError, SecurityError } = require('../utils/errors');
7
+ const { sendSecurityResponse } = require('../utils/response');
8
+ const { defaultLogger } = require('../utils/logger');
9
+
10
+ /**
11
+ * Parses and validates Content-Length header against HTTP smuggling / desync attacks.
12
+ *
13
+ * @param {import('express').Request} req
14
+ * @returns {{ length: number | null, error: string | null }}
15
+ */
16
+ function parseContentLength(req) {
17
+ const rawHeader = req.headers && req.headers['content-length'];
18
+
19
+ if (rawHeader === undefined || rawHeader === null || rawHeader === '') {
20
+ return { length: null, error: null };
21
+ }
22
+
23
+ // Handle array of headers or comma-separated multiple values
24
+ const values = Array.isArray(rawHeader)
25
+ ? rawHeader
26
+ : String(rawHeader).split(',').map((v) => v.trim());
27
+
28
+ // Check for differing values in multiple headers (HTTP request smuggling risk)
29
+ const uniqueValues = new Set(values);
30
+ if (uniqueValues.size > 1) {
31
+ return {
32
+ length: null,
33
+ error: 'Conflicting Content-Length headers detected (possible HTTP smuggling).'
34
+ };
35
+ }
36
+
37
+ const lengthStr = values[0];
38
+ if (!/^\d+$/.test(lengthStr)) {
39
+ return {
40
+ length: null,
41
+ error: 'Malformed Content-Length header: must be a non-negative integer.'
42
+ };
43
+ }
44
+
45
+ const parsed = parseInt(lengthStr, 10);
46
+ return { length: parsed, error: null };
47
+ }
48
+
49
+ /**
50
+ * Creates request size limiting middleware.
51
+ *
52
+ * @param {Object} [userOptions]
53
+ * @param {string|number} [userOptions.maxSize='1mb'] - Max size in bytes or string ('1mb', '500kb').
54
+ * @param {number} [userOptions.statusCode=413] - HTTP status code on limit exceed.
55
+ * @param {string} [userOptions.message] - Custom error message.
56
+ * @param {Function} [userOptions.handler] - Custom response handler.
57
+ * @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
58
+ * @returns {import('express').RequestHandler}
59
+ */
60
+ function createRequestSize(userOptions = {}) {
61
+ validateRequestSizeConfig(userOptions);
62
+
63
+ const options = {
64
+ ...defaults.requestSize,
65
+ ...userOptions
66
+ };
67
+
68
+ const maxBytes = typeof options.maxSize === 'number'
69
+ ? options.maxSize
70
+ : bytes.parse(options.maxSize) || 1048576; // default 1MB
71
+
72
+ const logger = options.logger || defaultLogger;
73
+
74
+ return function requestSizeMiddleware(req, res, next) {
75
+ // 1. Inspect Content-Length header
76
+ const { length: contentLength, error: headerError } = parseContentLength(req);
77
+
78
+ if (headerError) {
79
+ logger.warn('REQUEST_SIZE_HEADER_INVALID', headerError, { req });
80
+ const secError = new SecurityError(headerError, 400, 'BAD_REQUEST');
81
+ return sendSecurityResponse(req, res, secError, 400, options.handler);
82
+ }
83
+
84
+ if (contentLength !== null && contentLength > maxBytes) {
85
+ logger.warn('REQUEST_SIZE_EXCEEDED', `Content-Length (${contentLength}) exceeds limit (${maxBytes})`, {
86
+ req,
87
+ details: { contentLength, maxBytes }
88
+ });
89
+ res.setHeader('Connection', 'close');
90
+ const error = new PayloadTooLargeError(options.message, { contentLength, maxBytes });
91
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
92
+ }
93
+
94
+ // 2. Check if body is already parsed by upstream body parser
95
+ if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
96
+ try {
97
+ let estimatedSize = 0;
98
+ if (Buffer.isBuffer(req.body)) {
99
+ estimatedSize = req.body.length;
100
+ } else {
101
+ estimatedSize = Buffer.byteLength(JSON.stringify(req.body), 'utf8');
102
+ }
103
+
104
+ if (estimatedSize > maxBytes) {
105
+ logger.warn('REQUEST_SIZE_EXCEEDED', `Parsed body size (${estimatedSize}) exceeds limit (${maxBytes})`, {
106
+ req,
107
+ details: { estimatedSize, maxBytes }
108
+ });
109
+ const error = new PayloadTooLargeError(options.message, { estimatedSize, maxBytes });
110
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
111
+ }
112
+ } catch {
113
+ // Ignore serialization failure; proceed to streaming check
114
+ }
115
+ }
116
+
117
+ // 3. For unparsed streams or chunked transfer-encoding, monitor incoming chunks safely
118
+ let receivedBytes = 0;
119
+ let rejected = false;
120
+
121
+ const originalEmit = req.emit;
122
+ if (typeof originalEmit === 'function') {
123
+ req.emit = function (eventName, ...args) {
124
+ if (eventName === 'data' && !rejected) {
125
+ const chunk = args[0];
126
+ if (chunk) {
127
+ receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
128
+ if (receivedBytes > maxBytes) {
129
+ rejected = true;
130
+ if (typeof req.pause === 'function') {
131
+ req.pause();
132
+ }
133
+ logger.warn('REQUEST_SIZE_EXCEEDED', `Streamed body (${receivedBytes}) exceeded limit (${maxBytes})`, {
134
+ req,
135
+ details: { receivedBytes, maxBytes }
136
+ });
137
+
138
+ res.setHeader('Connection', 'close');
139
+ const error = new PayloadTooLargeError(options.message, { receivedBytes, maxBytes });
140
+ sendSecurityResponse(req, res, error, options.statusCode, options.handler);
141
+ return false;
142
+ }
143
+ }
144
+ }
145
+ return originalEmit.apply(this, [eventName, ...args]);
146
+ };
147
+ }
148
+
149
+ return next();
150
+ };
151
+ }
152
+
153
+ module.exports = {
154
+ requestSize: createRequestSize,
155
+ createRequestSize,
156
+ parseContentLength
157
+ };
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const defaults = require('../config/defaults');
4
+ const { ValidationError } = require('../utils/errors');
5
+ const { sendSecurityResponse } = require('../utils/response');
6
+ const { defaultLogger } = require('../utils/logger');
7
+
8
+ /**
9
+ * Formats validation issues into a clean, client-safe array of field-level errors.
10
+ *
11
+ * @param {Array<any>} rawIssues
12
+ * @param {'body'|'query'|'params'|'headers'} target
13
+ * @returns {Array<{ field: string, message: string, code?: string }>}
14
+ */
15
+ function formatIssues(rawIssues = [], target) {
16
+ return rawIssues.map((issue) => {
17
+ let field = target;
18
+ if (issue.path && issue.path.length > 0) {
19
+ field = `${target}.${issue.path.join('.')}`;
20
+ } else if (issue.field) {
21
+ field = `${target}.${issue.field}`;
22
+ }
23
+
24
+ return {
25
+ field,
26
+ message: issue.message || 'Invalid value',
27
+ code: issue.code || 'INVALID_INPUT'
28
+ };
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Validates a target object against a schema or validator function.
34
+ * Supports Zod schemas, Joi-like schemas, or custom functions.
35
+ *
36
+ * @param {any} schema - Validation schema or function.
37
+ * @param {any} data - Data to validate.
38
+ * @param {'body'|'query'|'params'|'headers'} target - Target name for error messages.
39
+ * @returns {Promise<{ success: boolean, data?: any, issues?: Array<any> }>}
40
+ */
41
+ async function validateTarget(schema, data, target) {
42
+ if (!schema) {
43
+ return { success: true, data };
44
+ }
45
+
46
+ // 1. Custom function: (data) => boolean | { success, errors, data }
47
+ if (typeof schema === 'function' && !schema.safeParse && !schema.parse) {
48
+ try {
49
+ const res = await schema(data);
50
+ if (res === true || (res && res.success === true)) {
51
+ return { success: true, data: res.data !== undefined ? res.data : data };
52
+ }
53
+ const issues = res && res.errors ? res.errors : [{ message: 'Validation rule failed' }];
54
+ return { success: false, issues: formatIssues(issues, target) };
55
+ } catch (err) {
56
+ return { success: false, issues: [{ field: target, message: err.message, code: 'VALIDATION_ERROR' }] };
57
+ }
58
+ }
59
+
60
+ // 2. Zod schema: safeParseAsync or safeParse
61
+ if (typeof schema.safeParseAsync === 'function') {
62
+ const result = await schema.safeParseAsync(data);
63
+ if (result.success) {
64
+ return { success: true, data: result.data };
65
+ }
66
+ return { success: false, issues: formatIssues(result.error.issues, target) };
67
+ }
68
+
69
+ if (typeof schema.safeParse === 'function') {
70
+ const result = schema.safeParse(data);
71
+ if (result.success) {
72
+ return { success: true, data: result.data };
73
+ }
74
+ return { success: false, issues: formatIssues(result.error.issues, target) };
75
+ }
76
+
77
+ // 3. Fallback for schema with parse() method
78
+ if (typeof schema.parse === 'function') {
79
+ try {
80
+ const parsedData = schema.parse(data);
81
+ return { success: true, data: parsedData };
82
+ } catch (err) {
83
+ const issues = err.issues || [{ message: err.message }];
84
+ return { success: false, issues: formatIssues(issues, target) };
85
+ }
86
+ }
87
+
88
+ throw new Error(`Unsupported schema format for ${target}. Must be a Zod schema or validator function.`);
89
+ }
90
+
91
+ /**
92
+ * Creates request validation middleware.
93
+ *
94
+ * @param {Object} schemas - Validation schemas.
95
+ * @param {any} [schemas.body] - Schema for req.body.
96
+ * @param {any} [schemas.query] - Schema for req.query.
97
+ * @param {any} [schemas.params] - Schema for req.params.
98
+ * @param {any} [schemas.headers] - Schema for req.headers.
99
+ * @param {Object} [userOptions]
100
+ * @param {boolean} [userOptions.assignToReq=false] - Whether to mutate req.body/req.query with parsed data (default false: store in req.validated).
101
+ * @param {number} [userOptions.statusCode=400] - Status code on failure.
102
+ * @param {string} [userOptions.message] - Custom error message.
103
+ * @param {Function} [userOptions.handler] - Custom error handler.
104
+ * @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
105
+ * @returns {import('express').RequestHandler}
106
+ */
107
+ function createRequestValidator(schemas = {}, userOptions = {}) {
108
+ // If user passed a single Zod schema directly as first arg
109
+ let targetSchemas = schemas;
110
+ if (schemas && (typeof schemas.safeParse === 'function' || typeof schemas.safeParseAsync === 'function')) {
111
+ targetSchemas = { body: schemas };
112
+ }
113
+
114
+ const options = {
115
+ ...defaults.requestValidator,
116
+ ...userOptions
117
+ };
118
+
119
+ const logger = options.logger || defaultLogger;
120
+
121
+ return async function requestValidatorMiddleware(req, res, next) {
122
+ const targets = ['body', 'query', 'params', 'headers'];
123
+ const allIssues = [];
124
+ const validatedData = {};
125
+
126
+ for (const target of targets) {
127
+ const schema = targetSchemas[target];
128
+ if (schema) {
129
+ const dataToValidate = req[target] || {};
130
+ const result = await validateTarget(schema, dataToValidate, target);
131
+
132
+ if (!result.success) {
133
+ allIssues.push(...result.issues);
134
+ } else {
135
+ validatedData[target] = result.data;
136
+ if (options.assignToReq) {
137
+ req[target] = result.data;
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ if (allIssues.length > 0) {
144
+ logger.warn('REQUEST_VALIDATION_FAILED', `Validation failed for ${req.method} ${req.path}`, {
145
+ req,
146
+ details: { issues: allIssues }
147
+ });
148
+
149
+ const error = new ValidationError(options.message, { issues: allIssues });
150
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
151
+ }
152
+
153
+ // Attach validated data securely to req.validated
154
+ req.validated = {
155
+ ...req.validated,
156
+ ...validatedData
157
+ };
158
+
159
+ return next();
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ requestValidator: createRequestValidator,
165
+ createRequestValidator,
166
+ formatIssues
167
+ };
168
+
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const helmet = require('helmet');
4
+ const defaults = require('../config/defaults');
5
+
6
+ /**
7
+ * Creates security headers middleware powered by Helmet with hardened API defaults.
8
+ *
9
+ * @param {Object} [userOptions]
10
+ * @param {boolean|Object} [userOptions.contentSecurityPolicy]
11
+ * @param {boolean|Object} [userOptions.hsts]
12
+ * @param {boolean|Object} [userOptions.frameguard]
13
+ * @param {boolean} [userOptions.noSniff]
14
+ * @param {boolean|Object} [userOptions.referrerPolicy]
15
+ * @param {boolean} [userOptions.noIndex=false] - Adds X-Robots-Tag: noindex, nofollow
16
+ * @param {boolean|string} [userOptions.permissionsPolicy=true] - Adds Permissions-Policy header
17
+ * @returns {import('express').RequestHandler}
18
+ */
19
+ function createSecurityHeaders(userOptions = {}) {
20
+ const helmetOptions = {
21
+ ...defaults.securityHeaders,
22
+ ...userOptions
23
+ };
24
+
25
+ // Extract non-helmet custom options
26
+ const noIndex = userOptions.noIndex === true;
27
+ const permissionsPolicy = userOptions.permissionsPolicy !== undefined
28
+ ? userOptions.permissionsPolicy
29
+ : 'geolocation=(), camera=(), microphone=(), payment=()';
30
+
31
+ delete helmetOptions.noIndex;
32
+ delete helmetOptions.permissionsPolicy;
33
+
34
+ const helmetMiddleware = helmet(helmetOptions);
35
+
36
+ return function securityHeadersMiddleware(req, res, next) {
37
+ // 1. Apply Helmet standard headers
38
+ helmetMiddleware(req, res, (err) => {
39
+ if (err) return next(err);
40
+
41
+ // 2. Extra API security headers
42
+ if (noIndex) {
43
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
44
+ }
45
+
46
+ if (permissionsPolicy && typeof permissionsPolicy === 'string') {
47
+ res.setHeader('Permissions-Policy', permissionsPolicy);
48
+ }
49
+
50
+ return next();
51
+ });
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ securityHeaders: createSecurityHeaders,
57
+ createSecurityHeaders
58
+ };
59
+
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const defaults = require('../config/defaults');
4
+ const { validateThreatDetectorConfig } = require('../config/validateConfig');
5
+ const { DEFAULT_THREAT_PATTERNS, scanValue } = require('../security/detector');
6
+ const { ThreatDetectedError } = require('../utils/errors');
7
+ const { sendSecurityResponse } = require('../utils/response');
8
+ const { defaultLogger } = require('../utils/logger');
9
+
10
+ /**
11
+ * Creates threat detection middleware.
12
+ *
13
+ * @param {Object} [userOptions]
14
+ * @param {'reject'|'log'|'pass'} [userOptions.action='reject'] - Action on threat detection.
15
+ * @param {number} [userOptions.statusCode=403] - HTTP status code when rejected.
16
+ * @param {string} [userOptions.message] - Custom rejection message.
17
+ * @param {boolean} [userOptions.inspectUrl=true] - Inspect URL path.
18
+ * @param {boolean} [userOptions.inspectQuery=true] - Inspect query parameters.
19
+ * @param {boolean} [userOptions.inspectBody=true] - Inspect request body.
20
+ * @param {boolean} [userOptions.inspectHeaders=false] - Inspect headers (default false to prevent false positives).
21
+ * @param {Array<string|RegExp>} [userOptions.whitelistedPaths=[]] - Paths to bypass threat detection.
22
+ * @param {string[]} [userOptions.whitelistedKeys=[]] - Field names to bypass inspection.
23
+ * @param {Array<Object>} [userOptions.customRules=[]] - Custom threat rules.
24
+ * @param {Function} [userOptions.handler] - Custom response handler.
25
+ * @param {import('../utils/logger').SecurityLogger} [userOptions.logger] - Logger instance.
26
+ * @returns {import('express').RequestHandler}
27
+ */
28
+ function createThreatDetector(userOptions = {}) {
29
+ validateThreatDetectorConfig(userOptions);
30
+
31
+ const options = {
32
+ ...defaults.threatDetector,
33
+ ...userOptions
34
+ };
35
+
36
+ const logger = options.logger || defaultLogger;
37
+ const allRules = [...DEFAULT_THREAT_PATTERNS, ...(options.customRules || [])];
38
+ const whitelistedKeys = (options.whitelistedKeys || []).map((k) => k.toLowerCase());
39
+
40
+ return function threatDetectorMiddleware(req, res, next) {
41
+ if (options.action === 'pass') {
42
+ return next();
43
+ }
44
+
45
+ const currentPath = req.originalUrl || req.path || req.url || '';
46
+
47
+ // Check whitelisted paths
48
+ if (options.whitelistedPaths && options.whitelistedPaths.length > 0) {
49
+ for (const whitePath of options.whitelistedPaths) {
50
+ if (typeof whitePath === 'string' && (currentPath === whitePath || currentPath.startsWith(whitePath))) {
51
+ return next();
52
+ }
53
+ if (whitePath instanceof RegExp && whitePath.test(currentPath)) {
54
+ return next();
55
+ }
56
+ }
57
+ }
58
+
59
+ const detectedThreats = [];
60
+
61
+ // 1. Inspect URL (path and raw query)
62
+ if (options.inspectUrl) {
63
+ let rawUrl = req.url || '';
64
+ try {
65
+ rawUrl = decodeURIComponent(rawUrl);
66
+ } catch {
67
+ // malformed URI encoding could itself be an attack vector
68
+ detectedThreats.push({
69
+ ruleId: 'MALFORMED_URI',
70
+ ruleName: 'Malformed URI Encoding',
71
+ path: 'url',
72
+ snippet: req.url,
73
+ severity: 'high'
74
+ });
75
+ }
76
+
77
+ if (rawUrl) {
78
+ detectedThreats.push(...scanValue(rawUrl, allRules, 'url', whitelistedKeys));
79
+ }
80
+ }
81
+
82
+ // 2. Inspect Query
83
+ if (options.inspectQuery && req.query && typeof req.query === 'object') {
84
+ detectedThreats.push(...scanValue(req.query, allRules, 'query', whitelistedKeys));
85
+ }
86
+
87
+ // 3. Inspect Body
88
+ if (options.inspectBody && req.body && typeof req.body === 'object') {
89
+ detectedThreats.push(...scanValue(req.body, allRules, 'body', whitelistedKeys));
90
+ }
91
+
92
+ // 4. Inspect Headers
93
+ if (options.inspectHeaders && req.headers && typeof req.headers === 'object') {
94
+ // Exclude standard headers that regularly contain symbols
95
+ const ignoredHeaders = ['user-agent', 'accept', 'cookie', 'host', 'authorization'];
96
+ const headersToScan = {};
97
+ for (const [k, v] of Object.entries(req.headers)) {
98
+ if (!ignoredHeaders.includes(k.toLowerCase())) {
99
+ headersToScan[k] = v;
100
+ }
101
+ }
102
+ detectedThreats.push(...scanValue(headersToScan, allRules, 'headers', whitelistedKeys));
103
+ }
104
+
105
+ if (detectedThreats.length > 0) {
106
+ req.detectedThreats = detectedThreats;
107
+
108
+ logger.warn('THREAT_DETECTED', `Threat patterns detected on ${req.method} ${currentPath}`, {
109
+ req,
110
+ details: {
111
+ threatCount: detectedThreats.length,
112
+ threats: detectedThreats.map((t) => ({ ruleId: t.ruleId, path: t.path, severity: t.severity }))
113
+ }
114
+ });
115
+
116
+ if (options.action === 'reject') {
117
+ const error = new ThreatDetectedError(options.message, options.statusCode, {
118
+ threats: detectedThreats.map((t) => ({ ruleId: t.ruleId, severity: t.severity }))
119
+ });
120
+ return sendSecurityResponse(req, res, error, options.statusCode, options.handler);
121
+ }
122
+ }
123
+
124
+ return next();
125
+ };
126
+ }
127
+
128
+ module.exports = {
129
+ threatDetector: createThreatDetector,
130
+ createThreatDetector
131
+ };
132
+