Files
wehub-resource-sync f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:07:03 +08:00

339 lines
12 KiB
TypeScript

import { describe, it, expect } from 'bun:test';
import os from 'os';
import {
redactHomeDir,
redactAbsolutePaths,
redactUrlQueryStrings,
redactSecrets,
collapseWhitespace,
redactText,
scrubMessage,
scrubStack,
scrubError,
extractErrorType,
messageTemplate,
REDACTED,
MESSAGE_MAX_CHARS,
STACK_MAX_CHARS,
STACK_MAX_FRAMES,
} from '../../src/services/telemetry/error-scrub';
describe('error-scrub: redactHomeDir', () => {
it('replaces the home directory with ~', () => {
const home = os.homedir();
const input = `Failed to read ${home}/projects/secret/file.ts`;
const out = redactHomeDir(input);
expect(out).not.toContain(home);
expect(out).toContain('~');
});
it('is a no-op when home is not present', () => {
expect(redactHomeDir('plain message')).toBe('plain message');
});
});
describe('error-scrub: redactAbsolutePaths', () => {
it('collapses POSIX absolute paths to basename', () => {
const out = redactAbsolutePaths('cannot open /var/lib/private/data/config.json now');
expect(out).toContain('config.json');
expect(out).not.toContain('/var/lib/private');
});
it('collapses Windows drive paths to basename', () => {
const out = redactAbsolutePaths('open C:\\Users\\bob\\secret\\app.log failed');
expect(out).toContain('app.log');
expect(out).not.toContain('C:\\Users\\bob');
});
it('leaves relative paths and basenames untouched', () => {
expect(redactAbsolutePaths('error in config.json')).toBe('error in config.json');
});
});
describe('error-scrub: redactUrlQueryStrings', () => {
it('strips query strings (keeps host + path)', () => {
const out = redactUrlQueryStrings('GET https://api.example.com/v1/data?token=secret123&x=1 failed');
expect(out).toContain('https://api.example.com/v1/data');
expect(out).not.toContain('token=secret123');
expect(out).not.toContain('x=1');
});
it('strips fragments too', () => {
const out = redactUrlQueryStrings('see http://host/path#sessionid=abc');
expect(out).not.toContain('sessionid=abc');
});
it('strips http userinfo (username AND password)', () => {
const out = redactUrlQueryStrings('GET https://alice:s3cr3t@10.0.0.5/path failed');
expect(out).not.toContain('alice');
expect(out).not.toContain('s3cr3t');
expect(out).toContain(REDACTED);
expect(out).toContain('@10.0.0.5/path');
});
it('redacts postgres connection-string credentials + query', () => {
const out = redactUrlQueryStrings('ECONNREFUSED postgres://u:p@host:5432/db?sslmode=require');
expect(out).not.toContain('u:p');
expect(out).not.toContain('sslmode=require');
expect(out).toContain('@host:5432/db');
});
it('redacts redis:// credentials', () => {
const out = redactUrlQueryStrings('redis://admin:hunter2@cache:6379 down');
expect(out).not.toContain('admin');
expect(out).not.toContain('hunter2');
expect(out).toContain('@cache:6379');
});
it('redacts mongodb+srv:// credentials', () => {
const out = redactUrlQueryStrings('mongodb+srv://user:pass@cluster0.mongodb.net/db retrying');
expect(out).not.toContain('user:pass');
expect(out).toContain('@cluster0.mongodb.net/db');
});
it('redacts mysql:// credentials', () => {
const out = redactUrlQueryStrings('mysql://root:toor@db.internal:3306/app failed');
expect(out).not.toContain('root:toor');
expect(out).toContain('@db.internal:3306/app');
});
it('redacts amqp:// credentials', () => {
const out = redactUrlQueryStrings('amqp://guest:guest@broker:5672 unreachable');
expect(out).not.toContain('guest:guest');
expect(out).toContain('@broker:5672');
});
});
describe('error-scrub: redactSecrets', () => {
it('masks email addresses', () => {
const out = redactSecrets('contact alice@example.com about this');
expect(out).not.toContain('alice@example.com');
expect(out).toContain(REDACTED);
});
it('masks sk- API keys', () => {
const out = redactSecrets('auth failed for sk-ABCdef1234567890ghij');
expect(out).not.toContain('sk-ABCdef1234567890ghij');
expect(out).toContain(REDACTED);
});
it('masks phc_ PostHog keys', () => {
const out = redactSecrets('key phc_ABCdef1234567890ghijKLMNOP invalid');
expect(out).not.toContain('phc_ABCdef1234567890ghijKLMNOP');
expect(out).toContain(REDACTED);
});
it('masks generic long tokens, hex blobs and JWTs', () => {
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N';
const hex = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
const out = redactSecrets(`jwt=${jwt} hash=${hex}`);
expect(out).not.toContain(jwt);
expect(out).not.toContain(hex);
expect(out).toContain(REDACTED);
});
it('masks AWS access key IDs', () => {
const out = redactSecrets('creds AKIAIOSFODNN7EXAMPLE rejected');
expect(out).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(out).toContain(REDACTED);
});
it('masks ASIA/AROA-prefixed AWS key IDs too', () => {
const out = redactSecrets('temp key ASIAY34FZKBOKMUTVV7A here');
expect(out).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(out).toContain(REDACTED);
});
it('masks IPv4 addresses', () => {
const out = redactSecrets('connect failed to 10.0.0.5:5432');
expect(out).not.toContain('10.0.0.5');
expect(out).toContain(REDACTED);
});
it('does NOT mask 3-part version numbers as IPs', () => {
const out = redactSecrets('claude-mem 13.6.2 ready');
expect(out).toContain('13.6.2');
});
it('still redacts real emails with the BOUNDED regex', () => {
const out = redactSecrets('contact user+tag@sub.example.com please');
expect(out).not.toContain('user+tag@sub.example.com');
expect(out).toContain(REDACTED);
});
});
describe('error-scrub: collapseWhitespace', () => {
it('collapses runs of whitespace and trims', () => {
expect(collapseWhitespace(' a b\t\tc ')).toBe('a b c');
});
});
describe('error-scrub: scrubMessage caps length', () => {
it('caps message at MESSAGE_MAX_CHARS', () => {
const out = scrubMessage('x'.repeat(MESSAGE_MAX_CHARS + 200));
expect(out.length).toBe(MESSAGE_MAX_CHARS);
});
it('applies the full pipeline in order', () => {
const home = os.homedir();
const out = scrubMessage(
`boom at ${home}/p/file.ts calling https://h/x?k=sk-ABCdef1234567890ghij mail bob@h.com`
);
expect(out).not.toContain(home);
expect(out).not.toContain('k=sk-');
expect(out).not.toContain('bob@h.com');
});
});
describe('error-scrub: scrubStack', () => {
it('keeps only the top N frames and caps total length', () => {
const frames = Array.from({ length: 50 }, (_, i) => ` at fn${i} (/abs/path/file${i}.ts:${i}:1)`);
const stack = ['Error: boom', ...frames].join('\n');
const out = scrubStack(stack);
const lineCount = out.split('\n').length;
// header + at most STACK_MAX_FRAMES frame lines
expect(lineCount).toBeLessThanOrEqual(STACK_MAX_FRAMES + 1);
expect(out.length).toBeLessThanOrEqual(STACK_MAX_CHARS);
expect(out).not.toContain('/abs/path');
});
it('returns empty string for non-string input', () => {
expect(scrubStack(undefined)).toBe('');
expect(scrubStack(null)).toBe('');
expect(scrubStack(123 as unknown as string)).toBe('');
});
});
describe('error-scrub: extractErrorType', () => {
it('reads the error name/type', () => {
expect(extractErrorType(new TypeError('x'))).toBe('TypeError');
expect(extractErrorType(new Error('x'))).toBe('Error');
});
it('classifies non-Error throws', () => {
expect(extractErrorType('boom')).toBe('StringError');
expect(extractErrorType(null)).toBe('NullError');
expect(extractErrorType(undefined)).toBe('UndefinedError');
expect(extractErrorType(42)).toBe('numberError');
// A plain object's constructor name ('Object') is preferred over the
// generic fallback; a null-prototype object falls back to 'ObjectError'.
expect(extractErrorType({})).toBe('Object');
expect(extractErrorType(Object.create(null))).toBe('ObjectError');
});
});
describe('error-scrub: scrubError never throws on hostile input', () => {
it('handles null / undefined / primitives', () => {
expect(() => scrubError(null)).not.toThrow();
expect(() => scrubError(undefined)).not.toThrow();
expect(() => scrubError(42)).not.toThrow();
expect(() => scrubError('a string error')).not.toThrow();
const r = scrubError('a string error');
expect(r.type).toBe('StringError');
expect(r.message).toBe('a string error');
});
it('handles objects with throwing getters', () => {
const hostile: Record<string, unknown> = {};
Object.defineProperty(hostile, 'message', {
enumerable: true,
get() {
throw new Error('gotcha');
},
});
Object.defineProperty(hostile, 'stack', {
enumerable: true,
get() {
throw new Error('gotcha2');
},
});
expect(() => scrubError(hostile)).not.toThrow();
const r = scrubError(hostile);
expect(typeof r.type).toBe('string');
expect(typeof r.message).toBe('string');
expect(typeof r.stack).toBe('string');
});
it('handles circular references', () => {
const circular: Record<string, unknown> = { name: 'X' };
circular.self = circular;
expect(() => scrubError(circular)).not.toThrow();
});
it('redacts a real Error end to end', () => {
const home = os.homedir();
const err = new Error(`open ${home}/repo/secret.ts for user bob@h.com token sk-ABCdef1234567890ghij`);
const out = scrubError(err);
expect(out.type).toBe('Error');
expect(out.message).not.toContain(home);
expect(out.message).not.toContain('bob@h.com');
expect(out.message).not.toContain('sk-ABCdef1234567890ghij');
});
});
describe('error-scrub: messageTemplate collapses varying ids', () => {
it('collapses numbers and quoted values to a stable template', () => {
const a = messageTemplate('User 12 not found in "repo-alpha"');
const b = messageTemplate('User 9999 not found in "repo-beta"');
expect(a).toBe(b);
});
it('never throws on non-string', () => {
expect(() => messageTemplate(null)).not.toThrow();
expect(messageTemplate(null)).toBe('');
});
});
describe('error-scrub: redactText handles non-strings', () => {
it('returns empty for null/undefined', () => {
expect(redactText(null)).toBe('');
expect(redactText(undefined)).toBe('');
});
});
describe('error-scrub: ReDoS / CPU-DoS bound (perf)', () => {
// These inputs drove the email/JWT/generic-token regexes O(n²) before the
// MAX_RAW_INPUT_CHARS cap + bounded quantifiers. Pre-fix, scrubbing a 200KB
// hostile string took seconds (measured 200KB → ~32s). Post-fix it must be
// well under 100ms because no regex ever sees more than ~8KB.
const BUDGET_MS = 100;
it('scrubs a 200KB email-local-part run (no @) well under 100ms', () => {
const hostile = 'a.b-c_d%e+f'.repeat(20000); // ~220KB of local-part chars, no '@'
const t0 = performance.now();
const out = scrubMessage(hostile);
const elapsed = performance.now() - t0;
expect(typeof out).toBe('string');
expect(out.length).toBeLessThanOrEqual(MESSAGE_MAX_CHARS);
expect(elapsed).toBeLessThan(BUDGET_MS);
});
it('scrubs a 100KB base64 blob well under 100ms', () => {
const blob = 'A1b2C3d4E5f6G7h8'.repeat(7000); // ~112KB base64url-ish, digit-laden
const t0 = performance.now();
const out = scrubMessage(blob);
const elapsed = performance.now() - t0;
expect(typeof out).toBe('string');
expect(elapsed).toBeLessThan(BUDGET_MS);
});
it('scrubs a 200KB hostile stack well under 100ms', () => {
const hostile = 'x.y-z_w%v+u'.repeat(20000);
const t0 = performance.now();
const out = scrubStack(`Error: boom\n at fn (${hostile})`);
const elapsed = performance.now() - t0;
expect(typeof out).toBe('string');
expect(out.length).toBeLessThanOrEqual(STACK_MAX_CHARS);
expect(elapsed).toBeLessThan(BUDGET_MS);
});
it('scrubError end-to-end on a 200KB hostile message is bounded', () => {
const hostile = 'a.b-c_d%e+f'.repeat(20000);
const t0 = performance.now();
const out = scrubError(new Error(hostile));
const elapsed = performance.now() - t0;
expect(out.message.length).toBeLessThanOrEqual(MESSAGE_MAX_CHARS);
expect(elapsed).toBeLessThan(BUDGET_MS);
});
});