Skip to content

Commit 5f5f1fa

Browse files
committed
security(logging): redact payment credential fields
1 parent eabab1f commit 5f5f1fa

7 files changed

Lines changed: 124 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- 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.
2727
- 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.
2828
- 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.
29+
- 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.
2930
- 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.
3031
- 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.
3132
- 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.

docs/gateway/logging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ masked before JSONL lines or messages are written to disk.
6363
- `logging.redactPatterns`: array of regex strings (overrides defaults)
6464
- Use raw regex strings (auto `gi`), or `/pattern/flags` if you need custom flags.
6565
- Matches are masked by keeping the first 6 + last 4 chars (length >= 18), otherwise `***`.
66-
- Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, and popular token prefixes.
66+
- 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.
6767

6868
Some safety boundaries always redact regardless of `logging.redactSensitive`.
6969
That includes Control UI tool-call events, `sessions_history` tool output,

docs/logging.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ masked before the line or message is written to disk. Redaction is best-effort:
220220
it applies to text-bearing message content and log strings, not every
221221
identifier or binary payload field.
222222

223+
The built-in defaults cover common API credentials and payment-credential field
224+
names such as card number, CVC/CVV, shared payment token, and payment credential
225+
when they appear as JSON fields, URL parameters, CLI flags, or assignments.
226+
223227
`logging.redactSensitive: "off"` only disables this general log/transcript
224228
policy. OpenClaw still redacts safety-boundary payloads that can be shown to UI
225229
clients, support bundles, diagnostics observers, approval prompts, or agent

src/agents/pi-embedded-subscribe.tools.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,39 @@ describe("sanitizeToolResult", () => {
6464
expect(text).toContain("model");
6565
});
6666

