forked from emdevelopa/Stellar_Payment_API
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaudit-security.js
More file actions
327 lines (284 loc) · 9.78 KB
/
Copy pathaudit-security.js
File metadata and controls
327 lines (284 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
import crypto from "node:crypto";
const SENSITIVE_KEY_RE = /(secret|token|password|api[_-]?key|authorization|signature)/i;
const DEFAULT_AUDIT_RATE_LIMIT_MAX = 60;
const DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS = 60_000;
const DEFAULT_AUDIT_FIELD_MAX_LENGTH = 2048;
/**
* Allowlist of permitted audit action identifiers.
* Restricting actions to a known set prevents log-injection attacks where
* a crafted action string embeds control characters or arbitrary content
* into the audit trail (issue #772).
*/
const ALLOWED_AUDIT_ACTIONS = new Set([
"login",
"logout",
"update",
"create",
"delete",
"export",
"import",
"password_change",
"api_key_rotate",
"webhook_trigger",
"payment_initiated",
"payment_confirmed",
"payment_failed",
]);
const auditRateLimitState = new Map();
function stableStringify(value, depth = 0, seen = new WeakSet()) {
if (depth > 10) {
return '"[Too Deep]"';
}
if (value === null || value === undefined) return "null";
if (typeof value !== "object") return JSON.stringify(value);
// Date has no own enumerable properties, so the generic Object.entries()
// path below silently serializes any Date to "{}", discarding the actual
// timestamp. old_value/new_value in a profile-change audit event can be a
// Date (e.g. a timestamp field changing), so this must be special-cased
// before the object/array branches — otherwise the audit trail records a
// meaningless "{}" instead of the value that actually changed.
if (value instanceof Date) {
return JSON.stringify(value.toISOString());
}
if (seen.has(value)) {
return '"[Circular]"';
}
seen.add(value);
if (Array.isArray(value)) {
const serialized = value.map((item) => stableStringify(item, depth + 1, seen)).join(",");
seen.delete(value);
return `[${serialized}]`;
}
const entries = Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableStringify(nested, depth + 1, seen)}`);
seen.delete(value);
return `{${entries.join(",")}}`;
}
function truncateString(value, maxLength = DEFAULT_AUDIT_FIELD_MAX_LENGTH) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength)}...[truncated]`;
}
export function sanitizeAuditValue(value) {
if (value === null || value === undefined) return null;
if (typeof value === "object") {
const serialized = stableStringify(value);
return truncateString(serialized);
}
if (typeof value === "string") {
return truncateString(value.trim());
}
return truncateString(String(value));
}
export function sanitizeAuditKey(value) {
const normalized = sanitizeAuditValue(value);
if (!normalized) return null;
return SENSITIVE_KEY_RE.test(normalized) ? "[REDACTED]" : normalized;
}
export function hashAuditPayload(payload) {
return crypto
.createHash("sha256")
.update(stableStringify(payload), "utf8")
.digest("hex");
}
export function signAuditPayload(payload, secret = process.env.AUDIT_LOG_SIGNING_SECRET) {
if (!secret) {
return null;
}
return crypto
.createHmac("sha256", secret)
.update(stableStringify(payload), "utf8")
.digest("hex");
}
/**
* Verify a stored HMAC signature against the original payload using a
* constant-time comparison to prevent timing-oracle attacks (issue #769).
*
* @param {object} payload - The original audit payload object
* @param {string} signature - The hex-encoded HMAC stored in the database
* @param {string} [secret] - Signing secret (defaults to AUDIT_LOG_SIGNING_SECRET env var)
* @returns {boolean} true if the signature is valid and untampered
*/
export function verifyAuditSignature(payload, signature, secret = process.env.AUDIT_LOG_SIGNING_SECRET) {
if (!secret || !signature || typeof signature !== "string") return false;
const expected = signAuditPayload(payload, secret);
if (!expected) return false;
try {
// timingSafeEqual requires same-length Buffers; mismatched lengths → false
const expectedBuf = Buffer.from(expected, "hex");
const actualBuf = Buffer.from(signature, "hex");
if (expectedBuf.length !== actualBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, actualBuf);
} catch {
return false;
}
}
/**
* Validate that an action string is within the known-safe allowlist before
* storing it in the audit trail. Prevents log-injection through crafted
* action values (issue #772).
*
* @param {string} action
* @returns {boolean}
*/
export function validateAuditAction(action) {
if (!action || typeof action !== "string") return false;
return ALLOWED_AUDIT_ACTIONS.has(action.toLowerCase().trim());
}
export function createAuditLogRateLimitKey({ merchantId, action, ipAddress }) {
return [merchantId || "anonymous", action || "unknown-action", ipAddress || "unknown-ip"].join(":");
}
export function consumeAuditLogRateLimit(
key,
{
now = Date.now(),
max = Number(process.env.AUDIT_LOG_RATE_LIMIT_MAX || DEFAULT_AUDIT_RATE_LIMIT_MAX),
windowMs = Number(
process.env.AUDIT_LOG_RATE_LIMIT_WINDOW_MS || DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS,
),
} = {},
) {
if (!key) return { allowed: true, remaining: max, resetTime: now + windowMs };
// Evict expired entries if Map size exceeds safety threshold (DoS / OOM protection)
if (auditRateLimitState.size >= 10000) {
for (const [k, v] of auditRateLimitState.entries()) {
if (now >= v.windowStart + windowMs) {
auditRateLimitState.delete(k);
}
}
// Hard cap eviction if still over threshold
if (auditRateLimitState.size >= 10000) {
const oldestKeys = Array.from(auditRateLimitState.keys()).slice(0, 100);
for (const k of oldestKeys) {
auditRateLimitState.delete(k);
}
}
}
const current = auditRateLimitState.get(key);
if (!current || now >= current.windowStart + windowMs) {
const next = { count: 1, windowStart: now };
auditRateLimitState.set(key, next);
return { allowed: true, remaining: Math.max(0, max - 1), resetTime: now + windowMs };
}
current.count += 1;
if (current.count > max) {
return {
allowed: false,
remaining: 0,
resetTime: current.windowStart + windowMs,
};
}
return {
allowed: true,
remaining: Math.max(0, max - current.count),
resetTime: current.windowStart + windowMs,
};
}
/**
* Get comprehensive rate limit statistics for audit logging (issue #902).
* Useful for monitoring and debugging rate limit behavior.
*/
export function getAuditRateLimitStats() {
const now = Date.now();
const stats = {
totalKeys: auditRateLimitState.size,
activeWindows: 0,
expiredWindows: 0,
maxRequestsPerWindow: Number(process.env.AUDIT_LOG_RATE_LIMIT_MAX || DEFAULT_AUDIT_RATE_LIMIT_MAX),
windowMs: Number(process.env.AUDIT_LOG_RATE_LIMIT_WINDOW_MS || DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS),
};
for (const [key, state] of auditRateLimitState.entries()) {
if (now >= state.windowStart + stats.windowMs) {
stats.expiredWindows++;
} else {
stats.activeWindows++;
}
}
return stats;
}
/**
* Cleanup expired audit rate limit entries to prevent memory exhaustion (issue #902).
* Should be called periodically (e.g., via cron or on a schedule).
*/
export function cleanupExpiredAuditRateLimits() {
const now = Date.now();
const windowMs = Number(
process.env.AUDIT_LOG_RATE_LIMIT_WINDOW_MS || DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS,
);
const staleThreshold = windowMs * 2; // Remove entries older than 2x the window
let cleaned = 0;
for (const [key, state] of auditRateLimitState.entries()) {
if (now - state.windowStart > staleThreshold) {
auditRateLimitState.delete(key);
cleaned++;
}
}
return cleaned;
}
export function resetAuditRateLimitStateForTests() {
auditRateLimitState.clear();
}
/**
* Reconstructs the original audit log payload from a database row.
*
* @param {object} row
* @returns {object}
*/
export function reconstructPayloadFromRow(row) {
if (!row) return {};
if (row.action === "login") {
return {
merchant_id: row.merchant_id ?? null,
action: row.action,
status: row.status ?? null,
ip_address: row.ip_address ?? null,
user_agent: row.user_agent ?? null,
event_type: "login_attempt",
};
} else {
return {
merchant_id: row.merchant_id ?? null,
action: row.action,
field_changed: row.field_changed ?? null,
old_value: row.old_value ?? null,
new_value: row.new_value ?? null,
ip_address: row.ip_address ?? null,
user_agent: row.user_agent ?? null,
};
}
}
/**
* Validates the cryptographic integrity of a single audit log database row.
*
* @param {object} row
* @param {string} [secret] - Optional signing secret
* @returns {{ verified: boolean, status: string, reason?: string }}
*/
export function verifyRowIntegrity(row, secret = process.env.AUDIT_LOG_SIGNING_SECRET) {
if (!row) {
return { verified: false, status: "failed", reason: "empty_row" };
}
if (!row.payload_hash) {
return { verified: false, status: "failed", reason: "missing_hash" };
}
try {
const payload = reconstructPayloadFromRow(row);
const expectedHash = hashAuditPayload(payload);
if (row.payload_hash !== expectedHash) {
return { verified: false, status: "failed", reason: "hash_mismatch" };
}
if (!row.signature) {
return { verified: true, status: "unsigned_verified" };
}
if (secret) {
const signatureValid = verifyAuditSignature(payload, row.signature, secret);
if (!signatureValid) {
return { verified: false, status: "failed", reason: "signature_invalid" };
}
return { verified: true, status: "verified" };
}
return { verified: true, status: "unsigned_verified" };
} catch (err) {
return { verified: false, status: "failed", reason: `error: ${err.message}` };
}
}