mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
security(logging): redact payment credential fields
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<object>()): unknow
|
||||
seen.add(value);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = redactStringsDeep(child, seen);
|
||||
out[key] =
|
||||
typeof child === "string"
|
||||
? redactSensitiveFieldValue(key, child)
|
||||
: redactStringsDeep(child, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user