secure-api-guard 0.0.0-stage → 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.
@@ -0,0 +1,90 @@
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
+ return !Number.isNaN(expiresTime) && Date.now() > expiresTime;
82
+ }
83
+
84
+ module.exports = {
85
+ hashApiKey,
86
+ generateApiKey,
87
+ secureCompare,
88
+ isKeyExpired
89
+ };
90
+
@@ -0,0 +1,159 @@
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
+ * Evaluates a threat rule against a string value.
50
+ *
51
+ * @param {Object} rule
52
+ * @param {string} value
53
+ * @param {string} path
54
+ * @param {string} [snippetPrefix]
55
+ * @returns {Object|null}
56
+ */
57
+ function matchRule(rule, value, path, snippetPrefix) {
58
+ let matched = false;
59
+ if (typeof rule.pattern === 'function') {
60
+ matched = Boolean(rule.pattern(value, path));
61
+ } else if (rule.pattern instanceof RegExp) {
62
+ matched = rule.pattern.test(value);
63
+ }
64
+
65
+ if (matched) {
66
+ const raw = snippetPrefix ? `${snippetPrefix}: ${value}` : value;
67
+ return {
68
+ ruleId: rule.id || 'CUSTOM_RULE',
69
+ ruleName: rule.name || 'Custom Threat Rule',
70
+ path,
71
+ snippet: raw.length > 80 ? `${raw.substring(0, 80)}...` : raw,
72
+ severity: rule.severity || 'medium'
73
+ };
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Recursively inspects values in strings, arrays, or objects for threat signatures.
80
+ *
81
+ * @param {any} value - Value to inspect.
82
+ * @param {Array<Object>} rules - Compiled threat rules.
83
+ * @param {string} [currentPath=''] - Current object path for error reporting.
84
+ * @param {string[]} [whitelistedKeys=[]] - Keys to skip inspection.
85
+ * @param {Set<any>} [seen] - Visited objects to prevent infinite loops.
86
+ * @returns {Array<{ ruleId: string, ruleName: string, path: string, snippet: string, severity: string }>}
87
+ */
88
+ function scanValue(value, rules, currentPath = '', whitelistedKeys = [], seen = new Set()) {
89
+ const matches = [];
90
+
91
+ if (value === null || value === undefined || seen.has(value)) {
92
+ return matches;
93
+ }
94
+
95
+ // Handle strings
96
+ if (typeof value === 'string') {
97
+ for (const rule of rules) {
98
+ const match = matchRule(rule, value, currentPath);
99
+ if (match) matches.push(match);
100
+ }
101
+ return matches;
102
+ }
103
+
104
+ // Handle Arrays
105
+ if (Array.isArray(value)) {
106
+ seen.add(value);
107
+ for (let i = 0; i < value.length; i++) {
108
+ const arrayPath = currentPath ? `${currentPath}[${i}]` : `[${i}]`;
109
+ matches.push(...scanValue(value[i], rules, arrayPath, whitelistedKeys, seen));
110
+ }
111
+ return matches;
112
+ }
113
+
114
+ // Handle Objects
115
+ if (typeof value === 'object') {
116
+ seen.add(value);
117
+
118
+ // Get all own property names including non-enumerable or __proto__
119
+ const propNames = Object.getOwnPropertyNames(value);
120
+ for (const propName of propNames) {
121
+ const keyPath = currentPath ? `${currentPath}.${propName}` : propName;
122
+ for (const rule of rules) {
123
+ const match = matchRule(rule, propName, keyPath, 'Key name');
124
+ if (match) matches.push(match);
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
+ if (whitelistedKeys.includes(key.toLowerCase())) {
146
+ continue;
147
+ }
148
+ const childPath = currentPath ? `${currentPath}.${key}` : key;
149
+ matches.push(...scanValue(val, rules, childPath, whitelistedKeys, seen));
150
+ }
151
+ }
152
+
153
+ return matches;
154
+ }
155
+
156
+ module.exports = {
157
+ DEFAULT_THREAT_PATTERNS,
158
+ scanValue
159
+ };
@@ -0,0 +1,219 @@
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
+ * Formats an IP string into its canonical parsed representation or original string.
137
+ *
138
+ * @param {string} ip
139
+ * @returns {string}
140
+ */
141
+ function formatIp(ip) {
142
+ if (!ip) return '127.0.0.1';
143
+ const parsed = parseIp(ip);
144
+ return parsed ? parsed.toString() : ip;
145
+ }
146
+
147
+ /**
148
+ * Safely extracts client IP according to proxy configuration.
149
+ * Never blindly trusts X-Forwarded-For unless explicitly configured.
150
+ *
151
+ * @param {import('express').Request} req
152
+ * @param {Object} [options]
153
+ * @param {boolean|string[]|number|Function} [options.trustProxy=false]
154
+ * @returns {string} Extracted client IP
155
+ */
156
+ function getClientIp(req, options = {}) {
157
+ const trustProxy = options.trustProxy !== undefined ? options.trustProxy : false;
158
+
159
+ // Custom extractor function
160
+ if (typeof options.getIp === 'function') {
161
+ return options.getIp(req);
162
+ }
163
+
164
+ const directIp = (req.socket && req.socket.remoteAddress) ||
165
+ (req.connection && req.connection.remoteAddress) ||
166
+ '127.0.0.1';
167
+
168
+ if (!trustProxy) {
169
+ return formatIp(directIp);
170
+ }
171
+
172
+ // If trustProxy is boolean true, prefer express req.ip if set
173
+ if (trustProxy === true && req.ip) {
174
+ return formatIp(req.ip);
175
+ }
176
+
177
+ const xForwardedFor = req.headers && req.headers['x-forwarded-for'];
178
+ if (xForwardedFor) {
179
+ const rawList = Array.isArray(xForwardedFor) ? xForwardedFor.join(',') : xForwardedFor;
180
+ const ips = rawList.split(',').map((s) => s.trim()).filter(Boolean);
181
+
182
+ if (ips.length > 0) {
183
+ if (typeof trustProxy === 'number') {
184
+ const index = Math.max(0, ips.length - trustProxy);
185
+ return formatIp(ips[index] || directIp);
186
+ }
187
+
188
+ if (Array.isArray(trustProxy)) {
189
+ const trustedRules = compileRules(trustProxy);
190
+ if (isIpInRules(directIp, trustedRules)) {
191
+ // Walk backwards to find first untrusted IP
192
+ for (let i = ips.length - 1; i >= 0; i--) {
193
+ if (!isIpInRules(ips[i], trustedRules)) {
194
+ return formatIp(ips[i]);
195
+ }
196
+ }
197
+ return formatIp(ips[0]);
198
+ }
199
+ // Direct socket is not a trusted proxy: ignore spoofed header
200
+ return formatIp(directIp);
201
+ }
202
+
203
+ return formatIp(ips[0]);
204
+ }
205
+ }
206
+
207
+ return formatIp(directIp);
208
+ }
209
+
210
+ module.exports = {
211
+ parseIp,
212
+ formatIp,
213
+ parseRule,
214
+ matchesRule,
215
+ isIpInRules,
216
+ compileRules,
217
+ getClientIp
218
+ };
219
+
@@ -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
+