diff --git a/src/model/secret-redaction.test.ts b/src/model/secret-redaction.test.ts index 5eb78cf..fc79ce3 100644 --- a/src/model/secret-redaction.test.ts +++ b/src/model/secret-redaction.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'bun:test'; -import { SecretRedaction } from './secret-redaction.ts'; +import { SecretRedaction, createStreamRedactor } from './secret-redaction.ts'; afterEach(() => { // Module-level registry shared across the whole test process. @@ -76,3 +76,39 @@ describe('SecretRedaction', () => { expect(SecretRedaction.redact('cd /home/runner/work/project')).toBe('cd /home/runner/work/project'); }); }); + +describe('createStreamRedactor', () => { + const collect = (chunks: string[]) => { + const redactor = createStreamRedactor(); + return chunks.map((chunk) => redactor.push(chunk)).join('') + redactor.flush(); + }; + + it('passes text through untouched when nothing is registered', () => { + expect(collect(['hello ', 'world'])).toBe('hello world'); + }); + + it('redacts a secret split across two chunks', () => { + SecretRedaction.register('hunter2secret'); + + expect(collect(['password=hunt', 'er2secret done'])).toBe('password=*** done'); + }); + + it('redacts a secret split one character at a time', () => { + SecretRedaction.register('hunter2secret'); + + expect(collect([...'a hunter2secret b'])).toBe('a *** b'); + }); + + it('never emits a secret prefix before the rest arrives', () => { + SecretRedaction.register('hunter2secret'); + const redactor = createStreamRedactor(); + + expect(redactor.push('x hunter2')).not.toContain('hunter2'); + }); + + it('flushes held-back text that turned out not to be a secret', () => { + SecretRedaction.register('hunter2secret'); + + expect(collect(['ends with hunter2'])).toBe('ends with hunter2'); + }); +}); diff --git a/src/model/secret-redaction.ts b/src/model/secret-redaction.ts index 3330c4c..c01bf8f 100644 --- a/src/model/secret-redaction.ts +++ b/src/model/secret-redaction.ts @@ -107,4 +107,45 @@ const SecretRedaction = { }, }; -export { SecretRedaction, secretOptionKeys }; +/** + * Redacts text that arrives in pieces (live subprocess output), where a secret + * can be split across two chunks. A tail long enough to hold the start of any + * registered secret (twice its length: escaped characters gain a backslash) is + * withheld until more text arrives or flush() is called. + */ +function createStreamRedactor() { + let pending = ''; + + const emit = (final: boolean): string => { + if (secrets.size === 0) { + const text = pending; + pending = ''; + return text; + } + + let cut = final ? pending.length : pending.length - (Math.max(...[...secrets].map((s) => s.length)) * 2 - 1); + // A complete secret straddling the cut must not be half-emitted. + for (const secret of secrets) { + for (const match of pending.matchAll(secretPattern(secret))) { + if (match.index < cut && cut < match.index + match[0].length) cut = match.index; + } + } + if (cut <= 0) return ''; + + const out = SecretRedaction.redact(pending.slice(0, cut)); + pending = pending.slice(cut); + return out; + }; + + return { + push(text: string): string { + pending += text; + return emit(false); + }, + flush(): string { + return emit(true); + }, + }; +} + +export { SecretRedaction, createStreamRedactor, secretOptionKeys }; diff --git a/src/model/system/system.test.ts b/src/model/system/system.test.ts index e88b606..d9003b6 100644 --- a/src/model/system/system.test.ts +++ b/src/model/system/system.test.ts @@ -5,6 +5,7 @@ import { describe, test, expect } from 'bun:test'; import { System } from './system.ts'; +import { SecretRedaction } from '../secret-redaction.ts'; describe.skip('System (unit)', () => { test('placeholder', () => { @@ -86,3 +87,23 @@ describe('System.run exit-code-based failure', () => { } }); }); + +describe('System.run live output redaction', () => { + test('redacts a registered secret that is split across stdout chunks, but keeps runResult.output intact', async () => { + SecretRedaction.register('hunter2secret'); + const written: string[] = []; + const original = process.stdout.write; + process.stdout.write = ((chunk: any) => (written.push(chunk.toString()), true)) as any; + try { + const command = `node -e "process.stdout.write('pw=hunt'); setTimeout(() => process.stdout.write('er2secret end'), 100)"`; + const result = await System.run(command); + + expect(result.output).toContain('hunter2secret'); + expect(written.join('')).toContain('pw=*** end'); + expect(written.join('')).not.toContain('hunter2'); + } finally { + process.stdout.write = original; + SecretRedaction.reset(); + } + }); +}); diff --git a/src/model/system/system.ts b/src/model/system/system.ts index f5df14f..38901f4 100644 --- a/src/model/system/system.ts +++ b/src/model/system/system.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; -import { SecretRedaction } from '../secret-redaction.ts'; +import { StringDecoder } from 'node:string_decoder'; +import { SecretRedaction, createStreamRedactor } from '../secret-redaction.ts'; export interface RunOptions { cwd?: string; @@ -62,12 +63,31 @@ class System { const runResult: RunResult = { output: '', error: '' }; + // Only the write-out path is redacted; runResult keeps the true text so + // callers can still match on it. + const liveWriter = (stream: NodeJS.WriteStream) => { + const decoder = new StringDecoder('utf8'); + const redactor = createStreamRedactor(); + return { + write(chunk: Buffer) { + const out = redactor.push(decoder.write(chunk)); + if (out) stream.write(out); + }, + end() { + const out = redactor.push(decoder.end()) + redactor.flush(); + if (out) stream.write(out); + }, + }; + }; + const liveOut = liveWriter(process.stdout); + const liveErr = liveWriter(process.stderr); + proc.stdout!.on('data', (chunk: Buffer) => { const text = chunk.toString(); runResult.output += text; if (!options.silent) { - process.stdout.write(chunk); + liveOut.write(chunk); } }); @@ -76,11 +96,15 @@ class System { runResult.error += text; if (!options.silent) { - process.stderr.write(chunk); + liveErr.write(chunk); } }); proc.on('close', (code: number | null) => { + if (!options.silent) { + liveOut.end(); + liveErr.end(); + } const exitCode = code ?? 1; runResult.status = { success: exitCode === 0, code: exitCode };