Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions src/lib/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:.-]*)(?=[^>]*?(?<![\\w:.-])(?:name|key|id)[^\\S]*=[^\\S]*["']?(?:${CREDENTIAL_HEADER_LABEL})["']?(?=[\\s/>]))[\\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.
Expand Down Expand Up @@ -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(
`(?<![\\w:.-])(?:name|key|id)[^\\S]*=[^\\S]*["']?(?:${CREDENTIAL_HEADER_LABEL})["']?(?=[\\s/>])`,
"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 {
Expand Down
8 changes: 8 additions & 0 deletions structure/transports/byte-accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion tests/lib/redact.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -335,6 +335,70 @@ describe("redactSecretString", () => {
.toBe('<field data-name="authorization">public-status</field>');
});

test("XML delimiter search work scales linearly without a wall-clock deadline", () => {
for (const tag of ["<a ", "<a >", '<a title="x>y">', '<a title="x>y" ']) {
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 originalCharAt = String.prototype.charAt;
const characters = spyOn(String.prototype, "charAt").mockImplementation(function (this: string, index) {
searched += 1;
return originalCharAt.call(this, index);
});
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();
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 [
'<a '.repeat(4_000) + 'name="authorization">synthetic-xml-canary',
'<a >'.repeat(4_000) + '<field id="x-api-key">synthetic-xml-canary',
'<field name="author&#105;zation">synthetic-xml-canary',
'<field key="authorization"\n value="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 [
'<field title="a>b" name="authorization">',
"<field title='a>b' key='x-api-key'>",
'<field title="a>b" note=\'c>d\' id="password">',
'<field title="a&gt;b" name="author&#105;zation">',
]) {
const result = redactSecretString("diagnostic: safe\n" + tag + "synthetic-xml-canary");
expect(result).toBe("diagnostic: safe\n<field" + REDACTED_SECRET);
}
const harmless = '<field title="a>b">public-status</field>';
expect(redactSecretString(harmless)).toBe(harmless);
expect(redactSecretString(harmless + '<field name="authorization">synthetic-xml-canary'))
.toBe(harmless + "<field" + REDACTED_SECRET);
});

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
Expand Down
Loading