fix: harden diagnostics support redaction

This commit is contained in:
Gustavo Madeira Santana
2026-04-22 16:41:14 -04:00
parent 02b220388f
commit 7cdaa92b3f
6 changed files with 48 additions and 5 deletions

View File

@@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai
- TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev.
- CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus.
- Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev.
- Gateway/diagnostics: add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
### Fixes

View File

@@ -135,7 +135,7 @@ Options:
Notes:
- The recorder is active by default. Set `diagnostics.enabled: false` only when you need to disable Gateway diagnostic heartbeat collection.
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and session ids. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, or secret values.
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids.
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
### `gateway diagnostics export`

View File

@@ -81,6 +81,9 @@ describe("diagnostic stability bundles", () => {
name: "Error",
code: "ERR_TEST",
},
host: {
hostname: "<redacted-hostname>",
},
snapshot: {
count: 2,
},
@@ -94,6 +97,7 @@ describe("diagnostic stability bundles", () => {
expect(raw).not.toContain("chat-secret");
expect(raw).not.toContain("message body");
expect(raw).not.toContain("contains secret message");
expect(raw).not.toContain(os.hostname());
});
it("skips empty recorder snapshots by default", () => {

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { resolveStateDir } from "../config/paths.js";
@@ -17,6 +16,7 @@ export const DEFAULT_DIAGNOSTIC_STABILITY_BUNDLE_RETENTION = 20;
const SAFE_REASON_CODE = /^[A-Za-z0-9_.:-]{1,120}$/u;
const BUNDLE_PREFIX = "openclaw-stability-";
const BUNDLE_SUFFIX = ".json";
const REDACTED_HOSTNAME = "<redacted-hostname>";
export type DiagnosticStabilityBundle = {
version: typeof DIAGNOSTIC_STABILITY_BUNDLE_VERSION;
@@ -294,7 +294,7 @@ export function writeDiagnosticStabilityBundleSync(
uptimeMs: Math.round(process.uptime() * 1000),
},
host: {
hostname: os.hostname(),
hostname: REDACTED_HOSTNAME,
},
...(error ? { error } : {}),
snapshot,

View File

@@ -48,6 +48,12 @@ describe("diagnostic support export", () => {
it("writes a shareable zip without raw chats, webhook bodies, or secrets", async () => {
const fakeToken = "sk-test-support-export-secret-token-1234567890";
const fakeAwsKey = ["AKIA", "IOSFODNN7EXAMPLE"].join("");
const fakeJwt = [
"eyJhbGciOiJIUzI1NiIs",
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4i",
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
].join(".");
const privateChat = "private user said diagnose my bank transfer";
const webhookBody = "raw webhook body with message contents";
const credentialUrl =
@@ -122,7 +128,7 @@ describe("diagnostic support export", () => {
subsystem: "gateway",
component: "gateway/server",
channel: "telegram",
msg: `gateway websocket listening at ${credentialUrl}`,
msg: `gateway websocket listening at ${credentialUrl} Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== ${fakeAwsKey} ${fakeJwt} Cookie: sid=secret`,
hostname: "support-host",
message: privateChat,
body: webhookBody,
@@ -237,9 +243,15 @@ describe("diagnostic support export", () => {
expect(combined).not.toContain("short-token");
expect(combined).not.toContain(tempDir);
expect(combined).not.toContain("cron/jobs.json");
expect(combined).not.toContain(os.hostname());
expect(combined).not.toContain("QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
expect(combined).not.toContain("sid=secret");
expect(combined).not.toContain(fakeAwsKey);
expect(combined).not.toContain(fakeJwt);
expect(combined).toContain("payload.large");
expect(combined).toContain("gateway.http.json");
expect(combined).toContain("$OPENCLAW_STATE_DIR");
expect(combined).toContain("<redacted-hostname>");
expect(combined).toContain("gateway-status.json");
expect(combined).toContain("gateway-health.json");
expect(combined).toContain("Attach this zip to the bug report");
@@ -252,6 +264,10 @@ describe("diagnostic support export", () => {
expect(sanitizedLogs).toContain(
"wss://<redacted>:<redacted>@gateway.example/ws?token=<redacted>",
);
expect(sanitizedLogs).toContain("Basic <redacted>");
expect(sanitizedLogs).toContain("Cookie: <redacted>");
expect(sanitizedLogs).toContain("<redacted-aws-key>");
expect(sanitizedLogs).toContain("<redacted-jwt>");
expect(sanitizedLogs).toContain('"module":"matrix-auto-reply"');
expect(sanitizedLogs).toContain('"subsystem":"gateway/channels/matrix"');
expect(sanitizedLogs).toContain('"logger":"gateway-runtime"');
@@ -350,11 +366,21 @@ describe("diagnostic support export", () => {
});
it("redacts support text identifiers without hiding useful URL hosts", () => {
const fakeAwsKey = ["ASIA", "IOSFODNN7EXAMPLE"].join("");
const fakeJwt = [
"eyJhbGciOiJIUzI1NiIs",
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4i",
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
].join(".");
const cases = [
[
"connect wss://support-user:support-password@gateway.example/ws?token=short-token&ok=1",
"connect wss://<redacted>:<redacted>@gateway.example/ws?token=<redacted>",
],
["auth Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", "auth Basic <redacted>"],
["Cookie: sid=secret; theme=light", "Cookie: <redacted>"],
[`aws ${fakeAwsKey}`, "aws <redacted-aws-key>"],
[`jwt ${fakeJwt}`, "jwt <redacted-jwt>"],
["email alice@example.com", "email <redacted-email>"],
["matrix @support-user:matrix.example.com", "matrix <redacted-matrix-user>"],
["room !support-room:matrix.example.com", "room <redacted-matrix-room>"],

View File

@@ -12,6 +12,10 @@ const CONFIG_PRIVATE_FIELD_RE =
/(?:allow[-_]?from|allow[-_]?to|deny[-_]?from|deny[-_]?to|blocked[-_]?from|blocked[-_]?users|owner[-_]?id|sender[-_]?id|recipient[-_]?id)/iu;
const SENSITIVE_COMMAND_ARG_RE =
/^--(?:api[-_]?key|hook[-_]?token|password|password-file|passwd|secret|token)(?:=.*)?$/iu;
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 SENSITIVE_URL_PARAM_RE =
/([?&](?:api[-_]?key|access[-_]?token|auth[-_]?token|hook[-_]?token|password|passwd|refresh[-_]?token|secret|token)=)[^&#\s]+/giu;
@@ -115,6 +119,7 @@ function redactKnownPathPrefixesForSupport(
export function redactTextForSupport(value: string): string {
let redacted = redactSensitiveTextForSupport(value);
redacted = redactCommonCredentialTextForSupport(redacted);
redacted = redactUrlSecretsForSupport(redacted);
redacted = redactServiceIdentifiersForSupport(redacted);
redacted = redactContactIdentifiersForSupport(redacted);
@@ -125,6 +130,14 @@ function redactSensitiveTextForSupport(value: string): string {
return redactSensitiveText(value, { mode: "tools" });
}
function redactCommonCredentialTextForSupport(value: string): string {
return value
.replace(BASIC_AUTH_RE, "Basic <redacted>")
.replace(COOKIE_HEADER_RE, "$1: <redacted>")
.replace(AWS_ACCESS_KEY_ID_RE, "<redacted-aws-key>")
.replace(JWT_RE, "<redacted-jwt>");
}
function redactUrlSecretsForSupport(value: string): string {
return value
.replace(URL_USERINFO_RE, "$1<redacted>:<redacted>@")