From e1fcab7dbc7b166bdb46b19a9a363fa5503a0711 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 05:35:41 -0700 Subject: [PATCH 1/2] fix(redaction): bound XML identifying-attribute scans Carry luvs01/opencodex#691 at f42e713c58745e1d0788ea7307b8a95785fbcd8c. Replace wall-clock regression with delimiter-work accounting and preserve malformed/escaped credential coverage. --- src/lib/redact.ts | 46 ++++++++++++++++++++----- structure/transports/byte-accounting.md | 6 ++++ tests/lib/redact.test.ts | 44 ++++++++++++++++++++++- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 2206a9baa9b..928e84b6d5f 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -100,13 +100,6 @@ const OTHER_FRAMED_CREDENTIALS: Array<[RegExp, string]> = [ ), "element", ], - [ - new RegExp( - `(<[^\\S\\r\\n]*(?:[A-Za-z_][\\w.-]*:)?[A-Za-z_][\\w:.-]*)(?=[^>]*?(?]))[\\s\\S]*`, - "gi", - ), - "element", - ], // Multipart part: everything from a credential-named part header to the end // of the input. Part-based, not line-based — a body can span lines and the // blank line is often missing in a malformed echo. @@ -136,7 +129,44 @@ const OTHER_FRAMED_CREDENTIALS: Array<[RegExp, string]> = [ function maskOtherFramings(value: string): string { // Same union rule as the header pass: decoding may add coverage, never // remove it. - return maskOtherFramingsOnce(maskOtherFramingsOnce(value, true), false); + const decoded = maskOtherFramingsOnce(value, true); + const decodedXml = maskXmlIdentifyingAttribute(decoded, true); + const plain = maskOtherFramingsOnce(decodedXml, false); + return maskXmlIdentifyingAttribute(plain, false); +} + +const XML_TAG_NAME = /<[^\S\r\n]*(?:[A-Za-z_][\w.-]*:)?[A-Za-z_][\w:.-]*/y; +const XML_CREDENTIAL_ATTRIBUTE = new RegExp( + `(?])`, + "i", +); + +/** Scan each tag once; a suffix-searching lookahead here makes unterminated tags quadratic. */ +function maskXmlIdentifyingAttribute(value: string, decodeEscapes: boolean): string { + const { folded, map } = foldForMatching(value, decodeEscapes); + let searchFrom = 0; + while (searchFrom < folded.length) { + const relativeStart = folded.indexOf("<", searchFrom); + if (relativeStart === -1) return value; + const tagStart = relativeStart; + XML_TAG_NAME.lastIndex = tagStart; + const tagName = XML_TAG_NAME.exec(folded); + if (!tagName) { + searchFrom = tagStart + 1; + continue; + } + const close = folded.indexOf(">", tagStart + tagName[0].length); + const tagEnd = close === -1 ? folded.length : close + 1; + const attributes = folded.slice(tagStart + tagName[0].length, tagEnd); + if (XML_CREDENTIAL_ATTRIBUTE.test(attributes)) { + const start = map[tagStart] ?? value.length; + const headEnd = map[tagStart + tagName[0].length] ?? value.length; + return value.slice(0, start) + value.slice(start, headEnd) + REDACTED_SECRET; + } + if (close === -1) return value; + searchFrom = tagEnd; + } + return value; } function maskOtherFramingsOnce(value: string, decodeEscapes: boolean): string { diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index f514bcdcb5e..527ab0f4e18 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -104,6 +104,12 @@ Canonical Responses identity sanitation and narrowly scoped pre-output combo rec ## Response-log inspection +`src/lib/redact.ts` scans XML identifying attributes over disjoint tag spans rather than +searching the remaining suffix from each opening delimiter. The decoded and raw passes +retain the original-offset mapping and mask a credential-bearing element through the rest +of the input. `tests/lib/redact.test.ts` checks delimiter search work across doubled inputs +without a machine-speed deadline, as well as malformed and escaped credential coverage. + `src/server/response-log-body.ts` forwards raw response chunks on downstream demand. Diagnostic retention is limited to 32 MiB for JSON and an 8 KiB prefix for other HTTP error bodies. Fixed 64 KiB blocks also bound per-chunk bookkeeping. These diff --git a/tests/lib/redact.test.ts b/tests/lib/redact.test.ts index f15e4aa30db..65e6deb3933 100644 --- a/tests/lib/redact.test.ts +++ b/tests/lib/redact.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { readFileSync } from "node:fs"; import { foldForMatching } from "../../src/lib/redact-folding"; import { repoPath } from "../helpers/repo-root"; @@ -335,6 +335,48 @@ describe("redactSecretString", () => { .toBe('public-status'); }); + test("XML delimiter search work scales linearly without a wall-clock deadline", () => { + for (const tag of [""]) { + const work: number[] = []; + for (const count of [4_000, 8_000]) { + const input = tag.repeat(count); + let searched = 0; + const original = String.prototype.indexOf; + const scan = spyOn(String.prototype, "indexOf").mockImplementation(function (this: string, needle, start) { + const found = original.call(this, needle, start); + if (needle === "<" || needle === ">") { + searched += (found < 0 ? this.length : found + 1) - (start ?? 0); + } + return found; + }); + try { + expect(redactSecretString(input)).toBe(input); + } finally { + scan.mockRestore(); + } + expect(searched).toBeGreaterThan(0); + expect(searched).toBeLessThanOrEqual(4 * input.length); + work.push(searched); + } + expect(work[1]!).toBeLessThanOrEqual(2 * work[0]! + 8); + } + }); + + test("XML attribute scanning keeps credential coverage after malformed and escaped prefixes", () => { + const prefix = "π ratio∶1\n"; + for (const input of [ + 'synthetic-xml-canary', + ''.repeat(4_000) + 'synthetic-xml-canary', + 'synthetic-xml-canary', + '', + ]) { + const result = redactSecretString(prefix + input); + expect(result.startsWith(prefix)).toBe(true); + expect(result).toContain(REDACTED_SECRET); + expect(result).not.toContain("synthetic-xml-canary"); + } + }); + test("a multipart credential part is masked through the rest of the body", () => { // Line-based masking left a multi-line body and the no-blank-line shape // partly intact, and stopping at the first `--` trusted an attacker-chosen From 0ae9ef5d5c12bb3bfb6428491dab99698ee1f9f6 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 06:34:00 -0700 Subject: [PATCH 2/2] fix(redaction): recognize quoted XML tag delimiters Scan tag terminators outside quoted values, cover credential masking after embedded delimiters, and count manual character work in linear-scaling regressions. --- src/lib/redact.ts | 18 +++++++++++++++++- structure/transports/byte-accounting.md | 6 ++++-- tests/lib/redact.test.ts | 24 +++++++++++++++++++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 928e84b6d5f..482192b85d4 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -141,6 +141,22 @@ const XML_CREDENTIAL_ATTRIBUTE = new RegExp( "i", ); +/** Find a tag terminator without treating a quoted attribute's `>` as syntax. */ +function findXmlTagClose(text: string, from: number): number { + let quote = ""; + for (let index = from; index < text.length; index += 1) { + const character = text.charAt(index); + if (quote) { + if (character === quote) quote = ""; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + return -1; +} + /** Scan each tag once; a suffix-searching lookahead here makes unterminated tags quadratic. */ function maskXmlIdentifyingAttribute(value: string, decodeEscapes: boolean): string { const { folded, map } = foldForMatching(value, decodeEscapes); @@ -155,7 +171,7 @@ function maskXmlIdentifyingAttribute(value: string, decodeEscapes: boolean): str searchFrom = tagStart + 1; continue; } - const close = folded.indexOf(">", tagStart + tagName[0].length); + const close = findXmlTagClose(folded, tagStart + tagName[0].length); const tagEnd = close === -1 ? folded.length : close + 1; const attributes = folded.slice(tagStart + tagName[0].length, tagEnd); if (XML_CREDENTIAL_ATTRIBUTE.test(attributes)) { diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 527ab0f4e18..72f3945eb10 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -105,9 +105,11 @@ Canonical Responses identity sanitation and narrowly scoped pre-output combo rec ## Response-log inspection `src/lib/redact.ts` scans XML identifying attributes over disjoint tag spans rather than -searching the remaining suffix from each opening delimiter. The decoded and raw passes +searching the remaining suffix from each opening delimiter. Tag terminators are quote-aware, +so `>` inside a single- or double-quoted attribute cannot hide later credential attributes. +The decoded and raw passes retain the original-offset mapping and mask a credential-bearing element through the rest -of the input. `tests/lib/redact.test.ts` checks delimiter search work across doubled inputs +of the input. `tests/lib/redact.test.ts` counts delimiter searches and scanned characters across doubled inputs without a machine-speed deadline, as well as malformed and escaped credential coverage. `src/server/response-log-body.ts` forwards raw response chunks on downstream demand. diff --git a/tests/lib/redact.test.ts b/tests/lib/redact.test.ts index 65e6deb3933..0f8f1a5ec94 100644 --- a/tests/lib/redact.test.ts +++ b/tests/lib/redact.test.ts @@ -336,12 +336,17 @@ describe("redactSecretString", () => { }); test("XML delimiter search work scales linearly without a wall-clock deadline", () => { - for (const tag of [""]) { + for (const tag of ["", '', '") { @@ -353,6 +358,7 @@ describe("redactSecretString", () => { expect(redactSecretString(input)).toBe(input); } finally { scan.mockRestore(); + characters.mockRestore(); } expect(searched).toBeGreaterThan(0); expect(searched).toBeLessThanOrEqual(4 * input.length); @@ -377,6 +383,22 @@ describe("redactSecretString", () => { } }); + test("XML quoted tag delimiters cannot hide a later credential attribute", () => { + for (const tag of [ + '', + "", + 'd\' id="password">', + '', + ]) { + const result = redactSecretString("diagnostic: safe\n" + tag + "synthetic-xml-canary"); + expect(result).toBe("diagnostic: safe\nsynthetic-xml-canary')) + .toBe(harmless + " { // Line-based masking left a multi-line body and the no-blank-line shape // partly intact, and stopping at the first `--` trusted an attacker-chosen