From 5650e6a58c7f70d0807f559670c3db9f85854404 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sat, 25 Apr 2026 17:31:54 +0000 Subject: [PATCH] fix(security): strip ANSI escapes from session-derived bash commands (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash command strings extracted from session JSONL flow into `bashBreakdown` keys that the dashboard, CLI, and JSON export render directly. A user paste or scripted Bash invocation containing ANSI escape sequences (terminal colour copies, hyperlinks, cursor movement) would render those raw codes into the user's terminal — first cosmetic noise, but the same vector also covers OSC hyperlink injection and cursor-control tricks. Add a small inline `stripAnsi` (CSI / OSC / ECMA-48 patterns, no new dependency) and apply it as the first step in `extractBashCommands` so all callers (claude / pi / opencode parsers) get a clean string before token splitting and basename extraction. This addresses gap #1 from #153 ("ANSI escapes in session JSONL render raw in the dashboard. Add strip-ansi to displayed strings."). Gap #2 (symlink walk via lstat) is a follow-up PR. Co-Authored-By: Ora Studio --- src/ansi.ts | 17 +++++++++++++++++ src/bash-utils.ts | 10 ++++++++-- tests/ansi.test.ts | 32 ++++++++++++++++++++++++++++++++ tests/bash-commands.test.ts | 5 +++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/ansi.ts create mode 100644 tests/ansi.test.ts diff --git a/src/ansi.ts b/src/ansi.ts new file mode 100644 index 0000000..d3d8454 --- /dev/null +++ b/src/ansi.ts @@ -0,0 +1,17 @@ +/// Removes ANSI escape sequences from strings sourced from session JSONL before they're +/// rendered by the dashboard / CLI / JSON export. The regex below covers CSI (`ESC [`), +/// OSC (`ESC ]`), and other 7-bit / 8-bit control sequences as defined by ECMA-48 — same +/// shape used by the well-known `ansi-regex` package, kept inline here to avoid a direct +/// dependency. Any non-string input is passed through unchanged. + +const ANSI_PATTERN = new RegExp( + [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', + ].join('|'), + 'g', +) + +export function stripAnsi(input: string): string { + return input.replace(ANSI_PATTERN, '') +} diff --git a/src/bash-utils.ts b/src/bash-utils.ts index c578972..ebfe5c1 100644 --- a/src/bash-utils.ts +++ b/src/bash-utils.ts @@ -1,12 +1,18 @@ import { basename } from 'path' +import { stripAnsi } from './ansi.js' + function stripQuotedStrings(command: string): string { return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length)) } -export function extractBashCommands(command: string): string[] { - if (!command || !command.trim()) return [] +export function extractBashCommands(rawCommand: string): string[] { + if (!rawCommand || !rawCommand.trim()) return [] + // Bash inputs in session JSONL can carry ANSI escape sequences (terminal pastes, + // colorized command-line snippets). Stripping here keeps `bashBreakdown` keys + // and any other downstream display free of raw escape codes. + const command = stripAnsi(rawCommand) const stripped = stripQuotedStrings(command) const separatorRegex = /\s*(?:&&|;|\|)\s*/g diff --git a/tests/ansi.test.ts b/tests/ansi.test.ts new file mode 100644 index 0000000..57a2907 --- /dev/null +++ b/tests/ansi.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' + +import { stripAnsi } from '../src/ansi.js' + +describe('stripAnsi', () => { + it('removes basic SGR colour codes', () => { + expect(stripAnsi('red')).toBe('red') + expect(stripAnsi('warn')).toBe('warn') + }) + + it('removes 256-color and truecolor codes', () => { + expect(stripAnsi('fgbg')).toBe('fgbg') + }) + + it('removes cursor-movement sequences', () => { + expect(stripAnsi('topleft')).toBe('topleft') + }) + + it('removes OSC hyperlink sequences (ESC ] … BEL)', () => { + expect(stripAnsi(']8;;https://example.orgclicky]8;;')).toBe('clicky') + }) + + it('removes 8-bit CSI introducer (\\u009B)', () => { + expect(stripAnsi('›31mred›0m')).toBe('red') + }) + + it('leaves non-ANSI input untouched', () => { + expect(stripAnsi('npm install --save')).toBe('npm install --save') + expect(stripAnsi('')).toBe('') + expect(stripAnsi('emoji 🔥 ok')).toBe('emoji 🔥 ok') + }) +}) diff --git a/tests/bash-commands.test.ts b/tests/bash-commands.test.ts index 2c830e9..4dc058b 100644 --- a/tests/bash-commands.test.ts +++ b/tests/bash-commands.test.ts @@ -47,6 +47,11 @@ describe('extractBashCommands', () => { expect(extractBashCommands(' git status ')).toEqual(['git']) }) + it('strips ANSI escape codes before extracting basenames (no escapes leak into bashBreakdown keys)', () => { + expect(extractBashCommands('npm install')).toEqual(['npm']) + expect(extractBashCommands('git status && ls')).toEqual(['git', 'ls']) + }) + it('handles command with quotes containing separators', () => { expect(extractBashCommands('echo "hello && world"')).toEqual(['echo']) })