morphit/apps/relay/scripts/log-sanitize-smoke.ts

234 lines
8.5 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Morphit relay — log sanitize smoke (cp139-E-1).
*
* Asserts that textSink + formatValue strip terminal-control
* escapes before writing to stdout/stderr. jsonSink is also
* exercised; JSON.stringify natively escapes control bytes so
* the JSON path should pass through these tests trivially —
* including jsonSink is defense-in-depth in case a future
* refactor changes how either sink emits.
*
* Threat model: operator-configurable context values (RPC
* endpoint URLs, persistPath, recipient account names) or
* chain-RPC error messages could contain ANSI/CSI escapes;
* without sanitize the textSink bare-string emission path
* would let them reach the operator's journal/console
* verbatim.
*
* Wired into scripts/run-smokes.sh.
*/
import { textSink, jsonSink, type LogRecord } from '../src/log/index.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
function assertEqual<T>(actual: T, expected: T, label: string): void {
if (actual !== expected) {
throw new Error(`${label}: actual=${JSON.stringify(actual)}, expected=${JSON.stringify(expected)}`);
}
}
function assertNotMatch(haystack: string, needle: string | RegExp, label: string): void {
const found = typeof needle === 'string' ? haystack.includes(needle) : needle.test(haystack);
if (found) {
throw new Error(`${label}: unexpectedly found ${JSON.stringify(needle.toString())} in:\n${haystack}`);
}
}
function assertMatch(haystack: string, needle: string | RegExp, label: string): void {
const found = typeof needle === 'string' ? haystack.includes(needle) : needle.test(haystack);
if (!found) {
throw new Error(`${label}: expected to find ${JSON.stringify(needle.toString())} in:\n${haystack}`);
}
}
/** Capture process.stdout/stderr writes during a closure. */
function captureWrites(fn: () => void): { stdout: string; stderr: string } {
const origOut = process.stdout.write.bind(process.stdout);
const origErr = process.stderr.write.bind(process.stderr);
let stdout = '';
let stderr = '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdout.write as any) = (chunk: unknown): boolean => {
stdout += String(chunk);
return true;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stderr.write as any) = (chunk: unknown): boolean => {
stderr += String(chunk);
return true;
};
try {
fn();
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdout.write as any) = origOut;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stderr.write as any) = origErr;
}
return { stdout, stderr };
}
function mkRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
ts: '2026-05-25T00:00:00.000Z',
level: 'info',
module: 'test',
event: 'evt',
context: {},
...overrides
};
}
console.log('\n── relay log sanitize smoke (cp139-E-1) ─────────────────\n');
// ─── textSink: bare-string emission strips control bytes ──────
scenario('cp139-E-1: textSink strips ANSI ESC from string-without-space context value', () => {
const r = mkRecord({
level: 'info',
module: 'access',
event: 'request',
context: { path: '/api/foo\x1b[2J' } // <-- ANSI clear-screen embedded
});
const { stdout } = captureWrites(() => textSink(r));
// The ESC byte must NOT appear raw in the output stream.
assertNotMatch(stdout, '\x1b[2J', 'raw ANSI ESC[2J must not reach stdout');
assertNotMatch(stdout, '\x1b', 'raw ESC byte must not reach stdout');
// The visible filename portion should survive (sanitize keeps printables).
assertMatch(stdout, 'path=/api/foo[2J', 'sanitized value still readable');
});
scenario('cp139-E-1: textSink strips C0 controls (NUL/BEL/BS) from bare value', () => {
const r = mkRecord({
level: 'info',
context: { token: 'abc\x00def\x07ghi\x08jkl' }
});
const { stdout } = captureWrites(() => textSink(r));
for (const code of [0x00, 0x07, 0x08]) {
assertNotMatch(stdout, String.fromCharCode(code), `C0 0x${code.toString(16)} must not reach stdout`);
}
assertMatch(stdout, 'token=abcdefghijkl', 'visible portions survive');
});
scenario('cp139-E-1: textSink strips DEL (0x7F) from bare value', () => {
const r = mkRecord({ context: { account: 'alice\x7fbob' } });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x7f', 'DEL must not reach stdout');
assertMatch(stdout, 'account=alicebob', 'survives');
});
scenario('cp139-E-1: textSink strips C1 (0x80-0x9F) from bare value', () => {
// C1 8-bit ESC introducer (0x9b = CSI) is a hostile byte
// that some terminals interpret as ESC [. Strip.
const r = mkRecord({ context: { rpc: 'wss://node\x9b2Jevil' } });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x9b', 'C1 CSI 0x9b must not reach stdout');
assertMatch(stdout, 'rpc=wss://node2Jevil', 'survives');
});
scenario('cp139-E-1: textSink with string-with-space goes JSON path (still no control bytes)', () => {
// formatValue JSON.stringify path also gets sanitize (defense
// in depth). Even though JSON.stringify would already escape
// \x1b → \u001b, we sanitize before JSON.stringify too.
const r = mkRecord({ context: { note: 'with spaces \x1b[31mred\x1b[0m and stuff' } });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x1b', 'no raw ESC in JSON path');
});
scenario('cp139-E-1: textSink preserves printable ASCII intact', () => {
const r = mkRecord({
event: 'request',
context: {
method: 'POST',
path: '/api/account/create',
status: 200,
dur_ms: 42
}
});
const { stdout } = captureWrites(() => textSink(r));
assertMatch(stdout, '[test] request method=POST path=/api/account/create status=200 dur_ms=42', 'expected line');
});
scenario('cp139-E-1: textSink preserves SGR escape (legitimate color)', () => {
// SGR escapes (ESC [ N;N;...m) are allowed through because
// they're used by fmt.X color helpers — would-be useful for
// future colored log output.
const r = mkRecord({ context: { tag: '\x1b[31m' } });
const { stdout } = captureWrites(() => textSink(r));
assertMatch(stdout, 'tag=\x1b[31m', 'SGR survives');
});
scenario('cp139-E-1: textSink drops non-SGR ESC sequence', () => {
// ESC [ 2J is non-SGR (J = clear screen), must be dropped.
const r = mkRecord({ context: { tag: '\x1b[2J' } });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x1b[2J', 'ESC [ 2J dropped');
});
scenario('cp139-E-1: textSink sanitizes module name', () => {
const r = mkRecord({ module: 'mod\x1b[2J' });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x1b[2J', 'module sanitized');
assertMatch(stdout, '[mod[2J]', 'module visible portion present');
});
scenario('cp139-E-1: textSink sanitizes event name', () => {
const r = mkRecord({ event: 'evt\x1bX' });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x1b', 'event ESC stripped');
assertMatch(stdout, 'evtX', 'event visible portion present');
});
scenario('cp139-E-1: textSink sanitizes context key name', () => {
const r = mkRecord({ context: { 'a\x1bb': 'val' } });
const { stdout } = captureWrites(() => textSink(r));
assertNotMatch(stdout, '\x1b', 'context key ESC stripped');
});
scenario('cp139-E-1: textSink sanitizes error stack trace', () => {
const r = mkRecord({
level: 'error',
error: {
name: 'TestErr',
message: 'boom',
stack: 'TestErr: boom\n at \x1b[2Jevil:1:1'
}
});
const { stderr } = captureWrites(() => textSink(r));
assertNotMatch(stderr, '\x1b[2J', 'stack trace ESC stripped');
});
// ─── jsonSink: JSON.stringify already escapes control bytes ──
scenario('jsonSink already escapes control bytes via JSON.stringify', () => {
const r = mkRecord({ context: { evil: 'foo\x1b[2Jbar' } });
const { stdout } = captureWrites(() => jsonSink(r));
assertNotMatch(stdout, '\x1b', 'no raw ESC in JSON');
assertMatch(stdout, '\\u001b', 'JSON-escaped ESC present');
});
// ─── Summary ─────────────────────────────────────────────────
console.log(`\n${'─'.repeat(54)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} log-sanitize scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}