From 1179d7e75aa3f76019d9ee18a98098dc5a85aaf0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Mar 2026 10:38:16 -0600 Subject: [PATCH] Log redacted OpenClaw outbound payload details --- .../adapters/openclaw/src/server/execute.ts | 65 +++++++++++++++++++ server/src/__tests__/openclaw-adapter.test.ts | 49 ++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts index ac131ef9..1a77b928 100644 --- a/packages/adapters/openclaw/src/server/execute.ts +++ b/packages/adapters/openclaw/src/server/execute.ts @@ -1,5 +1,6 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { createHash } from "node:crypto"; import { parseOpenClawResponse } from "./parse.js"; type SessionKeyStrategy = "fixed" | "issue" | "run"; @@ -75,6 +76,62 @@ function toStringRecord(value: unknown): Record { return out; } +const SENSITIVE_LOG_KEY_PATTERN = + /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-auth$/i; + +function isSensitiveLogKey(key: string): boolean { + return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); +} + +function sha256Prefix(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +function redactSecretForLog(value: string): string { + return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; +} + +function truncateForLog(value: string, maxChars = 320): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; +} + +function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { + const currentKey = keyPath[keyPath.length - 1] ?? ""; + if (typeof value === "string") { + if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); + return truncateForLog(value); + } + if (typeof value === "number" || typeof value === "boolean" || value == null) { + return value; + } + if (Array.isArray(value)) { + if (depth >= 6) return "[array-truncated]"; + const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); + if (value.length > 20) out.push(`[+${value.length - 20} more items]`); + return out; + } + if (typeof value === "object") { + if (depth >= 6) return "[object-truncated]"; + const entries = Object.entries(value as Record); + const out: Record = {}; + for (const [key, entry] of entries.slice(0, 80)) { + out[key] = redactForLog(entry, [...keyPath, key], depth + 1); + } + if (entries.length > 80) { + out.__truncated__ = `+${entries.length - 80} keys`; + } + return out; + } + return String(value); +} + +function stringifyForLog(value: unknown, maxChars: number): string { + const text = JSON.stringify(value); + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + type WakePayload = { runId: string; agentId: string; @@ -610,6 +667,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { ).toBe(true); }); + it("logs outbound payload with sensitive fields redacted", async () => { + const fetchMock = vi.fn().mockResolvedValue( + sseResponse([ + "event: response.completed\n", + 'data: {"type":"response.completed","status":"completed"}\n\n', + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const logs: string[] = []; + const result = await execute( + buildContext( + { + url: "https://agent.example/sse", + method: "POST", + headers: { + "x-openclaw-auth": "gateway-token", + }, + payloadTemplate: { + text: "task prompt", + nested: { + token: "secret-token", + visible: "keep-me", + }, + }, + }, + { + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }, + ), + ); + + expect(result.exitCode).toBe(0); + + const headerLog = logs.find((line) => line.includes("[openclaw] outbound headers (redacted):")); + expect(headerLog).toBeDefined(); + expect(headerLog).toContain("\"x-openclaw-auth\":\"[redacted"); + expect(headerLog).toContain("\"authorization\":\"[redacted"); + expect(headerLog).not.toContain("gateway-token"); + + const payloadLog = logs.find((line) => line.includes("[openclaw] outbound payload (redacted):")); + expect(payloadLog).toBeDefined(); + expect(payloadLog).toContain("\"token\":\"[redacted"); + expect(payloadLog).not.toContain("secret-token"); + expect(payloadLog).toContain("\"visible\":\"keep-me\""); + }); + it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => { const fetchMock = vi.fn().mockResolvedValue( sseResponse([