Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions backend/src/lib/audit-security.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const SENSITIVE_KEY_RE = /(secret|token|password|api[_-]?key|authorization|signa
const DEFAULT_AUDIT_RATE_LIMIT_MAX = 60;
const DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS = 60_000;
const DEFAULT_AUDIT_FIELD_MAX_LENGTH = 2048;
const MAX_AUDIT_RATE_LIMIT_KEYS = 10_000;

/**
* Allowlist of permitted audit action identifiers.
Expand All @@ -29,6 +30,19 @@ const ALLOWED_AUDIT_ACTIONS = new Set([

const auditRateLimitState = new Map();

function pruneExpiredAuditRateLimitEntries(now, windowMs) {
let cleaned = 0;

for (const [key, state] of auditRateLimitState.entries()) {
if (now >= state.windowStart + windowMs) {
auditRateLimitState.delete(key);
cleaned += 1;
}
}

return cleaned;
}

function stableStringify(value, depth = 0, seen = new WeakSet()) {
if (depth > 10) {
return '"[Too Deep]"';
Expand Down Expand Up @@ -154,19 +168,17 @@ export function consumeAuditLogRateLimit(
) {
if (!key) return { allowed: true, remaining: max, resetTime: now + windowMs };

pruneExpiredAuditRateLimitEntries(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);
}
if (auditRateLimitState.size >= MAX_AUDIT_RATE_LIMIT_KEYS) {
const oldestKeys = Array.from(auditRateLimitState.entries())
.sort(([, a], [, b]) => a.windowStart - b.windowStart)
.slice(0, Math.max(100, Math.ceil(auditRateLimitState.size * 0.1)))
.map(([k]) => k);

for (const k of oldestKeys) {
auditRateLimitState.delete(k);
}
}

Expand Down Expand Up @@ -198,17 +210,21 @@ export function consumeAuditLogRateLimit(
* 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();
export function getAuditRateLimitStats({ now = Date.now() } = {}) {
const windowMs = Number(
process.env.AUDIT_LOG_RATE_LIMIT_WINDOW_MS || DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS,
);
pruneExpiredAuditRateLimitEntries(now, windowMs);

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),
windowMs,
};

for (const [key, state] of auditRateLimitState.entries()) {
for (const [, state] of auditRateLimitState.entries()) {
if (now >= state.windowStart + stats.windowMs) {
stats.expiredWindows++;
} else {
Expand All @@ -223,8 +239,7 @@ export function getAuditRateLimitStats() {
* 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();
export function cleanupExpiredAuditRateLimits({ now = Date.now() } = {}) {
const windowMs = Number(
process.env.AUDIT_LOG_RATE_LIMIT_WINDOW_MS || DEFAULT_AUDIT_RATE_LIMIT_WINDOW_MS,
);
Expand Down
20 changes: 20 additions & 0 deletions backend/src/lib/audit-security.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import {
consumeAuditLogRateLimit,
createAuditLogRateLimitKey,
getAuditRateLimitStats,
hashAuditPayload,
resetAuditRateLimitStateForTests,
sanitizeAuditKey,
Expand Down Expand Up @@ -179,6 +180,25 @@ describe("audit-security", () => {
expect(res.allowed).toBe(true);
});

it("proactively removes expired rate-limit entries before they accumulate", () => {
consumeAuditLogRateLimit("stale-key", {
now: 0,
max: 2,
windowMs: 100,
});

consumeAuditLogRateLimit("fresh-key", {
now: 150,
max: 2,
windowMs: 100,
});

const stats = getAuditRateLimitStats({ now: 150 });
expect(stats.totalKeys).toBe(1);
expect(stats.activeWindows).toBe(1);
expect(stats.expiredWindows).toBe(0);
});

it("reconstructs payloads and verifies row integrity correctly", () => {
const secret = "test-secret-key";
const row = {
Expand Down
25 changes: 16 additions & 9 deletions backend/src/services/auditService.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,29 @@ export const auditService = {

const offset = (p - 1) * l;

// Single query: window function returns the full-table count alongside
// each row, eliminating the separate COUNT(*) round-trip (issue #770).
const result = await pool.query(
`SELECT id, merchant_id, action, field_changed, old_value, new_value, ip_address, user_agent, timestamp, payload_hash, signature,
COUNT(*) OVER() AS total_count
const countResult = await pool.query(
"SELECT COUNT(*)::int AS total_count FROM audit_logs WHERE merchant_id = $1",
[merchantId],
);

const totalCount = parseInt(countResult.rows[0]?.total_count ?? 0, 10);

const rowsResult = await pool.query(
`SELECT id, merchant_id, action, field_changed, old_value, new_value, ip_address, user_agent, timestamp, payload_hash, signature
FROM audit_logs
WHERE merchant_id = $1
ORDER BY timestamp DESC
LIMIT $2 OFFSET $3`,
[merchantId, l, offset],
);

const totalCount = result.rows.length > 0 ? parseInt(result.rows[0].total_count, 10) : 0;

// Verify cryptographic integrity of each row before returning
const logs = result.rows.map(({ total_count: _tc, ...row }) => {
const logs = rowsResult.rows.map((row) => {
const integrity = verifyRowIntegrity(row);
auditLogIntegrityVerificationsTotal.inc({ result: integrity.status });

const hashVerified = row.payload_hash == null ? null : integrity.verified && integrity.status === "verified";
const signatureVerified = row.signature == null || !process.env.AUDIT_LOG_SIGNING_SECRET ? null : integrity.verified && integrity.status === "verified";

return {
id: row.id,
action: row.action,
Expand All @@ -65,6 +70,8 @@ export const auditService = {
ip_address: row.ip_address,
user_agent: row.user_agent,
timestamp: row.timestamp,
hash_verified: hashVerified,
signature_verified: signatureVerified,
integrity_status: integrity.status,
};
});
Expand Down
Loading
Loading