#!/usr/bin/env node /* Copy stdin to stdout line by line, replacing the test credentials before any * byte reaches disk. Secrets come only from MT_ACCOUNT / MT_PASSWORD in the * environment and are never printed, logged or passed as arguments. * * node script/redact_stream.mjs client.log * * Uses blocking reads on fd 0: async stdin on a macOS FIFO does not reliably * report EOF, which would leave the runner waiting for the redactor. */ import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; const MIN_LENGTH = 2; export function secretLiterals(env = process.env) { return ['MT_PASSWORD', 'MT_ACCOUNT'] .map((name) => env[name] || '') .filter((value) => value.length >= MIN_LENGTH) // Longest first so an account contained in a password is not half-replaced. .sort((a, b) => b.length - a.length); } export function redact(line, literals = secretLiterals()) { let out = line; for (const literal of literals) out = out.split(literal).join('[redacted]'); return out; } function pump() { const literals = secretLiterals(); const buffer = Buffer.alloc(64 * 1024); let pending = ''; const write = (text) => { const bytes = Buffer.from(text); let offset = 0; while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset); }; for (;;) { let count; try { count = fs.readSync(0, buffer, 0, buffer.length, null); } catch (error) { if (error.code === 'EAGAIN') continue; if (error.code === 'EOF') break; throw error; } if (count === 0) break; pending += buffer.toString('utf8', 0, count); const lines = pending.split('\n'); pending = lines.pop(); if (lines.length > 0) write(lines.map((line) => `${redact(line, literals)}\n`).join('')); } // A trailing partial line is still redacted as a whole. if (pending !== '') write(`${redact(pending, literals)}\n`); } if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) { if (process.argv.includes('--help')) { console.log('usage: node script/redact_stream.mjs < INPUT > OUTPUT (reads MT_ACCOUNT/MT_PASSWORD from env)'); } else { pump(); } }