From 472e0c0f32839c19ef35bed6e9590edf0df697ce Mon Sep 17 00:00:00 2001 From: Mathews-Tom Date: Thu, 2 Jul 2026 02:36:31 +0530 Subject: [PATCH] fix(secrets): redact cut regex suffixes with context --- .../coding-agent/src/secrets/obfuscator.ts | 32 +++++++++++++------ .../test/secrets-obfuscator.test.ts | 20 ++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index 54e1d5c46..cd2a875e8 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -1197,15 +1197,15 @@ export class SecretObfuscator { // the secret's expanded value). Rewriting across the token drops bytes // (obfuscate) or drifts the redaction across re-obfuscation passes // (replace), so the cut secret must stay as its existing placeholder. - // But the wholly-outside prefix BEFORE the placeholder is still - // provider-visible content covered by the regex. Probe against the full - // expanded scan text so right-hand context supplied by the placeholder - // still satisfies lookahead/alternatives, then clamp the accepted match - // to the prefix boundary so bytes owned by the placeholder stay atomic. - // Otherwise skip past the placeholder and match trailing content fresh. + // But wholly-outside bytes on either side of the placeholder are still + // provider-visible content covered by the regex. Probe the prefix against + // the full expanded scan text so right-hand context supplied by the + // placeholder still satisfies lookahead/alternatives, then clamp the + // accepted match to the prefix boundary. If no prefix is available, redact + // the outside suffix that was covered by the full-context match. const cutResumeIndex = mapped.cutResumeIndex; const prefixScanEnd = mapped.firstPlaceholderScanStart; - let handledPrefix = false; + let handledOutside = false; if (prefixScanEnd > match.index) { regex.lastIndex = match.index; const prefixMatch = regex.exec(scanText); @@ -1220,11 +1220,25 @@ export class SecretObfuscator { scanMatchLength = scanMatchValue.length; mapped = prefixMapped; regex.lastIndex = prefixEnd; - handledPrefix = true; + handledOutside = true; } } } - if (!handledPrefix) { + if (!handledOutside && cutResumeIndex < end) { + const suffixStart = cutResumeIndex; + const suffixEnd = end; + const suffixMapped = mapReplaceRegexMatch(regexScan.segments, suffixStart, suffixEnd); + if (!suffixMapped.partialPlaceholderCut) { + start = suffixStart; + end = suffixEnd; + scanMatchValue = scanText.slice(suffixStart, suffixEnd); + scanMatchLength = scanMatchValue.length; + mapped = suffixMapped; + regex.lastIndex = suffixEnd; + handledOutside = true; + } + } + if (!handledOutside) { regex.lastIndex = cutResumeIndex; continue; } diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 118fe4592..14edce707 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -663,6 +663,26 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obf.obfuscate(second)).toBe(second); }); + it("redacts a cut suffix using placeholder left context instead of leaking it", () => { + const key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1n"; + const entries = [ + { type: "plain" as const, content: "ABCDEFGH" }, + { type: "regex" as const, mode: "replace" as const, content: "(?<=ABCD)[A-Z]{8}" }, + ]; + const obf = new SecretObfuscator(entries, key); + const placeholdered = obf.obfuscate("ABCDEFGH"); + const second = obf.obfuscate("ABCDEFGHIJKL"); + + // The suffix match depends on lookbehind supplied by the expanded placeholder. + // It must still be redacted while the placeholder's own bytes stay atomic. + expect(second).not.toContain("IJKL"); + expect(second).toContain(placeholdered); + expect(obf.obfuscate(second)).toBe(second); + + const restarted = new SecretObfuscator(entries, key); + expect(restarted.obfuscate(second)).toBe(second); + }); + it("keeps default replace markers stable when a lookbehind match spills into a prior placeholder", () => { const key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1n"; const entries = [