diff --git a/src/config/redact-snapshot.raw.test.ts b/src/config/redact-snapshot.raw.test.ts new file mode 100644 index 00000000000..17f3cdc09bf --- /dev/null +++ b/src/config/redact-snapshot.raw.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { replaceSensitiveValuesInRaw } from "./redact-snapshot.raw.js"; + +describe("replaceSensitiveValuesInRaw", () => { + it("redacts sensitive values from raw string", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"apiKey": "secret123", "name": "test"}', + sensitiveValues: ["secret123"], + redactedSentinel: "__REDACTED__", + }); + expect(result).toBe('{"apiKey": "__REDACTED__", "name": "test"}'); + }); + + it("handles multiple sensitive values (longest first)", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key1": "abc", "key2": "abcdef"}', + sensitiveValues: ["abc", "abcdef"], + redactedSentinel: "***", + }); + expect(result).toBe('{"key1": "***", "key2": "***"}'); + }); + + // Regression test for #41247 + it("handles empty strings without throwing RangeError", () => { + expect(() => + replaceSensitiveValuesInRaw({ + raw: '{"key": "value"}', + sensitiveValues: ["", "value"], + redactedSentinel: "__REDACTED__", + }), + ).not.toThrow(); + + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "value"}', + sensitiveValues: ["", "value"], + redactedSentinel: "__REDACTED__", + }); + expect(result).toBe('{"key": "__REDACTED__"}'); + }); + + it("handles null and undefined values", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "secret"}', + sensitiveValues: [null, undefined, "secret"] as unknown as string[], + redactedSentinel: "***", + }); + expect(result).toBe('{"key": "***"}'); + }); + + it("returns raw unchanged when no valid sensitive values", () => { + const raw = '{"key": "value"}'; + const result = replaceSensitiveValuesInRaw({ + raw, + sensitiveValues: ["", null, undefined] as unknown as string[], + redactedSentinel: "__REDACTED__", + }); + expect(result).toBe(raw); + }); + + it("uses default sentinel when provided sentinel is empty", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "secret"}', + sensitiveValues: ["secret"], + redactedSentinel: "", + }); + expect(result).toBe('{"key": "__REDACTED__"}'); + }); + + it("handles non-string raw input gracefully", () => { + const nullResult = replaceSensitiveValuesInRaw({ + raw: null as unknown as string, + sensitiveValues: ["test"], + redactedSentinel: "***", + }); + const objectResult = replaceSensitiveValuesInRaw({ + raw: { secret: "test" } as unknown as string, + sensitiveValues: ["test"], + redactedSentinel: "***", + }); + + expect(nullResult).toBe(""); + expect(objectResult).toBe(""); + }); + + it("handles unicode strings", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "🔑secret🔑"}', + sensitiveValues: ["🔑secret🔑"], + redactedSentinel: "***", + }); + expect(result).toBe('{"key": "***"}'); + }); +}); diff --git a/src/config/redact-snapshot.raw.ts b/src/config/redact-snapshot.raw.ts index 9f6f78a6724..06c0b2ef0bb 100644 --- a/src/config/redact-snapshot.raw.ts +++ b/src/config/redact-snapshot.raw.ts @@ -1,15 +1,43 @@ import { isDeepStrictEqual } from "node:util"; import JSON5 from "json5"; +/** + * Redacts sensitive values from a raw config string. + * Filters out empty/null/undefined values to prevent RangeError (#41247). + * + * Note: When `params.raw` is not a string, this returns an empty string + * defensively instead of returning a stringified unredacted value. + */ export function replaceSensitiveValuesInRaw(params: { raw: string; sensitiveValues: string[]; redactedSentinel: string; }): string { - const values = [...params.sensitiveValues].toSorted((a, b) => b.length - a.length); + // Defensive: validate input types + if (typeof params.raw !== "string") { + return ""; + } + + // Defensive: normalize and filter sensitive values + // Empty strings cause RangeError in String.replaceAll (#41247) + const values = [...params.sensitiveValues] + .filter((v): v is string => typeof v === "string" && v.length > 0) + .toSorted((a, b) => b.length - a.length); + + // Early return if no valid values to redact + if (values.length === 0) { + return params.raw; + } + + // Defensive: ensure sentinel is valid + const sentinel = + typeof params.redactedSentinel === "string" && params.redactedSentinel.length > 0 + ? params.redactedSentinel + : "__REDACTED__"; + let result = params.raw; for (const value of values) { - result = result.replaceAll(value, params.redactedSentinel); + result = result.replaceAll(value, sentinel); } return result; }