Log redacted OpenClaw outbound payload details
This commit is contained in:
@@ -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<string, string> {
|
||||
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 = {
|
||||
runId: 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();
|
||||
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] invoking ${method} ${url} (transport=sse)\n`);
|
||||
|
||||
|
||||
@@ -256,6 +256,55 @@ describe("openclaw adapter execute", () => {
|
||||
).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([
|
||||
|
||||
Reference in New Issue
Block a user