diff --git a/CLAUDE.md b/CLAUDE.md index 143fb82..a12df6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,9 @@ These are absolute rules — never violate them: - **Clipboard capture whitespace stripping**: `detectKeysInClipboard()` strips all whitespace and newlines from clipboard content before pattern matching (`components(separatedBy: .whitespacesAndNewlines).joined()`). API keys never contain spaces, but clipboard copy may introduce line breaks from word-wrap. - **Clipboard capture built-in patterns**: `ClipboardEngine.builtInCapturePatterns` contains 22 well-known API key patterns (OpenAI, Anthropic, GitHub, AWS, Google, Stripe, etc.) for detecting NEW keys not yet in the vault. This is separate from `patternCacheEntries()` which only matches stored keys. - **NSAlert in menu bar app**: Must call `alert.layout()` then set `alert.window.level = .floating` + `orderFrontRegardless()` before `runModal()`. Without this, the alert either doesn't appear or creates a dock icon. Do NOT use `setActivationPolicy(.regular)`. +- **Terminal masking sync block buffering** (experimental): Shielded Terminal buffers PTY output by detecting DEC 2026 sync block markers (`\x1b[?2026h`/`\x1b[?2026l`). Complete sync blocks are masked atomically. Non-sync data uses 30ms timeout buffer. This matches claude-chill's approach. +- **Terminal masking ANSI-aware matching**: `maskTerminalOutput()` strips ANSI escape codes AND all whitespace (spaces, tabs, newlines) before regex matching. Ink word-wraps long keys with `\r\n` + indentation; stripping all whitespace allows regex to match keys across visual line breaks. Structural characters (ANSI + whitespace) within matched ranges are preserved in output via `extractStructural()`. +- **Terminal masking node-pty loading**: Triple fallback: (1) `require('node-pty')`, (2) `require(vscode.env.appRoot + '/node_modules.asar.unpacked/node-pty')`, (3) `require(vscode.env.appRoot + '/node_modules/node-pty')`. Falls back to `child_process.spawn` line-mode terminal. ## Documentation diff --git a/README.md b/README.md index fe56a08..9c9fd7c 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ See the [docs/](docs/) directory for detailed specifications: - [x] Smart Key Extraction confirmation dialog (full Chrome ↔ Swift Core IPC: detect → submit → Keychain store → pattern sync) - [x] Linked Key Groups (sequential paste with ⌘V→Tab automation, Settings UI CRUD, pre-fetch Keychain) - [x] Clipboard Capture (⌃⌥⌘V hotkey, 22 built-in patterns, 3-tier confidence routing, whitespace-tolerant) +- [x] 🧪 Terminal masking — Shielded Terminal (node-pty proxy, DEC 2026 sync block buffering, ANSI-aware masking, Claude Code compatible) - [ ] API Key rotation & deployment sync -- [ ] Terminal masking (node-pty proxy) - [ ] System-wide masking (Accessibility API) ## Contributing diff --git a/docs/01-product-spec/implementation-status.md b/docs/01-product-spec/implementation-status.md index f62104b..f26652a 100644 --- a/docs/01-product-spec/implementation-status.md +++ b/docs/01-product-spec/implementation-status.md @@ -34,6 +34,7 @@ |------|--------------|---------|---------| | ~~`⌃⌥⌘V` capture clipboard~~ | Spec §4.4 | ~~中~~ | ✅ HotkeyManager → ClipboardEngine.detectKeysInClipboard() → 內建 22 種 pattern 偵測 → confidence 三階路由 → VaultManager 存儲 | | ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~中~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` 完成、Settings UI 群組管理(CRUD)、`request_paste_group` IPC handler | +| ~~Terminal masking (Shielded Terminal)~~ | Spec §3.2 | ~~中~~ | 🧪 實驗性。node-pty proxy + DEC 2026 sync block buffering + ANSI-aware masking。Known limitation: Rewind 確認頁部分洩漏 | | Shortcut conflict detection | Spec §4.4 | 低 | | | Import / Export vault | Spec §9.1 | 低 | | diff --git a/docs/en/01-product-spec/implementation-status.md b/docs/en/01-product-spec/implementation-status.md index 9e21d3a..5ac827d 100644 --- a/docs/en/01-product-spec/implementation-status.md +++ b/docs/en/01-product-spec/implementation-status.md @@ -34,6 +34,7 @@ |---------|-------------|----------|---------| | ~~`⌃⌥⌘V` capture clipboard~~ | Spec §4.4 | ~~Medium~~ | ✅ HotkeyManager → ClipboardEngine.detectKeysInClipboard() → 22 built-in patterns → 3-tier confidence routing → VaultManager store | | ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~Medium~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` complete, Settings UI group management (CRUD), `request_paste_group` IPC handler | +| ~~Terminal masking (Shielded Terminal)~~ | Spec §3.2 | ~~Medium~~ | 🧪 Experimental. node-pty proxy + DEC 2026 sync block buffering + ANSI-aware masking. Known limitation: Rewind confirmation page partial leak | | Shortcut conflict detection | Spec §4.4 | Low | | | Import / Export vault | Spec §9.1 | Low | | diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 53b84ed..f8828c2 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -26,6 +26,16 @@ "command": "demosafe.pasteKey", "title": "DemoSafe: Paste Key", "icon": "$(key)" + }, + { + "command": "demosafe.openShieldedTerminal", + "title": "DemoSafe: Open Shielded Terminal", + "icon": "$(shield)" + }, + { + "command": "demosafe.openTerminalWithCommand", + "title": "DemoSafe: Open Terminal With Command", + "icon": "$(terminal)" } ], "keybindings": [ @@ -38,11 +48,16 @@ "command": "demosafe.pasteKey", "key": "ctrl+alt+space", "mac": "ctrl+alt+space" + }, + { + "command": "demosafe.openShieldedTerminal", + "key": "ctrl+shift+t", + "mac": "ctrl+shift+t" } ] }, "scripts": { - "build": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node", + "build": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --external:node-pty --format=cjs --platform=node", "watch": "npm run build -- --watch", "type-check": "tsc --noEmit", "lint": "eslint --ext .ts src/", diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 918c5e0..5748c50 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -5,6 +5,8 @@ import { StatusBarManager, ConnectionState } from './statusbar/statusbar-manager import { PatternScanner } from './core/pattern-scanner'; import { PatternCache } from './core/pattern-cache'; import { pasteKeyCommand } from './commands/paste-key'; +import { ShieldedTerminal, FallbackShieldedTerminal } from './terminal/shielded-terminal'; +import { buildTerminalPatterns } from './terminal/terminal-patterns'; let ipcClient: IPCClient; let decorationManager: DecorationManager; @@ -12,8 +14,12 @@ let statusBarManager: StatusBarManager; let patternScanner: PatternScanner; let patternCache: PatternCache; let isDemoMode = false; +let isShieldActive = false; let outputChannel: vscode.OutputChannel; +// Track all shielded terminals for state sync +const shieldedTerminals: Set = new Set(); + export function activate(context: vscode.ExtensionContext) { outputChannel = vscode.window.createOutputChannel('DemoSafe'); outputChannel.appendLine('[DemoSafe] Extension activating...'); @@ -38,6 +44,19 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('demosafe.pasteKey', () => { pasteKeyCommand(patternCache, ipcClient); }), + vscode.commands.registerCommand('demosafe.openShieldedTerminal', () => { + openShieldedTerminal(); + }), + vscode.commands.registerCommand('demosafe.openTerminalWithCommand', async () => { + const cmd = await vscode.window.showInputBox({ + prompt: 'Command to run in shielded terminal', + placeHolder: 'e.g. claude, npm start, python app.py', + value: 'claude', + }); + if (cmd) { + openShieldedTerminal(cmd); + } + }), ); // IPC log forwarding @@ -59,6 +78,8 @@ export function activate(context: vscode.ExtensionContext) { ipcClient.on('stateChanged', (state: { isDemoMode: boolean; activeContext: { name: string } | null }) => { isDemoMode = state.isDemoMode; + isShieldActive = state.isDemoMode; // Sync shield with Demo Mode + syncShieldState(); statusBarManager.update({ isDemoMode: state.isDemoMode, contextName: state.activeContext?.name ?? null, @@ -68,6 +89,11 @@ export function activate(context: vscode.ExtensionContext) { ipcClient.on('patternsUpdated', () => { updateMasking(); + // Update terminal patterns when Core syncs new patterns + const patterns = buildTerminalPatterns(patternCache); + for (const t of shieldedTerminals) { + t.updatePatterns(patterns); + } }); ipcClient.on('clipboardCleared', () => { @@ -153,7 +179,57 @@ function updateConnectionState(state: ConnectionState) { } } +// --- Shielded Terminal --- + +function openShieldedTerminal(initialCommand?: string) { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.env.HOME || '/'; + const patterns = buildTerminalPatterns(patternCache); + + // Shielded Terminal always starts with shield ON — that's its purpose + const shieldOn = true; + let pty: ShieldedTerminal | FallbackShieldedTerminal; + let terminalName: string; + + try { + pty = new ShieldedTerminal(cwd, shieldOn, patterns, outputChannel); + terminalName = 'Shield'; + } catch { + pty = new FallbackShieldedTerminal(cwd, shieldOn, patterns); + terminalName = 'Shield (Fallback)'; + } + + shieldedTerminals.add(pty); + + const terminal = vscode.window.createTerminal({ + name: terminalName, + pty, + location: vscode.TerminalLocation.Editor, + }); + + terminal.show(); + + // Send initial command after shell starts + if (initialCommand) { + setTimeout(() => { + for (const char of initialCommand) { + pty.handleInput(char); + } + pty.handleInput('\r'); + }, 500); + } +} + +function syncShieldState() { + for (const t of shieldedTerminals) { + t.shieldEnabled = isShieldActive; + } +} + export function deactivate() { + for (const t of shieldedTerminals) { + t.close(); + } + shieldedTerminals.clear(); ipcClient?.disconnect(); decorationManager?.dispose(); statusBarManager?.dispose(); diff --git a/packages/vscode-extension/src/terminal/shielded-terminal.ts b/packages/vscode-extension/src/terminal/shielded-terminal.ts new file mode 100644 index 0000000..91eed1b --- /dev/null +++ b/packages/vscode-extension/src/terminal/shielded-terminal.ts @@ -0,0 +1,401 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ +import * as vscode from 'vscode'; +import * as os from 'os'; +import * as path from 'path'; +import { TerminalPattern, maskTerminalOutput } from './terminal-patterns'; + +// DEC private mode 2026 — Synchronized Output markers +const SYNC_START = '\x1b[?2026h'; +const SYNC_END = '\x1b[?2026l'; + +/** + * ShieldedTerminal — node-pty proxy terminal for API key masking. + * + * Architecture: + * [VS Code Terminal (Pseudoterminal)] + * ↕ handleInput() / onDidWrite + * [ShieldedTerminal] + * ↕ write() / onData() + * [node-pty (real shell process)] + * + * Sync block aware: Claude Code wraps each render in DEC 2026 sync blocks + * (\x1b[?2026h ... \x1b[?2026l). We buffer the entire sync block before + * masking, ensuring API keys split across PTY chunks are fully assembled. + * + * Non-sync data (regular shell output) is masked with a short timeout buffer. + */ +export class ShieldedTerminal implements vscode.Pseudoterminal { + private writeEmitter = new vscode.EventEmitter(); + onDidWrite: vscode.Event = this.writeEmitter.event; + + private closeEmitter = new vscode.EventEmitter(); + onDidClose: vscode.Event = this.closeEmitter.event; + + private nameEmitter = new vscode.EventEmitter(); + onDidChangeName: vscode.Event = this.nameEmitter.event; + + private ptyProcess: any = null; + private _shieldEnabled: boolean; + private patterns: TerminalPattern[]; + private maskedCount = 0; + private outputChannel: vscode.OutputChannel; + + // Raw accumulation buffer — ALL data goes here first + private rawBuffer = ''; + private inSyncBlock = false; + private flushTimer: ReturnType | null = null; + private static readonly FLUSH_DELAY_MS = 30; + // Max length of sync markers (for partial marker detection) + private static readonly MARKER_MAX_LEN = Math.max(SYNC_START.length, SYNC_END.length); + + constructor( + private cwd: string, + shieldEnabled: boolean, + patterns: TerminalPattern[], + outputChannel: vscode.OutputChannel, + private shellPath?: string, + private shellArgs?: string[], + ) { + this._shieldEnabled = shieldEnabled; + this.patterns = patterns; + this.outputChannel = outputChannel; + } + + get shieldEnabled(): boolean { + return this._shieldEnabled; + } + + set shieldEnabled(value: boolean) { + this._shieldEnabled = value; + const msg = value + ? '\r\n\x1b[42;30m Shield: ON \x1b[0m\r\n' + : '\r\n\x1b[43;30m Shield: OFF \x1b[0m\r\n'; + this.writeEmitter.fire(msg); + } + + updatePatterns(patterns: TerminalPattern[]) { + this.patterns = patterns; + } + + async open(initialDimensions: vscode.TerminalDimensions | undefined): Promise { + const cols = initialDimensions?.columns || 80; + const rows = initialDimensions?.rows || 30; + + try { + const pty = loadNodePty(); + + const shell = this.shellPath || getDefaultShell(); + const args = this.shellArgs || []; + + this.outputChannel.appendLine(`[ShieldedTerminal] Shell: ${shell} ${args.join(' ')}`); + this.outputChannel.appendLine(`[ShieldedTerminal] CWD: ${this.cwd}, ${cols}x${rows}`); + + this.ptyProcess = pty.spawn(shell, args, { + name: 'xterm-256color', + cols, + rows, + cwd: this.cwd, + env: { ...process.env } as { [key: string]: string }, + }); + + this.ptyProcess.onData((data: string) => { + if (this._shieldEnabled) { + this.processOutput(data); + } else { + this.writeEmitter.fire(data); + } + }); + + this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + this.outputChannel.appendLine(`[ShieldedTerminal] Exit code ${exitCode}`); + this.flushRemainingBuffer(); + this.closeEmitter.fire(exitCode); + }); + + const statusText = this._shieldEnabled ? 'ON' : 'OFF'; + this.writeEmitter.fire( + `\x1b[90m[DemoSafe Shield: ${statusText}] Terminal ready.\x1b[0m\r\n` + ); + } catch (err: any) { + this.outputChannel.appendLine(`[ShieldedTerminal] ERROR: ${err.message}`); + this.writeEmitter.fire( + `\r\n\x1b[31m[DemoSafe] Failed to start shielded terminal: ${err.message}\x1b[0m\r\n` + + `\x1b[33mFalling back to basic mode (no tab completion).\x1b[0m\r\n` + + `\x1b[33mFor full PTY support, ensure node-pty is available.\x1b[0m\r\n` + ); + } + } + + handleInput(data: string): void { + if (!this.ptyProcess) { return; } + this.ptyProcess.write(data); + } + + setDimensions(dimensions: vscode.TerminalDimensions): void { + if (this.ptyProcess) { + this.ptyProcess.resize(dimensions.columns, dimensions.rows); + } + } + + close(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.ptyProcess) { + this.ptyProcess.kill(); + this.ptyProcess = null; + } + } + + getMaskedCount(): number { + return this.maskedCount; + } + + /** + * Process PTY output with sync block awareness. + * + * All data accumulates in rawBuffer first, then we scan for complete + * sync blocks and plain segments. This handles the case where sync + * markers (\x1b[?2026h / \x1b[?2026l) are split across PTY chunks. + */ + private processOutput(data: string): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + this.rawBuffer += data; + this.drainBuffer(); + } + + /** + * Scan rawBuffer for complete sync blocks and plain segments. + * Output everything that's complete; keep partial data in buffer. + */ + private drainBuffer(): void { + let progress = true; + + while (progress && this.rawBuffer.length > 0) { + progress = false; + + if (this.inSyncBlock) { + // Look for SYNC_END in accumulated buffer + const endIdx = this.rawBuffer.indexOf(SYNC_END); + if (endIdx !== -1) { + // Complete sync block found — mask and output + const block = this.rawBuffer.slice(0, endIdx + SYNC_END.length); + this.rawBuffer = this.rawBuffer.slice(endIdx + SYNC_END.length); + this.inSyncBlock = false; + this.maskAndOutput(block); + progress = true; + } + // else: SYNC_END not found yet — keep accumulating + } else { + // Look for SYNC_START in accumulated buffer + const startIdx = this.rawBuffer.indexOf(SYNC_START); + if (startIdx !== -1) { + // Output plain data before SYNC_START + if (startIdx > 0) { + this.maskAndOutput(this.rawBuffer.slice(0, startIdx)); + } + this.rawBuffer = this.rawBuffer.slice(startIdx); + this.inSyncBlock = true; + progress = true; + } else { + // No SYNC_START found — but the tail might be a PARTIAL marker + // e.g. buffer ends with "\x1b[?202" (incomplete SYNC_START) + // Hold back the last few chars that could be start of a marker + const safeEnd = this.rawBuffer.length - ShieldedTerminal.MARKER_MAX_LEN; + if (safeEnd > 0) { + this.maskAndOutput(this.rawBuffer.slice(0, safeEnd)); + this.rawBuffer = this.rawBuffer.slice(safeEnd); + progress = true; + } + // else: buffer is small enough to hold entirely — wait for more + } + } + } + + // Schedule flush for remaining buffer (handles non-sync shell output) + if (this.rawBuffer.length > 0 && !this.inSyncBlock) { + this.flushTimer = setTimeout( + () => this.flushRemainingBuffer(), + ShieldedTerminal.FLUSH_DELAY_MS, + ); + } + } + + /** + * Mask a complete segment and output it. + */ + private maskAndOutput(data: string): void { + const result = maskTerminalOutput(data, this.patterns); + if (result.output !== data) { + this.maskedCount++; + this.outputChannel.appendLine(`[Shield] Masked #${this.maskedCount}`); + } + this.writeEmitter.fire(result.output); + } + + /** + * Flush remaining buffer on timeout (for regular shell output). + */ + private flushRemainingBuffer(): void { + this.flushTimer = null; + if (this.rawBuffer.length > 0 && !this.inSyncBlock) { + this.maskAndOutput(this.rawBuffer); + this.rawBuffer = ''; + } + } +} + +/** + * FallbackShieldedTerminal — no native module dependency. + * Uses child_process.spawn with basic line-mode I/O. + * Limitations: no tab completion, no full PTY features. + */ +export class FallbackShieldedTerminal implements vscode.Pseudoterminal { + private writeEmitter = new vscode.EventEmitter(); + onDidWrite: vscode.Event = this.writeEmitter.event; + + private closeEmitter = new vscode.EventEmitter(); + onDidClose: vscode.Event = this.closeEmitter.event; + + private childProcess: any = null; + private _shieldEnabled: boolean; + private patterns: TerminalPattern[]; + private inputBuffer = ''; + + constructor( + private cwd: string, + shieldEnabled: boolean, + patterns: TerminalPattern[], + ) { + this._shieldEnabled = shieldEnabled; + this.patterns = patterns; + } + + get shieldEnabled(): boolean { + return this._shieldEnabled; + } + + set shieldEnabled(value: boolean) { + this._shieldEnabled = value; + const msg = value + ? '\r\n Shield: ON \r\n' + : '\r\n Shield: OFF \r\n'; + this.writeEmitter.fire(msg); + } + + updatePatterns(patterns: TerminalPattern[]) { + this.patterns = patterns; + } + + async open(): Promise { + const { spawn } = require('child_process'); + const shell = getDefaultShell(); + + this.childProcess = spawn(shell, [], { + cwd: this.cwd, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + this.childProcess.stdout.on('data', (data: Buffer) => { + let text = data.toString(); + if (this._shieldEnabled) { + const result = maskTerminalOutput(text, this.patterns); + text = result.output; + } + this.writeEmitter.fire(text.replace(/\n/g, '\r\n')); + }); + + this.childProcess.stderr.on('data', (data: Buffer) => { + let text = data.toString(); + if (this._shieldEnabled) { + const result = maskTerminalOutput(text, this.patterns); + text = result.output; + } + this.writeEmitter.fire(text.replace(/\n/g, '\r\n')); + }); + + this.childProcess.on('exit', (code: number) => { + this.closeEmitter.fire(code); + }); + + this.writeEmitter.fire('[DemoSafe Shield - Fallback Mode] Terminal ready.\r\n$ '); + } + + handleInput(data: string): void { + if (!this.childProcess) { return; } + + if (data === '\r') { + this.writeEmitter.fire('\r\n'); + this.childProcess.stdin.write(this.inputBuffer + '\n'); + this.inputBuffer = ''; + } else if (data === '\x7f') { + if (this.inputBuffer.length > 0) { + this.inputBuffer = this.inputBuffer.slice(0, -1); + this.writeEmitter.fire('\b \b'); + } + } else if (data === '\x03') { + this.childProcess.kill('SIGINT'); + this.inputBuffer = ''; + this.writeEmitter.fire('^C\r\n$ '); + } else { + this.inputBuffer += data; + this.writeEmitter.fire(data); + } + } + + setDimensions(): void { } + + close(): void { + if (this.childProcess) { + this.childProcess.kill(); + this.childProcess = null; + } + } +} + +// --- Helpers --- + +function getDefaultShell(): string { + if (os.platform() === 'win32') { + return process.env.COMSPEC || 'powershell.exe'; + } + return process.env.SHELL || '/bin/bash'; +} + +/** + * Load node-pty with triple-layer fallback: + * 1. System npm install + * 2. VS Code internal (node_modules.asar.unpacked) + * 3. VS Code internal (node_modules) + */ +function loadNodePty(): any { + try { + return require('node-pty'); + } catch { /* continue */ } + + try { + const ptyPath = path.join( + vscode.env.appRoot, + 'node_modules.asar.unpacked', + 'node-pty' + ); + return require(ptyPath); + } catch { /* continue */ } + + try { + const ptyPath = path.join( + vscode.env.appRoot, + 'node_modules', + 'node-pty' + ); + return require(ptyPath); + } catch { /* continue */ } + + throw new Error('node-pty not found. Install it or use the fallback terminal.'); +} diff --git a/packages/vscode-extension/src/terminal/terminal-patterns.ts b/packages/vscode-extension/src/terminal/terminal-patterns.ts new file mode 100644 index 0000000..87321a1 --- /dev/null +++ b/packages/vscode-extension/src/terminal/terminal-patterns.ts @@ -0,0 +1,213 @@ +import { PatternCache } from '../core/pattern-cache'; + +export interface TerminalPattern { + name: string; + regex: RegExp; + mask: string; +} + +/** + * Built-in patterns for terminal output masking. + * Covers common API key formats and sensitive data that may appear in CLI output. + * Each regex uses /g flag and must have lastIndex reset before use. + */ +export const BUILTIN_TERMINAL_PATTERNS: TerminalPattern[] = [ + // Anthropic + { name: 'Anthropic API Key', regex: /sk-ant-api03-[a-zA-Z0-9_-]{20,}/g, mask: 'sk-ant-••••••••••' }, + { name: 'Anthropic API Key (short)', regex: /sk-ant-[a-zA-Z0-9_-]{20,}/g, mask: 'sk-ant-••••••••••' }, + // OpenAI + { name: 'OpenAI API Key', regex: /sk-proj-[a-zA-Z0-9_-]{20,}/g, mask: 'sk-proj-••••••••••' }, + { name: 'OpenAI API Key (generic)', regex: /sk-[a-zA-Z0-9_-]{30,}/g, mask: 'sk-••••••••••' }, + // GitHub + { name: 'GitHub Token (PAT)', regex: /ghp_[A-Za-z0-9]{36,}/g, mask: 'ghp_••••••••••' }, + { name: 'GitHub Token (OAuth)', regex: /gho_[A-Za-z0-9]{36,}/g, mask: 'gho_••••••••••' }, + { name: 'GitHub Token (User)', regex: /ghu_[A-Za-z0-9]{36,}/g, mask: 'ghu_••••••••••' }, + { name: 'GitHub Token (Server)', regex: /ghs_[A-Za-z0-9]{36,}/g, mask: 'ghs_••••••••••' }, + { name: 'GitHub Token (Refresh)', regex: /ghr_[A-Za-z0-9]{36,}/g, mask: 'ghr_••••••••••' }, + // GitLab + { name: 'GitLab Token', regex: /glpat-[A-Za-z0-9_-]{20,}/g, mask: 'glpat-••••••••••' }, + // AWS + { name: 'AWS Access Key', regex: /AKIA[A-Z0-9]{16}/g, mask: 'AKIA••••••••••••' }, + { name: 'AWS Temp Key', regex: /ASIA[A-Z0-9]{16}/g, mask: 'ASIA••••••••••••' }, + { name: 'AWS Secret Key', regex: /(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[:=]\s*['"]?([a-zA-Z0-9/+]{40})['"]?/gi, mask: 'AWS_SECRET=••••••••••' }, + // Google Cloud + { name: 'Google API Key', regex: /AIzaSy[0-9A-Za-z_-]{33}/g, mask: 'AIza••••••••••' }, + // Slack + { name: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z-]{10,}/g, mask: 'xox•-••••••••••' }, + // HuggingFace + { name: 'HuggingFace Token', regex: /hf_[A-Za-z0-9]{20,}/g, mask: 'hf_••••••••••' }, + // Stripe + { name: 'Stripe Secret Key', regex: /sk_live_[A-Za-z0-9]{20,}/g, mask: 'sk_live_••••••••••' }, + { name: 'Stripe Test Key', regex: /sk_test_[A-Za-z0-9]{20,}/g, mask: 'sk_test_••••••••••' }, + // SendGrid + { name: 'SendGrid API Key', regex: /SG\.[A-Za-z0-9_-]{20,}/g, mask: 'SG.••••••••••' }, + // Bearer Token (curl -H "Authorization: Bearer ...") + { name: 'Bearer Token', regex: /Bearer\s+[a-zA-Z0-9_.-]{20,}/gi, mask: 'Bearer ••••••••••' }, + // JWT Token + { name: 'JWT Token', regex: /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, mask: 'eyJ••••.eyJ••••.••••' }, + // Private Key block + { name: 'Private Key', regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g, mask: '-----[PRIVATE KEY REDACTED]-----' }, + // Connection strings + { name: 'Connection String', regex: /(?:mongodb|postgres|mysql|redis):\/\/[^\s'"]+/gi, mask: '••://••••••••••' }, + // Password in config + { name: 'Password', regex: /(?:password|passwd|pwd|secret)\s*[:=]\s*['"]?([^\s'"]{8,})['"]?/gi, mask: 'password=••••••••••' }, + // Generic key=value (long hex tokens) + { name: 'Hex Token', regex: /(?:token|secret|key|api_key|apikey)\s*[:=]\s*['"]?([0-9a-fA-F]{32,})['"]?/gi, mask: 'token=••••••••••' }, +]; + +/** + * Build the full pattern list by merging built-in patterns with + * Core-synced patterns from PatternCache. + */ +export function buildTerminalPatterns(cache: PatternCache): TerminalPattern[] { + const patterns = [...BUILTIN_TERMINAL_PATTERNS]; + + // Add patterns from Core (stored keys) + for (const entry of cache.getPatterns()) { + try { + patterns.push({ + name: `${entry.serviceName} (${entry.keyId.slice(0, 8)})`, + regex: new RegExp(entry.pattern, 'g'), + mask: entry.maskedPreview, + }); + } catch { + // Skip invalid patterns + } + } + + return patterns; +} + +/** + * Regex matching ANSI escape codes AND all whitespace. + * + * API keys never contain whitespace. Terminal renderers (Ink) insert + * \r\n, indentation spaces, cursor positioning, and padding when + * word-wrapping long lines. Stripping ALL whitespace alongside ANSI + * codes produces a collapsed text where regex can match keys that + * span multiple visual lines with arbitrary formatting in between. + */ +// eslint-disable-next-line no-control-regex +const ANSI_AND_STRUCTURAL_REGEX = new RegExp('\x1B\\[[0-9;]*[A-Za-z]|\x1B\\][^\x07]*\x07|\x1B[()][AB012]|\x1B\\[[?]?[0-9;]*[hlm]|[\\s]+', 'g'); + +export interface MaskResult { + output: string; + /** True if a match extends to the very end of the text (possibly truncated key) */ + hasTrailingMatch: boolean; +} + +/** + * Mask sensitive data in terminal output. + * ANSI-aware: strips ANSI codes for pattern matching, then replaces + * only the matched key portions in the original data while preserving + * all surrounding ANSI formatting. + * + * Fast path: if no patterns match, returns original data unchanged. + */ +export function maskTerminalOutput(data: string, patterns: TerminalPattern[]): MaskResult { + // Build a mapping from plain-text positions to original-data positions + // so we can replace only the matched portions in the original string + const ansiPositions: { origStart: number; origEnd: number }[] = []; + let match: RegExpExecArray | null; + const ansiRe = new RegExp(ANSI_AND_STRUCTURAL_REGEX.source, 'g'); + + while ((match = ansiRe.exec(data)) !== null) { + ansiPositions.push({ origStart: match.index, origEnd: match.index + match[0].length }); + } + + // Build plain text and position map (plain index → original index) + const plainToOrig: number[] = []; + let plainText = ''; + let origIdx = 0; + let ansiIdx = 0; + + while (origIdx < data.length) { + // Skip ANSI sequences + if (ansiIdx < ansiPositions.length && origIdx === ansiPositions[ansiIdx].origStart) { + origIdx = ansiPositions[ansiIdx].origEnd; + ansiIdx++; + continue; + } + plainToOrig.push(origIdx); + plainText += data[origIdx]; + origIdx++; + } + + // Find all matches in plain text + interface MatchInfo { plainStart: number; plainEnd: number; mask: string } + const allMatches: MatchInfo[] = []; + + for (const p of patterns) { + p.regex.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = p.regex.exec(plainText)) !== null) { + allMatches.push({ + plainStart: m.index, + plainEnd: m.index + m[0].length, + mask: p.mask, + }); + if (m[0].length === 0) { p.regex.lastIndex++; } + } + } + + if (allMatches.length === 0) { + return { output: data, hasTrailingMatch: false }; // Fast path: no changes + } + + // Sort matches by position, resolve overlaps (longest wins) + allMatches.sort((a, b) => a.plainStart - b.plainStart); + const resolved: MatchInfo[] = [allMatches[0]]; + for (let i = 1; i < allMatches.length; i++) { + const curr = allMatches[i]; + const last = resolved[resolved.length - 1]; + if (curr.plainStart < last.plainEnd) { + if ((curr.plainEnd - curr.plainStart) > (last.plainEnd - last.plainStart)) { + resolved[resolved.length - 1] = curr; + } + } else { + resolved.push(curr); + } + } + + // Check if any match extends to the very end of the plain text + // (possibly a truncated key that continues in the next chunk) + const lastMatch = resolved[resolved.length - 1]; + const hasTrailingMatch = lastMatch.plainEnd >= plainText.length; + + // Replace in original string (from end to start to preserve positions) + let result = data; + for (let i = resolved.length - 1; i >= 0; i--) { + const m = resolved[i]; + const origStart = plainToOrig[m.plainStart]; + const origEnd = m.plainEnd < plainToOrig.length + ? plainToOrig[m.plainEnd] + : data.length; + + // Preserve ANSI codes and line breaks within the matched range. + // This maintains terminal layout when a key spans multiple visual lines. + const matchedOriginal = data.slice(origStart, origEnd); + // eslint-disable-next-line no-control-regex + const hasStructural = /[\x1b\r\n]/.test(matchedOriginal); + const structural = hasStructural ? extractStructural(matchedOriginal) : ''; + + result = result.slice(0, origStart) + m.mask + structural + result.slice(origEnd); + } + + return { output: result, hasTrailingMatch }; +} + +/** + * Extract ANSI escape codes and line breaks from a string, + * preserving their order. This allows us to maintain terminal + * layout (line breaks, colors, cursor positioning) after replacing + * key text with a shorter mask. + */ +function extractStructural(text: string): string { + const parts: string[] = []; + ANSI_AND_STRUCTURAL_REGEX.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ANSI_AND_STRUCTURAL_REGEX.exec(text)) !== null) { + parts.push(m[0]); + } + return parts.join(''); +}