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
25 changes: 25 additions & 0 deletions docs/SENSITIVE_FIELD_REDACTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 🔒 Sensitive Field Redaction Policy & Logging Sanitation

This document defines the centralized logging redaction policies implemented across the NotifyChain platform (Issue #691).

---

## 1. Redaction Rules & Scope

All log messages and metadata emitted via `logger` are automatically sanitized before reaching any transport (Console, JSON log streams, File, CloudWatch, Datadog):

| Category | Target Patterns | Sanitized Representation |
|---|---|---|
| **Sensitive Keys** | `password`, `secret`, `authorization`, `auth_token`, `bearer`, `api_key`, `private_key`, `secret_key`, `webhook_url` | `[REDACTED]` |
| **Bearer Tokens** | `Bearer <token>` in headers or messages | `Bearer [REDACTED]` |
| **Discord Webhooks** | `https://discord.com/api/webhooks/<id>/<token>` | `https://discord.com/api/webhooks/[REDACTED_WEBHOOK_URL]` |
| **Stellar Secret Keys** | `S[A-Z2-7]{55}` (StrKey Ed25519) | `S[REDACTED_STELLAR_SECRET_KEY]` |
| **Error Stacks & Causes** | Error message, stack trace, and nested causal chains | Regex-sanitized |

---

## 2. Implementation & Unit Tests

* Redaction engine: [`listener/src/utils/redact.ts`](../listener/src/utils/redact.ts)
* Logger integration: [`listener/src/utils/logger.ts`](../listener/src/utils/logger.ts)
* Verification suite: [`listener/src/utils/redact.test.ts`](../listener/src/utils/redact.test.ts)
14 changes: 7 additions & 7 deletions listener/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,30 +79,30 @@ export function formatError(error: unknown): FormattedError | string {
return String(error);
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
import { redactSensitiveData, redactString } from './redact';

function formatMeta(meta: LogContext): LogContext {
if (!('error' in meta) || meta.error === undefined) {
return meta;
return redactSensitiveData(meta) as LogContext;
}

return {
const formatted = {
...meta,
error: formatError(meta.error),
};
return redactSensitiveData(formatted) as LogContext;
}

function logWithMeta(
level: LogLevel,
message: string,
meta?: LogContext
): void {
const sanitizedMessage = redactString(message);
if (meta && Object.keys(meta).length > 0) {
baseLogger[level](message, formatMeta(meta));
baseLogger[level](sanitizedMessage, formatMeta(meta));
} else {
baseLogger[level](message);
baseLogger[level](sanitizedMessage);
}
}

Expand Down
94 changes: 94 additions & 0 deletions listener/src/utils/redact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
isSensitiveKey,
redactSensitiveData,
redactString,
REDACTED_PLACEHOLDER,
} from './redact';

describe('Sensitive Field Redaction (Issue #691)', () => {
describe('isSensitiveKey', () => {
test('identifies sensitive key names regardless of casing', () => {
expect(isSensitiveKey('password')).toBe(true);
expect(isSensitiveKey('PASSWORD')).toBe(true);
expect(isSensitiveKey('userSecret')).toBe(true);
expect(isSensitiveKey('authorization')).toBe(true);
expect(isSensitiveKey('bearer_token')).toBe(true);
expect(isSensitiveKey('api_key')).toBe(true);
expect(isSensitiveKey('apiKey')).toBe(true);
expect(isSensitiveKey('webhook_url')).toBe(true);
expect(isSensitiveKey('stellar_secret_key')).toBe(true);
});

test('ignores non-sensitive key names', () => {
expect(isSensitiveKey('username')).toBe(false);
expect(isSensitiveKey('contractAddress')).toBe(false);
expect(isSensitiveKey('ledgerSequence')).toBe(false);
expect(isSensitiveKey('eventId')).toBe(false);
});
});

describe('redactString', () => {
test('redacts bearer tokens in string headers', () => {
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xyz';
const output = redactString(input);
expect(output).toBe('Authorization: Bearer [REDACTED]');
});

test('redacts Discord webhook URLs', () => {
const input = 'Sending notification to https://discord.com/api/webhooks/123456789/AbCdEfG_SecretToken';
const output = redactString(input);
expect(output).toBe('Sending notification to https://discord.com/api/webhooks/[REDACTED_WEBHOOK_URL]');
});

test('redacts Stellar secret keys', () => {
const secret = 'SCZANGBA5YHTNYVVV4C3U252E2B6P6IRKD45DCAHSKV2U2B6P6IRKD45';
const input = `Sign transaction using ${secret}`;
const output = redactString(input);
expect(output).toContain('S[REDACTED_STELLAR_SECRET_KEY]');
expect(output).not.toContain(secret);
});
});

describe('redactSensitiveData', () => {
test('redacts sensitive fields in nested objects', () => {
const rawPayload = {
userId: 'user-001',
credentials: {
apiKey: 'super-secret-key-12345',
password: 'my-plaintext-password',
},
webhookUrl: 'https://discord.com/api/webhooks/999/secret_token',
status: 'active',
};

const sanitized = redactSensitiveData(rawPayload) as typeof rawPayload;

expect(sanitized.userId).toBe('user-001');
expect(sanitized.status).toBe('active');
expect(sanitized.credentials.apiKey).toBe(REDACTED_PLACEHOLDER);
expect(sanitized.credentials.password).toBe(REDACTED_PLACEHOLDER);
expect(sanitized.webhookUrl).toBe(REDACTED_PLACEHOLDER);
});

test('redacts sensitive information in Error instances and stack traces', () => {
const secret = 'SCZANGBA5YHTNYVVV4C3U252E2B6P6IRKD45DCAHSKV2U2B6P6IRKD45';
const err = new Error(`Connection failed with key ${secret}`);

const sanitized = redactSensitiveData(err) as Record<string, unknown>;

expect(sanitized.name).toBe('Error');
expect(sanitized.message).not.toContain(secret);
expect(sanitized.message).toContain('S[REDACTED_STELLAR_SECRET_KEY]');
if (sanitized.stack) {
expect(sanitized.stack).not.toContain(secret);
}
});

test('handles circular references gracefully without crashing', () => {
const circular: Record<string, unknown> = { name: 'cyclic' };
circular.self = circular;

expect(() => redactSensitiveData(circular)).not.toThrow();
});
});
});
111 changes: 111 additions & 0 deletions listener/src/utils/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Sensitive Field Redaction Utility (Issue #691)
*
* Centralized redaction policy for credentials, webhook URLs, bearer tokens,
* private keys, and authorization headers before emission to log transports.
*/

export const REDACTED_PLACEHOLDER = '[REDACTED]';

// Sensitive key names (case-insensitive substring match)
export const SENSITIVE_KEY_PATTERNS = [
/password/i,
/secret/i,
/authorization/i,
/auth_token/i,
/bearer/i,
/api[_-]?key/i,
/private[_-]?key/i,
/secret[_-]?key/i,
/webhook[_-]?url/i,
/access[_-]?token/i,
/refresh[_-]?token/i,
];

// Regex patterns to redact sensitive values embedded inside strings
export const SENSITIVE_VALUE_REGEXES = [
// Bearer tokens: Bearer <token>
{ regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, replacement: 'Bearer [REDACTED]' },
// Discord Webhook URLs: https://discord.com/api/webhooks/<id>/<token>
{
regex: /https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/gi,
replacement: 'https://discord.com/api/webhooks/[REDACTED_WEBHOOK_URL]',
},
// Stellar secret keys (starts with S, 56 uppercase chars Base32)
{ regex: /\bS[A-Z2-7]{55}\b/g, replacement: 'S[REDACTED_STELLAR_SECRET_KEY]' },
// Embedded basic auth credentials in URLs: http(s)://user:pass@host
{ regex: /https?:\/\/[^/:]+:([^/@]+)@/gi, replacement: 'https://[REDACTED_AUTH]@' },
];

/**
* Checks whether a given object key is considered sensitive.
*/
export function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
}

/**
* Redacts known sensitive patterns inside arbitrary strings.
*/
export function redactString(str: string): string {
let result = str;
for (const { regex, replacement } of SENSITIVE_VALUE_REGEXES) {
result = result.replace(regex, replacement);
}
return result;
}

/**
* Recursively redacts sensitive keys and values from arbitrary objects/data.
*/
export function redactSensitiveData(data: unknown, seen = new WeakSet()): unknown {
if (data === null || data === undefined) {
return data;
}

if (typeof data === 'string') {
return redactString(data);
}

if (typeof data === 'number' || typeof data === 'boolean') {
return data;
}

if (typeof data === 'object') {
// Avoid cyclic references
if (seen.has(data)) {
return '[Circular Reference]';
}
seen.add(data);

if (Array.isArray(data)) {
return data.map((item) => redactSensitiveData(item, seen));
}

if (data instanceof Error) {
const copy: Record<string, unknown> = {
name: data.name,
message: redactString(data.message),
};
if (data.stack) {
copy.stack = redactString(data.stack);
}
if ('cause' in data && data.cause !== undefined) {
copy.cause = redactSensitiveData(data.cause, seen);
}
return copy;
}

const redactedObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (isSensitiveKey(key)) {
redactedObj[key] = REDACTED_PLACEHOLDER;
} else {
redactedObj[key] = redactSensitiveData(value, seen);
}
}
return redactedObj;
}

return data;
}