fix(secrets): redact cut regex suffixes with context

This commit is contained in:
Mathews-Tom
2026-07-02 02:36:31 +05:30
parent 2309d3bbea
commit 472e0c0f32
2 changed files with 43 additions and 9 deletions
@@ -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;
}
@@ -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 = [