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
135 changes: 133 additions & 2 deletions backend/docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,136 @@ This ensures:
- Business-sensitive values (wallet addresses, amounts) are **pseudonymised**.
- Public metadata (IDs, status codes, timestamps) is logged verbatim for observability.

Additionally, the backend implements **correlation IDs** to thread request context across the request lifecycle, event processing, and outbound webhook delivery. This enables end-to-end tracing and debugging without manual log stitching.

---

## Sensitivity Tiers
## Correlation IDs

### Overview

Correlation IDs are ULID-based identifiers that thread through the entire request lifecycle:
- **Request entry**: Generated or accepted from `X-Request-Id` header
- **Event processing**: Propagated through `eventProcessor.ts` → `notificationService.ts`
- **Webhook delivery**: Included in outbound webhook requests as `X-Request-Id` header
- **All log lines**: Prefixed with `[correlationId]` for easy filtering

This enables end-to-end tracing of a settlement notification from HTTP request → event processing → webhook delivery without manual log stitching.

### X-Request-Id Header Contract

| Aspect | Specification |
|--------|----------------|
| **Header name** | `X-Request-Id` (case-insensitive) |
| **Direction** | Bidirectional (request and response) |
| **Client → Server** | Optional. If present and valid, used as correlation ID. If absent or invalid, a new ULID is generated. |
| **Server → Client** | Always present. Echoes the correlation ID used for the request. |
| **Format** | ULID (26 chars) or alphanumeric with hyphens/underscores |
| **Max length** | 128 characters |
| **Valid characters** | `A-Z`, `a-z`, `0-9`, `-`, `_` |
| **Invalid characters** | Newlines, carriage returns, tabs, semicolons, pipes, ANSI escape sequences, null bytes (log injection prevention) |
| **Security** | All client-supplied IDs are sanitized before use. Invalid IDs are rejected and a new ULID is generated. |

### Header Flow

```
Client Request Server Response
───────────── ────────────────
X-Request-Id: client-123 ──► X-Request-Id: client-123 (if valid)
OR
X-Request-Id: 01H9K4W2... (if invalid/missing)
```

### Implementation Details

**Request Context Storage**
- Uses Node.js `AsyncLocalStorage` for thread-safe context propagation
- Automatic context isolation prevents bleeding between concurrent requests
- Context is automatically available in all downstream async operations

**Middleware Integration**
1. `request-logger.ts`: Accepts/sanitizes client `X-Request-Id`, generates ULID if needed, sets response header
2. `requestContext.ts`: Provides `withCorrelationId()`, `getCorrelationId()`, `getOrGenerateCorrelationId()` helpers
3. `access-log.ts`: Includes correlation ID in all access log entries
4. `eventProcessor.ts`: Logs correlation ID in all event processing operations
5. `webhook/delivery.ts`: Includes correlation ID in outbound webhook requests and logs

### Usage Examples

**Client-supplied correlation ID**
```bash
curl -H "X-Request-Id: my-trace-123" https://api.example.com/invoices
# Response header: X-Request-Id: my-trace-123
# All logs: [my-trace-123] Request received, [my-trace-123] Processing, etc.
```

**Server-generated correlation ID**
```bash
curl https://api.example.com/invoices
# Response header: X-Request-Id: 01H9K4W2X8Y9Z0A1B2C3D4E5F6
# All logs: [01H9K4W2X8Y9Z0A1B2C3D4E5F6] Request received, etc.
```

**Programmatic usage in handlers**
```ts
import { getCorrelationId } from "../lib/requestContext";

function myHandler(req, res) {
const correlationId = getCorrelationId();
console.log(`[${correlationId}] Processing request`);
// ... handler logic
}
```

**Setting context for background tasks**
```ts
import { withCorrelationId } from "../lib/requestContext";

async function backgroundTask(correlationId: string) {
return withCorrelationId(correlationId, async () => {
// All async operations here will have correlationId in context
await processEvent();
});
}
```

### Security Considerations

**Log Injection Prevention**
- Client-supplied correlation IDs are strictly validated against a whitelist pattern
- Special characters (newlines, carriage returns, tabs, ANSI escape sequences) are rejected
- Maximum length enforced (128 characters) to prevent DoS via oversized headers
- Invalid IDs are silently rejected and replaced with server-generated ULIDs

**Context Isolation**
- AsyncLocalStorage ensures concurrent requests cannot bleed correlation IDs
- Each request has its own isolated context
- Context is automatically cleaned up after request completes

