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,94 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * Hash an API key using the specified algorithm (default: sha256).
7
+ *
8
+ * @param {string} rawKey - Plaintext API key.
9
+ * @param {string} [algorithm='sha256'] - Hashing algorithm.
10
+ * @returns {string} Hex-encoded hash.
11
+ */
12
+ function hashApiKey(rawKey, algorithm = 'sha256') {
13
+ if (typeof rawKey !== 'string' || !rawKey) {
14
+ throw new Error('API key must be a non-empty string.');
15
+ }
16
+ return crypto.createHash(algorithm).update(rawKey, 'utf8').digest('hex');
17
+ }
18
+
19
+ /**
20
+ * Generate a cryptographically secure random API key.
21
+ *
22
+ * @param {Object} [options]
23
+ * @param {string} [options.prefix='sag_'] - Key prefix (e.g. 'sag_live_', 'sag_test_').
24
+ * @param {number} [options.bytes=32] - Number of random bytes.
25
+ * @param {string} [options.algorithm='sha256'] - Hash algorithm for returned hash.
26
+ * @returns {{ key: string, hash: string, prefix: string }}
27
+ */
28
+ function generateApiKey(options = {}) {
29
+ const prefix = options.prefix || 'sag_';
30
+ const byteLength = options.bytes && options.bytes >= 16 ? options.bytes : 32;
31
+ const algorithm = options.algorithm || 'sha256';
32
+
33
+ const randomPart = crypto.randomBytes(byteLength).toString('base64url');
34
+ const key = `${prefix}${randomPart}`;
35
+ const hash = hashApiKey(key, algorithm);
36
+
37
+ return {
38
+ key,
39
+ hash,
40
+ prefix
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Constant-time comparison between two hashes to prevent timing attacks.
46
+ *
47
+ * @param {string} a - First hash hex string.
48
+ * @param {string} b - Second hash hex string.
49
+ * @returns {boolean} True if hashes match.
50
+ */
51
+ function secureCompare(a, b) {
52
+ if (typeof a !== 'string' || typeof b !== 'string') {
53
+ return false;
54
+ }
55
+
56
+ const bufA = Buffer.from(a, 'utf8');
57
+ const bufB = Buffer.from(b, 'utf8');
58
+
59
+ if (bufA.length !== bufB.length) {
60
+ // Constant time dummy comparison to avoid leaking length differences
61
+ crypto.timingSafeEqual(bufA, bufA);
62
+ return false;
63
+ }
64
+
65
+ return crypto.timingSafeEqual(bufA, bufB);
66
+ }
67
+
68
+ /**
69
+ * Check if a key record is expired.
70
+ *
71
+ * @param {Object} keyRecord
72
+ * @param {Date|string|number} [keyRecord.expiresAt]
73
+ * @returns {boolean}
74
+ */
75
+ function isKeyExpired(keyRecord) {
76
+ if (!keyRecord || !keyRecord.expiresAt) {
77
+ return false;
78
+ }
79
+
80
+ const expiresTime = new Date(keyRecord.expiresAt).getTime();
81
+ if (isNaN(expiresTime)) {
82
+ return false;
83
+ }
84
+
85
+ return Date.now() > expiresTime;
86
+ }
87
+
88
+ module.exports = {
89
+ hashApiKey,
90
+ generateApiKey,
91
+ secureCompare,
92
+ isKeyExpired
93
+ };
94
+
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * High-confidence threat detection signatures.
5
+ *
6
+ * NOTE: Pattern matching is an auxiliary defense-in-depth heuristic, NOT a replacement
7
+ * for parameterized SQL queries, context-aware output encoding, or rigorous input validation.
8
+ */
9
+ const DEFAULT_THREAT_PATTERNS = [
10
+ {
11
+ id: 'PATH_TRAVERSAL',
12
+ name: 'Directory / Path Traversal Attack',
13
+ pattern: /(?:\.\.[/\\]|[/\\]\.\.|\.\.%2f|%2e%2e%2f|%2e%2e\/|\.\.%5c|%2e%2e%5c)/i,
14
+ severity: 'high'
15
+ },
16
+ {
17
+ id: 'NULL_BYTE_INJECTION',
18
+ name: 'Null Byte Injection',
19
+ pattern: /(?:\x00|%00|\\0)/i,
20
+ severity: 'high'
21
+ },
22
+ {
23
+ id: 'COMMAND_INJECTION',
24
+ name: 'Suspicious Shell Command Injection',
25
+ pattern: /(?:;\s*(?:cat|chmod|curl|wget|rm|bash|sh|whoami|nc|netcat|powershell)\b|\|\s*(?:bash|sh|curl|wget|powershell)\b|`\s*(?:cat|curl|wget|rm|bash|sh|whoami)\s*`|\$\(\s*(?:cat|curl|wget|rm|bash|sh|whoami)\s*\))/i,
26
+ severity: 'high'
27
+ },
28
+ {
29
+ id: 'SCRIPT_TAG_INJECTION',
30
+ name: 'Suspicious Inline Script / JavaScript Scheme',
31
+ pattern: /(?:<\s*script\b[^>]*>|javascript\s*:\s*(?:alert|document|window|location|eval|prompt)\b|data\s*:\s*text\/html)/i,
32
+ severity: 'medium'
33
+ },
34
+ {
35
+ id: 'PROTOTYPE_POLLUTION',
36
+ name: 'Prototype Pollution Attempt',
37
+ pattern: /(?:__proto__|constructor\s*\[\s*['"]?prototype['"]?\s*\]|prototype\s*\.\s*constructor)/i,
38
+ severity: 'high'
39
+ },
40
+ {
41
+ id: 'SSTI_INJECTION',
42
+ name: 'Suspicious Template Injection (SSTI)',
43
+ pattern: /(?:\{\{\s*(?:constructor|process|require|mainModule|__proto__)\s*\}\}|<%\s*=\s*(?:process|require)\s*%>)/i,
44
+ severity: 'high'
45
+ }
46
+ ];
47
+
48
+ /**
49
+ * Recursively inspects values in strings, arrays, or objects for threat signatures.
50
+ *
51
+ * @param {any} value - Value to inspect.
52
+ * @param {Array<Object>} rules - Compiled threat rules.
53
+ * @param {string} [currentPath=''] - Current object path for error reporting.
54
+ * @param {string[]} [whitelistedKeys=[]] - Keys to skip inspection.
55
+ * @param {Set<any>} [seen] - Visited objects to prevent infinite loops.
56
+ * @returns {Array<{ ruleId: string, ruleName: string, path: string, snippet: string, severity: string }>}
57
+ */
58
+ function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen = new Set()) {
59
+ const matches = [];
60
+
61
+ if (value === null || value === undefined) {
62
+ return matches;
63
+ }
64
+
65
+ if (seen.has(value)) {
66
+ return matches;
67
+ }
68
+
69
+ // Handle strings
70
+ if (typeof value === 'string') {
71
+ for (const rule of rules) {
72
+ if (typeof rule.pattern === 'function') {
73
+ const customMatch = rule.pattern(value, currentPath);
74
+ if (customMatch) {
75
+ matches.push({
76
+ ruleId: rule.id || 'CUSTOM_RULE',
77
+ ruleName: rule.name || 'Custom Threat Rule',
78
+ path: currentPath,
79
+ snippet: value.length > 80 ? `${value.substring(0, 80)}...` : value,
80
+ severity: rule.severity || 'medium'
81
+ });
82
+ }
83
+ } else if (rule.pattern instanceof RegExp) {
84
+ if (rule.pattern.test(value)) {
85
+ matches.push({
86
+ ruleId: rule.id,
87
+ ruleName: rule.name,
88
+ path: currentPath,
89
+ snippet: value.length > 80 ? `${value.substring(0, 80)}...` : value,
90
+ severity: rule.severity
91
+ });
92
+ }
93
+ }
94
+ }
95
+ return matches;
96
+ }
97
+
98
+ // Handle Arrays
99
+ if (Array.isArray(value)) {
100
+ seen.add(value);
101
+ for (let i = 0; i < value.length; i++) {
102
+ const arrayPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`;
103
+ matches.push(...scanValue(value[i], rules, arrayPath, whitelistedKeys, seen));
104
+ }
105
+ return matches;
106
+ }
107
+
108
+ // Handle Objects
109
+ if (typeof value === 'object') {
110
+ seen.add(value);
111
+
112
+ // Get all own property names including non-enumerable or __proto__
113
+ const propNames = Object.getOwnPropertyNames(value);
114
+ for (const propName of propNames) {
115
+ for (const rule of rules) {
116
+ if (rule.pattern instanceof RegExp && rule.pattern.test(propName)) {
117
+ matches.push({
118
+ ruleId: rule.id,
119
+ ruleName: rule.name,
120
+ path: currentPath ? `${currentPath}.${propName}` : propName,
121
+ snippet: `Key name: ${propName}`,
122
+ severity: rule.severity
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ // Check if prototype was directly manipulated
129
+ try {
130
+ const proto = Object.getPrototypeOf(value);
131
+ if (proto && proto !== Object.prototype && proto.constructor !== Object && typeof proto === 'object') {
132
+ matches.push({
133
+ ruleId: 'PROTOTYPE_POLLUTION',
134
+ ruleName: 'Prototype Pollution Attempt',
135
+ path: currentPath ? `${currentPath}.__proto__` : '__proto__',
136
+ snippet: 'Object has non-standard prototype',
137
+ severity: 'high'
138
+ });
139
+ }
140
+ } catch {
141
+ // Ignore prototype check failures on odd object types
142
+ }
143
+
144
+ for (const [key, val] of Object.entries(value)) {
145
+ // Check if key is whitelisted
146
+ if (whitelistedKeys.includes(key.toLowerCase())) {
147
+ continue;
148
+ }
149
+
150
+ const childPath = currentPath ? `${currentPath}.${key}` : key;
151
+ matches.push(...scanValue(val, rules, childPath, whitelistedKeys, seen));
152
+ }
153
+ }
154
+
155
+ return matches;
156
+ }
157
+
158
+ module.exports = {
159
+ DEFAULT_THREAT_PATTERNS,
160
+ scanValue
161
+ };
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ const ipaddr = require('ipaddr.js');
4
+
5
+ /**
6
+ * Normalizes an IP string into a standard ipaddr.IPv4 or ipaddr.IPv6 object.
7
+ * Converts IPv4-mapped IPv6 (e.g. ::ffff:192.168.1.1) to pure IPv4.
8
+ *
9
+ * @param {string} ipString
10
+ * @returns {ipaddr.IPv4|ipaddr.IPv6|null}
11
+ */
12
+ function parseIp(ipString) {
13
+ if (typeof ipString !== 'string') {
14
+ return null;
15
+ }
16
+
17
+ const clean = ipString.trim();
18
+ if (!clean) {
19
+ return null;
20
+ }
21
+
22
+ try {
23
+ let parsed = ipaddr.parse(clean);
24
+ // Convert IPv4-mapped IPv6 to pure IPv4
25
+ if (parsed.kind() === 'ipv6' && parsed.isIPv4MappedAddress()) {
26
+ parsed = parsed.toIPv4Address();
27
+ }
28
+ return parsed;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Parses an IP rule which can be a single IP or CIDR range.
36
+ *
37
+ * @param {string} ruleString
38
+ * @returns {{ type: 'cidr'|'ip', parsed: ipaddr.IPv4|ipaddr.IPv6|Array<any> } | null}
39
+ */
40
+ function parseRule(ruleString) {
41
+ if (typeof ruleString !== 'string') {
42
+ return null;
43
+ }
44
+
45
+ const trimmed = ruleString.trim();
46
+ if (!trimmed) {
47
+ return null;
48
+ }
49
+
50
+ // Check if CIDR notation
51
+ if (trimmed.includes('/')) {
52
+ try {
53
+ const cidr = ipaddr.parseCIDR(trimmed);
54
+ return { type: 'cidr', parsed: cidr, raw: trimmed };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // Single IP
61
+ const parsedIp = parseIp(trimmed);
62
+ if (parsedIp) {
63
+ return { type: 'ip', parsed: parsedIp, raw: trimmed };
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Checks if a given IP address matches a compiled rule.
71
+ *
72
+ * @param {ipaddr.IPv4|ipaddr.IPv6} clientIp
73
+ * @param {Object} rule
74
+ * @returns {boolean}
75
+ */
76
+ function matchesRule(clientIp, rule) {
77
+ if (!clientIp || !rule) {
78
+ return false;
79
+ }
80
+
81
+ if (rule.type === 'ip') {
82
+ return clientIp.toString() === rule.parsed.toString();
83
+ }
84
+
85
+ if (rule.type === 'cidr') {
86
+ try {
87
+ return clientIp.match(rule.parsed);
88
+ } catch {
89
+ // Kind mismatch (e.g. matching IPv6 client against IPv4 CIDR)
90
+ return false;
91
+ }
92
+ }
93
+
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Checks if a client IP matches any rule in an array of rules.
99
+ *
100
+ * @param {string} clientIpStr
101
+ * @param {Array<Object>} compiledRules
102
+ * @returns {boolean}
103
+ */
104
+ function isIpInRules(clientIpStr, compiledRules) {
105
+ const parsedClient = parseIp(clientIpStr);
106
+ if (!parsedClient) {
107
+ return false;
108
+ }
109
+
110
+ for (const rule of compiledRules) {
111
+ if (matchesRule(parsedClient, rule)) {
112
+ return true;
113
+ }
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * Compiles a list of IP and CIDR rule strings into parsed rule objects.
121
+ *
122
+ * @param {string[]} ruleStrings
123
+ * @returns {Array<Object>}
124
+ */
125
+ function compileRules(ruleStrings = []) {
126
+ if (!Array.isArray(ruleStrings)) {
127
+ return [];
128
+ }
129
+
130
+ return ruleStrings
131
+ .map((r) => parseRule(r))
132
+ .filter((r) => r !== null);
133
+ }
134
+
135
+ /**
136
+ * Safely extracts client IP according to proxy configuration.
137
+ * Never blindly trusts X-Forwarded-For unless explicitly configured.
138
+ *
139
+ * @param {import('express').Request} req
140
+ * @param {Object} [options]
141
+ * @param {boolean|string[]|number|Function} [options.trustProxy=false]
142
+ * @returns {string} Extracted client IP
143
+ */
144
+ function getClientIp(req, options = {}) {
145
+ const trustProxy = options.trustProxy !== undefined ? options.trustProxy : false;
146
+
147
+ // Custom function
148
+ if (typeof options.getIp === 'function') {
149
+ return options.getIp(req);
150
+ }
151
+
152
+ const directIp = (req.socket && req.socket.remoteAddress) ||
153
+ (req.connection && req.connection.remoteAddress) ||
154
+ '127.0.0.1';
155
+
156
+ if (!trustProxy) {
157
+ // When trustProxy is disabled, ONLY use direct socket address
158
+ const parsed = parseIp(directIp);
159
+ return parsed ? parsed.toString() : directIp;
160
+ }
161
+
162
+ // If trustProxy is boolean true, rely on express req.ip if available
163
+ if (trustProxy === true) {
164
+ if (req.ip) {
165
+ const parsed = parseIp(req.ip);
166
+ return parsed ? parsed.toString() : req.ip;
167
+ }
168
+ }
169
+
170
+ // If X-Forwarded-For is present and proxy is trusted
171
+ const xForwardedFor = req.headers && req.headers['x-forwarded-for'];
172
+ if (xForwardedFor) {
173
+ const rawList = Array.isArray(xForwardedFor) ? xForwardedFor.join(',') : xForwardedFor;
174
+ const ips = rawList.split(',').map((s) => s.trim()).filter(Boolean);
175
+
176
+ if (ips.length > 0) {
177
+ if (typeof trustProxy === 'number') {
178
+ // Trust specified number of hops from the right
179
+ const index = Math.max(0, ips.length - trustProxy);
180
+ const targetIp = ips[index] || directIp;
181
+ const parsed = parseIp(targetIp);
182
+ return parsed ? parsed.toString() : targetIp;
183
+ }
184
+
185
+ if (Array.isArray(trustProxy)) {
186
+ // Trust only if directIp is in the trusted proxy list
187
+ const trustedRules = compileRules(trustProxy);
188
+ if (isIpInRules(directIp, trustedRules)) {
189
+ // Find first untrusted IP walking backwards from right to left
190
+ for (let i = ips.length - 1; i >= 0; i--) {
191
+ if (!isIpInRules(ips[i], trustedRules)) {
192
+ const parsed = parseIp(ips[i]);
193
+ return parsed ? parsed.toString() : ips[i];
194
+ }
195
+ }
196
+ // All are trusted; return the leftmost
197
+ const parsed = parseIp(ips[0]);
198
+ return parsed ? parsed.toString() : ips[0];
199
+ } else {
200
+ // Direct connection is not a trusted proxy! Reject X-Forwarded-For header spoofing
201
+ const parsed = parseIp(directIp);
202
+ return parsed ? parsed.toString() : directIp;
203
+ }
204
+ }
205
+
206
+ // Boolean true fallback: take the first valid IP
207
+ const parsed = parseIp(ips[0]);
208
+ return parsed ? parsed.toString() : ips[0];
209
+ }
210
+ }
211
+
212
+ const parsed = parseIp(directIp);
213
+ return parsed ? parsed.toString() : directIp;
214
+ }
215
+
216
+ module.exports = {
217
+ parseIp,
218
+ parseRule,
219
+ matchesRule,
220
+ isIpInRules,
221
+ compileRules,
222
+ getClientIp
223
+ };
224
+
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * In-memory store for rate limiting with automatic background cleanup.
5
+ */
6
+ class MemoryStore {
7
+ /**
8
+ * @param {Object} [options]
9
+ * @param {number} [options.windowMs=60000] - Window duration in milliseconds.
10
+ * @param {number} [options.cleanupIntervalMs=60000] - How often expired keys are purged.
11
+ */
12
+ constructor(options = {}) {
13
+ this.windowMs = options.windowMs || 60000;
14
+ this.hits = new Map();
15
+
16
+ const cleanupMs = options.cleanupIntervalMs || Math.min(this.windowMs, 60000);
17
+ this.interval = setInterval(() => this.cleanup(), cleanupMs);
18
+ // Unref so the cleanup interval does not block Node process exit
19
+ if (this.interval.unref) {
20
+ this.interval.unref();
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Called by express-rate-limit with middleware options.
26
+ * @param {Object} options
27
+ */
28
+ init(options) {
29
+ if (options && options.windowMs) {
30
+ this.windowMs = options.windowMs;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Purge expired rate limit entries.
36
+ */
37
+ cleanup() {
38
+ const now = Date.now();
39
+ for (const [key, record] of this.hits.entries()) {
40
+ if (record.resetTime <= now) {
41
+ this.hits.delete(key);
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Increment hits for a key.
48
+ *
49
+ * @param {string} key
50
+ * @returns {Promise<{ totalHits: number, resetTime: Date }>}
51
+ */
52
+ async increment(key) {
53
+ const now = Date.now();
54
+ let record = this.hits.get(key);
55
+
56
+ if (!record || record.resetTime <= now) {
57
+ record = {
58
+ totalHits: 1,
59
+ resetTime: now + this.windowMs
60
+ };
61
+ this.hits.set(key, record);
62
+ } else {
63
+ record.totalHits += 1;
64
+ }
65
+
66
+ return {
67
+ totalHits: record.totalHits,
68
+ resetTime: new Date(record.resetTime)
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Decrement hits for a key.
74
+ *
75
+ * @param {string} key
76
+ * @returns {Promise<void>}
77
+ */
78
+ async decrement(key) {
79
+ const record = this.hits.get(key);
80
+ if (record && record.totalHits > 0) {
81
+ record.totalHits -= 1;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Reset hits for a single key.
87
+ *
88
+ * @param {string} key
89
+ * @returns {Promise<void>}
90
+ */
91
+ async resetKey(key) {
92
+ this.hits.delete(key);
93
+ }
94
+
95
+ /**
96
+ * Reset all keys in memory.
97
+ *
98
+ * @returns {Promise<void>}
99
+ */
100
+ async resetAll() {
101
+ this.hits.clear();
102
+ }
103
+
104
+ /**
105
+ * Retrieve current hits for a key without incrementing.
106
+ *
107
+ * @param {string} key
108
+ * @returns {Promise<{ totalHits: number, resetTime: Date } | undefined>}
109
+ */
110
+ async get(key) {
111
+ const record = this.hits.get(key);
112
+ if (!record || record.resetTime <= Date.now()) {
113
+ return undefined;
114
+ }
115
+ return {
116
+ totalHits: record.totalHits,
117
+ resetTime: new Date(record.resetTime)
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Clean up timer on teardown.
123
+ */
124
+ shutdown() {
125
+ if (this.interval) {
126
+ clearInterval(this.interval);
127
+ }
128
+ this.hits.clear();
129
+ }
130
+ }
131
+
132
+ module.exports = {
133
+ MemoryStore
134
+ };
135
+