67+
it("redacts Link-like payment credential fields in tool result payloads", () => {
68+
const result = {
69+
content: [
70+
{
71+
type: "text",
72+
text: '{"shared_payment_token":"spt_abcdefghijklmnopqrstuvwxyz","paymentCredential":"paycred_abcdefghijklmnopqrstuvwxyz","card_number":"4242424242424242","cvc":"123","amount":"4200"}',
73+
},
74+
],
75+
details: {
76+
structuredContent: {
77+
sharedPaymentToken: "spt_zyxwvutsrqponmlkjihgfedcba",
78+
cardNumber: "4000056655665556",
79+
amount: "4200",
80+
},
81+
},
82+
};
83+
const sanitized = sanitizeToolResult(result) as {
84+
content: Array<{ text: string }>;
85+
details: {
86+
structuredContent: { sharedPaymentToken: string; cardNumber: string; amount: string };
87+
};
88+
};
89+
const serialized = JSON.stringify(sanitized);
90+
expect(serialized).not.toContain("spt_abcdefghijklmnopqrstuvwxyz");
91+
expect(serialized).not.toContain("paycred_abcdefghijklmnopqrstuvwxyz");
92+
expect(serialized).not.toContain("4242424242424242");
93+
expect(serialized).not.toContain("123");
94+
expect(serialized).not.toContain("spt_zyxwvutsrqponmlkjihgfedcba");
95+
expect(serialized).not.toContain("4000056655665556");
96+
expect(sanitized.content[0]?.text).toContain('"amount":"4200"');
97+
expect(sanitized.details.structuredContent.amount).toBe("4200");
98+
});
99+
67100
it("redacts ENV-style credential assignments", () => {
68101
const result = {
69102
content: [

src/agents/pi-embedded-subscribe.tools.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
22
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
3-
import { redactToolPayloadText } from "../logging/redact.js";
3+
import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js";
44
import { splitMediaFromOutput } from "../media/parse.js";
55
import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js";
66
import {
@@ -133,7 +133,10 @@ function redactStringsDeep(value: unknown, seen = new WeakSet<object>()): unknow
133133
seen.add(value);
134134
const out: Record<string, unknown> = {};
135135
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
136-
out[key] = redactStringsDeep(child, seen);
136+
out[key] =
137+
typeof child === "string"
138+
? redactSensitiveFieldValue(key, child)
139+
: redactStringsDeep(child, seen);
137140
}
138141
return out;
139142
}

src/logging/redact.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import { afterEach, describe, expect, it } from "vitest";
55
import {
66
getDefaultRedactPatterns,
7+
redactSensitiveFieldValue,
78
redactSensitiveLines,
89
redactSensitiveText,
910
resolveRedactOptions,
@@ -97,6 +98,61 @@ describe("redactSensitiveText", () => {
9798
expect(output).toBe('{"token":"abcdef…ghij"}');
9899
});
99100

101+
it("masks payment credential JSON fields without redacting unrelated amounts", () => {
102+
const input =
103+
'{"card_number":"4242424242424242","cvc":"123","sharedPaymentToken":"spt_abcdefghijklmnopqrstuvwxyz","payment_credential":"paycred_abcdefghijklmnopqrstuvwxyz","amount":"4200"}';
104+
const output = redactSensitiveText(input, {
105+
mode: "tools",
106+
patterns: defaults,
107+
});
108+
expect(output).toBe(
109+
'{"card_number":"***","cvc":"***","sharedPaymentToken":"spt_ab…wxyz","payment_credential":"paycre…wxyz","amount":"4200"}',
110+
);
111+
});
112+
113+
it("masks payment credential assignments and flags", () => {
114+
const input = [
115+
"LINK_CARD_NUMBER=4242424242424242",
116+
"LINK_CVC=123",
117+
"shared_payment_token=spt_abcdefghijklmnopqrstuvwxyz",
118+
"--payment-credential paycred_abcdefghijklmnopqrstuvwxyz",
119+
"--card-number 4000056655665556",
120+
].join(" ");
121+
const output = redactSensitiveText(input, {
122+
mode: "tools",
123+
patterns: defaults,
124+
});
125+
expect(output).not.toContain("4242424242424242");
126+
expect(output).not.toContain("4000056655665556");
127+
expect(output).not.toContain("spt_abcdefghijklmnopqrstuvwxyz");
128+
expect(output).not.toContain("paycred_abcdefghijklmnopqrstuvwxyz");
129+
expect(output).toContain("LINK_CARD_NUMBER=***");
130+
expect(output).toContain("LINK_CVC=***");
131+
expect(output).toContain("shared_payment_token=spt_ab…wxyz");
132+
expect(output).toContain("--payment-credential paycre…wxyz");
133+
expect(output).toContain("--card-number ***");
134+
});
135+
136+
it("masks payment credential URL query parameters", () => {
137+
const input =
138+
"POST /authorize?shared_payment_token=spt_abcdefghijklmnopqrstuvwxyz&card_number=4242424242424242&amount=4200";
139+
const output = redactSensitiveText(input, {
140+
mode: "tools",
141+
patterns: defaults,
142+
});
143+
expect(output).toBe(
144+
"POST /authorize?shared_payment_token=spt_ab…wxyz&card_number=***&amount=4200",
145+
);
146+
});
147+
148+
it("masks structured payment credential field values by key", () => {
149+
expect(redactSensitiveFieldValue("sharedPaymentToken", "spt_abcdefghijklmnopqrstuvwxyz")).toBe(
150+
"spt_ab…wxyz",
151+
);
152+
expect(redactSensitiveFieldValue("cardNumber", "4242424242424242")).toBe("***");
153+
expect(redactSensitiveFieldValue("amount", "4200")).toBe("4200");
154+
});
155+
100156
it("masks bearer tokens", () => {
101157
const input = "Authorization: Bearer abcdef1234567890ghij";
102158
const output = redactSensitiveText(input, {

src/logging/redact.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,31 @@ const DEFAULT_REDACT_MIN_LENGTH = 18;
1010
const DEFAULT_REDACT_KEEP_START = 6;
1111
const DEFAULT_REDACT_KEEP_END = 4;
1212

13+
const PAYMENT_CREDENTIAL_ENV_KEYS = String.raw`CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN`;
14+
const PAYMENT_CREDENTIAL_QUERY_KEYS = String.raw`card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token`;
15+
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`;
16+
const STRUCTURED_SECRET_FIELD_RE = new RegExp(
17+
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})$`,
18+
"i",
19+
);
20+
1321
const DEFAULT_REDACT_PATTERNS: string[] = [
1422
// ENV-style assignments. Keep this case-sensitive so diagnostics like
1523
// `Unrecognized key: "llm"` do not lose the actual config key.
16-
String.raw`/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g`,
24+
String.raw`/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|${PAYMENT_CREDENTIAL_ENV_KEYS})\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g`,
1725
// URL query parameters. Keep this separate from ENV-style assignments so
1826
// lower-case URL secrets stay redacted without hiding config-key diagnostics.
19-
String.raw`/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature)=([^&\s"'<>]+)/gi`,
27+
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`,
2028
// JSON fields.
21-
String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`,
29+
String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|${PAYMENT_CREDENTIAL_JSON_KEYS})"\s*:\s*"([^"]+)"`,
2230
// CLI flags.
23-
String.raw`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`,
31+
String.raw`--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd|${PAYMENT_CREDENTIAL_QUERY_KEYS})\s+(["']?)([^\s"']+)\1`,
2432
// Authorization headers.
2533
String.raw`Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)`,
2634
String.raw`\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b`,
2735
// Standalone token assignments in CLI or HTTP diagnostics. URL query params
2836
// are handled above so non-secret params survive and long values stay hinted.
29-
String.raw`(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd)=([^\s&#]+)`,
37+
String.raw`(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd|${PAYMENT_CREDENTIAL_QUERY_KEYS})=([^\s&#]+)`,
3038
// PEM blocks.
3139
String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`,
3240
// Common token prefixes.
@@ -186,6 +194,17 @@ export function redactToolPayloadText(text: string): string {
186194
return redactSensitiveText(text, { mode: "tools", patterns });
187195
}
188196

197+
export function redactSensitiveFieldValue(key: string, value: string): string {
198+
const redacted = redactToolPayloadText(value);
199+
if (redacted !== value) {
200+
return redacted;
201+
}
202+
if (STRUCTURED_SECRET_FIELD_RE.test(key)) {
203+
return maskToken(value);
204+
}
205+
return value;
206+
}
207+
189208
export function getDefaultRedactPatterns(): string[] {
190209
return [...DEFAULT_REDACT_PATTERNS];
191210
}

0 commit comments

Comments
 (0)