diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 2206a9baa9..482192b85d 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,60 @@ 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", +); + +/** 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); + 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 = 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)) { + 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 f514bcdcb5..72f3945eb1 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -104,6 +104,14 @@ 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. 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` 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. 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 f15e4aa30d..0f8f1a5ec9 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,70 @@ describe("redactSecretString", () => { .toBe('public-status'); }); + test("XML delimiter search work scales linearly without a wall-clock deadline", () => { + for (const tag of ["", '', '") { + searched += (found < 0 ? this.length : found + 1) - (start ?? 0); + } + return found; + }); + try { + expect(redactSecretString(input)).toBe(input); + } finally { + scan.mockRestore(); + characters.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("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