diff --git a/src/lib/redact.ts b/src/lib/redact.ts
index 2206a9baa9b..482192b85d4 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 f514bcdcb5e..72f3945eb10 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 f15e4aa30db..0f8f1a5ec94 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