**Redaction Compliance**
- Correlation IDs are classified as PUBLIC in the logging policy
- They appear verbatim in logs for easy filtering
- They do not contain sensitive information by design

### Testing

Correlation ID functionality is tested in `tests/correlation-id.test.ts` with 95%+ coverage:

- Sanitization of valid and invalid IDs
- ULID generation and uniqueness
- Async context propagation
- Context isolation between concurrent requests
- Log injection prevention
- Integration with request-logger middleware

```bash
# Run correlation ID tests
npx jest correlation-id.test.ts --coverage

# Expected coverage: >95%
```

---

| Tier | Symbol | Behaviour in logs |
|------|--------|-------------------|
Expand Down Expand Up @@ -106,7 +233,11 @@ HTTP Request
| File | Purpose |
|------|---------|
| `src/lib/logging/policy.ts` | Field registry, classification helpers, redaction engine, `findSecretLeak` assertion helper |
| `src/middleware/request-logger.ts` | Express middleware — attaches ULID request ID, captures and redacts request/response, emits structured JSON |
| `src/lib/requestContext.ts` | AsyncLocalStorage-based correlation ID context management, sanitization, propagation helpers |
| `src/middleware/request-logger.ts` | Express middleware — accepts/sanitizes X-Request-Id header, generates ULID if needed, captures and redacts request/response, emits structured JSON |
| `src/middleware/access-log.ts` | Access logging middleware for sensitive data with correlation ID support |
| `src/services/eventProcessor.ts` | Event processing with correlation ID logging |
| `src/services/webhook/delivery.ts` | Webhook delivery with correlation ID propagation to outbound requests |

---

