diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index 8136884ce62..0a9f3a7cdfb 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -148,6 +148,12 @@ describe("diagnostic support export", () => { }, time: "2026-04-22T12:00:00.100Z", }), + JSON.stringify({ + time: "2026-04-22T12:00:00.200Z", + level: "info", + component: "gateway/server", + msg: "user said structured secret payload", + }), JSON.stringify({ "0": JSON.stringify({ subsystem: "gateway/channels/matrix" }), "1": privateChat, @@ -156,7 +162,7 @@ describe("diagnostic support export", () => { name: "gateway-runtime", hostname: "support-host", }, - time: "2026-04-22T12:00:00.200Z", + time: "2026-04-22T12:00:00.300Z", }), `plain fallback ${privateChat} ${fakeToken}`, ], @@ -246,6 +252,7 @@ describe("diagnostic support export", () => { expect(combined).not.toContain(os.hostname()); expect(combined).not.toContain("QWxhZGRpbjpvcGVuIHNlc2FtZQ=="); expect(combined).not.toContain("sid=secret"); + expect(combined).not.toContain("structured secret payload"); expect(combined).not.toContain(fakeAwsKey); expect(combined).not.toContain(fakeJwt); expect(combined).toContain("payload.large"); @@ -377,6 +384,7 @@ describe("diagnostic support export", () => { "connect wss://support-user:support-password@gateway.example/ws?token=short-token&ok=1", "connect wss://:@gateway.example/ws?token=", ], + ["connect https://token@gateway.example/ws", "connect https://@gateway.example/ws"], ["auth Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", "auth Basic "], ["Cookie: sid=secret; theme=light", "Cookie: "], [`aws ${fakeAwsKey}`, "aws "], diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index 233931bf86c..b2ca6a05631 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -272,6 +272,7 @@ function sanitizeConfigDetails(parsed: unknown, redaction: SupportRedactionConte function configShapeReadFailure(params: { configPath: string; + redaction: SupportRedactionContext; stat?: fs.Stats; error?: string; }): ConfigShape { @@ -286,7 +287,7 @@ function configShapeReadFailure(params: { shape.mtime = params.stat.mtime.toISOString(); } if (params.error) { - shape.error = redactTextForSupport(params.error); + shape.error = redactSupportString(params.error, params.redaction); } return shape; } @@ -300,7 +301,7 @@ function readConfigExport(options: { const stat = fs.existsSync(options.configPath) ? fs.statSync(options.configPath) : null; if (!stat) { return { - shape: configShapeReadFailure({ configPath: redactedConfigPath }), + shape: configShapeReadFailure({ configPath: redactedConfigPath, redaction: options }), }; } try { @@ -309,6 +310,7 @@ function readConfigExport(options: { return { shape: configShapeReadFailure({ configPath: redactedConfigPath, + redaction: options, stat, error: parsed.error, }), @@ -322,6 +324,7 @@ function readConfigExport(options: { return { shape: configShapeReadFailure({ configPath: redactedConfigPath, + redaction: options, stat, error: error instanceof Error ? error.message : String(error), }), @@ -541,7 +544,12 @@ function addSafeLogField( return; } if (typeof value === "string") { - sanitized[key] = sanitizeLogString(value, redaction); + const message = sanitizeLogString(value, redaction); + if (key === "msg" && (!message || UNSAFE_LOG_MESSAGE_RE.test(message))) { + addOmittedLogMessageMetadata(sanitized, value); + return; + } + sanitized[key] = message; } else if (typeof value === "number" || typeof value === "boolean" || value === null) { sanitized[key] = value; } diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index d4a9cb6a494..bf283314ec2 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -16,7 +16,7 @@ const BASIC_AUTH_RE = /\bBasic\s+[A-Za-z0-9+/]+={0,2}/giu; const COOKIE_HEADER_RE = /\b(Cookie|Set-Cookie)\s*:\s*[^\r\n]+/giu; const AWS_ACCESS_KEY_ID_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu; const JWT_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu; -const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s:?#]+):([^/@\s?#]+)@/giu; +const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s:?#]+)(?::([^/@\s?#]+))?@/giu; const SENSITIVE_URL_PARAM_RE = /([?&](?:api[-_]?key|access[-_]?token|auth[-_]?token|hook[-_]?token|password|passwd|refresh[-_]?token|secret|token)=)[^&#\s]+/giu; const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu; @@ -140,7 +140,9 @@ function redactCommonCredentialTextForSupport(value: string): string { function redactUrlSecretsForSupport(value: string): string { return value - .replace(URL_USERINFO_RE, "$1:@") + .replace(URL_USERINFO_RE, (_match, scheme: string, _username: string, password?: string) => + password ? `${scheme}:@` : `${scheme}@`, + ) .replace(SENSITIVE_URL_PARAM_RE, "$1"); }