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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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`.
75 changes: 74 additions & 1 deletion src/middleware/auditLog.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createAuditLog>): Express {
const app = express();
Expand Down Expand Up @@ -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);
});
});

55 changes: 53 additions & 2 deletions src/middleware/auditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,54 @@ export interface AuditEntry {
status: number;
requestId?: string;
timestamp: string;
headers?: Record<string, string | string[] | undefined>;
body?: Record<string, any>;
}

export const SENSITIVE_FIELD_DENYLIST: ReadonlySet<string> = 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<T>(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<string, any> = {};
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"]);
Expand Down Expand Up @@ -44,17 +92,20 @@ 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();
},
entries: () => history.all(),
};
}