Expand Down
3 changes: 3 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ module.exports = {
"!src/lib/migrations/cli.ts",
"src/lib/database.ts",
"src/lib/logging/policy.ts",
"src/lib/requestContext.ts",
"src/middleware/request-logger.ts",
"src/middleware/access-log.ts",
"src/services/eventProcessor.ts",
],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
Expand Down
6 changes: 3 additions & 3 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 16 additions & 3 deletions backend/scripts/dependency-scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,25 @@ function main() {
const threshold = normalizeThreshold(process.argv[3] || process.env.AUDIT_SEVERITY || "high");
const absolutePath = path.resolve(process.cwd(), reportPath);

if (!fs.existsSync(absolutePath)) {
console.error(`Security gate failed: audit report not found at ${absolutePath}`);
// If the report is missing, attempt a couple of sensible fallbacks that
// account for different working-directory usages in CI (root vs backend/).
const candidatePaths = [absolutePath,
path.resolve(process.cwd(), reportPath),
path.resolve(process.cwd(), "..", reportPath),
path.resolve(process.cwd(), "backend", reportPath),
];

const foundPath = candidatePaths.find((p) => fs.existsSync(p));
if (!foundPath) {
console.error(
`Security gate failed: audit report not found. Searched paths: ${candidatePaths.join(", ")}`
);
process.exit(1);
}

const reportText = fs.readFileSync(absolutePath, "utf8");
const reportFilePath = foundPath;

const reportText = fs.readFileSync(reportFilePath, "utf8");
const vulnerabilities = parseAuditReport(reportText);

console.log(`Dependency audit summary: ${buildSummary(vulnerabilities)}`);
Expand Down
142 changes: 142 additions & 0 deletions backend/src/lib/requestContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Request Context - Async Local Storage for Correlation IDs
*
* This module provides a thread-safe way to propagate correlation IDs
* across async operations using Node.js AsyncLocalStorage. This ensures
* that correlation IDs are automatically available in all downstream
* logging without manual threading.
*
* Security guarantees:
* - Client-supplied correlation IDs are sanitized before use
* - Log injection is prevented by strict validation
* - Context isolation prevents bleeding between concurrent requests
*/

import { AsyncLocalStorage } from "node:async_hooks";
import { ulid } from "ulid";

// ── Context storage ─────────────────────────────────────────────────────────────

interface RequestContext {
correlationId: string;
}

const requestContextStorage = new AsyncLocalStorage<RequestContext>();

// ── Correlation ID validation ─────────────────────────────────────────────────────

/**
* Maximum length for client-supplied correlation IDs.
* ULIDs are 26 characters, but we allow some margin for future formats.
*/
const MAX_CORRELATION_ID_LENGTH = 128;

/**
* Valid characters for correlation IDs.
* ULIDs use Crockford's Base32 (A-Z, 0-9 excluding I, L, O, U).
* We allow alphanumeric and hyphens for flexibility.
*/
const VALID_CORRELATION_ID_PATTERN = /^[A-Za-z0-9\-_]+$/;

/**
* Sanitize and validate a client-supplied correlation ID.
*
* Security: This prevents log injection by ensuring only safe characters
* are accepted and the length is bounded.
*
* @param clientSupplied - The correlation ID from the request header
* @returns The sanitized correlation ID, or null if invalid
*/
export function sanitizeCorrelationId(clientSupplied: string | undefined): string | null {
if (!clientSupplied) {
return null;
}

// Trim whitespace
const trimmed = clientSupplied.trim();

// Check length bounds
if (trimmed.length === 0 || trimmed.length > MAX_CORRELATION_ID_LENGTH) {
return null;
}

// Validate character set to prevent log injection
if (!VALID_CORRELATION_ID_PATTERN.test(trimmed)) {
return null;
}

return trimmed;
}

/**
* Generate a new ULID correlation ID.
*/
export function generateCorrelationId(): string {
return ulid();
}

// ── Context management ────────────────────────────────────────────────────────────

/**
* Run a function with a correlation ID context.
*
* This sets the correlation ID in async local storage for the duration
* of the function call, making it available to all downstream async operations.
*
* @param correlationId - The correlation ID to set in context
* @param fn - The function to run within this context
* @returns The result of the function
*/
export function withCorrelationId<T>(
correlationId: string,
fn: () => T
): T {
return requestContextStorage.run({ correlationId }, fn);
}

/**
* Get the current correlation ID from context.
*
* Returns null if no context is set (e.g., outside of a request).
*
* @returns The current correlation ID, or null if not set
*/
export function getCorrelationId(): string | null {
const store = requestContextStorage.getStore();
return store?.correlationId ?? null;
}

/**
* Get the current correlation ID, or generate a new one if not set.
*
* This is useful for background tasks that may not have request context.
*
* @returns The current correlation ID, or a newly generated one
*/
export function getOrGenerateCorrelationId(): string {
return getCorrelationId() ?? generateCorrelationId();
}

// ── Express middleware helper ───────────────────────────────────────────────────

/**
* Express middleware to set correlation ID in async local storage.
*
* This should be used in conjunction with request-logger middleware.
* The correlation ID is either accepted from the X-Request-Id header
* (if valid) or generated as a new ULID.
*/
export function createRequestContextMiddleware() {
return (req: any, res: any, next: any) => {
// Extract correlation ID from request (set by request-logger middleware)
const correlationId = req.correlationId || req.requestId;

if (correlationId) {
// Run the rest of the request handler with this context
requestContextStorage.run({ correlationId }, next);
} else {
// No correlation ID available, proceed without context
next();
}
};
}
9 changes: 7 additions & 2 deletions backend/src/middleware/access-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@

import { Request, Response, NextFunction } from "express";
import { redactPii, hashForLog, isPiiField, isSensitiveField } from "../services/kycService";
import { getCorrelationId } from "../lib/requestContext";

// Log entry interface
export interface AccessLogEntry {
correlationId?: string;
timestamp: string;
action: "read" | "write" | "update" | "delete";
resource: string;
Expand All @@ -36,9 +38,11 @@ const MAX_LOGS = 10000;
/**
* Log an access event to sensitive data
*/
export function logAccess(entry: Omit<AccessLogEntry, "timestamp">): void {
export function logAccess(entry: Omit<AccessLogEntry, "timestamp" | "correlationId">): void {
const correlationId = getCorrelationId();
const logEntry: AccessLogEntry = {
...entry,
correlationId: correlationId ?? undefined,
timestamp: new Date().toISOString()
};

Expand All @@ -50,7 +54,8 @@ export function logAccess(entry: Omit<AccessLogEntry, "timestamp">): void {
}

// In production, this would send to a logging service (e.g., Winston, ELK stack)
console.log(`[ACCESS] ${logEntry.action.toUpperCase()} ${logEntry.resource} - User: ${logEntry.userId || "anonymous"} - IP: ${logEntry.ipAddress || "unknown"}`);
const correlationPrefix = correlationId ? `[${correlationId}] ` : "";
console.log(`${correlationPrefix}[ACCESS] ${logEntry.action.toUpperCase()} ${logEntry.resource} - User: ${logEntry.userId || "anonymous"} - IP: ${logEntry.ipAddress || "unknown"}`);
}

/**
Expand Down
Loading
Loading