mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(logging): redact sensitive portal and Electron diagnostics (#1182)
* fix: redact sensitive log data * fix(ci): keep logging preload self-contained * fix(logging): preserve shared diagnostics * fix(logging): close trace redaction gaps * fix(logging): redact Xtream path credentials * fix(logging): close credential redaction gaps * fix(logging): suppress external player arguments * fix(logging): harden URL and date redaction * fix(logging): redact map keys and URL fragments * fix(logging): redact sensitive map values * fix(logging): redact credentials in diagnostic text * fix(logging): close remaining credential leaks
This commit is contained in:
1 parent
29e624b0dd
commit
5cae310430
29 files changed
+1203
-158
No files matched your search
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
REDACTED_VALUE,
|
||||
redactSensitiveData,
|
||||
} from './lib/redact-sensitive-data';
|
||||
export type { RedactionOptions } from './lib/redact-sensitive-data';
|
||||
@@ -0,0 +1,357 @@
|
||||
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 Stalker identity credentials while retaining request diagnostics', () => {
|
||||
const identitySecrets = {
|
||||
sn: 'stalker-sn-secret',
|
||||
serialNumber: 'stalker-serial-number-secret',
|
||||
device_id: 'stalker-device-id-secret',
|
||||
deviceId1: 'stalker-device-id1-secret',
|
||||
device_id2: 'stalker-device-id2-secret',
|
||||
signature: 'stalker-signature-secret',
|
||||
signature1: 'stalker-signature1-secret',
|
||||
signature2: 'stalker-signature2-secret',
|
||||
stalkerSerialNumber: 'playlist-stalker-serial-number-secret',
|
||||
stalkerDeviceId1: 'playlist-stalker-device-id1-secret',
|
||||
stalkerDeviceId2: 'playlist-stalker-device-id2-secret',
|
||||
stalkerSignature1: 'playlist-stalker-signature1-secret',
|
||||
stalkerSignature2: 'playlist-stalker-signature2-secret',
|
||||
prehash: 'stalker-prehash-secret',
|
||||
};
|
||||
|
||||
const result = redactSensitiveData({
|
||||
action: 'get_profile',
|
||||
requestId: 'stalker-request-id',
|
||||
params: identitySecrets,
|
||||
});
|
||||
const output = serialized(result);
|
||||
|
||||
for (const secret of Object.values(identitySecrets)) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
expect(output).toContain('get_profile');
|
||||
expect(output).toContain('stalker-request-id');
|
||||
});
|
||||
|
||||
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 in non-HTTP stream URL strings', () => {
|
||||
const username = 'rtsp-user-secret';
|
||||
const password = 'rtsp-password-secret';
|
||||
const token = 'rtmp-token-secret';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData([
|
||||
`rtsp://${username}:${password}@stream.example/live?token=${token}&channel=news`,
|
||||
`Playback failed: rtmp://stream.example/live?password=${password}&channel=sports`,
|
||||
])
|
||||
);
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('channel=news');
|
||||
expect(output).toContain('channel=sports');
|
||||
});
|
||||
|
||||
it('redacts a credential from a single-parameter string', () => {
|
||||
const password = 'single-param-password-secret';
|
||||
const token = 'single-param-token-secret';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData([
|
||||
`password=${password}`,
|
||||
`token=${token}`,
|
||||
'operation=get_profile',
|
||||
])
|
||||
);
|
||||
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
it('redacts credentials embedded in diagnostic text', () => {
|
||||
const token = 'diagnostic-token-secret';
|
||||
const authorization = 'diagnostic-authorization-secret';
|
||||
const authorizationAssignment =
|
||||
'diagnostic-authorization-assignment-secret';
|
||||
const stalkerDeviceId = 'diagnostic-stalker-device-id-secret';
|
||||
const diagnostic = [
|
||||
`Request failed: token=${token}&action=get_profile`,
|
||||
`Upstream response Authorization: Bearer ${authorization}; status=401`,
|
||||
`Retrying with authorization=Bearer ${authorizationAssignment}, attempt=2`,
|
||||
`Identity rejected: stalkerDeviceId1: ${stalkerDeviceId}; action=handshake`,
|
||||
];
|
||||
|
||||
const output = serialized(redactSensitiveData(diagnostic));
|
||||
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).not.toContain(authorization);
|
||||
expect(output).not.toContain(authorizationAssignment);
|
||||
expect(output).not.toContain(stalkerDeviceId);
|
||||
expect(output).toContain('get_profile');
|
||||
expect(output).toContain('status=401');
|
||||
expect(output).toContain('attempt=2');
|
||||
expect(output).toContain('action=handshake');
|
||||
});
|
||||
|
||||
it('does not repeat a redacted Error message secret in its stack', () => {
|
||||
const secret = 'error-stack-password-secret';
|
||||
const error = new Error(`password=${secret}&operation=get_profile`);
|
||||
|
||||
const output = serialized(redactSensitiveData(error));
|
||||
|
||||
expect(output).not.toContain(secret);
|
||||
expect(output).toContain(encodeURIComponent(REDACTED_VALUE));
|
||||
expect(output).toContain('get_profile');
|
||||
});
|
||||
|
||||
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('redacts credentials from URL fragments while retaining diagnostics', () => {
|
||||
const token = 'fragment-token-secret';
|
||||
const url = new URL(
|
||||
`https://example.com/callback#access_token=${token}&state=diagnostic`
|
||||
);
|
||||
|
||||
const output = serialized(redactSensitiveData(url));
|
||||
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('state=diagnostic');
|
||||
});
|
||||
|
||||
it('redacts credentials from Map keys while retaining values', () => {
|
||||
const username = 'map-key-user-secret';
|
||||
const password = 'map-key-password-secret';
|
||||
const token = 'map-key-token-secret';
|
||||
const requests = new Map([
|
||||
[
|
||||
`https://example.com/live/${username}/${password}/101.ts?token=${token}`,
|
||||
{ status: 200 },
|
||||
],
|
||||
]);
|
||||
|
||||
const output = serialized(redactSensitiveData(requests));
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).not.toContain(token);
|
||||
expect(output).toContain('101.ts');
|
||||
expect(output).toContain('"status":200');
|
||||
});
|
||||
|
||||
it('redacts Map values selected by sensitive keys', () => {
|
||||
const authorization = 'map-authorization-secret';
|
||||
const password = 'map-password-secret';
|
||||
const headers = new Map([
|
||||
['Authorization', `Bearer ${authorization}`],
|
||||
['password', password],
|
||||
['requestId', 'diagnostic-request-id'],
|
||||
]);
|
||||
|
||||
const result = redactSensitiveData(headers);
|
||||
const output = serialized(result);
|
||||
|
||||
expect(output).not.toContain(authorization);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).toContain('diagnostic-request-id');
|
||||
expect(result).toEqual({
|
||||
Authorization: REDACTED_VALUE,
|
||||
password: REDACTED_VALUE,
|
||||
requestId: 'diagnostic-request-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts Xtream credentials in playback URL paths', () => {
|
||||
const username = 'xtream-path-user-secret';
|
||||
const password = 'xtream-path-password-secret';
|
||||
const urls = [
|
||||
new URL(
|
||||
`https://example.com/base/live/${username}/${password}/101.ts`
|
||||
),
|
||||
new URL(
|
||||
`https://example.com/movie/${username}/${password}/202.mkv`
|
||||
),
|
||||
new URL(
|
||||
`https://example.com/series/${username}/${password}/303.mp4`
|
||||
),
|
||||
new URL(
|
||||
`https://example.com/timeshift/${username}/${password}/60/2026-07-18:12-00/404.ts`
|
||||
),
|
||||
];
|
||||
const embedded = `Playback failed for https://example.com/live/${username}/${password}/505.m3u8`;
|
||||
const ordinaryLiveUrl = 'https://example.com/live/channel.m3u8';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData({ urls, embedded, ordinaryLiveUrl })
|
||||
);
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
expect(output).toContain('101.ts');
|
||||
expect(output).toContain('202.mkv');
|
||||
expect(output).toContain('303.mp4');
|
||||
expect(output).toContain('404.ts');
|
||||
expect(output).toContain('505.m3u8');
|
||||
expect(output).toContain(ordinaryLiveUrl);
|
||||
});
|
||||
|
||||
it('redacts Xtream path credentials when no resource segment follows', () => {
|
||||
const username = 'terminal-xtream-user-secret';
|
||||
const password = 'terminal-xtream-password-secret';
|
||||
|
||||
const output = serialized(
|
||||
redactSensitiveData(
|
||||
new URL(`https://example.com/live/${username}/${password}`)
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).not.toContain(username);
|
||||
expect(output).not.toContain(password);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('serializes invalid dates without throwing', () => {
|
||||
const invalidDate = new Date(Number.NaN);
|
||||
|
||||
expect(() => redactSensitiveData(invalidDate)).not.toThrow();
|
||||
expect(redactSensitiveData([invalidDate])).toEqual(['[Invalid Date]']);
|
||||
});
|
||||
|
||||
it('preserves repeated non-circular references while still redacting them', () => {
|
||||
const shared = {
|
||||
operation: 'get_profile',
|
||||
password: TEST_SECRETS[2],
|
||||
};
|
||||
|
||||
const result = redactSensitiveData({ first: shared, second: shared });
|
||||
|
||||
expect(result).toEqual({
|
||||
first: {
|
||||
operation: 'get_profile',
|
||||
password: REDACTED_VALUE,
|
||||
},
|
||||
second: {
|
||||
operation: 'get_profile',
|
||||
password: REDACTED_VALUE,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
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',
|
||||
'deviceid',
|
||||
'deviceid2',
|
||||
'login',
|
||||
'mac',
|
||||
'macaddress',
|
||||
'mpvplayerarguments',
|
||||
'passwd',
|
||||
'password',
|
||||
'pwd',
|
||||
'secret',
|
||||
'setcookie',
|
||||
'signature',
|
||||
'signature2',
|
||||
'sn',
|
||||
'token',
|
||||
'username',
|
||||
'vlcplayerarguments',
|
||||
]);
|
||||
|
||||
const SENSITIVE_KEY_SUFFIXES = [
|
||||
'apikey', 'authorization', 'cookie',
|
||||
'deviceid', 'deviceid1', 'deviceid2',
|
||||
'macaddress', 'passwd', 'password', 'prehash',
|
||||
'serialnumber', 'signature', 'signature1', 'signature2',
|
||||
'secret', 'token', 'username',
|
||||
];
|
||||
|
||||
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
|
||||
'live',
|
||||
'movie',
|
||||
'series',
|
||||
'timeshift',
|
||||
]);
|
||||
|
||||
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 pathSegments = redacted.pathname.split('/');
|
||||
for (let index = 0; index < pathSegments.length - 2; index += 1) {
|
||||
if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) {
|
||||
pathSegments[index + 1] = REDACTED_VALUE;
|
||||
pathSegments[index + 2] = REDACTED_VALUE;
|
||||
}
|
||||
}
|
||||
redacted.pathname = pathSegments.join('/');
|
||||
|
||||
const search = redactSearchParams(
|
||||
redacted.searchParams,
|
||||
sanitizeValue
|
||||
).toString();
|
||||
redacted.search = search ? `?${search}` : '';
|
||||
|
||||
const fragment = redacted.hash.slice(1);
|
||||
if (looksLikeSearchParams(fragment)) {
|
||||
const hash = redactSearchParams(
|
||||
new URLSearchParams(fragment),
|
||||
sanitizeValue
|
||||
).toString();
|
||||
redacted.hash = hash ? `#${hash}` : '';
|
||||
}
|
||||
|
||||
return redacted.toString();
|
||||
}
|
||||
|
||||
function redactUrlStrings(
|
||||
value: string,
|
||||
sanitizeValue: (value: string) => string
|
||||
): string {
|
||||
return value.replace(
|
||||
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
|
||||
(candidate) => {
|
||||
try {
|
||||
return redactUrl(new URL(candidate), sanitizeValue);
|
||||
} catch {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function redactEmbeddedSensitivePairs(value: string): string {
|
||||
const redactedAssignments = value.replace(
|
||||
/\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu,
|
||||
(match, key: string, separator: string) =>
|
||||
isSensitiveKey(key)
|
||||
? `${key}${separator}${REDACTED_VALUE}`
|
||||
: match
|
||||
);
|
||||
return redactedAssignments.replace(
|
||||
/\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu,
|
||||
(_match, key: string, separator: string) =>
|
||||
`${key}${separator}${REDACTED_VALUE}`
|
||||
);
|
||||
}
|
||||
|
||||
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 (/^[a-z][a-z0-9+.-]*:\/\//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
|
||||
);
|
||||
}
|
||||
|
||||
const redactedText = redactEmbeddedSensitivePairs(input);
|
||||
return truncateString(
|
||||
redactUrlStrings(redactedText, (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) {
|
||||
const [, ...stackFrames] = error.stack.split('\n');
|
||||
output['stack'] = visitString(
|
||||
[`${output['name']}: ${output['message']}`, ...stackFrames].join(
|
||||
'\n'
|
||||
),
|
||||
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);
|
||||
|
||||
try {
|
||||
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 Number.isNaN(input.getTime())
|
||||
? '[Invalid Date]'
|
||||
: 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> = {};
|
||||
const mapEntries = Array.from(input).slice(
|
||||
0,
|
||||
resolved.maxObjectKeys
|
||||
);
|
||||
for (const [key, entry] of mapEntries) {
|
||||
const stringKey = String(key);
|
||||
const redactedKey = visitString(stringKey, depth + 1);
|
||||
entries[redactedKey] =
|
||||
isSensitiveKey(stringKey) &&
|
||||
!/[/:?=&]/u.test(stringKey)
|
||||
? REDACTED_VALUE
|
||||
: visit(entry, depth + 1);
|
||||
}
|
||||
if (input.size > resolved.maxObjectKeys) {
|
||||
entries['__truncatedKeys'] =
|
||||
input.size - resolved.maxObjectKeys;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
if (input instanceof Set) {
|
||||
return visit(Array.from(input), depth);
|
||||
}
|
||||
|
||||
return visitObject(input as Record<string, unknown>, depth);
|
||||
} finally {
|
||||
seen.delete(object);
|
||||
}
|
||||
};
|
||||
|
||||
return visit(value, 0);
|
||||
}
|
||||
Reference in new issue
Block a user