From 64c26ab47a282d042b7914667f682a5761b047c6 Mon Sep 17 00:00:00 2001 From: emmyoat Date: Thu, 23 Jul 2026 19:32:43 +0100 Subject: [PATCH] fix(audit): redact sensitive fields and headers from audit log entries --- README.md | 2 +- docs/ARCHITECTURE.md | 23 ++++++++++ src/middleware/auditLog.test.ts | 75 ++++++++++++++++++++++++++++++++- src/middleware/auditLog.ts | 55 +++++++++++++++++++++++- 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 docs/ARCHITECTURE.md diff --git a/README.md b/README.md index 65c3640..e5559a1 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Server runs at `http://localhost:3001` by default. Set `PORT` to override. - `GET /api/v1/openapi.json` – hand-maintained OpenAPI-shaped description of every route below - `GET /api/v1/audit` – the most recent mutating requests (method, path, - status, request id, timestamp), last 200 in memory + status, request id, timestamp), last 200 in memory; sensitive fields/headers (such as `x-api-key`, `authorization`, `secret`) are automatically redacted via a denylist ### Liquidity diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..815867f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,23 @@ +# AnchorNet Backend Architecture & Security Guarantees + +## Overview + +The `anchornet-backend` service provides REST APIs for Stellar liquidity coordination, settlement, and routing. + +## Security Architecture & Audit Log Guarantees + +### Audit Endpoint (`GET /api/v1/audit`) + +The audit log middleware (`src/middleware/auditLog.ts`) captures recent mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) in an in-memory bounded ring buffer. + +#### Captured Fields +- `method`: HTTP method +- `path`: Request path (without query parameters) +- `status`: Response status code +- `requestId`: Correlation ID from `x-request-id` response header +- `timestamp`: ISO timestamp of request completion + +#### Sensitive Data Redaction & Security Guarantees +- **Strict Redaction via Denylist**: Any header, body parameter, or metadata stored in audit log entries is processed through `redactSensitiveData()`. +- **Denylisted Fields**: Secret-bearing keys such as `x-api-key`, `authorization`, `cookie`, `set-cookie`, `token`, `access_token`, `refresh_token`, `secret`, `password`, `bearer`, `private_key`, `client_secret` are matched case-insensitively and replaced with `"[REDACTED]"`. +- **Preventing Plaintext Exposure**: Under no circumstances should raw credentials or API keys be captured or retained in plaintext in the in-memory audit ring buffer or exposed via `GET /api/v1/audit`. diff --git a/src/middleware/auditLog.test.ts b/src/middleware/auditLog.test.ts index 41c750b..0c564a9 100644 --- a/src/middleware/auditLog.test.ts +++ b/src/middleware/auditLog.test.ts @@ -1,6 +1,11 @@ import express, { Express } from "express"; import request from "supertest"; -import { createAuditLog } from "./auditLog"; +import { + createAuditLog, + isSensitiveField, + redactSensitiveData, + SENSITIVE_FIELD_DENYLIST, +} from "./auditLog"; function makeApp(audit: ReturnType): Express { const app = express(); @@ -45,3 +50,71 @@ describe("createAuditLog", () => { expect(audit.entries()).toHaveLength(2); }); }); + +describe("audit log sensitive data redaction", () => { + it("includes x-api-key, authorization, and secret fields in denylist", () => { + expect(SENSITIVE_FIELD_DENYLIST.has("x-api-key")).toBe(true); + expect(SENSITIVE_FIELD_DENYLIST.has("authorization")).toBe(true); + expect(SENSITIVE_FIELD_DENYLIST.has("password")).toBe(true); + expect(SENSITIVE_FIELD_DENYLIST.has("secret")).toBe(true); + expect(SENSITIVE_FIELD_DENYLIST.has("token")).toBe(true); + }); + + it("matches denylisted fields case-insensitively", () => { + expect(isSensitiveField("X-API-KEY")).toBe(true); + expect(isSensitiveField("x-api-key")).toBe(true); + expect(isSensitiveField("Authorization")).toBe(true); + expect(isSensitiveField("AUTHORIZATION")).toBe(true); + expect(isSensitiveField("Secret")).toBe(true); + }); + + it("redacts denylisted headers and body fields while preserving non-sensitive data", () => { + const sensitiveData = { + method: "POST", + path: "/api/v1/anchors", + headers: { + "x-api-key": "secret_key_12345", + "Authorization": "Bearer token_xyz", + "content-type": "application/json", + }, + body: { + id: "anchor-1", + name: "Test Anchor", + password: "super_secret_password", + nested: { + token: "nested_token_abc", + safeField: "safeValue", + }, + }, + }; + + const redacted = redactSensitiveData(sensitiveData); + + expect(redacted).toEqual({ + method: "POST", + path: "/api/v1/anchors", + headers: { + "x-api-key": "[REDACTED]", + "Authorization": "[REDACTED]", + "content-type": "application/json", + }, + body: { + id: "anchor-1", + name: "Test Anchor", + password: "[REDACTED]", + nested: { + token: "[REDACTED]", + safeField: "safeValue", + }, + }, + }); + }); + + it("handles null or primitive values gracefully", () => { + expect(redactSensitiveData(null)).toBeNull(); + expect(redactSensitiveData(undefined)).toBeUndefined(); + expect(redactSensitiveData("plain_string")).toBe("plain_string"); + expect(redactSensitiveData(123)).toBe(123); + }); +}); + diff --git a/src/middleware/auditLog.ts b/src/middleware/auditLog.ts index 75e2748..5fc34c9 100644 --- a/src/middleware/auditLog.ts +++ b/src/middleware/auditLog.ts @@ -16,6 +16,54 @@ export interface AuditEntry { status: number; requestId?: string; timestamp: string; + headers?: Record; + body?: Record; +} + +export const SENSITIVE_FIELD_DENYLIST: ReadonlySet = new Set([ + "x-api-key", + "api-key", + "apikey", + "authorization", + "cookie", + "set-cookie", + "token", + "access_token", + "refresh_token", + "secret", + "password", + "bearer", + "private_key", + "privatekey", + "client_secret", +]); + +export function isSensitiveField(key: string): boolean { + return SENSITIVE_FIELD_DENYLIST.has(key.toLowerCase().trim()); +} + +export function redactSensitiveData(data: T): T { + if (data === null || data === undefined) { + return data; + } + if (typeof data !== "object") { + return data; + } + if (Array.isArray(data)) { + return data.map((item) => redactSensitiveData(item)) as unknown as T; + } + + const redactedObj: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (isSensitiveField(key)) { + redactedObj[key] = "[REDACTED]"; + } else if (typeof value === "object" && value !== null) { + redactedObj[key] = redactSensitiveData(value); + } else { + redactedObj[key] = value; + } + } + return redactedObj as T; } const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); @@ -44,13 +92,15 @@ export function createAuditLog( const path = req.originalUrl.split("?")[0]; res.on("finish", () => { - history.push({ + const entry: AuditEntry = { method, path, status: res.statusCode, requestId: res.getHeader("x-request-id") as string | undefined, timestamp: new Date().toISOString(), - }); + }; + + history.push(redactSensitiveData(entry)); }); next(); @@ -58,3 +108,4 @@ export function createAuditLog( entries: () => history.all(), }; } +