From 515db84164c410c661a4b3bc5616b247efdc4e6e Mon Sep 17 00:00:00 2001 From: zoya-brd Date: Tue, 1 Sep 2026 17:31:54 +0400 Subject: [PATCH 1/2] fix(cli): sanitize terminal output --- src/__tests__/utils/output.test.ts | 37 ++++++++++++++++++++++++++++++ src/utils/output.ts | 7 +++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/__tests__/utils/output.test.ts b/src/__tests__/utils/output.test.ts index 97c4deb..49b89c1 100644 --- a/src/__tests__/utils/output.test.ts +++ b/src/__tests__/utils/output.test.ts @@ -153,4 +153,41 @@ describe('utils/output.print writes correct format from extension', ()=>{ const content = fs.readFileSync(out, 'utf8'); expect(JSON.parse(content)).toEqual([{a: 1}]); }); + it('-o file preserves terminal escape sequences', ()=>{ + const out = make_tmp('.txt'); + const content = 'hello\x1b[31mRED\x1b[0m'; + print(content, {output: out}); + expect(fs.readFileSync(out, 'utf8')).toBe(content); + }); +}); + +describe('utils/output.print terminal sanitization', ()=>{ + let stdout_write: ReturnType; + beforeEach(()=>{ + stdout_write = vi.spyOn(process.stdout, 'write') + .mockImplementation(()=>true); + }); + afterEach(()=>{ + vi.restoreAllMocks(); + }); + it('strips terminal escape sequences before writing to stdout', ()=>{ + const malicious = 'hello' + + '\x1b[2J' + + '\x1b[31mRED\x1b[0m' + + '\x1b]0;Title-pwn\x07' + + 'world'; + print(malicious); + const output = stdout_write.mock.calls + .map((call: unknown[])=>String(call[0])) + .join(''); + expect(output).toBe('helloREDworld\n'); + expect(output).not.toContain('\x1b'); + }); + it('keeps normal stdout content unchanged', ()=>{ + print('hello world'); + const output = stdout_write.mock.calls + .map((call: unknown[])=>String(call[0])) + .join(''); + expect(output).toBe('hello world\n'); + }); }); diff --git a/src/utils/output.ts b/src/utils/output.ts index 7869d02..5cff6ca 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,5 +1,9 @@ import fs from 'fs'; import path from 'path'; +import { stripVTControlCharacters } from 'util'; + +const terminal_safe = (val: unknown): string=> + stripVTControlCharacters(String(val)); const is_tty = process.stdout.isTTY === true; @@ -183,7 +187,8 @@ const print = (data: unknown, opts: Print_opts = {})=>{ } if (!is_tty && fmt == 'raw') fmt = typeof data == 'string' ? 'raw' : 'json'; - process.stdout.write(serialize(data, fmt)+'\n'); + const content = serialize(data, fmt); + process.stdout.write(terminal_safe(content) + '\n'); }; const print_table = (rows: Record[], cols: string[])=>{ From e8b4a0a9c1ca0783a9c893354a4d59fe1670e0df Mon Sep 17 00:00:00 2001 From: zoya-brd Date: Mon, 7 Sep 2026 10:39:20 +0400 Subject: [PATCH 2/2] fix(cli): improve terminal output safety --- src/__tests__/commands/discover.test.ts | 2 +- src/__tests__/commands/scraper.test.ts | 2 +- src/__tests__/utils/output.test.ts | 120 +++++++++++++++++++++++- src/commands/discover.ts | 2 +- src/commands/init.ts | 14 +-- src/commands/scraper.ts | 2 +- src/utils/output.ts | 58 ++++++++---- src/utils/spinner.ts | 2 +- 8 files changed, 170 insertions(+), 32 deletions(-) diff --git a/src/__tests__/commands/discover.test.ts b/src/__tests__/commands/discover.test.ts index ac07f34..b281257 100644 --- a/src/__tests__/commands/discover.test.ts +++ b/src/__tests__/commands/discover.test.ts @@ -32,7 +32,7 @@ vi.mock('../../utils/output', ()=>({ print_table: mocks.print_table, fail: mocks.fail, dim: mocks.dim, - is_tty: true, + is_tty: ()=>true, })); vi.mock('../../utils/polling', ()=>({ diff --git a/src/__tests__/commands/scraper.test.ts b/src/__tests__/commands/scraper.test.ts index 92c1409..e8be7cb 100644 --- a/src/__tests__/commands/scraper.test.ts +++ b/src/__tests__/commands/scraper.test.ts @@ -38,7 +38,7 @@ vi.mock('../../utils/output', ()=>({ fail: mocks.fail, success: mocks.success, dim: mocks.dim, - is_tty: false, + is_tty: ()=>false, })); vi.mock('../../utils/polling', ()=>({ diff --git a/src/__tests__/utils/output.test.ts b/src/__tests__/utils/output.test.ts index 49b89c1..d6391af 100644 --- a/src/__tests__/utils/output.test.ts +++ b/src/__tests__/utils/output.test.ts @@ -2,7 +2,7 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; -import {serialize, format_from_ext, print} from '../../utils/output'; +import {serialize, format_from_ext, print, print_table} from '../../utils/output'; describe('utils/output.serialize csv', ()=>{ it('serializes array of flat objects as RFC 4180 CSV with header row', ()=>{ @@ -163,31 +163,145 @@ describe('utils/output.print writes correct format from extension', ()=>{ describe('utils/output.print terminal sanitization', ()=>{ let stdout_write: ReturnType; + const original_is_tty = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', + ); + const set_tty = (value: boolean)=>{ + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }); + }; beforeEach(()=>{ stdout_write = vi.spyOn(process.stdout, 'write') .mockImplementation(()=>true); }); afterEach(()=>{ vi.restoreAllMocks(); + if (original_is_tty) + { + Object.defineProperty( + process.stdout, + 'isTTY', + original_is_tty, + ); + } + else + delete (process.stdout as {isTTY?: boolean}).isTTY; }); - it('strips terminal escape sequences before writing to stdout', ()=>{ + it('sanitizes terminal escape sequences on TTY stdout', ()=>{ + set_tty(true); const malicious = 'hello' + '\x1b[2J' + '\x1b[31mRED\x1b[0m' + '\x1b]0;Title-pwn\x07' + 'world'; + print(malicious); const output = stdout_write.mock.calls .map((call: unknown[])=>String(call[0])) .join(''); expect(output).toBe('helloREDworld\n'); expect(output).not.toContain('\x1b'); + expect(output).not.toContain('\x07'); }); - it('keeps normal stdout content unchanged', ()=>{ + it('keeps normal TTY stdout content unchanged', ()=>{ + set_tty(true); print('hello world'); const output = stdout_write.mock.calls .map((call: unknown[])=>String(call[0])) .join(''); expect(output).toBe('hello world\n'); }); + it('preserves row stdout for non-TTY stdout', ()=>{ + set_tty(false); + const content = 'hello\x1b[31mRED\x1b[0m'; + print(content); + const output = stdout_write.mock.calls + .map((call: unknown[])=>String(call[0])) + .join(''); + expect(output).toBe(content + '\n'); + }); + it('preserves structured output for non-TTY stdout', ()=>{ + set_tty(false); + const data = [{ + value: 'hello\x1b[31mRED\x1b[0m', + }]; + print(data, {format: 'json'}); + const output = stdout_write.mock.calls + .map((call: unknown[])=>String(call[0])) + .join(''); + expect(output).toBe(JSON.stringify(data) + '\n'); + }); + it('removes standalone terminal control characters on TTY', ()=>{ + set_tty(true); + print('a\x07b\bcd\ref'); + const output = stdout_write.mock.calls + .map((call: unknown[])=>String(call[0])) + .join(''); + expect(output).toBe('abcd\nef\n'); + expect(output).not.toContain('\x07'); + expect(output).not.toContain('\b'); + expect(output).not.toContain('\r'); + }); }); + +describe('utils/output.print_table terminal sanitization', ()=>{ + const original_is_tty = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', + ); + const set_tty = (value: boolean)=>{ + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }); + }; + afterEach(()=>{ + vi.restoreAllMocks(); + if (original_is_tty) + { + Object.defineProperty( + process.stdout, + 'isTTY', + original_is_tty, + ); + } + else + delete (process.stdout as {isTTY?: boolean}).isTTY; + }); + it('sanitizes malicious values passed through print_table', ()=>{ + set_tty(true); + const log = vi.spyOn(console, 'log') + .mockImplementation(()=>{}); + print_table( + [{ + title: 'hello\x1b[31mRED\x1b[0m', + url: 'before\x1b[2Jafter', + }], + ['title', 'url'], + ); + const output = log.mock.calls + .map((call: unknown[])=>call.map(String).join(' ')) + .join('\n'); + expect(output).toContain('helloRED'); + expect(output).toContain('beforeafter'); + expect(output).not.toContain('\x1b[31m'); + expect(output).not.toContain('\x1b[2J'); + }); + it('flattens multiline table cells before printing', ()=>{ + set_tty(true); + const log = vi.spyOn(console, 'log') + .mockImplementation(()=>{}); + print_table( + [{title: 'line1\nline2'}], + ['title'], + ); + const output = log.mock.calls + .map((call: unknown[])=>call.map(String).join(' ')) + .join('\n'); + expect(output).toContain('line1 line2'); + expect(output).not.toContain('line1\nline2'); + }); +}); \ No newline at end of file diff --git a/src/commands/discover.ts b/src/commands/discover.ts index 2416be1..ae64e99 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -147,7 +147,7 @@ const handle_discover = async(query: string, opts: Discover_opts)=>{ } const print_opts = {json: opts.json, pretty: opts.pretty, output: opts.output}; - if (opts.json || opts.pretty || opts.output || !is_tty) + if (opts.json || opts.pretty || opts.output || !is_tty()) { print(response, print_opts); return; diff --git a/src/commands/init.ts b/src/commands/init.ts index ad77a55..b2d4d92 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -15,8 +15,8 @@ import { } from '../utils/output'; import {start as start_spinner} from '../utils/spinner'; -const white = (text: string)=>is_tty ? `\x1b[37m${text}\x1b[0m` : text; -const blue = (text: string)=>is_tty ? `\x1b[34m${text}\x1b[0m` : text; +const white = (text: string)=>is_tty() ? `\x1b[37m${text}\x1b[0m` : text; +const blue = (text: string)=>is_tty() ? `\x1b[34m${text}\x1b[0m` : text; const BANNER_SPLIT_COL = 62; type Init_opts = { @@ -95,7 +95,7 @@ const prompt_zone = async( zone_names: string[], suggested: string|undefined ): Promise=>{ - if (!is_tty) + if (!is_tty()) return suggested; if (!zone_names.length) { @@ -129,7 +129,7 @@ const prompt_zone = async( const prompt_default_format = async(current: string|undefined): Promise=>{ - if (!is_tty) + if (!is_tty()) return current ?? 'markdown'; const selected = await select({ message: 'Choose default output format', @@ -161,7 +161,7 @@ const resolve_initial_api_key = (flag_key: string|undefined): const prompt_api_key = async( initial: string|undefined ): Promise=>{ - if (!is_tty) + if (!is_tty()) return initial; if (initial) { @@ -236,7 +236,7 @@ const show_quick_start = ( }; const maybe_show_install_hint = async()=>{ - if (!is_tty) + if (!is_tty()) return; const show = await confirm({ message: 'Show global install command?', @@ -287,7 +287,7 @@ const handle_init = async(opts: Init_opts)=>{ ); unlocker_zone = pick_best_zone(zone_names, unlocker_zone); serp_zone = pick_best_zone(zone_names, serp_zone ?? unlocker_zone); - if (is_tty) + if (is_tty()) { unlocker_zone = await prompt_zone( 'Select default Web Unlocker zone', diff --git a/src/commands/scraper.ts b/src/commands/scraper.ts index d96bbd9..0cdd63a 100644 --- a/src/commands/scraper.ts +++ b/src/commands/scraper.ts @@ -279,7 +279,7 @@ const build_heal_envelope = (params: { const wants_machine_output = ( opts: {json?: boolean; pretty?: boolean; output?: string} ): boolean=> - !!(opts.json || opts.pretty || opts.output) || !is_tty; + !!(opts.json || opts.pretty || opts.output) || !is_tty(); const emit_create_output = ( envelope: Create_envelope, diff --git a/src/utils/output.ts b/src/utils/output.ts index 5cff6ca..ca9afaa 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -3,23 +3,30 @@ import path from 'path'; import { stripVTControlCharacters } from 'util'; const terminal_safe = (val: unknown): string=> - stripVTControlCharacters(String(val)); + stripVTControlCharacters(String(val)) + .replace(/\r\n?/g, '\n') + .replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, ''); -const is_tty = process.stdout.isTTY === true; +const is_tty = ()=>process.stdout.isTTY === true; const ansi = (code: string, text: string)=> - is_tty ? `\x1b[${code}m${text}\x1b[0m` : text; + is_tty() ? `\x1b[${code}m${text}\x1b[0m` : text; const green = (s: string)=>ansi('32', s); const red = (s: string)=>ansi('31', s); const yellow = (s: string)=>ansi('33', s); const dim = (s: string)=>ansi('2', s); -const success = (msg: string)=>console.error(green(`✓ ${msg}`)); -const warn = (msg: string)=>console.error(yellow(`⚠ ${msg}`)); -const info = (msg: string)=>console.error(dim(msg)); -const fail = (msg: string)=>{ console.error(red(`✗ ${msg}`)); - process.exit(1); }; +const success = (msg: string)=> + console.error(green(`✓ ${terminal_safe(msg)}`)); +const warn = (msg: string)=> + console.error(yellow(`⚠ ${terminal_safe(msg)}`)); +const info = (msg: string)=> + console.error(dim(terminal_safe(msg))); +const fail = (msg: string)=>{ + console.error(red(`✗ ${terminal_safe(msg)}`)); + process.exit(1); +}; type Output_format = 'markdown'|'json'|'pretty'|'html'|'csv'|'raw'; @@ -185,27 +192,44 @@ const print = (data: unknown, opts: Print_opts = {})=>{ info(`Output written to ${opts.output}`); return; } - if (!is_tty && fmt == 'raw') + if (!is_tty() && fmt == 'raw') fmt = typeof data == 'string' ? 'raw' : 'json'; const content = serialize(data, fmt); - process.stdout.write(terminal_safe(content) + '\n'); + process.stdout.write( + (is_tty() ? terminal_safe(content) : content) + '\n' + ); }; const print_table = (rows: Record[], cols: string[])=>{ if (!rows.length) return; - const widths = cols.map(c=> - Math.max(c.length, ...rows.map(r=>String(r[c] ?? '').length)) + const tty = is_tty(); + const safe_value = (value: unknown): string=>{ + const text = String(value ?? ''); + return tty + ? terminal_safe(text).replace(/\n/g, ' ') + : text; + }; + const safe_cols = cols.map(safe_value); + const safe_rows = rows.map(r=> + cols.map(c=>safe_value(r[c]))); + const widths = safe_cols.map((c, i)=> + Math.max( + c.length, + ...safe_rows.map(row=>row[i].length), + ) ); const divider = widths.map(w=>'-'.repeat(w)).join('-+-'); - const header = cols.map((c, i)=>c.padEnd(widths[i])).join(' | '); + const header = safe_cols.map((c, i)=> + c.padEnd(widths[i])).join(' | '); console.log(dim(header)); console.log(dim(divider)); - for (let i=0; iString(rows[i][c] ?? ''). - padEnd(widths[j])); - console.log(row.join(' | ')); + console.log( + row.map((cell, i)=> + cell.padEnd(widths[i])).join(' | ') + ); } }; diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts index 1c3ed49..c8ad8eb 100644 --- a/src/utils/spinner.ts +++ b/src/utils/spinner.ts @@ -8,7 +8,7 @@ type Spinner = { }; const start = (msg: string): Spinner=>{ - if (!is_tty) + if (!is_tty()) { process.stderr.write(msg+'\n'); return {stop: ()=>{}};