mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-05 13:43:33 +00:00
* redact tool output secrets * Expand tool-output secret redaction * fix(security): keep redaction prefilter in sync with expanded defaults - build DEFAULT_REDACT_PREFILTER_RE from sources covering every default pattern family: new vendor prefixes, webhook hosts, bare query/form keys, userinfo/connection-string passwords, and percent/plus/invisible obfuscated keys (including trailing separator splices) - run default-pattern redaction tests through the default options path and redact the vendor corpus per token so prefilter gaps fail tests - fix quoted standalone assignment values containing the other quote char or an unterminated quote; never re-mask *** placeholders - align net-policy URL query-name separator stripping with logging key normalization (Hangul fillers) * fix(security): keep base64-prefix redaction out of media payloads - pure-base64-alphabet token prefixes (gAAAA, AKIA, ASIA, dapi, ATCTT3xFfG, ATATT, ATBB) now require a non-alphanumeric left boundary, skip explicit ;base64, payload spans, and run unchunked so chunk starts cannot fake the boundary or hide the container from the lookbehind - tokens after URL/path delimiters or assignments still mask; data-URL media survives redaction byte-identical (fixes chat media mirror CI) - regression tests: tiny-PNG data URL, in-blob plus boundary, chunk-aligned large data URL, reset-path Fernet token, path AWS key --------- Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
294 lines
13 KiB
TypeScript
294 lines
13 KiB
TypeScript
// Verifies shell command display strings for exec approval prompts.
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
resolveExecApprovalCommandDisplay,
|
|
sanitizeExecApprovalDisplayText,
|
|
sanitizeExecApprovalWarningText,
|
|
} from "./exec-approval-command-display.js";
|
|
|
|
describe("sanitizeExecApprovalDisplayText", () => {
|
|
it.each([
|
|
["echo hi\u200Bthere", "echo hi\\u{200B}there"],
|
|
["date\u3164\uFFA0\u115F\u1160가", "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가"],
|
|
["echo safe\n\rcurl https://example.test", "echo safe\\u{A}\\u{D}curl https://example.test"],
|
|
["echo ok\u2028curl https://example.test", "echo ok\\u{2028}curl https://example.test"],
|
|
["echo ok\u2029curl https://example.test", "echo ok\\u{2029}curl https://example.test"],
|
|
])("sanitizes exec approval display text for %j", (input, expected) => {
|
|
expect(sanitizeExecApprovalDisplayText(input)).toBe(expected);
|
|
});
|
|
|
|
it("redacts bearer tokens embedded in commands", () => {
|
|
const cmd =
|
|
'curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig" https://api.example.com';
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig");
|
|
expect(result).toContain("curl");
|
|
expect(result).toContain("https://api.example.com");
|
|
});
|
|
|
|
it("redacts API keys in environment variable assignments", () => {
|
|
const cmd = 'API_SECRET="sk-abc123456789012345678" python script.py';
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123456789012345678");
|
|
expect(result).toContain("python script.py");
|
|
});
|
|
|
|
it("redacts GitHub personal access tokens", () => {
|
|
const cmd = "git clone https://ghp_1234567890abcdefghij1234567890abcdef@github.com/user/repo";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("ghp_1234567890abcdefghij1234567890abcdef");
|
|
expect(result).toContain("git clone");
|
|
});
|
|
|
|
it("masks the full token when a zero-width character is spliced into the middle", () => {
|
|
const cmd = "echo sk-abc123\u200B456789012345678 remainder";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("echo ");
|
|
expect(result).toContain("remainder");
|
|
});
|
|
|
|
it("masks the full token when NBSP (Zs) is spliced into the middle", () => {
|
|
const cmd = "echo sk-abc123\u00A0456789012345678 remainder";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("echo ");
|
|
expect(result).toContain("remainder");
|
|
});
|
|
|
|
it("masks the full token when narrow no-break space is spliced into the middle", () => {
|
|
const cmd = "echo sk-abc123\u202F456789012345678 remainder";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("remainder");
|
|
});
|
|
|
|
it("keeps newline boundaries visible as escape markers even when bypass is detected", () => {
|
|
// Stripping invisibles lets the stripped-view greedy-match across the original newline
|
|
// boundaries, so the trailing `line3` gets absorbed into the union mask alongside the
|
|
// secret. The important guarantees are: (1) the secret is not visible, and (2) the
|
|
// newlines that existed in the original are still visible as `\u{A}` escapes so the
|
|
// operator is not misled about multi-line structure.
|
|
const cmd = "line1\necho sk-abc123\u00A0456789012345678\nline3";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("line1");
|
|
expect(result).toContain("\\u{A}");
|
|
});
|
|
|
|
it("detects bypass even when raw and stripped redactions happen to produce the same normalized length", () => {
|
|
// Raw masks the 16-char prefix `sk-abc1234567890` as the fixed literal `***` while the
|
|
// trailing 8 chars past the zero-width stay visible. The stripped view masks the full
|
|
// 24-char token as `sk-abc…5678`. Both normalized outputs are the same length (11 chars),
|
|
// so a length-based bypass check would falsely return the raw view and leak the tail.
|
|
const cmd = "sk-abc1234567890\u200B12345678";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("12345678");
|
|
expect(result).not.toContain("1234567890");
|
|
});
|
|
|
|
it("does not leak bearer tokens when bypass is triggered by a separate spliced secret", () => {
|
|
// Bearer+NBSP is caught by the raw view (NBSP matches \s in non-u JS regex) but stripping
|
|
// removes NBSP, turning `Bearer<jwt>` into a pattern the bearer regex no longer matches.
|
|
// A separate spliced-invisible token triggers bypass detection, and the union-mask output
|
|
// must cover both the bearer span (from raw) and the spliced sk- span (from stripped).
|
|
const cmd =
|
|
'curl -H "Authorization: Bearer\u00A0eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig" https://api.example.com; echo sk-abc123\u200B456789012345678';
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("https://api.example.com");
|
|
});
|
|
|
|
it("masks newly added vendor token prefixes through the default redaction path", () => {
|
|
const token = "glpat-abcdefghijklmnopqrstuv";
|
|
const result = sanitizeExecApprovalDisplayText(`deploy --with ${token}`);
|
|
expect(result).not.toContain(token);
|
|
});
|
|
|
|
it("does not let contextual secret matches hide split-token bypass detection", () => {
|
|
const discordToken = `${"A".repeat(24)}.${"B".repeat(6)}.${"C".repeat(27)}`;
|
|
const cmd = `discord sk-abc123\u200B456789012345678 ${discordToken}`;
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).not.toContain(discordToken);
|
|
});
|
|
|
|
it("keeps PEM private-key context visible when raw redaction already covers the key (not a bypass)", () => {
|
|
const cmd =
|
|
"echo -----BEGIN RSA PRIVATE KEY-----\nABCDEF0123456789abcdef\n-----END RSA PRIVATE KEY----- > key.pem";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("ABCDEF0123456789abcdef");
|
|
expect(result).toContain("BEGIN RSA PRIVATE KEY");
|
|
expect(result).toContain("END RSA PRIVATE KEY");
|
|
expect(result).toContain("> key.pem");
|
|
});
|
|
|
|
it("truncates the redacted output (not the raw input) so large commands are bounded", () => {
|
|
const padding = "x".repeat(20 * 1024);
|
|
const result = sanitizeExecApprovalDisplayText(padding);
|
|
expect(result.length).toBeLessThan(padding.length);
|
|
expect(result).toContain("[truncated]");
|
|
});
|
|
|
|
it("refuses to display commands above the hard input cap", () => {
|
|
const huge = "x".repeat(300 * 1024);
|
|
const result = sanitizeExecApprovalDisplayText(huge);
|
|
expect(result).toContain("exceeds display size limit");
|
|
expect(result.length).toBeLessThan(1024);
|
|
});
|
|
|
|
it("redacts tokens at the tail of long inputs instead of truncating them below pattern length", () => {
|
|
// Pad with non-token content, then append a secret at the end. Truncating BEFORE redaction
|
|
// would split the token below the pattern's minimum length and leak the prefix. With
|
|
// redaction first, the full token is masked before any size-based truncation runs.
|
|
const padding = "a ".repeat(10 * 1024);
|
|
const cmd = padding + "ghp_1234567890abcdefghij1234567890abcdef";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("ghp_1234567890abcdefghij1234567890abcdef");
|
|
expect(result).not.toContain("ghp_1234567890");
|
|
});
|
|
|
|
it("escapes astral-plane invisible characters (e.g. U+E0061 tag characters)", () => {
|
|
const cmd = "echo hi\u{E0061}there";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).toContain("\\u{E0061}");
|
|
expect(result).not.toMatch(/hi[\uDB40\uDC61]there/u);
|
|
});
|
|
|
|
it("masks a secret spliced with an astral-plane invisible character", () => {
|
|
// U+E0061 is a Cf (format) code point in the supplementary plane. Iterating the input by
|
|
// UTF-16 code unit would see two surrogate halves, neither of which matches \p{Cf}, so
|
|
// the splice would survive stripping and the stripped-view redaction would miss the
|
|
// full token. Code-point iteration strips it correctly and bypass detection fires.
|
|
const cmd = "echo sk-abc123\u{E0061}456789012345678 remainder";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("sk-abc123");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("remainder");
|
|
});
|
|
|
|
it("masks form body values whose sensitive key is spliced with an invisible character", () => {
|
|
const cmd = "client_id=visible&app_se\u200Bcret=opaque-app-secret&safe=value";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("opaque-app-secret");
|
|
expect(result).toContain("client_id=visible");
|
|
expect(result).toContain("safe=value");
|
|
});
|
|
|
|
it("masks form body values whose encoded sensitive key is spliced with an invisible character", () => {
|
|
const cmd = "client_id=visible&client%5Fse\u200Bcret=oauth-secret&safe=value";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("oauth-secret");
|
|
expect(result).toContain("client_id=visible");
|
|
expect(result).toContain("safe=value");
|
|
});
|
|
|
|
it("masks form body values whose sensitive key is spliced with a plus separator", () => {
|
|
const cmd = "client_id=visible&client_se+cret=oauth-secret&safe=value";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("oauth-secret");
|
|
expect(result).toContain("client_id=visible");
|
|
expect(result).toContain("safe=value");
|
|
});
|
|
|
|
it("keeps parsed form-body secrets masked when a separate spliced token triggers bypass rendering", () => {
|
|
const cmd =
|
|
"client_id=visible&client%5Fsecret=oauth,secret&safe=1 echo sk-abc123\u200B456789012345678";
|
|
const result = sanitizeExecApprovalDisplayText(cmd);
|
|
expect(result).not.toContain("oauth,secret");
|
|
expect(result).not.toContain(",secret");
|
|
expect(result).not.toContain("456789012345678");
|
|
expect(result).toContain("client_id=visible");
|
|
expect(result).toContain("safe=1");
|
|
});
|
|
});
|
|
|
|
describe("sanitizeExecApprovalWarningText", () => {
|
|
it("keeps approval warning prose line breaks readable", () => {
|
|
const warning =
|
|
"Diagnostics can include sensitive local logs.\n\nOpenAI Codex harness:\nApproving diagnostics will also send Codex feedback.";
|
|
|
|
expect(sanitizeExecApprovalWarningText(warning)).toBe(warning);
|
|
});
|
|
|
|
it("normalizes escaped line separators while still escaping hidden spoofing characters", () => {
|
|
const warning = "Line one\r\nLine two\u2028Line three\u200B";
|
|
|
|
expect(sanitizeExecApprovalWarningText(warning)).toBe(
|
|
"Line one\nLine two\nLine three\\u{200B}",
|
|
);
|
|
});
|
|
|
|
it("redacts secrets in warning prose without escaping newlines", () => {
|
|
const warning = "Token:\nsk-abc123456789012345678";
|
|
const result = sanitizeExecApprovalWarningText(warning);
|
|
|
|
expect(result).toContain("Token:\n");
|
|
expect(result).not.toContain("sk-abc123456789012345678");
|
|
expect(result).not.toContain("\\u{A}");
|
|
});
|
|
});
|
|
|
|
describe("resolveExecApprovalCommandDisplay", () => {
|
|
it.each([
|
|
{
|
|
name: "prefers explicit command fields and drops identical previews after trimming",
|
|
input: {
|
|
command: "echo hi",
|
|
commandPreview: " echo hi ",
|
|
host: "gateway" as const,
|
|
},
|
|
expected: {
|
|
commandText: "echo hi",
|
|
commandPreview: null,
|
|
},
|
|
},
|
|
{
|
|
name: "falls back to node systemRunPlan values and sanitizes preview text",
|
|
input: {
|
|
command: "",
|
|
host: "node" as const,
|
|
systemRunPlan: {
|
|
argv: ["python3", "-c", "print(1)"],
|
|
cwd: null,
|
|
commandText: 'python3 -c "print(1)"',
|
|
commandPreview: "print\u200B(1)",
|
|
agentId: null,
|
|
sessionKey: null,
|
|
},
|
|
},
|
|
expected: {
|
|
commandText: 'python3 -c "print(1)"',
|
|
commandPreview: "print\\u{200B}(1)",
|
|
},
|
|
},
|
|
{
|
|
name: "ignores systemRunPlan fallback for non-node hosts",
|
|
input: {
|
|
command: "",
|
|
host: "sandbox" as const,
|
|
systemRunPlan: {
|
|
argv: ["echo", "hi"],
|
|
cwd: null,
|
|
commandText: "echo hi",
|
|
commandPreview: "echo hi",
|
|
agentId: null,
|
|
sessionKey: null,
|
|
},
|
|
},
|
|
expected: {
|
|
commandText: "",
|
|
commandPreview: null,
|
|
},
|
|
},
|
|
])("$name", ({ input, expected }) => {
|
|
expect(resolveExecApprovalCommandDisplay(input)).toEqual(expected);
|
|
});
|
|
});
|