376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* term-sanitize-smoke — regression for cp139-C* terminal-escape
|
|
* hardening in ops-cli.
|
|
*
|
|
* Bug class: external content (DB rows, RPC responses, file
|
|
* content, library error messages) reaching the operator's
|
|
* terminal could contain ANSI/CSI/OSC escape sequences that
|
|
* terminals interpret as commands rather than displaying as
|
|
* text. Attack examples:
|
|
*
|
|
* - ESC ] 0 ; EVIL BEL set terminal window title
|
|
* - ESC [ 2 J ESC [ H clear screen + home cursor
|
|
* - ESC [ ? 1049 h switch to alternate buffer
|
|
* (hides previous output incl.
|
|
* evidence of attack)
|
|
* - ESC ] 52 ; c ; <b64> BEL write to clipboard (xterm/iterm2)
|
|
* - ESC [ 8 m ... ESC [ 0 m hide text inside legitimate-
|
|
* looking output
|
|
* - C1 0x9D OSC introducer same in 8-bit terminal mode
|
|
*
|
|
* Defense: `sanitizeForTerm` in apps/ops-cli/src/render/term.ts.
|
|
* Strips all C0/C1/DEL bytes and all ESC sequences EXCEPT SGR
|
|
* (color/style) so legitimate fmt.X output is preserved.
|
|
*
|
|
* This smoke verifies:
|
|
*
|
|
* 1. sanitizeForTerm strips every dangerous sequence class.
|
|
* 2. SGR color codes the fmt.* helpers emit DO survive.
|
|
* 3. Combined cases (mix of SGR + hostile) → only hostile parts
|
|
* stripped, SGR preserved.
|
|
* 4. Call-site application: the term.ts primitives (info, warn,
|
|
* error, row, section) call sanitize internally so any
|
|
* caller that goes through them inherits the defense.
|
|
* 5. Specific callsites that DIRECTLY use console.log
|
|
* (systemCheck.renderSystemCheck, paymentMethod list,
|
|
* explorerHealth.renderProbeStatus, init.ts write-failure
|
|
* branch, edit.ts atomicEnvWrite failure branch, register.ts
|
|
* env-error branch, importAltnetKey.ts err.message paths)
|
|
* apply sanitize at the call site.
|
|
*
|
|
* Tamper test: revert the sanitizeForTerm body (return s
|
|
* unchanged) → every sanitize scenario fails. Revert any single
|
|
* callsite → that scenario fails specifically.
|
|
*/
|
|
|
|
import { sanitizeForTerm, info, warn, error, row, section, fmt, initColor } from '../src/render/term.ts';
|
|
import { renderProbeStatus } from '../src/init/explorerHealth.ts';
|
|
import { renderSystemCheck, type SystemCheckResult } from '../src/init/systemCheck.ts';
|
|
|
|
// Initialize term with color disabled so fmt.* helpers are
|
|
// identity (we don't want SGR injected into our test inputs).
|
|
initColor({
|
|
databaseUrl: '',
|
|
relayAccount: '',
|
|
feesAccount: '',
|
|
// cp474 — both REQUIRED by Config and absent until now. `initColor` only
|
|
// reads `.color`, so nothing broke, but the literal stopped being a Config.
|
|
officialAccount: '',
|
|
operatorAccount: '',
|
|
signupDailyCeiling: 0,
|
|
thresholds: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any,
|
|
color: 'never'
|
|
});
|
|
|
|
interface Scenario {
|
|
readonly name: string;
|
|
readonly run: () => string | null;
|
|
}
|
|
|
|
// ─── Helper: capture stdout/stderr produced by a callback ────────
|
|
|
|
function captureWrites(fn: () => void): { stdout: string; stderr: string } {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const origOut = process.stdout.write.bind(process.stdout);
|
|
const origErr = process.stderr.write.bind(process.stderr);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(process.stdout.write as any) = (chunk: string): boolean => {
|
|
stdout += chunk;
|
|
return true;
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(process.stderr.write as any) = (chunk: string): boolean => {
|
|
stderr += chunk;
|
|
return true;
|
|
};
|
|
const origLog = console.log;
|
|
console.log = (...args: unknown[]): void => {
|
|
stdout += args.map((a) => String(a)).join(' ') + '\n';
|
|
};
|
|
try {
|
|
fn();
|
|
} finally {
|
|
process.stdout.write = origOut;
|
|
process.stderr.write = origErr;
|
|
console.log = origLog;
|
|
}
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
// ─── sanitize-direct scenarios (the helper itself) ───────────────
|
|
|
|
const sanitizeScenarios: Scenario[] = [
|
|
{
|
|
name: 'A-1: OSC set-title sequence (ESC ] 0 ; EVIL BEL) is stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('hello \x1b]0;EVIL\x07 world');
|
|
if (r.includes('\x1b') || r.includes('\x07')) return `survived ESC/BEL: ${JSON.stringify(r)}`;
|
|
if (!r.includes('hello') || !r.includes('world')) return `lost legit text: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-2: CSI clear-screen (ESC [ 2 J) is stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('before \x1b[2J after');
|
|
if (r.includes('\x1b')) return `survived ESC: ${JSON.stringify(r)}`;
|
|
// `[2J` is no longer a CSI without the ESC — the literal
|
|
// characters `[`, `2`, `J` remain but are inert. Test
|
|
// only verifies ESC is gone.
|
|
if (!r.includes('before') || !r.includes('after')) return `lost legit text: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-3: CSI alternate-buffer (ESC [ ? 1049 h) is stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('a\x1b[?1049hb');
|
|
if (r.includes('\x1b')) return `survived ESC: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-4: OSC clipboard-write is stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('text\x1b]52;c;c2VjcmV0\x07more');
|
|
if (r.includes('\x1b')) return `survived ESC: ${JSON.stringify(r)}`;
|
|
if (r.includes('\x07')) return `survived BEL: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-5: SGR red color (ESC [ 31 m) is PRESERVED',
|
|
run: () => {
|
|
const r = sanitizeForTerm('\x1b[31mRED\x1b[0m');
|
|
if (r !== '\x1b[31mRED\x1b[0m') return `SGR mangled: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-6: SGR multi-arg (ESC [ 1 ; 31 m) is PRESERVED',
|
|
run: () => {
|
|
const r = sanitizeForTerm('\x1b[1;31mBR\x1b[0m');
|
|
if (r !== '\x1b[1;31mBR\x1b[0m') return `multi-SGR mangled: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-7: Combined SGR + hostile OSC: only OSC stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('\x1b[31mred\x1b]0;EVIL\x07normal\x1b[0m');
|
|
if (r.includes('\x1b]')) return `survived OSC: ${JSON.stringify(r)}`;
|
|
if (r.includes('\x07')) return `survived BEL: ${JSON.stringify(r)}`;
|
|
if (!r.includes('\x1b[31m') || !r.includes('\x1b[0m'))
|
|
return `SGR stripped: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-8: C0 BS (0x08), FF (0x0c), VT (0x0b) all stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('a\x08b\x0bc\x0cd');
|
|
if (/[\x08\x0b\x0c]/.test(r)) return `survived C0: ${JSON.stringify(r)}`;
|
|
if (r !== 'abcd') return `wrong result: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-9: Tab (\\t) and newline (\\n) are PRESERVED',
|
|
run: () => {
|
|
const r = sanitizeForTerm('a\tb\nc');
|
|
if (r !== 'a\tb\nc') return `tab/newline mangled: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-10: DEL (0x7F) is stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('a\x7fb');
|
|
if (r.includes('\x7f')) return `survived DEL: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-11: C1 control bytes (0x80-0x9f) stripped',
|
|
run: () => {
|
|
const r = sanitizeForTerm('a\x9bb\x9dc');
|
|
if (/[\x80-\x9f]/.test(r)) return `survived C1: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-12: Empty string returns empty',
|
|
run: () => {
|
|
const r = sanitizeForTerm('');
|
|
if (r !== '') return `non-empty result: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-13: Pure ASCII passes through unchanged',
|
|
run: () => {
|
|
const r = sanitizeForTerm('Hello, world! 123 abc');
|
|
if (r !== 'Hello, world! 123 abc') return `ASCII mangled: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-14: UTF-8 non-ASCII (✓✗⚠) passes through unchanged',
|
|
run: () => {
|
|
const r = sanitizeForTerm('✓ ok, ✗ err, ⚠ warn');
|
|
if (r !== '✓ ok, ✗ err, ⚠ warn') return `UTF-8 mangled: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-15: ESC without [ is stripped (e.g. ESC c terminal reset)',
|
|
run: () => {
|
|
const r = sanitizeForTerm('a\x1bcb');
|
|
if (r.includes('\x1b')) return `survived bare ESC: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'A-16: malformed CSI without `m` is stripped',
|
|
run: () => {
|
|
// ESC [ 123 z — not an SGR (terminator z), should be stripped
|
|
const r = sanitizeForTerm('a\x1b[123zb');
|
|
if (r.includes('\x1b')) return `survived ESC: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
}
|
|
];
|
|
|
|
// ─── Call-site coverage: term.ts primitives ──────────────────────
|
|
|
|
const primitiveScenarios: Scenario[] = [
|
|
{
|
|
name: 'P-1: info() strips OSC sequence',
|
|
run: () => {
|
|
const { stdout } = captureWrites(() => info('hostile \x1b]0;EVIL\x07 content'));
|
|
if (stdout.includes('\x1b]')) return `info() let OSC through: ${JSON.stringify(stdout)}`;
|
|
if (stdout.includes('\x07')) return `info() let BEL through: ${JSON.stringify(stdout)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'P-2: warn() strips control bytes',
|
|
run: () => {
|
|
const { stderr } = captureWrites(() => warn('a\x08b'));
|
|
if (stderr.includes('\x08')) return `warn() let BS through: ${JSON.stringify(stderr)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'P-3: error() strips OSC sequence',
|
|
run: () => {
|
|
const { stderr } = captureWrites(() => error('err \x1b]0;X\x07 msg'));
|
|
if (stderr.includes('\x1b]')) return `error() let OSC through: ${JSON.stringify(stderr)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'P-4: row() strips control bytes in label/value/detail',
|
|
run: () => {
|
|
const { stdout } = captureWrites(() =>
|
|
row({
|
|
label: 'L\x08abel',
|
|
value: 'V\x0balue',
|
|
status: 'warn',
|
|
detail: 'D\x1b]0;X\x07etail'
|
|
})
|
|
);
|
|
if (/[\x08\x0b]/.test(stdout)) return `row() let C0 through: ${JSON.stringify(stdout)}`;
|
|
if (stdout.includes('\x1b]')) return `row() let OSC through: ${JSON.stringify(stdout)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'P-5: section() strips control bytes',
|
|
run: () => {
|
|
const { stdout } = captureWrites(() => section('Title\x1b]0;EVIL\x07'));
|
|
if (stdout.includes('\x1b]')) return `section() let OSC through: ${JSON.stringify(stdout)}`;
|
|
return null;
|
|
}
|
|
}
|
|
];
|
|
|
|
// ─── Call-site coverage: external content interpolation sites ────
|
|
|
|
const callsiteScenarios: Scenario[] = [
|
|
{
|
|
name: 'C-1: renderProbeStatus("wrong_shape") sanitizes reason',
|
|
run: () => {
|
|
const r = renderProbeStatus({
|
|
kind: 'wrong_shape',
|
|
latencyMs: 123,
|
|
reason: 'evil \x1b]0;X\x07 reason'
|
|
});
|
|
if (r.includes('\x1b]')) return `OSC survived: ${JSON.stringify(r)}`;
|
|
if (r.includes('\x07')) return `BEL survived: ${JSON.stringify(r)}`;
|
|
if (!r.includes('123ms')) return `legit content lost: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'C-2: renderProbeStatus("unreachable") sanitizes reason',
|
|
run: () => {
|
|
const r = renderProbeStatus({ kind: 'unreachable', reason: 'a\x1b[2Jb' });
|
|
if (r.includes('\x1b')) return `ESC survived: ${JSON.stringify(r)}`;
|
|
return null;
|
|
}
|
|
},
|
|
{
|
|
name: 'C-3: renderSystemCheck sanitizes c.name, c.actual, c.note',
|
|
run: () => {
|
|
const result: SystemCheckResult = {
|
|
checks: [
|
|
{
|
|
name: 'evil-name\x1b]0;A\x07',
|
|
actual: 'evil-actual\x08value',
|
|
recommended: '≥1',
|
|
status: 'warn',
|
|
note: 'evil-note \x1b]2;TITLE\x07 here'
|
|
}
|
|
],
|
|
hasErrors: false,
|
|
hasWarnings: true
|
|
};
|
|
const { stdout } = captureWrites(() => renderSystemCheck(result, false));
|
|
if (stdout.includes('\x1b]')) return `OSC survived: stdout=${JSON.stringify(stdout.slice(0, 200))}`;
|
|
if (stdout.includes('\x07')) return `BEL survived`;
|
|
if (stdout.includes('\x08')) return `BS survived`;
|
|
if (!stdout.includes('evil-name') || !stdout.includes('evil-actual'))
|
|
return `legit content stripped`;
|
|
return null;
|
|
}
|
|
}
|
|
];
|
|
|
|
// ─── Runner ──────────────────────────────────────────────────────
|
|
|
|
const scenarios = [...sanitizeScenarios, ...primitiveScenarios, ...callsiteScenarios];
|
|
|
|
console.log(`term-sanitize smoke (cp139-C): ${scenarios.length} scenarios\n`);
|
|
let failed = 0;
|
|
for (const s of scenarios) {
|
|
const result = s.run();
|
|
if (result === null) {
|
|
console.log(` ✓ ${s.name}`);
|
|
} else {
|
|
console.log(` ✗ ${s.name}: ${result}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log('');
|
|
if (failed === 0) {
|
|
console.log(`✓ all ${scenarios.length} term-sanitize checks hold`);
|
|
process.exit(0);
|
|
}
|
|
console.error(`✗ ${failed} failed, ${scenarios.length - failed} passed`);
|
|
process.exit(1);
|