Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/model/secret-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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');
});
});
43 changes: 42 additions & 1 deletion src/model/secret-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
21 changes: 21 additions & 0 deletions src/model/system/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
}
});
});
30 changes: 27 additions & 3 deletions src/model/system/system.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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 };

Expand Down