Log redacted OpenClaw outbound payload details
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { parseOpenClawResponse } from "./parse.js";
|
import { parseOpenClawResponse } from "./parse.js";
|
||||||
|
|
||||||
type SessionKeyStrategy = "fixed" | "issue" | "run";
|
type SessionKeyStrategy = "fixed" | "issue" | "run";
|
||||||
@@ -75,6 +76,62 @@ function toStringRecord(value: unknown): Record<string, string> {
|
|||||||
return out;
|
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<string, unknown>);
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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 = {
|
type WakePayload = {
|
||||||
runId: string;
|
runId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -610,6 +667,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outboundHeaderKeys = Array.from(new Set([...Object.keys(headers), "accept"])).sort();
|
const outboundHeaderKeys = Array.from(new Set([...Object.keys(headers), "accept"])).sort();
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
||||||
|
);
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(paperclipBody), 12_000)}\n`,
|
||||||
|
);
|
||||||
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||||
await onLog("stdout", `[openclaw] invoking ${method} ${url} (transport=sse)\n`);
|
await onLog("stdout", `[openclaw] invoking ${method} ${url} (transport=sse)\n`);
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,55 @@ describe("openclaw adapter execute", () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
sseResponse([
|
sseResponse([
|
||||||
|
|||||||
Reference in New Issue
Block a user