From 5f5f1fadbbd120dfcc98885b9cfd5efa7bee4bfa Mon Sep 17 00:00:00 2001 From: stainlu Date: Fri, 1 May 2026 03:19:46 +0800 Subject: [PATCH] security(logging): redact payment credential fields --- CHANGELOG.md | 1 + docs/gateway/logging.md | 2 +- docs/logging.md | 4 ++ .../pi-embedded-subscribe.tools.test.ts | 33 +++++++++++ src/agents/pi-embedded-subscribe.tools.ts | 7 ++- src/logging/redact.test.ts | 56 +++++++++++++++++++ src/logging/redact.ts | 29 ++++++++-- 7 files changed, 124 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8773edab7..e16ba3df786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord. - TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens. - Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord. +- Security/logging: redact payment credential field names such as card number, CVC/CVV, shared payment token, and payment credential across default log and tool-payload redaction patterns so wallet-style MCP tools do not expose raw payment credentials in UI events or transcripts. Thanks @stainlu. - Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent. - Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24. - Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt. diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index 0b8d21da422..22b2ba6ad77 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -63,7 +63,7 @@ masked before JSONL lines or messages are written to disk. - `logging.redactPatterns`: array of regex strings (overrides defaults) - Use raw regex strings (auto `gi`), or `/pattern/flags` if you need custom flags. - Matches are masked by keeping the first 6 + last 4 chars (length >= 18), otherwise `***`. - - Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, and popular token prefixes. + - Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, popular token prefixes, and payment credential field names such as card number, CVC/CVV, shared payment token, and payment credential. Some safety boundaries always redact regardless of `logging.redactSensitive`. That includes Control UI tool-call events, `sessions_history` tool output, diff --git a/docs/logging.md b/docs/logging.md index c12b2326225..c37d5cd9a0e 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -220,6 +220,10 @@ masked before the line or message is written to disk. Redaction is best-effort: it applies to text-bearing message content and log strings, not every identifier or binary payload field. +The built-in defaults cover common API credentials and payment-credential field +names such as card number, CVC/CVV, shared payment token, and payment credential +when they appear as JSON fields, URL parameters, CLI flags, or assignments. + `logging.redactSensitive: "off"` only disables this general log/transcript policy. OpenClaw still redacts safety-boundary payloads that can be shown to UI clients, support bundles, diagnostics observers, approval prompts, or agent diff --git a/src/agents/pi-embedded-subscribe.tools.test.ts b/src/agents/pi-embedded-subscribe.tools.test.ts index 53b9977b2b3..ccf81790d76 100644 --- a/src/agents/pi-embedded-subscribe.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.test.ts @@ -64,6 +64,39 @@ describe("sanitizeToolResult", () => { expect(text).toContain("model"); }); + it("redacts Link-like payment credential fields in tool result payloads", () => { + const result = { + content: [ + { + type: "text", + text: '{"shared_payment_token":"spt_abcdefghijklmnopqrstuvwxyz","paymentCredential":"paycred_abcdefghijklmnopqrstuvwxyz","card_number":"4242424242424242","cvc":"123","amount":"4200"}', + }, + ], + details: { + structuredContent: { + sharedPaymentToken: "spt_zyxwvutsrqponmlkjihgfedcba", + cardNumber: "4000056655665556", + amount: "4200", + }, + }, + }; + const sanitized = sanitizeToolResult(result) as { + content: Array<{ text: string }>; + details: { + structuredContent: { sharedPaymentToken: string; cardNumber: string; amount: string }; + }; + }; + const serialized = JSON.stringify(sanitized); + expect(serialized).not.toContain("spt_abcdefghijklmnopqrstuvwxyz"); + expect(serialized).not.toContain("paycred_abcdefghijklmnopqrstuvwxyz"); + expect(serialized).not.toContain("4242424242424242"); + expect(serialized).not.toContain("123"); + expect(serialized).not.toContain("spt_zyxwvutsrqponmlkjihgfedcba"); + expect(serialized).not.toContain("4000056655665556"); + expect(sanitized.content[0]?.text).toContain('"amount":"4200"'); + expect(sanitized.details.structuredContent.amount).toBe("4200"); + }); + it("redacts ENV-style credential assignments", () => { const result = { content: [ diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 7a334ce372f..cf91eef3aab 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -1,6 +1,6 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; -import { redactToolPayloadText } from "../logging/redact.js"; +import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js"; import { splitMediaFromOutput } from "../media/parse.js"; import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js"; import { @@ -133,7 +133,10 @@ function redactStringsDeep(value: unknown, seen = new WeakSet()): unknow seen.add(value); const out: Record = {}; for (const [key, child] of Object.entries(value as Record)) { - out[key] = redactStringsDeep(child, seen); + out[key] = + typeof child === "string" + ? redactSensitiveFieldValue(key, child) + : redactStringsDeep(child, seen); } return out; } diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index d64934f776b..22907235443 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getDefaultRedactPatterns, + redactSensitiveFieldValue, redactSensitiveLines, redactSensitiveText, resolveRedactOptions, @@ -97,6 +98,61 @@ describe("redactSensitiveText", () => { expect(output).toBe('{"token":"abcdef…ghij"}'); }); + it("masks payment credential JSON fields without redacting unrelated amounts", () => { + const input = + '{"card_number":"4242424242424242","cvc":"123","sharedPaymentToken":"spt_abcdefghijklmnopqrstuvwxyz","payment_credential":"paycred_abcdefghijklmnopqrstuvwxyz","amount":"4200"}'; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe( + '{"card_number":"***","cvc":"***","sharedPaymentToken":"spt_ab…wxyz","payment_credential":"paycre…wxyz","amount":"4200"}', + ); + }); + + it("masks payment credential assignments and flags", () => { + const input = [ + "LINK_CARD_NUMBER=4242424242424242", + "LINK_CVC=123", + "shared_payment_token=spt_abcdefghijklmnopqrstuvwxyz", + "--payment-credential paycred_abcdefghijklmnopqrstuvwxyz", + "--card-number 4000056655665556", + ].join(" "); + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).not.toContain("4242424242424242"); + expect(output).not.toContain("4000056655665556"); + expect(output).not.toContain("spt_abcdefghijklmnopqrstuvwxyz"); + expect(output).not.toContain("paycred_abcdefghijklmnopqrstuvwxyz"); + expect(output).toContain("LINK_CARD_NUMBER=***"); + expect(output).toContain("LINK_CVC=***"); + expect(output).toContain("shared_payment_token=spt_ab…wxyz"); + expect(output).toContain("--payment-credential paycre…wxyz"); + expect(output).toContain("--card-number ***"); + }); + + it("masks payment credential URL query parameters", () => { + const input = + "POST /authorize?shared_payment_token=spt_abcdefghijklmnopqrstuvwxyz&card_number=4242424242424242&amount=4200"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe( + "POST /authorize?shared_payment_token=spt_ab…wxyz&card_number=***&amount=4200", + ); + }); + + it("masks structured payment credential field values by key", () => { + expect(redactSensitiveFieldValue("sharedPaymentToken", "spt_abcdefghijklmnopqrstuvwxyz")).toBe( + "spt_ab…wxyz", + ); + expect(redactSensitiveFieldValue("cardNumber", "4242424242424242")).toBe("***"); + expect(redactSensitiveFieldValue("amount", "4200")).toBe("4200"); + }); + it("masks bearer tokens", () => { const input = "Authorization: Bearer abcdef1234567890ghij"; const output = redactSensitiveText(input, { diff --git a/src/logging/redact.ts b/src/logging/redact.ts index 59d15ac70ab..50476b77958 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -10,23 +10,31 @@ const DEFAULT_REDACT_MIN_LENGTH = 18; const DEFAULT_REDACT_KEEP_START = 6; const DEFAULT_REDACT_KEEP_END = 4; +const PAYMENT_CREDENTIAL_ENV_KEYS = String.raw`CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN`; +const PAYMENT_CREDENTIAL_QUERY_KEYS = String.raw`card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token`; +const PAYMENT_CREDENTIAL_JSON_KEYS = String.raw`cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token`; +const STRUCTURED_SECRET_FIELD_RE = new RegExp( + String.raw`^(?:api[-_]?key|apiKey|token|secret|password|passwd|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|client[-_]?secret|clientSecret|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`, + "i", +); + const DEFAULT_REDACT_PATTERNS: string[] = [ // ENV-style assignments. Keep this case-sensitive so diagnostics like // `Unrecognized key: "llm"` do not lose the actual config key. - String.raw`/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g`, + String.raw`/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|${PAYMENT_CREDENTIAL_ENV_KEYS})\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g`, // URL query parameters. Keep this separate from ENV-style assignments so // lower-case URL secrets stay redacted without hiding config-key diagnostics. - String.raw`/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature)=([^&\s"'<>]+)/gi`, + String.raw`/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature|${PAYMENT_CREDENTIAL_QUERY_KEYS})=([^&\s"'<>]+)/gi`, // JSON fields. - String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`, + String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|${PAYMENT_CREDENTIAL_JSON_KEYS})"\s*:\s*"([^"]+)"`, // CLI flags. - String.raw`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`, + String.raw`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd|${PAYMENT_CREDENTIAL_QUERY_KEYS})\s+(["']?)([^\s"']+)\1`, // Authorization headers. String.raw`Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)`, String.raw`\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b`, // Standalone token assignments in CLI or HTTP diagnostics. URL query params // are handled above so non-secret params survive and long values stay hinted. - String.raw`(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd)=([^\s&#]+)`, + String.raw`(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd|${PAYMENT_CREDENTIAL_QUERY_KEYS})=([^\s&#]+)`, // PEM blocks. String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`, // Common token prefixes. @@ -186,6 +194,17 @@ export function redactToolPayloadText(text: string): string { return redactSensitiveText(text, { mode: "tools", patterns }); } +export function redactSensitiveFieldValue(key: string, value: string): string { + const redacted = redactToolPayloadText(value); + if (redacted !== value) { + return redacted; + } + if (STRUCTURED_SECRET_FIELD_RE.test(key)) { + return maskToken(value); + } + return value; +} + export function getDefaultRedactPatterns(): string[] { return [...DEFAULT_REDACT_PATTERNS]; }