🔐 fix: Redact Before Colorize To Survive ANSI Word-Boundary Interference

The console pipeline runs `redactFormat → colorize({ all: true }) → printf`.
With `all: true`, winston wraps `info.message` in ANSI escapes whose
trailing `m` is a word character. That means `\b(Bearer )…` placed at the
start of a colorized segment can fall on a (word,word) boundary and miss —
the earlier line-wise `redactMessage(line)` pass in printf suffers the
same issue because it runs after colorize.

Extend `redactFormat` to run for `warn` in addition to `error`, operating
on the raw pre-colorize `info.message` + `Symbol.for('message')` strings.
The later in-printf `redactMessage(line)` stays as a backstop, but the
primary redaction now happens where the regex can actually see the text.

Metadata redaction already operates on the raw info object via
`formatConsoleMeta`, so it was never affected by ANSI — no change there.

Includes regression tests for the new warn-level behavior and for the
info/debug no-op path.

Reviewed-by: Codex (P2 finding on PR #12737, commit fdb6b361).
This commit is contained in:
Danny Avila 2026-04-18 11:40:38 -04:00
parent fdb6b36179
commit 1e43d63687
2 changed files with 35 additions and 5 deletions

View file

@ -1,6 +1,7 @@
jest.unmock('winston');
const { formatConsoleMeta, redactMessage, debugTraverse } = jest.requireActual('../parsers');
const { formatConsoleMeta, redactMessage, redactFormat, debugTraverse } =
jest.requireActual('../parsers');
const SPLAT_SYMBOL = Symbol.for('splat');
describe('formatConsoleMeta', () => {
@ -153,6 +154,28 @@ describe('redactMessage', () => {
});
});
describe('redactFormat', () => {
const runFormat = (info) => redactFormat().transform(info) || info;
it('redacts info.message for error level before any colorize step runs', () => {
const info = runFormat({ level: 'error', message: 'Bearer secretvalue' });
expect(info.message).toBe('Bearer [REDACTED]');
});
it('redacts info.message for warn level too (avoids ANSI boundary issues later)', () => {
const info = runFormat({ level: 'warn', message: 'apiKey=sk-abc123def' });
expect(info.message).toContain('sk-[REDACTED]');
});
it('leaves info.message untouched for info and debug levels', () => {
const infoInfo = runFormat({ level: 'info', message: 'Bearer looksSensitive' });
expect(infoInfo.message).toBe('Bearer looksSensitive');
const infoDebug = runFormat({ level: 'debug', message: 'Bearer looksSensitive' });
expect(infoDebug.message).toBe('Bearer looksSensitive');
});
});
describe('debugTraverse', () => {
const runFormatter = (info) => {
const transformed = debugTraverse.transform(info);

View file

@ -41,15 +41,22 @@ function redactMessage(str, trimLength) {
}
/**
* Redacts sensitive information from log messages if the log level is 'error'.
* Redacts sensitive information from log messages when the log level is
* `error` or `warn`. Runs on the raw `info.message` before any colorize /
* splat transforms so the sensitive-token regexes don't have to contend
* with ANSI escape sequences (whose trailing `m` would otherwise defeat
* `\b` anchors).
*
* Note: Intentionally mutates the object.
* @param {Object} info - The log information object.
* @returns {Object} - The modified log information object.
*/
const redactFormat = winston.format((info) => {
if (info.level === 'error') {
info.message = redactMessage(info.message);
if (info[MESSAGE_SYMBOL]) {
if (info.level === 'error' || info.level === 'warn') {
if (typeof info.message === 'string') {
info.message = redactMessage(info.message);
}
if (typeof info[MESSAGE_SYMBOL] === 'string') {
info[MESSAGE_SYMBOL] = redactMessage(info[MESSAGE_SYMBOL]);
}
}