Skip to content
Open
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
22 changes: 22 additions & 0 deletions backend/migrations/1782699458929_add-status-to-audit-logs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
pgm.addColumn('audit_logs', {
status: { type: 'integer', notNull: false },
});
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropColumn('audit_logs', 'status');
};
61 changes: 34 additions & 27 deletions backend/src/middleware/auditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ import logger from '../utils/logger.js';
function sanitizePayload(body: unknown): unknown {
if (!body || typeof body !== 'object') return body;

if (Array.isArray(body)) {
return body.map(sanitizePayload);
}

const sanitized = { ...body } as Record<string, unknown>;
// List of fields that should be redacted in audit logs
const sensitiveFields = ['secret', 'apiKey', 'password', 'token', 'signedTxXdr', 'x-api-key'];

for (const field of sensitiveFields) {
if (field in sanitized) {
sanitized[field] = '[REDACTED]';
for (const key of Object.keys(sanitized)) {
if (sensitiveFields.includes(key)) {
sanitized[key] = '[REDACTED]';
} else if (typeof sanitized[key] === 'object' && sanitized[key] !== null) {
sanitized[key] = sanitizePayload(sanitized[key]);
}
}

// Handle nested objects if necessary (shallow for now)
return sanitized;
}

Expand Down Expand Up @@ -52,7 +56,7 @@ function extractTarget(req: Request): string | undefined {
* It identifies the actor (JWT user or API key), the action (method+path),
* any target entity, and the sanitized request payload.
*/
export const auditLog = async (req: Request, _res: Response, next: NextFunction): Promise<void> => {
export const auditLog = (req: Request, res: Response, next: NextFunction): void => {
try {
const actor =
req.user?.publicKey ?? (req.headers['x-api-key'] ? 'INTERNAL_API_KEY' : 'unknown');
Expand All @@ -67,29 +71,32 @@ export const auditLog = async (req: Request, _res: Response, next: NextFunction)
)?.split(',')[0] ||
req.socket.remoteAddress;

// Log the action asynchronously to avoid blocking the main request thread
void (async () => {
try {
await query(
`INSERT INTO audit_logs (actor, action, target, payload, ip_address)
VALUES ($1, $2, $3, $4, $5)`,
[
res.on('finish', () => {
// Log the action asynchronously to avoid blocking the main request thread
void (async () => {
try {
await query(
`INSERT INTO audit_logs (actor, action, target, payload, ip_address, status)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
actor,
action,
target ?? null,
payload ? JSON.stringify(payload) : null,
ipAddress ?? null,
res.statusCode,
],
);
} catch (err) {
logger.error('Audit logging failure', {
err,
actor,
action,
target ?? null,
payload ? JSON.stringify(payload) : null,
ipAddress ?? null,
],
);
} catch (err) {
logger.error('Audit logging failure', {
err,
actor,
action,
target,
});
}
})();
target,
});
}
})();
});
} catch (err) {
// If the audit log logic fails, we still want to proceed with the request
logger.warn('Audit log middleware error', { err });
Expand Down
6 changes: 5 additions & 1 deletion backend/src/services/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,11 +512,13 @@ describe('EventIndexer – transaction atomicity via ingestRawEvents', () => {
// Exactly one audit_logs INSERT must have been made
expect(auditInsertCalls).toHaveLength(1);

const [actor, action, target, payload] = auditInsertCalls[0] as [
const [actor, action, target, payload, ip_address, status] = auditInsertCalls[0] as [
string,
string,
string,
string,
string | null,
number,
];

// actor = admin address from topic[1]
Expand All @@ -525,6 +527,8 @@ describe('EventIndexer – transaction atomicity via ingestRawEvents', () => {
expect(action).toBe('loan_approved');
// target = 'loan:<loanId>'
expect(target).toBe('loan:42');
// status = 200
expect(status).toBe(200);

// payload JSON must contain loanId, borrower, txHash
const parsed = JSON.parse(payload);
Expand Down
14 changes: 8 additions & 6 deletions backend/src/services/eventIndexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,12 +510,12 @@ export class EventIndexer {

if (this.isAdminConfigEventType(event.eventType)) {
await client.query(
`INSERT INTO audit_logs (actor, action, target, payload, ip_address)
VALUES ($1, $2, $3, $4::jsonb, $5)`,
`INSERT INTO audit_logs (actor, action, target, payload, ip_address, status)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)`,
[
event.address ?? 'SYSTEM',
`ADMIN_CONFIG_${event.eventType}`,
`contract:${event.contractId}`,
null,
JSON.stringify({
eventId: event.eventId,
eventType: event.eventType,
Expand All @@ -524,7 +524,8 @@ export class EventIndexer {
ledger: event.ledger,
txHash: event.txHash,
}),
null,
'internal-indexer',
200,
],
);
}
Expand All @@ -541,8 +542,8 @@ export class EventIndexer {
*/
if (event.eventType === 'LoanApprv') {
await client.query(
`INSERT INTO audit_logs (actor, action, target, payload, ip_address)
VALUES ($1, $2, $3, $4::jsonb, $5)`,
`INSERT INTO audit_logs (actor, action, target, payload, ip_address, status)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)`,
[
event.adminAddress ?? 'SYSTEM',
'loan_approved',
Expand All @@ -554,6 +555,7 @@ export class EventIndexer {
txHash: event.txHash,
}),
null,
200, // Loan approved on-chain
],
);
}
Expand Down
56 changes: 48 additions & 8 deletions backend/src/tests/auditLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,26 @@ describe('Audit Log Middleware', () => {
socket: {} as import('net').Socket,
params: {},
};
res = {};
res = {
statusCode: 200,
on: jest.fn((event: string, callback: () => void) => {
if (event === 'finish') {
setTimeout(callback, 0);
}
return res as Response;
}),
};
next = jest.fn();
jest.clearAllMocks();
});

it('should log admin action to audit_logs table', async () => {
await auditLog(req as Request, res as Response, next);
auditLog(req as Request, res as Response, next);

expect(next).toHaveBeenCalled();

// The query is called asynchronously (void ...), so we might need to wait a tick
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 20));

expect(mockedQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO audit_logs'),
Expand All @@ -55,18 +63,26 @@ describe('Audit Log Middleware', () => {
'LoanIDs:[1,2,3]',
expect.stringContaining('"loanIds":[1,2,3]'),
'127.0.0.1',
200,
]),
);
});

it('should redact sensitive fields in payload', async () => {
it('should redact sensitive fields in payload at any depth', async () => {
req.body = {
secret: 'sensitive-data',
loanId: 123,
tx: {
signedTxXdr: 'nested-sensitive-data',
details: {
token: 'deep-secret',
publicInfo: 'safe',
},
},
};

await auditLog(req as Request, res as Response, next);
await new Promise((resolve) => setTimeout(resolve, 10));
auditLog(req as Request, res as Response, next);
await new Promise((resolve) => setTimeout(resolve, 20));

expect(mockedQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO audit_logs'),
Expand All @@ -76,6 +92,7 @@ describe('Audit Log Middleware', () => {
'LoanID:123',
expect.stringContaining('[REDACTED]'),
expect.anything(),
200,
]),
);

Expand All @@ -86,6 +103,9 @@ describe('Audit Log Middleware', () => {
const parsedPayload = JSON.parse(callPayload);
expect(parsedPayload.secret).toBe('[REDACTED]');
expect(parsedPayload.loanId).toBe(123);
expect(parsedPayload.tx.signedTxXdr).toBe('[REDACTED]');
expect(parsedPayload.tx.details.token).toBe('[REDACTED]');
expect(parsedPayload.tx.details.publicInfo).toBe('safe');
} else {
throw new Error('Payload was not recorded as a string');
}
Expand All @@ -97,8 +117,8 @@ describe('Audit Log Middleware', () => {
role: 'admin',
};

await auditLog(req as Request, res as Response, next);
await new Promise((resolve) => setTimeout(resolve, 10));
auditLog(req as Request, res as Response, next);
await new Promise((resolve) => setTimeout(resolve, 20));

expect(mockedQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO audit_logs'),
Expand All @@ -108,6 +128,26 @@ describe('Audit Log Middleware', () => {
expect.anything(),
expect.anything(),
expect.anything(),
200,
]),
);
});

it('should capture a denied admin request', async () => {
res.statusCode = 403; // Simulate a denied request

auditLog(req as Request, res as Response, next);
await new Promise((resolve) => setTimeout(resolve, 20));

expect(mockedQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO audit_logs'),
expect.arrayContaining([
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
403,
]),
);
});
Expand Down
13 changes: 13 additions & 0 deletions pr_body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Closes #1183

### What does this PR do?
This PR addresses the security vulnerability where audit logs recorded the request but never the outcome, and where deep secrets could leak into the database because of shallow payload redaction.

### Description
- **Recursive Redaction:** Upgraded the `sanitizePayload` logic in `auditLog.ts` to recursively scan objects and arrays, safely redacting keys defined in `sensitiveFields` (like `secret`, `token`, `signedTxXdr`) at any nested depth.
- **Outcome Status Tracking:** Created a database migration to add a nullable `status` (integer) column to the `audit_logs` table.
- **Asynchronous Response Logging:** Hooked the audit log database insertion logic to the Express `res.on('finish')` event, accurately capturing the final `res.statusCode` representing success (200) or denial (403/400).
- **Test Coverage:** Added missing test cases to `auditLog.test.ts` to simulate deep nested secrets and denied requests. Also updated `eventIndexer` test logic and assertions to account for the new column schema.

### Note
A few unrelated endpoints in `loanEndpoints.test.ts` and background jobs were failing locally on the main branch before these changes, but `auditLog.ts` and `eventIndexer.ts` related tests passed with flying colors.
Loading