From 1d04ec650d53aaf23f3e301aeb97968a28bebfcf Mon Sep 17 00:00:00 2001 From: Mathews-Tom Date: Sun, 21 Jun 2026 04:58:03 +0530 Subject: [PATCH] Fix replacement prefix placeholder redaction --- .../coding-agent/src/secrets/obfuscator.ts | 46 +++++++++++++++---- .../test/secrets-obfuscator.test.ts | 17 +++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index 69b682fb3..053c028f6 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -313,19 +313,11 @@ export class SecretObfuscator { // 3. Process regex entries — discover new matches for (const entry of this.#regexEntries) { entry.regex.lastIndex = 0; - const matches = this.#collectRegexMatches(result, entry.regex, entry.mode, origin); + const matches = this.#collectRegexMatches(result, entry.regex, entry.mode, origin, entry.replacement); for (const match of matches) { if (entry.mode === "replace") { if (match.preserveGeneratedPlaceholders) { - if ( - match.preserveInputPlaceholders && - !match.inputPlaceholderOutsideIndependentlyMatches && - entry.replacement !== undefined && - entry.replacement.startsWith(match.inputPlaceholderOutside) - ) { - continue; - } const span = result.slice(match.start, match.end); // A custom replacement is a single redaction marker for the whole // match, so emit it once around the preserved placeholder rather @@ -650,6 +642,7 @@ export class SecretObfuscator { regex: RegExp, mode: "obfuscate" | "replace", origin: string, + replacement: string | undefined, ): Array<{ start: number; end: number; @@ -660,6 +653,7 @@ export class SecretObfuscator { preserveInputPlaceholders: boolean; inputPlaceholderOutside: string; inputPlaceholderOutsideIndependentlyMatches: boolean; + inputPlaceholderOutsideStart: number; }> { const knownPlaceholderRanges = this.#knownPlaceholderRanges(text); const regexScan = buildReplaceRegexScan(text, knownPlaceholderRanges, this.#deobfuscateMap); @@ -675,6 +669,7 @@ export class SecretObfuscator { preserveInputPlaceholders: boolean; inputPlaceholderOutside: string; inputPlaceholderOutsideIndependentlyMatches: boolean; + inputPlaceholderOutsideStart: number; }> = []; for (;;) { const match = regex.exec(scanText); @@ -691,6 +686,7 @@ export class SecretObfuscator { let preserveInputPlaceholders = false; let inputPlaceholderOutside = ""; let inputPlaceholderOutsideIndependentlyMatches = false; + let inputPlaceholderOutsideStart = -1; const mapped = mapReplaceRegexMatch(regexScan.segments, start, end); start = mapped.start; @@ -708,6 +704,21 @@ export class SecretObfuscator { preserveInputPlaceholders = overlapsInputPlaceholder; if (overlapsInputPlaceholder) { inputPlaceholderOutside = textOutsidePlaceholderRanges(text, start, end, knownPlaceholderRanges); + inputPlaceholderOutsideStart = + firstOutsidePlaceholderRange(start, end, knownPlaceholderRanges)?.start ?? -1; + if ( + mode === "replace" && + replacement !== undefined && + inputPlaceholderOutsideStart >= 0 && + text.slice(inputPlaceholderOutsideStart, inputPlaceholderOutsideStart + replacement.length) === + replacement + ) { + regex.lastIndex = Math.max( + regex.lastIndex, + match.index + match[0].length + replacement.length - inputPlaceholderOutside.length, + ); + continue; + } if (inputPlaceholderOutside.length === 0) continue; const resumeIndex = regex.lastIndex; regex.lastIndex = 0; @@ -744,6 +755,7 @@ export class SecretObfuscator { preserveInputPlaceholders, inputPlaceholderOutside, inputPlaceholderOutsideIndependentlyMatches, + inputPlaceholderOutsideStart, }); } return matches.reverse(); @@ -986,6 +998,22 @@ function textOutsidePlaceholderRanges( return result; } +function firstOutsidePlaceholderRange( + start: number, + end: number, + ranges: ReadonlyArray<{ start: number; end: number }>, +): { start: number; end: number } | undefined { + let cursor = start; + for (const range of ranges) { + if (range.end <= start || range.start >= end) continue; + const overlapStart = Math.max(range.start, start); + const overlapEnd = Math.min(range.end, end); + if (cursor < overlapStart) return { start: cursor, end: overlapStart }; + cursor = overlapEnd; + } + return cursor < end ? { start: cursor, end } : undefined; +} + function replaceRange(text: string, start: number, end: number, replacement: string): string { return text.slice(0, start) + replacement + text.slice(end); } diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 81dc0d52b..d8f39a07c 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -541,6 +541,23 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obf.obfuscate(out)).toBe(out); }); + it("redacts custom replacement prefixes that are raw regex remainders", () => { + const obf = new SecretObfuscator( + [ + { type: "plain", content: "SECRET" }, + { type: "regex", mode: "replace", content: "[A-Z0-9]{7}", replacement: "REDACTED" }, + ], + "K".repeat(43), + ); + const token = obf.obfuscate("SECRET"); + + const out = obf.obfuscate(`${token}R`); + + expect(out).toBe(`${token}REDACTED`); + expect(obf.deobfuscate(out)).toBe("SECRETREDACTED"); + expect(obf.obfuscate(out)).toBe(out); + }); + it("redacts default replace regex remainders around prior placeholders", () => { const obf = new SecretObfuscator( [