fix: redact sensitive log data

This commit is contained in:
4gray committed 2026-07-13 21:53:09 +02:00
1 parent eb1bfaa474
commit 4c6044f810
27 files changed
+816 -153

No files matched your search

+1
View File
@@ -0,0 +1 @@
export * from './lib/redact-sensitive-data';
@@ -0,0 +1,124 @@
import { REDACTED_VALUE, redactSensitiveData } from './redact-sensitive-data';
const TEST_SECRETS = [
'settings-api-key-secret',
'nested-user-secret',
'nested-password-secret',
'nested-token-secret',
'nested-auth-secret',
'nested-mac-secret',
'query-password-secret',
'query-token-secret',
];
function serialized(value: unknown): string {
return JSON.stringify(value);
}
describe('redactSensitiveData', () => {
it('recursively redacts credentials while retaining diagnostic fields', () => {
const input = {
operation: 'get_profile',
settings: { tmdb: { apiKey: TEST_SECRETS[0] } },
params: {
username: TEST_SECRETS[1],
PASSWORD: TEST_SECRETS[2],
access_token: TEST_SECRETS[3],
headers: { Authorization: `Bearer ${TEST_SECRETS[4]}` },
macAddress: TEST_SECRETS[5],
},
};
const result = redactSensitiveData(input);
const output = serialized(result);
for (const secret of TEST_SECRETS) {
expect(output).not.toContain(secret);
}
expect(result).toEqual({
operation: 'get_profile',
settings: { tmdb: { apiKey: REDACTED_VALUE } },
params: {
username: REDACTED_VALUE,
PASSWORD: REDACTED_VALUE,
access_token: REDACTED_VALUE,
headers: { Authorization: REDACTED_VALUE },
macAddress: REDACTED_VALUE,
},
});
});
it('redacts credentials embedded in URL, URLSearchParams, errors, and serialized strings', () => {
const url = new URL(
`https://user:pass@example.com/live?password=${TEST_SECRETS[6]}&token=${TEST_SECRETS[7]}&action=get_live_streams`
);
const params = new URLSearchParams({
authorization: TEST_SECRETS[4],
category: 'news',
});
const error = new Error(
`Request failed: https://example.com/api?token=${TEST_SECRETS[3]}&action=profile`
);
const json = JSON.stringify({
refreshToken: TEST_SECRETS[3],
status: 401,
});
const output = serialized(
redactSensitiveData({ url, params, error, json })
);
for (const secret of TEST_SECRETS) {
expect(output).not.toContain(secret);
}
expect(output).toContain('action=get_live_streams');
expect(output).toContain('category=news');
expect(output).toContain('status');
expect(output).toContain('401');
});
it('redacts credentials nested inside non-sensitive query values', () => {
const nestedUrl = `https://identity.example/callback?token=${TEST_SECRETS[3]}&step=authorize`;
const url = new URL('https://example.com/portal');
url.searchParams.set('redirect', nestedUrl);
url.searchParams.set(
'payload',
JSON.stringify({ password: TEST_SECRETS[2], action: 'profile' })
);
const output = serialized(redactSensitiveData(url));
expect(output).not.toContain(TEST_SECRETS[2]);
expect(output).not.toContain(TEST_SECRETS[3]);
expect(output).toContain('authorize');
expect(output).toContain('profile');
});
it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => {
const input: Record<string, unknown> = {
status: 'ok',
password: TEST_SECRETS[2],
items: [1, 2, 3, 4],
long: 'abcdefghij',
nested: { level: { value: 'too deep' } },
extraA: 'a',
extraB: 'b',
};
input['self'] = input;
const originalItems = input['items'];
const result = redactSensitiveData(input, {
maxArrayItems: 2,
maxDepth: 2,
maxObjectKeys: 6,
maxStringLength: 8,
});
expect(input['password']).toBe(TEST_SECRETS[2]);
expect(input['items']).toBe(originalItems);
expect(result).not.toBe(input);
expect(() => serialized(result)).not.toThrow();
expect(serialized(result)).not.toContain(TEST_SECRETS[2]);
expect(serialized(result)).toContain('[Truncated');
});
});
@@ -0,0 +1,320 @@
export const REDACTED_VALUE = '[Redacted]';
const CIRCULAR_VALUE = '[Circular]';
const MAX_DEPTH_VALUE = '[MaxDepth]';
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_ARRAY_ITEMS = 50;
const DEFAULT_MAX_OBJECT_KEYS = 50;
const DEFAULT_MAX_STRING_LENGTH = 2_000;
const SENSITIVE_KEY_NAMES = new Set([
'apikey',
'auth',
'authorization',
'cookie',
'credentials',
'login',
'mac',
'macaddress',
'passwd',
'password',
'pwd',
'secret',
'setcookie',
'token',
'username',
]);
const SENSITIVE_KEY_SUFFIXES = [
'apikey',
'authorization',
'cookie',
'macaddress',
'passwd',
'password',
'secret',
'token',
'username',
];
export interface RedactionOptions {
maxDepth?: number;
maxArrayItems?: number;
maxObjectKeys?: number;
maxStringLength?: number;
}
interface ResolvedRedactionOptions {
maxDepth: number;
maxArrayItems: number;
maxObjectKeys: number;
maxStringLength: number;
}
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function isSensitiveKey(key: string): boolean {
const normalized = normalizeKey(key);
return (
SENSITIVE_KEY_NAMES.has(normalized) ||
SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
);
}
function resolveOptions(options: RedactionOptions): ResolvedRedactionOptions {
return {
maxDepth: Math.max(0, options.maxDepth ?? DEFAULT_MAX_DEPTH),
maxArrayItems: Math.max(
0,
options.maxArrayItems ?? DEFAULT_MAX_ARRAY_ITEMS
),
maxObjectKeys: Math.max(
0,
options.maxObjectKeys ?? DEFAULT_MAX_OBJECT_KEYS
),
maxStringLength: Math.max(
0,
options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH
),
};
}
function truncateString(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
const omitted = value.length - maxLength;
return `${value.slice(0, maxLength)}[Truncated ${omitted} chars]`;
}
function redactSearchParams(
params: URLSearchParams,
sanitizeValue: (value: string) => string
): URLSearchParams {
const redacted = new URLSearchParams();
params.forEach((value, key) => {
redacted.append(
key,
isSensitiveKey(key) ? REDACTED_VALUE : sanitizeValue(value)
);
});
return redacted;
}
function redactUrl(
value: URL,
sanitizeValue: (value: string) => string
): string {
const redacted = new URL(value.toString());
if (redacted.username) {
redacted.username = REDACTED_VALUE;
}
if (redacted.password) {
redacted.password = REDACTED_VALUE;
}
const search = redactSearchParams(
redacted.searchParams,
sanitizeValue
).toString();
redacted.search = search ? `?${search}` : '';
return redacted.toString();
}
function redactUrlStrings(
value: string,
sanitizeValue: (value: string) => string
): string {
return value.replace(/https?:\/\/[^\s"'<>]+/giu, (candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
});
}
function looksLikeSearchParams(value: string): boolean {
return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)+$/u.test(value);
}
export function redactSensitiveData(
value: unknown,
options: RedactionOptions = {}
): unknown {
const resolved = resolveOptions(options);
const seen = new WeakSet<object>();
const visitString = (input: string, depth: number): string => {
const trimmed = input.trim();
if (depth >= resolved.maxDepth) {
return MAX_DEPTH_VALUE;
}
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
return truncateString(
JSON.stringify(visit(JSON.parse(trimmed), depth + 1)),
resolved.maxStringLength
);
} catch {
// Keep processing malformed or non-JSON diagnostic strings.
}
}
if (/^https?:\/\//iu.test(trimmed)) {
try {
return truncateString(
redactUrl(new URL(trimmed), (entry) =>
visitString(entry, depth + 1)
),
resolved.maxStringLength
);
} catch {
// Continue with embedded URL handling for malformed URLs.
}
}
if (looksLikeSearchParams(trimmed)) {
return truncateString(
redactSearchParams(new URLSearchParams(trimmed), (entry) =>
visitString(entry, depth + 1)
).toString(),
resolved.maxStringLength
);
}
return truncateString(
redactUrlStrings(input, (entry) => visitString(entry, depth + 1)),
resolved.maxStringLength
);
};
const visitObject = (
input: Record<string, unknown>,
depth: number
): Record<string, unknown> => {
const output: Record<string, unknown> = {};
const keys = Object.keys(input);
for (const key of keys.slice(0, resolved.maxObjectKeys)) {
if (isSensitiveKey(key)) {
output[key] = REDACTED_VALUE;
continue;
}
try {
output[key] = visit(input[key], depth + 1);
} catch {
output[key] = '[Unserializable]';
}
}
if (keys.length > resolved.maxObjectKeys) {
output['__truncatedKeys'] = keys.length - resolved.maxObjectKeys;
}
return output;
};
const visitError = (
error: Error,
depth: number
): Record<string, unknown> => {
const output = visitObject(
error as Error & Record<string, unknown>,
depth
);
output['name'] = visitString(error.name, depth + 1);
output['message'] = visitString(error.message, depth + 1);
if (error.stack) {
output['stack'] = visitString(error.stack, depth + 1);
}
if ('cause' in error) {
output['cause'] = visit(error.cause, depth + 1);
}
return output;
};
const visit = (input: unknown, depth: number): unknown => {
if (
input == null ||
typeof input === 'boolean' ||
typeof input === 'number'
) {
return input;
}
if (typeof input === 'string') {
return visitString(input, depth);
}
if (typeof input === 'bigint') {
return input.toString();
}
if (typeof input === 'symbol') {
return input.toString();
}
if (typeof input === 'function') {
return undefined;
}
if (depth >= resolved.maxDepth) {
return MAX_DEPTH_VALUE;
}
const object = input as object;
if (seen.has(object)) {
return CIRCULAR_VALUE;
}
seen.add(object);
if (input instanceof URL) {
return redactUrl(input, (entry) => visitString(entry, depth + 1));
}
if (input instanceof URLSearchParams) {
return redactSearchParams(input, (entry) =>
visitString(entry, depth + 1)
).toString();
}
if (input instanceof Error) {
return visitError(input, depth);
}
if (input instanceof Date) {
return input.toISOString();
}
if (Array.isArray(input)) {
const output = input
.slice(0, resolved.maxArrayItems)
.map((entry) => visit(entry, depth + 1));
if (input.length > resolved.maxArrayItems) {
output.push(
`[Truncated ${input.length - resolved.maxArrayItems} items]`
);
}
return output;
}
if (input instanceof Map) {
const entries: Record<string, unknown> = {};
for (const [key, entry] of input) {
entries[String(key)] = entry;
}
return visitObject(entries, depth);
}
if (input instanceof Set) {
return visit(Array.from(input), depth);
}
return visitObject(input as Record<string, unknown>, depth);
};
return visit(value, 0);
}