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.
- package/LICENSE +22 -0
- package/README.md +549 -2
- package/package.json +64 -4
- package/src/config/defaults.js +98 -0
- package/src/config/validateConfig.js +148 -0
- package/src/index.js +132 -0
- package/src/middleware/apiKeyAuth.js +210 -0
- package/src/middleware/ipFilter.js +77 -0
- package/src/middleware/rateLimiter.js +129 -0
- package/src/middleware/requestSize.js +157 -0
- package/src/middleware/requestValidator.js +168 -0
- package/src/middleware/securityHeaders.js +59 -0
- package/src/middleware/threatDetector.js +132 -0
- package/src/security/apiKey.js +94 -0
- package/src/security/detector.js +161 -0
- package/src/security/ipAddress.js +224 -0
- package/src/stores/memoryStore.js +135 -0
- package/src/stores/redisStore.js +222 -0
- package/src/types/index.d.ts +313 -0
- package/src/utils/errors.js +88 -0
- package/src/utils/logger.js +229 -0
- package/src/utils/response.js +58 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* List of sensitive key substrings or names that must always be redacted.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_SENSITIVE_KEYS = [
|
|
9
|
+
'password',
|
|
10
|
+
'pass',
|
|
11
|
+
'secret',
|
|
12
|
+
'token',
|
|
13
|
+
'api_key',
|
|
14
|
+
'apikey',
|
|
15
|
+
'authorization',
|
|
16
|
+
'bearer',
|
|
17
|
+
'auth',
|
|
18
|
+
'cookie',
|
|
19
|
+
'set-cookie',
|
|
20
|
+
'access_token',
|
|
21
|
+
'refresh_token',
|
|
22
|
+
'private_key',
|
|
23
|
+
'session_id',
|
|
24
|
+
'credit_card',
|
|
25
|
+
'card_number',
|
|
26
|
+
'cvv',
|
|
27
|
+
'ssn'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Redact sensitive fields from any data structure.
|
|
32
|
+
* Handles objects, arrays, circular references, and string representations.
|
|
33
|
+
*
|
|
34
|
+
* @param {any} value - Value to redact.
|
|
35
|
+
* @param {string[]} [customSensitiveKeys] - Additional keys to redact.
|
|
36
|
+
* @param {Set<any>} [seen] - Visited objects to prevent cyclic recursion.
|
|
37
|
+
* @returns {any} Redacted data.
|
|
38
|
+
*/
|
|
39
|
+
function redact(value, customSensitiveKeys = [], seen = new Set()) {
|
|
40
|
+
if (value === null || value === undefined) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof value !== 'object') {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (seen.has(value)) {
|
|
49
|
+
return '[Circular]';
|
|
50
|
+
}
|
|
51
|
+
seen.add(value);
|
|
52
|
+
|
|
53
|
+
const sensitiveKeys = [...DEFAULT_SENSITIVE_KEYS, ...customSensitiveKeys].map((k) => k.toLowerCase());
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.map((item) => redact(item, customSensitiveKeys, seen));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = {};
|
|
60
|
+
for (const [key, val] of Object.entries(value)) {
|
|
61
|
+
const lowerKey = key.toLowerCase();
|
|
62
|
+
const isSensitive = sensitiveKeys.some((s) => lowerKey.includes(s));
|
|
63
|
+
|
|
64
|
+
if (typeof val === 'object' && val !== null) {
|
|
65
|
+
// Recurse into nested objects and arrays so child properties are inspected and redacted
|
|
66
|
+
result[key] = redact(val, customSensitiveKeys, seen);
|
|
67
|
+
} else if (isSensitive) {
|
|
68
|
+
result[key] = '[REDACTED]';
|
|
69
|
+
} else {
|
|
70
|
+
result[key] = val;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Retrieve or generate a unique request identifier.
|
|
79
|
+
*
|
|
80
|
+
* @param {import('express').Request} req - Express request object.
|
|
81
|
+
* @returns {string} Request ID.
|
|
82
|
+
*/
|
|
83
|
+
function getRequestId(req) {
|
|
84
|
+
if (!req) {
|
|
85
|
+
return crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (req.id && typeof req.id === 'string') {
|
|
89
|
+
return req.id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const headerId = req.headers && (req.headers['x-request-id'] || req.headers['x-correlation-id']);
|
|
93
|
+
if (headerId && typeof headerId === 'string') {
|
|
94
|
+
return headerId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const generated = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
|
|
98
|
+
req.id = generated;
|
|
99
|
+
return generated;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Security Logger wrapper.
|
|
104
|
+
*/
|
|
105
|
+
class SecurityLogger {
|
|
106
|
+
/**
|
|
107
|
+
* @param {Object} [options]
|
|
108
|
+
* @param {boolean} [options.enabled=true]
|
|
109
|
+
* @param {'info'|'warn'|'error'|'debug'|'silent'} [options.level='warn']
|
|
110
|
+
* @param {Function} [options.customLogger] - Custom logger callback (event => void)
|
|
111
|
+
* @param {string[]} [options.redactFields=[]] - Custom fields to redact
|
|
112
|
+
*/
|
|
113
|
+
constructor(options = {}) {
|
|
114
|
+
this.enabled = options.enabled !== false;
|
|
115
|
+
this.level = options.level || 'warn';
|
|
116
|
+
this.customLogger = typeof options.customLogger === 'function' ? options.customLogger : null;
|
|
117
|
+
this.redactFields = Array.isArray(options.redactFields) ? options.redactFields : [];
|
|
118
|
+
|
|
119
|
+
this.levelWeights = {
|
|
120
|
+
debug: 10,
|
|
121
|
+
info: 20,
|
|
122
|
+
warn: 30,
|
|
123
|
+
error: 40,
|
|
124
|
+
silent: 99
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if a log level is enabled.
|
|
130
|
+
* @param {'debug'|'info'|'warn'|'error'} level
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
shouldLog(level) {
|
|
134
|
+
if (!this.enabled || this.level === 'silent') {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const currentWeight = this.levelWeights[this.level] || 30;
|
|
138
|
+
const targetWeight = this.levelWeights[level] || 30;
|
|
139
|
+
return targetWeight >= currentWeight;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Log a security event.
|
|
144
|
+
*
|
|
145
|
+
* @param {'debug'|'info'|'warn'|'error'} level - Log severity level.
|
|
146
|
+
* @param {string} eventType - Type of security event (e.g. 'RATE_LIMIT_EXCEEDED', 'API_KEY_AUTH_FAILED').
|
|
147
|
+
* @param {string} message - Descriptive event message.
|
|
148
|
+
* @param {Object} [context] - Context data (req, ip, details).
|
|
149
|
+
*/
|
|
150
|
+
log(level, eventType, message, context = {}) {
|
|
151
|
+
if (!this.shouldLog(level)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const req = context.req;
|
|
156
|
+
const reqId = getRequestId(req);
|
|
157
|
+
|
|
158
|
+
const event = {
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
level: level.toUpperCase(),
|
|
161
|
+
eventType,
|
|
162
|
+
message,
|
|
163
|
+
requestId: reqId,
|
|
164
|
+
ip: context.ip || (req && (req.ip || req.socket?.remoteAddress)) || 'unknown',
|
|
165
|
+
method: req ? req.method : undefined,
|
|
166
|
+
path: req ? (req.originalUrl || req.url) : undefined,
|
|
167
|
+
details: redact(context.details || {}, this.redactFields)
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Remove undefined fields
|
|
171
|
+
Object.keys(event).forEach((k) => event[k] === undefined && delete event[k]);
|
|
172
|
+
|
|
173
|
+
if (this.customLogger) {
|
|
174
|
+
try {
|
|
175
|
+
this.customLogger(event);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Fallback to console.error if custom logger throws
|
|
178
|
+
console.error('[secure-api-guard] Custom logger error:', err.message);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const formatted = `[secure-api-guard] [${event.timestamp}] [${event.level}] [${event.eventType}] ${event.message} (reqId=${event.requestId}, ip=${event.ip})`;
|
|
184
|
+
|
|
185
|
+
switch (level) {
|
|
186
|
+
case 'error':
|
|
187
|
+
console.error(formatted, event.details);
|
|
188
|
+
break;
|
|
189
|
+
case 'warn':
|
|
190
|
+
console.warn(formatted, event.details);
|
|
191
|
+
break;
|
|
192
|
+
case 'info':
|
|
193
|
+
console.info(formatted, event.details);
|
|
194
|
+
break;
|
|
195
|
+
case 'debug':
|
|
196
|
+
console.debug(formatted, event.details);
|
|
197
|
+
break;
|
|
198
|
+
default:
|
|
199
|
+
console.log(formatted, event.details);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
debug(eventType, message, context) {
|
|
204
|
+
this.log('debug', eventType, message, context);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
info(eventType, message, context) {
|
|
208
|
+
this.log('info', eventType, message, context);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
warn(eventType, message, context) {
|
|
212
|
+
this.log('warn', eventType, message, context);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
error(eventType, message, context) {
|
|
216
|
+
this.log('error', eventType, message, context);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Default singleton logger instance with silent/warn behavior
|
|
221
|
+
const defaultLogger = new SecurityLogger({ level: 'warn' });
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
SecurityLogger,
|
|
225
|
+
defaultLogger,
|
|
226
|
+
redact,
|
|
227
|
+
getRequestId,
|
|
228
|
+
DEFAULT_SENSITIVE_KEYS
|
|
229
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SecurityError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Send a standardized security rejection response.
|
|
7
|
+
*
|
|
8
|
+
* @param {import('express').Request} req - Express request object.
|
|
9
|
+
* @param {import('express').Response} res - Express response object.
|
|
10
|
+
* @param {SecurityError|Error|string} error - The error or message to respond with.
|
|
11
|
+
* @param {number} [statusCode] - Optional status code override.
|
|
12
|
+
* @param {Function} [customHandler] - Optional custom rejection handler (req, res, error).
|
|
13
|
+
*/
|
|
14
|
+
function sendSecurityResponse(req, res, error, statusCode, customHandler) {
|
|
15
|
+
if (res.headersSent) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof customHandler === 'function') {
|
|
20
|
+
return customHandler(req, res, error);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let status = statusCode;
|
|
24
|
+
let code = 'SECURITY_ERROR';
|
|
25
|
+
let message = 'Access denied by security policy.';
|
|
26
|
+
let details = null;
|
|
27
|
+
|
|
28
|
+
if (error instanceof SecurityError) {
|
|
29
|
+
status = status || error.statusCode;
|
|
30
|
+
code = error.code;
|
|
31
|
+
message = error.message;
|
|
32
|
+
details = error.details;
|
|
33
|
+
} else if (error instanceof Error) {
|
|
34
|
+
status = status || 500;
|
|
35
|
+
message = error.message;
|
|
36
|
+
} else if (typeof error === 'string') {
|
|
37
|
+
status = status || 400;
|
|
38
|
+
message = error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
status = status || 403;
|
|
42
|
+
|
|
43
|
+
const payload = {
|
|
44
|
+
success: false,
|
|
45
|
+
error: {
|
|
46
|
+
code,
|
|
47
|
+
message,
|
|
48
|
+
...(details ? { details } : {})
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
res.status(status).json(payload);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
sendSecurityResponse
|
|
57
|
+
};
|
|
58
|
+
|